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 app shows

+3326 -420
+6 -6
apps/mobile/app/(tabs)/index.tsx
··· 158 158 style={styles.buttonIcon} 159 159 /> 160 160 <Text style={[styles.buttonText, { color: colors.onPrimary }]}> 161 - Search Movies 161 + Search 162 162 </Text> 163 163 </Button> 164 164 </View> ··· 290 290 { color: colors.onSurfaceVariant }, 291 291 ]} 292 292 > 293 - {totalMoviesInLists} movies across lists 293 + {totalMoviesInLists} items across lists 294 294 </Text> 295 295 </CardContent> 296 296 </Card> ··· 417 417 <Text 418 418 style={[styles.buttonText, { color: colors.onPrimary }]} 419 419 > 420 - Search movies 420 + Search 421 421 </Text> 422 422 </Button> 423 423 </CardContent> ··· 488 488 { color: colors.onSurfaceVariant }, 489 489 ]} 490 490 > 491 - {list.movieCount} movie{list.movieCount !== 1 ? "s" : ""} 491 + {list.movieCount} item{list.movieCount !== 1 ? "s" : ""} 492 492 </Text> 493 493 </View> 494 494 </Pressable> ··· 508 508 { color: colors.onSurfaceVariant }, 509 509 ]} 510 510 > 511 - Create your first list to organize movies. 511 + Create your first list to organize items. 512 512 </Text> 513 513 </CardContent> 514 514 </Card> ··· 553 553 style={styles.buttonIcon} 554 554 /> 555 555 <Text style={[styles.buttonText, { color: colors.onPrimary }]}> 556 - Search Movies 556 + Search 557 557 </Text> 558 558 </Button> 559 559 </View>
+3 -3
apps/mobile/app/(tabs)/profile/lists.tsx
··· 107 107 { color: colors.onSurfaceVariant }, 108 108 ]} 109 109 > 110 - Your default lists will appear after you add movies 110 + Your default lists will appear after you add items 111 111 </Text> 112 112 </CardHeader> 113 113 <CardContent> 114 114 <Button onPress={() => router.push("/(tabs)/search")}> 115 115 <Text style={[styles.buttonText, { color: colors.onPrimary }]}> 116 - Search for movies 116 + Search for items 117 117 </Text> 118 118 </Button> 119 119 </CardContent> ··· 199 199 <Text 200 200 style={[styles.listCardCount, { color: colors.onSurfaceVariant }]} 201 201 > 202 - {list.movieCount} movie{list.movieCount !== 1 ? "s" : ""} 202 + {list.movieCount} item{list.movieCount !== 1 ? "s" : ""} 203 203 </Text> 204 204 </View> 205 205 </TouchableOpacity>
+139 -28
apps/mobile/app/(tabs)/profile/shelf.tsx
··· 4 4 moviesControllerGetUserMoviesQueryKey, 5 5 moviesControllerUnmarkWatchedMutation, 6 6 showsControllerGetUserShowsOptions, 7 - type TrackedShowSummaryDto, 8 7 } from "@opnshelf/api"; 9 8 import { FlashList } from "@shopify/flash-list"; 10 9 import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; 10 + import { Image } from "expo-image"; 11 11 import { router } from "expo-router"; 12 12 import { BookOpen } from "lucide-react-native"; 13 - import { useCallback } from "react"; 14 - import { StyleSheet, Text, View } from "react-native"; 13 + import { useCallback, useMemo } from "react"; 14 + import { StyleSheet, Text, TouchableOpacity, View } from "react-native"; 15 15 import { SafeAreaView } from "react-native-safe-area-context"; 16 16 import { MovieCard } from "@/components/MovieCard"; 17 17 import { Button } from "@/components/ui/Button"; ··· 22 22 import { useTheme } from "@/contexts/theme"; 23 23 import { useToast } from "@/contexts/toast"; 24 24 import { useUserSettings } from "@/hooks/useUserSettings"; 25 - import { createTitleSlug } from "@/lib/utils"; 25 + import { createTitleSlug, getTmdbPosterUrl } from "@/lib/utils"; 26 26 27 27 export default function ShelfScreen() { 28 28 const { user } = useAuth(); ··· 97 97 ); 98 98 99 99 const keyExtractor = useCallback((item: TrackedMovieDto) => item.id, []); 100 + const trackedShowItems = useMemo( 101 + () => 102 + (trackedShows ?? []).map((tracked) => ({ 103 + id: tracked.showId, 104 + name: tracked.show.title, 105 + posterPath: tracked.show.posterPath ?? null, 106 + firstAirDate: tracked.show.firstAirDate ?? null, 107 + watchCount: tracked.watchCount ?? 0, 108 + })), 109 + [trackedShows], 110 + ); 100 111 101 112 if (isMoviesLoading) { 102 113 return ( ··· 165 176 {trackedShows.length} show{trackedShows.length !== 1 ? "s" : ""}{" "} 166 177 tracked 167 178 </Text> 168 - {trackedShows.map((show: TrackedShowSummaryDto) => ( 169 - <Button 170 - key={show.showId} 171 - variant="outlined" 172 - style={styles.showButton} 173 - onPress={() => 174 - router.push({ 175 - pathname: "/show/[id]", 176 - params: { 177 - id: show.showId, 178 - title: createTitleSlug(show.show.title), 179 - }, 180 - }) 181 - } 182 - > 183 - <Text style={{ color: colors.onBackground }}> 184 - {show.show.title} ({show.watchCount} watched episode 185 - {show.watchCount === 1 ? "" : "s"}) 186 - </Text> 187 - </Button> 188 - ))} 179 + <View style={styles.showGrid}> 180 + {trackedShowItems.map((item) => ( 181 + <ShelfShowCard 182 + key={item.id} 183 + name={item.name} 184 + posterPath={item.posterPath} 185 + watchCount={item.watchCount} 186 + onPress={() => 187 + router.push({ 188 + pathname: "/show/[id]", 189 + params: { 190 + id: item.id.toString(), 191 + title: createTitleSlug(item.name), 192 + }, 193 + }) 194 + } 195 + /> 196 + ))} 197 + </View> 189 198 </View> 190 199 )} 191 200 ··· 246 255 showsSection: { 247 256 paddingHorizontal: spacing.lg, 248 257 paddingBottom: spacing.lg, 249 - gap: spacing.sm, 258 + }, 259 + showGrid: { 260 + paddingTop: spacing.md, 261 + }, 262 + showCard: { 263 + flexDirection: "row", 264 + borderRadius: borderRadius.lg, 265 + overflow: "hidden", 266 + borderWidth: 1, 267 + marginBottom: spacing.md, 268 + }, 269 + showPosterContainer: { 270 + width: 80, 271 + aspectRatio: 2 / 3, 272 + }, 273 + showPoster: { 274 + width: "100%", 275 + height: "100%", 276 + }, 277 + showCardContent: { 278 + flex: 1, 279 + padding: spacing.md, 280 + justifyContent: "center", 281 + }, 282 + showTitle: { 283 + fontSize: 16, 284 + fontWeight: "600", 285 + marginBottom: spacing.xs, 286 + lineHeight: 22, 250 287 }, 251 - showButton: { 252 - justifyContent: "flex-start", 288 + showMeta: { 289 + fontSize: 14, 253 290 }, 254 291 centerContent: { 255 292 flex: 1, ··· 300 337 padding: spacing.md, 301 338 justifyContent: "center", 302 339 }, 340 + noPoster: { 341 + justifyContent: "center", 342 + alignItems: "center", 343 + }, 344 + noPosterText: { 345 + fontSize: 12, 346 + fontWeight: "500", 347 + }, 303 348 }); 349 + 350 + interface ShelfShowCardProps { 351 + name: string; 352 + posterPath?: string | null; 353 + watchCount: number; 354 + onPress: () => void; 355 + } 356 + 357 + function ShelfShowCard({ 358 + name, 359 + posterPath, 360 + watchCount, 361 + onPress, 362 + }: ShelfShowCardProps) { 363 + const { colors } = useTheme(); 364 + const posterUrl = getTmdbPosterUrl(posterPath); 365 + const watchLabel = `${watchCount} watched episode${watchCount === 1 ? "" : "s"}`; 366 + 367 + return ( 368 + <TouchableOpacity 369 + onPress={onPress} 370 + style={[ 371 + styles.showCard, 372 + { 373 + backgroundColor: colors.surfaceContainer, 374 + borderColor: colors.outline, 375 + }, 376 + ]} 377 + activeOpacity={0.8} 378 + > 379 + <View 380 + style={[ 381 + styles.showPosterContainer, 382 + { backgroundColor: colors.surfaceContainerHigh }, 383 + ]} 384 + > 385 + {posterUrl ? ( 386 + <Image 387 + source={{ uri: posterUrl }} 388 + style={styles.showPoster} 389 + contentFit="cover" 390 + /> 391 + ) : ( 392 + <View style={[styles.showPoster, styles.noPoster]}> 393 + <Text 394 + style={[styles.noPosterText, { color: colors.onSurfaceVariant }]} 395 + > 396 + No poster 397 + </Text> 398 + </View> 399 + )} 400 + </View> 401 + <View style={styles.showCardContent}> 402 + <Text 403 + style={[styles.showTitle, { color: colors.onSurface }]} 404 + numberOfLines={2} 405 + > 406 + {name} 407 + </Text> 408 + <Text style={[styles.showMeta, { color: colors.onSurfaceVariant }]}> 409 + {watchLabel} 410 + </Text> 411 + </View> 412 + </TouchableOpacity> 413 + ); 414 + }
+21 -73
apps/mobile/app/(tabs)/search.tsx
··· 17 17 import { Pressable, StyleSheet, Text, View } from "react-native"; 18 18 import { SafeAreaView } from "react-native-safe-area-context"; 19 19 import { MovieItem } from "@/components/MovieItem"; 20 + import { ShowItem } from "@/components/ShowItem"; 20 21 import { SearchInput } from "@/components/ui/Input"; 21 22 import { Skeleton } from "@/components/ui/Skeleton"; 22 23 import { borderRadius, spacing } from "@/constants/spacing"; ··· 207 208 (item: TmdbMovieResultDto) => item.id.toString(), 208 209 [], 209 210 ); 211 + const showKeyExtractor = useCallback( 212 + (item: TmdbShowResultDto) => item.id.toString(), 213 + [], 214 + ); 215 + 216 + const renderShowItem: ListRenderItem<TmdbShowResultDto> = useCallback( 217 + ({ item }) => ( 218 + <ShowItem 219 + show={item} 220 + onPress={() => handleShowPress(item as TmdbShowResultDto)} 221 + /> 222 + ), 223 + [handleShowPress], 224 + ); 210 225 211 226 const renderSkeleton = () => ( 212 227 <View style={styles.skeletonGrid}> ··· 306 321 {showData && showResults.length > 0 && ( 307 322 <FlashList 308 323 data={showResults} 309 - renderItem={({ item }) => ( 310 - <View style={styles.showRow}> 311 - <Pressable 312 - onPress={() => handleShowPress(item as TmdbShowResultDto)} 313 - style={[ 314 - styles.showCard, 315 - { backgroundColor: colors.surfaceContainer }, 316 - ]} 317 - > 318 - <Text style={[styles.showTitle, { color: colors.onSurface }]}> 319 - {(item as TmdbShowResultDto).name} 320 - </Text> 321 - <Text 322 - style={[styles.showMeta, { color: colors.onSurfaceVariant }]} 323 - > 324 - {(item as TmdbShowResultDto).first_air_date 325 - ? (item as TmdbShowResultDto).first_air_date?.split("-")[0] 326 - : "Unknown year"} 327 - </Text> 328 - </Pressable> 329 - </View> 330 - )} 331 - keyExtractor={(item) => `show-${item.id}`} 324 + renderItem={renderShowItem} 325 + keyExtractor={showKeyExtractor} 326 + numColumns={2} 332 327 contentContainerStyle={styles.listContent} 333 328 /> 334 329 )} ··· 370 365 {discoverShowResults.length > 0 && ( 371 366 <FlashList 372 367 data={discoverShowResults} 373 - renderItem={({ item }) => ( 374 - <View style={styles.showRow}> 375 - <Pressable 376 - onPress={() => 377 - handleShowPress(item as TmdbShowResultDto) 378 - } 379 - style={[ 380 - styles.showCard, 381 - { backgroundColor: colors.surfaceContainer }, 382 - ]} 383 - > 384 - <Text 385 - style={[ 386 - styles.showTitle, 387 - { color: colors.onSurface }, 388 - ]} 389 - > 390 - {(item as TmdbShowResultDto).name} 391 - </Text> 392 - <Text 393 - style={[ 394 - styles.showMeta, 395 - { color: colors.onSurfaceVariant }, 396 - ]} 397 - > 398 - {(item as TmdbShowResultDto).first_air_date 399 - ? (item as TmdbShowResultDto).first_air_date?.split( 400 - "-", 401 - )[0] 402 - : "Unknown year"} 403 - </Text> 404 - </Pressable> 405 - </View> 406 - )} 407 - keyExtractor={(item) => `discover-show-${item.id}`} 368 + renderItem={renderShowItem} 369 + keyExtractor={showKeyExtractor} 370 + numColumns={2} 408 371 contentContainerStyle={styles.listContent} 409 372 /> 410 373 )} ··· 446 409 }, 447 410 listContent: { 448 411 padding: spacing.lg, 449 - }, 450 - showRow: { 451 - marginBottom: spacing.sm, 452 - }, 453 - showCard: { 454 - borderRadius: borderRadius.md, 455 - padding: spacing.md, 456 - }, 457 - showTitle: { 458 - fontSize: 16, 459 - fontWeight: "600", 460 - }, 461 - showMeta: { 462 - fontSize: 13, 463 - marginTop: spacing.xs, 464 412 }, 465 413 resultsCount: { 466 414 fontSize: 14,
+9 -5
apps/mobile/app/_layout.tsx
··· 14 14 15 15 useEffect(() => { 16 16 async function setupLocale() { 17 - const { registerTranslation, en } = await import( 18 - "react-native-paper-dates" 19 - ); 20 - registerTranslation("en", en); 21 - setIsReady(true); 17 + try { 18 + const { registerTranslation, en } = await import( 19 + "react-native-paper-dates" 20 + ); 21 + registerTranslation("en", en); 22 + setIsReady(true); 23 + } catch (error) { 24 + console.error("Failed to initialize locale:", error); 25 + } 22 26 } 23 27 setupLocale(); 24 28 }, []);
+4 -4
apps/mobile/app/list/[slug].tsx
··· 305 305 <Text 306 306 style={[styles.resultsCount, { color: colors.onSurfaceVariant }]} 307 307 > 308 - {movies.length} movie{movies.length !== 1 ? "s" : ""} 308 + {movies.length} item{movies.length !== 1 ? "s" : ""} 309 309 </Text> 310 310 <FlashList 311 311 data={movies} ··· 329 329 style={styles.emptyIcon} 330 330 /> 331 331 <Text style={[styles.emptyTitle, { color: colors.onSurface }]}> 332 - No movies yet 332 + No items yet 333 333 </Text> 334 334 <Text 335 335 style={[ ··· 337 337 { color: colors.onSurfaceVariant }, 338 338 ]} 339 339 > 340 - Add movies to this list from the search page 340 + Add items to this list from the search page 341 341 </Text> 342 342 </CardHeader> 343 343 <CardContent> ··· 354 354 { color: colors.onPrimary }, 355 355 ]} 356 356 > 357 - Search for movies 357 + Search for items 358 358 </Text> 359 359 </TouchableOpacity> 360 360 </CardContent>
+8 -7
apps/mobile/app/movie/[id].tsx
··· 5 5 TmdbMovieDetailDto, 6 6 } from "@opnshelf/api"; 7 7 import { 8 - listsControllerGetListsForMovieOptions, 8 + listsControllerGetListsForItemOptions, 9 9 type MovieListsForItemDto, 10 10 moviesControllerDeleteWatchHistoryEntryMutation, 11 11 moviesControllerGetMovieDetailsOptions, ··· 131 131 132 132 // Fetch lists for this movie 133 133 const { data: listsForMovie } = useQuery({ 134 - ...listsControllerGetListsForMovieOptions({ 135 - path: { movieId }, 134 + ...listsControllerGetListsForItemOptions({ 135 + path: { mediaType: "movie", mediaId: movieId }, 136 136 }), 137 137 enabled: !!user?.did, 138 138 }); ··· 835 835 </Pressable> 836 836 </View> 837 837 <Text style={styles.modalDescription}> 838 - When did you watch this movie? 838 + When did you watch this? 839 839 </Text> 840 840 841 841 <View style={styles.dateTimeContainer}> ··· 923 923 isLoading={markMutation.isPending} 924 924 style={{ backgroundColor: colors.primary }} 925 925 > 926 - <Text style={styles.buttonText}>Add Play</Text> 926 + <Text style={styles.buttonText}>Add Watch</Text> 927 927 </Button> 928 928 </View> 929 929 </View> ··· 1003 1003 <AddToListModal 1004 1004 visible={showAddToListModal} 1005 1005 onClose={() => setShowAddToListModal(false)} 1006 - movieId={movieId} 1007 - movieTitle={movie?.title || title || ""} 1006 + mediaType="movie" 1007 + mediaId={movieId} 1008 + mediaTitle={movie?.title || title || ""} 1008 1009 /> 1009 1010 </> 1010 1011 );
+581 -40
apps/mobile/app/show/[id].tsx
··· 1 + import { Ionicons } from "@expo/vector-icons"; 1 2 import { 2 3 showsControllerGetShowDetailsOptions, 3 4 type TmdbShowDetailDto, 4 5 } from "@opnshelf/api"; 5 6 import { useQuery } from "@tanstack/react-query"; 7 + import { Image } from "expo-image"; 8 + import { LinearGradient } from "expo-linear-gradient"; 6 9 import { useLocalSearchParams, useRouter } from "expo-router"; 7 10 import { 8 11 ScrollView, ··· 12 15 View, 13 16 } from "react-native"; 14 17 import { SafeAreaView } from "react-native-safe-area-context"; 18 + import { borderRadius, spacing } from "@/constants/spacing"; 15 19 import { useTheme } from "@/contexts/theme"; 20 + import { 21 + getReleaseYear, 22 + getTmdbBackdropUrl, 23 + getTmdbPosterUrl, 24 + getTmdbProfileUrl, 25 + } from "@/lib/utils"; 16 26 17 27 export default function ShowDetailScreen() { 18 28 const { id } = useLocalSearchParams<{ id: string }>(); ··· 28 38 const show = data as TmdbShowDetailDto | undefined; 29 39 const seasonCount = show?.number_of_seasons || 0; 30 40 41 + const showColors = show?.colors || { 42 + primary: colors.primary, 43 + secondary: colors.secondary, 44 + accent: colors.tertiary, 45 + muted: colors.surfaceContainer, 46 + }; 47 + 48 + const backdropUrl = getTmdbBackdropUrl(show?.backdrop_path); 49 + const posterUrl = getTmdbPosterUrl(show?.poster_path, "w500"); 50 + const releaseYear = getReleaseYear(show?.first_air_date); 51 + 31 52 return ( 32 53 <SafeAreaView 33 54 style={[styles.container, { backgroundColor: colors.background }]} 34 55 > 35 - <ScrollView contentContainerStyle={styles.content}> 36 - <Text style={[styles.title, { color: colors.onBackground }]}> 37 - {show?.name} 38 - </Text> 39 - <Text style={[styles.overview, { color: colors.onSurfaceVariant }]}> 40 - {show?.overview || "No overview available."} 41 - </Text> 42 - <View style={styles.grid}> 43 - {Array.from({ length: seasonCount }).map((_, index) => { 44 - const seasonNumber = index + 1; 45 - return ( 46 - <TouchableOpacity 47 - key={seasonNumber} 56 + <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} 77 + /> 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 + 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> 193 + 194 + {show?.overview && ( 195 + <View style={styles.section}> 196 + <Text 197 + style={[ 198 + styles.sectionTitle, 199 + { color: showColors.primary || colors.primary }, 200 + ]} 201 + > 202 + Overview 203 + </Text> 204 + <Text 205 + style={[styles.overview, { color: colors.onSurfaceVariant }]} 206 + > 207 + {show.overview} 208 + </Text> 209 + </View> 210 + )} 211 + 212 + {show?.genres && show.genres.length > 0 && ( 213 + <View style={styles.section}> 214 + <Text 215 + style={[ 216 + styles.sectionTitle, 217 + { color: showColors.primary || colors.primary }, 218 + ]} 219 + > 220 + Genres 221 + </Text> 222 + <View style={styles.genresContainer}> 223 + {show.genres.map((genre) => ( 224 + <View 225 + key={genre.id} 226 + style={[ 227 + styles.genreBadge, 228 + { 229 + backgroundColor: `${showColors.primary || colors.primary}20`, 230 + borderColor: `${showColors.primary || colors.primary}40`, 231 + }, 232 + ]} 233 + > 234 + <Text 235 + style={[ 236 + styles.genreText, 237 + { color: showColors.primary || colors.primary }, 238 + ]} 239 + > 240 + {genre.name} 241 + </Text> 242 + </View> 243 + ))} 244 + </View> 245 + </View> 246 + )} 247 + 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 + })} 290 + </View> 291 + </View> 292 + 293 + {show?.credits?.cast && show.credits.cast.length > 0 ? ( 294 + <View style={styles.section}> 295 + <Text 296 + style={[ 297 + styles.sectionTitle, 298 + { color: showColors.primary || colors.primary }, 299 + ]} 300 + > 301 + Cast 302 + </Text> 303 + <View style={styles.castContainer}> 304 + <ScrollView 305 + horizontal 306 + showsHorizontalScrollIndicator={false} 307 + contentContainerStyle={styles.castScrollContent} 308 + > 309 + {show.credits.cast.map((person) => { 310 + const profileUrl = getTmdbProfileUrl(person.profile_path); 311 + return ( 312 + <TouchableOpacity 313 + key={person.id} 314 + style={styles.castCard} 315 + activeOpacity={0.8} 316 + > 317 + <View style={styles.castImageContainer}> 318 + {profileUrl ? ( 319 + <Image 320 + source={{ uri: profileUrl }} 321 + style={styles.castImage} 322 + contentFit="cover" 323 + /> 324 + ) : ( 325 + <View 326 + style={[ 327 + styles.castImagePlaceholder, 328 + { backgroundColor: colors.surfaceContainer }, 329 + ]} 330 + > 331 + <Text 332 + style={[ 333 + styles.castImagePlaceholderText, 334 + { color: colors.onSurfaceVariant }, 335 + ]} 336 + > 337 + No photo 338 + </Text> 339 + </View> 340 + )} 341 + </View> 342 + <Text 343 + style={[styles.castName, { color: colors.onSurface }]} 344 + numberOfLines={2} 345 + > 346 + {person.name} 347 + </Text> 348 + {person.character ? ( 349 + <Text 350 + style={[ 351 + styles.castCharacter, 352 + { color: colors.onSurfaceVariant }, 353 + ]} 354 + numberOfLines={2} 355 + > 356 + as {person.character} 357 + </Text> 358 + ) : null} 359 + </TouchableOpacity> 360 + ); 361 + })} 362 + </ScrollView> 363 + <LinearGradient 364 + colors={["rgba(3, 7, 18, 0)", "rgba(3, 7, 18, 1)"]} 365 + start={{ x: 0, y: 0.5 }} 366 + end={{ x: 1, y: 0.5 }} 367 + style={styles.castGradient} 368 + /> 369 + </View> 370 + </View> 371 + ) : null} 372 + 373 + {show?.credits?.crew && show.credits.crew.length > 0 ? ( 374 + <View style={styles.section}> 375 + <Text 48 376 style={[ 49 - styles.seasonCard, 50 - { 51 - borderColor: colors.outline, 52 - backgroundColor: colors.surfaceContainer, 53 - }, 377 + styles.sectionTitle, 378 + { color: showColors.primary || colors.primary }, 54 379 ]} 55 - onPress={() => 56 - router.push({ 57 - pathname: "/show/[id]/season/[seasonNumber]", 58 - params: { 59 - id, 60 - seasonNumber: String(seasonNumber), 61 - title: show?.name || "", 62 - }, 63 - }) 64 - } 65 380 > 66 - <Text style={[styles.seasonText, { color: colors.onSurface }]}> 67 - Season {seasonNumber} 68 - </Text> 69 - </TouchableOpacity> 70 - ); 71 - })} 381 + Crew 382 + </Text> 383 + <View style={styles.crewGrid}> 384 + {show.credits.crew.map((person) => ( 385 + <TouchableOpacity 386 + key={`${person.id}-${person.job || "crew"}`} 387 + style={[ 388 + styles.crewCard, 389 + { backgroundColor: colors.surfaceContainer }, 390 + ]} 391 + activeOpacity={0.8} 392 + > 393 + <Text 394 + style={[styles.crewName, { color: colors.onSurface }]} 395 + numberOfLines={1} 396 + > 397 + {person.name} 398 + </Text> 399 + <Text 400 + style={[ 401 + styles.crewJob, 402 + { color: colors.onSurfaceVariant }, 403 + ]} 404 + numberOfLines={1} 405 + > 406 + {person.job || person.department || "Crew"} 407 + </Text> 408 + </TouchableOpacity> 409 + ))} 410 + </View> 411 + </View> 412 + ) : null} 72 413 </View> 73 414 </ScrollView> 74 415 </SafeAreaView> ··· 77 418 78 419 const styles = StyleSheet.create({ 79 420 container: { flex: 1 }, 80 - content: { padding: 16 }, 81 - title: { fontSize: 28, fontWeight: "700", marginBottom: 8 }, 82 - overview: { fontSize: 15, marginBottom: 16, lineHeight: 22 }, 83 - grid: { flexDirection: "row", flexWrap: "wrap", gap: 12 }, 84 - seasonCard: { borderWidth: 1, borderRadius: 12, padding: 12, minWidth: 140 }, 85 - seasonText: { fontSize: 16, fontWeight: "600" }, 421 + scrollContent: { 422 + paddingBottom: spacing.xxl, 423 + }, 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: 48, 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 + 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 + paddingHorizontal: spacing.md, 514 + paddingVertical: 6, 515 + }, 516 + metaPillText: { 517 + fontSize: 13, 518 + }, 519 + section: { 520 + marginTop: spacing.sm, 521 + }, 522 + sectionTitle: { 523 + fontSize: 18, 524 + fontWeight: "600", 525 + marginBottom: spacing.md, 526 + }, 527 + overview: { 528 + fontSize: 15, 529 + lineHeight: 22, 530 + }, 531 + genresContainer: { 532 + flexDirection: "row", 533 + flexWrap: "wrap", 534 + gap: spacing.sm, 535 + }, 536 + genreBadge: { 537 + paddingHorizontal: spacing.md, 538 + paddingVertical: spacing.sm, 539 + borderRadius: borderRadius.full, 540 + borderWidth: 1, 541 + }, 542 + genreText: { 543 + fontSize: 14, 544 + fontWeight: "500", 545 + }, 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", 562 + }, 563 + castContainer: { 564 + position: "relative", 565 + }, 566 + castScrollContent: { 567 + gap: 12, 568 + }, 569 + castGradient: { 570 + position: "absolute", 571 + right: 0, 572 + top: 0, 573 + bottom: 16, 574 + width: 48, 575 + pointerEvents: "none", 576 + }, 577 + castCard: { 578 + width: 100, 579 + }, 580 + castImageContainer: { 581 + borderRadius: borderRadius.md, 582 + overflow: "hidden", 583 + marginBottom: 8, 584 + }, 585 + castImage: { 586 + width: 100, 587 + height: 140, 588 + }, 589 + castImagePlaceholder: { 590 + width: 100, 591 + height: 140, 592 + justifyContent: "center", 593 + alignItems: "center", 594 + }, 595 + castImagePlaceholderText: { 596 + fontSize: 12, 597 + textAlign: "center", 598 + paddingHorizontal: 8, 599 + }, 600 + castName: { 601 + fontSize: 13, 602 + fontWeight: "500", 603 + marginBottom: 2, 604 + }, 605 + castCharacter: { 606 + fontSize: 11, 607 + }, 608 + crewGrid: { 609 + flexDirection: "row", 610 + flexWrap: "wrap", 611 + gap: 8, 612 + }, 613 + crewCard: { 614 + padding: spacing.md, 615 + borderRadius: borderRadius.md, 616 + flex: 1, 617 + minWidth: "45%", 618 + }, 619 + crewName: { 620 + fontSize: 14, 621 + fontWeight: "500", 622 + marginBottom: 2, 623 + }, 624 + crewJob: { 625 + fontSize: 12, 626 + }, 86 627 });
+1419 -68
apps/mobile/app/show/[id]/season/[seasonNumber]/episode/[episodeNumber]/index.tsx
··· 1 + import { Ionicons } from "@expo/vector-icons"; 1 2 import { 2 3 authControllerMeOptions, 4 + type EpisodeHistoryItemDto, 5 + listsControllerGetListsForItemOptions, 6 + showsControllerDeleteEpisodeWatchHistoryEntryMutation, 3 7 showsControllerGetEpisodeDetailsOptions, 8 + showsControllerGetSeasonDetailsOptions, 9 + showsControllerGetShowDetailsOptions, 4 10 showsControllerGetShowWatchHistoryOptions, 11 + showsControllerGetShowWatchHistoryQueryKey, 12 + showsControllerGetUserShowsQueryKey, 5 13 showsControllerMarkWatchedMutation, 6 14 showsControllerUnmarkWatchedMutation, 7 15 type TmdbEpisodeDto, 16 + type TmdbSeasonDetailDto, 17 + usersControllerGetMySettingsOptions, 8 18 } from "@opnshelf/api"; 9 19 import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; 10 - import { useLocalSearchParams } from "expo-router"; 11 - import { StyleSheet, Text, View } from "react-native"; 20 + import { Image } from "expo-image"; 21 + import { LinearGradient } from "expo-linear-gradient"; 22 + import { useLocalSearchParams, useRouter } from "expo-router"; 23 + import { useMemo, useState } from "react"; 24 + import { 25 + ActivityIndicator, 26 + Modal, 27 + Pressable, 28 + ScrollView, 29 + Share, 30 + StyleSheet, 31 + Text, 32 + TouchableOpacity, 33 + View, 34 + } from "react-native"; 35 + import { DatePickerModal, TimePickerModal } from "react-native-paper-dates"; 12 36 import { SafeAreaView } from "react-native-safe-area-context"; 37 + import { AddToListModal } from "@/components/AddToListModal"; 13 38 import { Button } from "@/components/ui/Button"; 39 + import { borderRadius, spacing } from "@/constants/spacing"; 14 40 import { useTheme } from "@/contexts/theme"; 15 41 import { useToast } from "@/contexts/toast"; 42 + import { 43 + getTmdbBackdropUrl, 44 + getTmdbPosterUrl, 45 + getTmdbProfileUrl, 46 + } from "@/lib/utils"; 47 + 48 + function formatWatchDate( 49 + dateString: string, 50 + timezone: string, 51 + is24Hour: boolean, 52 + ): string { 53 + return new Date(dateString).toLocaleString("en-US", { 54 + year: "numeric", 55 + month: "short", 56 + day: "numeric", 57 + hour: "2-digit", 58 + minute: "2-digit", 59 + hour12: !is24Hour, 60 + timeZone: timezone, 61 + }); 62 + } 63 + 64 + function formatDateOnly(dateString?: string): string { 65 + if (!dateString) return "Unknown"; 66 + return new Date(dateString).toLocaleDateString("en-US", { 67 + year: "numeric", 68 + month: "short", 69 + day: "numeric", 70 + }); 71 + } 16 72 17 73 export default function ShowEpisodeScreen() { 18 - const { id, seasonNumber, episodeNumber } = useLocalSearchParams<{ 74 + const { id, seasonNumber, episodeNumber, title } = useLocalSearchParams<{ 19 75 id: string; 20 76 seasonNumber: string; 21 77 episodeNumber: string; 78 + title?: string; 22 79 }>(); 80 + const router = useRouter(); 23 81 const { colors } = useTheme(); 24 82 const { showToast } = useToast(); 25 83 const queryClient = useQueryClient(); 26 84 85 + const [showDateModal, setShowDateModal] = useState(false); 86 + const [showDatePicker, setShowDatePicker] = useState(false); 87 + const [showTimePicker, setShowTimePicker] = useState(false); 88 + const [customDate, setCustomDate] = useState(new Date()); 89 + const [showAddToListModal, setShowAddToListModal] = useState(false); 90 + const [showHistoryModal, setShowHistoryModal] = useState(false); 91 + 27 92 const { data: user } = useQuery({ 28 93 ...authControllerMeOptions(), 29 94 staleTime: 5 * 60 * 1000, 30 95 retry: false, 31 96 }); 97 + const resolvedUserDid = user?.did || ""; 98 + 99 + const { data: showData } = useQuery({ 100 + ...showsControllerGetShowDetailsOptions({ 101 + path: { showId: id }, 102 + }), 103 + }); 32 104 33 105 const { data } = useQuery({ 34 106 ...showsControllerGetEpisodeDetailsOptions({ ··· 37 109 }); 38 110 const episode = data as TmdbEpisodeDto | undefined; 39 111 112 + const { data: seasonData } = useQuery({ 113 + ...showsControllerGetSeasonDetailsOptions({ 114 + path: { showId: id, seasonNumber }, 115 + }), 116 + }); 117 + const season = seasonData as TmdbSeasonDetailDto | undefined; 118 + 40 119 const { data: history } = useQuery({ 41 120 ...showsControllerGetShowWatchHistoryOptions({ 42 - path: { userDid: user?.did || "", showId: id }, 121 + path: { userDid: resolvedUserDid, showId: id }, 43 122 }), 44 - enabled: !!user?.did, 123 + enabled: !!resolvedUserDid, 45 124 }); 46 125 47 - const watchedCount = 48 - history?.filter( 49 - (h) => 50 - h.seasonNumber === Number(seasonNumber) && 51 - h.episodeNumber === Number(episodeNumber), 52 - ).length || 0; 126 + const { data: userSettings } = useQuery({ 127 + ...usersControllerGetMySettingsOptions(), 128 + enabled: !!resolvedUserDid, 129 + }); 130 + 131 + const { data: listsForShow } = useQuery({ 132 + ...listsControllerGetListsForItemOptions({ 133 + path: { mediaType: "show", mediaId: id }, 134 + }), 135 + enabled: !!resolvedUserDid, 136 + }); 137 + 138 + const showColors = showData?.colors || { 139 + primary: colors.primary, 140 + secondary: colors.secondary, 141 + accent: colors.tertiary, 142 + muted: colors.surfaceContainer, 143 + }; 144 + const backdropUrl = getTmdbBackdropUrl( 145 + episode?.still_path || showData?.backdrop_path, 146 + ); 147 + const posterUrl = getTmdbPosterUrl(showData?.poster_path, "w500"); 148 + 149 + const userTimezone = userSettings?.timezone || "UTC"; 150 + const is24Hour = userSettings?.timeFormat === "24h"; 151 + const listsCount = listsForShow?.filter((list) => list.isInList).length ?? 0; 152 + const isInAnyList = listsCount > 0; 153 + 154 + const episodeWatchHistory = useMemo(() => { 155 + if (!history?.length) return []; 156 + return history 157 + .filter( 158 + (item) => 159 + item.seasonNumber === Number(seasonNumber) && 160 + item.episodeNumber === Number(episodeNumber), 161 + ) 162 + .sort( 163 + (a, b) => 164 + new Date(b.watchedDate).getTime() - new Date(a.watchedDate).getTime(), 165 + ); 166 + }, [history, seasonNumber, episodeNumber]); 167 + const latestEpisodeWatch = episodeWatchHistory[0] || null; 168 + const watchedCount = episodeWatchHistory.length; 169 + const isWatchedEpisode = watchedCount > 0; 170 + 171 + const seasonEpisodeContext = useMemo(() => { 172 + if (!season?.episodes?.length) { 173 + return { previous: null, current: null, next: null }; 174 + } 175 + 176 + const sortedEpisodes = [...season.episodes].sort( 177 + (a, b) => a.episode_number - b.episode_number, 178 + ); 179 + const currentIndex = sortedEpisodes.findIndex( 180 + (item) => item.episode_number === Number(episodeNumber), 181 + ); 182 + if (currentIndex < 0) { 183 + return { previous: null, current: null, next: null }; 184 + } 185 + 186 + return { 187 + previous: sortedEpisodes[currentIndex - 1] ?? null, 188 + current: sortedEpisodes[currentIndex] ?? null, 189 + next: sortedEpisodes[currentIndex + 1] ?? null, 190 + }; 191 + }, [season?.episodes, episodeNumber]); 53 192 54 193 const markMutation = useMutation({ 55 194 ...showsControllerMarkWatchedMutation(), 56 195 onSuccess: () => { 57 - showToast("Episode marked watched", "success"); 58 - queryClient.invalidateQueries(); 196 + queryClient.invalidateQueries({ 197 + queryKey: showsControllerGetUserShowsQueryKey({ 198 + path: { userDid: resolvedUserDid }, 199 + }), 200 + }); 201 + queryClient.invalidateQueries({ 202 + queryKey: showsControllerGetShowWatchHistoryQueryKey({ 203 + path: { userDid: resolvedUserDid, showId: id }, 204 + }), 205 + }); 206 + setShowDateModal(false); 207 + showToast("Added to your shelf", "success"); 59 208 }, 60 209 onError: () => { 61 - showToast("Failed to mark watched", "error"); 210 + showToast("Failed to add. Please try again.", "error"); 62 211 }, 63 212 }); 64 213 65 214 const unmarkMutation = useMutation({ 66 215 ...showsControllerUnmarkWatchedMutation(), 67 216 onSuccess: () => { 68 - showToast("Episode unmarked", "success"); 69 - queryClient.invalidateQueries(); 217 + queryClient.invalidateQueries({ 218 + queryKey: showsControllerGetUserShowsQueryKey({ 219 + path: { userDid: resolvedUserDid }, 220 + }), 221 + }); 222 + queryClient.invalidateQueries({ 223 + queryKey: showsControllerGetShowWatchHistoryQueryKey({ 224 + path: { userDid: resolvedUserDid, showId: id }, 225 + }), 226 + }); 227 + showToast("Removed from your shelf", "success"); 228 + }, 229 + onError: () => { 230 + showToast("Failed to remove from shelf. Please try again.", "error"); 231 + }, 232 + }); 233 + 234 + const deleteWatchEntryMutation = useMutation({ 235 + ...showsControllerDeleteEpisodeWatchHistoryEntryMutation(), 236 + onSuccess: () => { 237 + queryClient.invalidateQueries({ 238 + queryKey: showsControllerGetUserShowsQueryKey({ 239 + path: { userDid: resolvedUserDid }, 240 + }), 241 + }); 242 + queryClient.invalidateQueries({ 243 + queryKey: showsControllerGetShowWatchHistoryQueryKey({ 244 + path: { userDid: resolvedUserDid, showId: id }, 245 + }), 246 + }); 247 + showToast("Watch entry removed", "success"); 70 248 }, 71 249 onError: () => { 72 - showToast("Failed to unmark", "error"); 250 + showToast("Failed to remove watch entry. Please try again.", "error"); 73 251 }, 74 252 }); 75 253 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 + const handleMarkWatched = () => { 261 + markMutation.mutate({ 262 + body: { 263 + showId: id, 264 + seasonNumber: Number(seasonNumber), 265 + episodeNumber: Number(episodeNumber), 266 + }, 267 + }); 268 + }; 269 + 270 + const handleMarkWatchedWithDate = () => { 271 + markMutation.mutate({ 272 + body: { 273 + showId: id, 274 + seasonNumber: Number(seasonNumber), 275 + episodeNumber: Number(episodeNumber), 276 + watchedAt: customDate.toISOString(), 277 + }, 278 + }); 279 + }; 280 + 281 + const handleUnmarkWatched = () => { 282 + unmarkMutation.mutate({ 283 + path: { showId: id }, 284 + query: { 285 + mode: "all", 286 + seasonNumber, 287 + episodeNumber, 288 + }, 289 + }); 290 + }; 291 + 292 + const handleShare = async () => { 293 + try { 294 + 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}`, 297 + }); 298 + } catch { 299 + showToast("Failed to share", "error"); 300 + } 301 + }; 302 + 303 + const handleOpenDateModal = () => { 304 + setCustomDate(new Date()); 305 + setShowDateModal(true); 306 + }; 307 + 308 + const navigateToEpisode = (targetEpisode: TmdbEpisodeDto) => { 309 + router.push({ 310 + pathname: "/show/[id]/season/[seasonNumber]/episode/[episodeNumber]", 311 + params: { 312 + id, 313 + seasonNumber, 314 + episodeNumber: String(targetEpisode.episode_number), 315 + title: title || "", 316 + }, 317 + }); 318 + }; 319 + 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 + ]; 349 + 76 350 return ( 77 - <SafeAreaView 78 - style={[styles.container, { backgroundColor: colors.background }]} 79 - > 80 - <View style={styles.content}> 81 - <Text style={[styles.title, { color: colors.onBackground }]}> 82 - Episode {episodeNumber} 83 - </Text> 84 - <Text style={[styles.subtitle, { color: colors.onSurface }]}> 85 - {episode?.name} 86 - </Text> 87 - <Text style={[styles.overview, { color: colors.onSurfaceVariant }]}> 88 - {episode?.overview || "No overview available."} 89 - </Text> 90 - <Text style={[styles.count, { color: colors.onSurfaceVariant }]}> 91 - Times watched: {watchedCount} 92 - </Text> 93 - <View style={styles.actions}> 94 - <Button 95 - onPress={() => 96 - markMutation.mutate({ 97 - body: { 98 - showId: id, 99 - seasonNumber: Number(seasonNumber), 100 - episodeNumber: Number(episodeNumber), 101 - }, 102 - }) 103 - } 351 + <> 352 + <SafeAreaView 353 + style={[styles.container, { backgroundColor: colors.background }]} 354 + > 355 + <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} 380 + /> 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 } }) 396 + } 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> 443 + 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 + /> 500 + <Text 501 + style={[styles.metaText, { color: colors.onSurfaceVariant }]} 502 + > 503 + {formatDateOnly(episode?.air_date)} 504 + </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 + <TouchableOpacity 538 + onPress={handleMarkWatched} 539 + disabled={isPending} 540 + activeOpacity={0.8} 541 + style={[ 542 + styles.primaryAction, 543 + { 544 + backgroundColor: colors.primary, 545 + opacity: isPending ? 0.7 : 1, 546 + }, 547 + ]} 548 + > 549 + {isPending ? ( 550 + <ActivityIndicator 551 + size="small" 552 + color={colors.onPrimary} 553 + /> 554 + ) : ( 555 + <Ionicons 556 + name={isWatchedEpisode ? "refresh" : "add"} 557 + size={18} 558 + color={colors.onPrimary} 559 + /> 560 + )} 561 + <Text 562 + style={[ 563 + styles.primaryActionText, 564 + { color: colors.onPrimary }, 565 + ]} 566 + > 567 + {isWatchedEpisode ? "Watch Again" : "Add to Shelf"} 568 + </Text> 569 + </TouchableOpacity> 570 + 571 + <TouchableOpacity 572 + onPress={handleOpenDateModal} 573 + activeOpacity={0.8} 574 + style={[ 575 + styles.secondaryAction, 576 + { 577 + backgroundColor: colors.surfaceContainer, 578 + borderColor: colors.outline, 579 + }, 580 + ]} 581 + > 582 + <Ionicons 583 + name="calendar-outline" 584 + size={18} 585 + color={colors.onSurfaceVariant} 586 + /> 587 + <Text 588 + style={[ 589 + styles.secondaryActionText, 590 + { color: colors.onSurfaceVariant }, 591 + ]} 592 + > 593 + Watch on different date 594 + </Text> 595 + </TouchableOpacity> 596 + 597 + <TouchableOpacity 598 + onPress={() => setShowAddToListModal(true)} 599 + activeOpacity={0.8} 600 + style={[ 601 + styles.secondaryAction, 602 + { 603 + backgroundColor: isInAnyList 604 + ? `${colors.primary}20` 605 + : colors.surfaceContainer, 606 + borderColor: isInAnyList 607 + ? colors.primary 608 + : colors.outline, 609 + }, 610 + ]} 611 + > 612 + <Ionicons 613 + name={isInAnyList ? "checkmark" : "list-outline"} 614 + size={18} 615 + color={ 616 + isInAnyList ? colors.primary : colors.onSurfaceVariant 617 + } 618 + /> 619 + <Text 620 + style={[ 621 + styles.secondaryActionText, 622 + { 623 + color: isInAnyList 624 + ? colors.primary 625 + : colors.onSurfaceVariant, 626 + }, 627 + ]} 628 + > 629 + {isInAnyList 630 + ? `In ${listsCount} list${listsCount > 1 ? "s" : ""}` 631 + : "Add to List"} 632 + </Text> 633 + </TouchableOpacity> 634 + </> 635 + ) : ( 636 + <Button onPress={() => router.push("/login")}> 637 + <Text style={{ color: colors.onPrimary }}> 638 + Sign in to Track 639 + </Text> 640 + </Button> 641 + )} 642 + 643 + <TouchableOpacity 644 + onPress={handleShare} 645 + activeOpacity={0.8} 646 + style={[ 647 + styles.secondaryAction, 648 + { 649 + backgroundColor: colors.surfaceContainer, 650 + borderColor: colors.outline, 651 + }, 652 + ]} 653 + > 654 + <Ionicons 655 + name="share-outline" 656 + size={18} 657 + color={colors.onSurfaceVariant} 658 + /> 659 + <Text 660 + style={[ 661 + styles.secondaryActionText, 662 + { color: colors.onSurfaceVariant }, 663 + ]} 664 + > 665 + Share 666 + </Text> 667 + </TouchableOpacity> 668 + </View> 669 + 670 + {isWatchedEpisode && ( 671 + <View 672 + style={[ 673 + styles.watchedCard, 674 + { 675 + backgroundColor: colors.surfaceContainer, 676 + borderColor: colors.outline, 677 + }, 678 + ]} 679 + > 680 + <View style={styles.watchedHeader}> 681 + <Ionicons 682 + name="checkmark-circle" 683 + size={20} 684 + color={colors.primary} 685 + /> 686 + <Text 687 + style={[styles.watchedTitle, { color: colors.primary }]} 688 + > 689 + On Your Shelf 690 + </Text> 691 + </View> 692 + {latestEpisodeWatch && ( 693 + <Text 694 + style={[ 695 + styles.watchedDate, 696 + { color: colors.onSurfaceVariant }, 697 + ]} 698 + > 699 + Watched on{" "} 700 + {formatWatchDate( 701 + latestEpisodeWatch.watchedDate, 702 + userTimezone, 703 + is24Hour, 704 + )} 705 + </Text> 706 + )} 707 + {watchedCount > 1 ? ( 708 + <TouchableOpacity 709 + onPress={() => setShowHistoryModal(true)} 710 + activeOpacity={0.7} 711 + style={styles.linkRow} 712 + > 713 + <Ionicons 714 + name="eye-outline" 715 + size={16} 716 + color={colors.onSurfaceVariant} 717 + /> 718 + <Text 719 + style={[ 720 + styles.linkText, 721 + { color: colors.onSurfaceVariant }, 722 + ]} 723 + > 724 + View all watches ({watchedCount}) 725 + </Text> 726 + </TouchableOpacity> 727 + ) : ( 728 + <TouchableOpacity 729 + onPress={handleUnmarkWatched} 730 + disabled={unmarkMutation.isPending} 731 + activeOpacity={0.7} 732 + style={styles.linkRow} 733 + > 734 + {unmarkMutation.isPending ? ( 735 + <ActivityIndicator size="small" color={colors.error} /> 736 + ) : ( 737 + <Ionicons 738 + name="trash-outline" 739 + size={16} 740 + color={colors.error} 741 + /> 742 + )} 743 + <Text style={[styles.linkText, { color: colors.error }]}> 744 + Remove from shelf 745 + </Text> 746 + </TouchableOpacity> 747 + )} 748 + </View> 749 + )} 750 + 751 + {seasonEpisodeContext.current ? ( 752 + <View style={styles.contextSection}> 753 + <Text 754 + style={[styles.sectionTitle, { color: colors.onSurface }]} 755 + > 756 + More In This Season 757 + </Text> 758 + <View style={styles.contextList}> 759 + {contextCards.map((slot) => { 760 + if (!slot.episode) return null; 761 + return ( 762 + <TouchableOpacity 763 + key={slot.key} 764 + onPress={() => 765 + navigateToEpisode(slot.episode as TmdbEpisodeDto) 766 + } 767 + activeOpacity={0.8} 768 + style={[ 769 + styles.contextCard, 770 + { 771 + backgroundColor: slot.highlighted 772 + ? `${colors.primary}20` 773 + : colors.surfaceContainer, 774 + borderColor: slot.highlighted 775 + ? colors.primary 776 + : colors.outline, 777 + }, 778 + ]} 779 + > 780 + <View style={styles.contextLabelRow}> 781 + <Ionicons 782 + name={slot.iconName} 783 + size={14} 784 + color={colors.onSurfaceVariant} 785 + /> 786 + <Text 787 + style={[ 788 + styles.contextLabel, 789 + { color: colors.onSurfaceVariant }, 790 + ]} 791 + > 792 + {slot.label} 793 + </Text> 794 + </View> 795 + <Text 796 + style={[ 797 + styles.contextTitle, 798 + { color: colors.onSurface }, 799 + ]} 800 + numberOfLines={1} 801 + > 802 + E{slot.episode.episode_number}: {slot.episode.name} 803 + </Text> 804 + <Text 805 + style={[ 806 + styles.contextDate, 807 + { color: colors.onSurfaceVariant }, 808 + ]} 809 + > 810 + {formatDateOnly(slot.episode.air_date)} 811 + </Text> 812 + </TouchableOpacity> 813 + ); 814 + })} 815 + </View> 816 + </View> 817 + ) : null} 818 + 819 + {showData?.credits?.cast && showData.credits.cast.length > 0 ? ( 820 + <View style={styles.section}> 821 + <Text 822 + style={[ 823 + styles.sectionTitle, 824 + { color: showColors.primary || colors.primary }, 825 + ]} 826 + > 827 + Cast 828 + </Text> 829 + <View style={styles.castContainer}> 830 + <ScrollView 831 + horizontal 832 + showsHorizontalScrollIndicator={false} 833 + contentContainerStyle={styles.castScrollContent} 834 + > 835 + {showData.credits.cast.map((person) => { 836 + const profileUrl = getTmdbProfileUrl(person.profile_path); 837 + return ( 838 + <TouchableOpacity 839 + key={person.id} 840 + style={styles.castCard} 841 + activeOpacity={0.8} 842 + > 843 + <View style={styles.castImageContainer}> 844 + {profileUrl ? ( 845 + <Image 846 + source={{ uri: profileUrl }} 847 + style={styles.castImage} 848 + contentFit="cover" 849 + /> 850 + ) : ( 851 + <View style={styles.castImagePlaceholder}> 852 + <Text style={styles.castImagePlaceholderText}> 853 + No photo 854 + </Text> 855 + </View> 856 + )} 857 + </View> 858 + <Text style={styles.castName} numberOfLines={2}> 859 + {person.name} 860 + </Text> 861 + {person.character ? ( 862 + <Text 863 + style={styles.castCharacter} 864 + numberOfLines={2} 865 + > 866 + as {person.character} 867 + </Text> 868 + ) : null} 869 + </TouchableOpacity> 870 + ); 871 + })} 872 + </ScrollView> 873 + <LinearGradient 874 + colors={["rgba(3, 7, 18, 0)", "rgba(3, 7, 18, 1)"]} 875 + start={{ x: 0, y: 0.5 }} 876 + end={{ x: 1, y: 0.5 }} 877 + style={styles.castGradient} 878 + /> 879 + </View> 880 + </View> 881 + ) : null} 882 + 883 + {showData?.credits?.crew && showData.credits.crew.length > 0 ? ( 884 + <View style={styles.section}> 885 + <Text 886 + style={[ 887 + styles.sectionTitle, 888 + { color: showColors.primary || colors.primary }, 889 + ]} 890 + > 891 + Crew 892 + </Text> 893 + <View style={styles.crewGrid}> 894 + {showData.credits.crew.map((person) => ( 895 + <TouchableOpacity 896 + key={`${person.id}-${person.job || "crew"}`} 897 + style={styles.crewCard} 898 + activeOpacity={0.8} 899 + > 900 + <Text style={styles.crewName} numberOfLines={1}> 901 + {person.name} 902 + </Text> 903 + <Text style={styles.crewJob} numberOfLines={1}> 904 + {person.job || person.department || "Crew"} 905 + </Text> 906 + </TouchableOpacity> 907 + ))} 908 + </View> 909 + </View> 910 + ) : null} 911 + </View> 912 + </ScrollView> 913 + </SafeAreaView> 914 + 915 + <Modal 916 + visible={showDateModal} 917 + animationType="fade" 918 + transparent={true} 919 + onRequestClose={() => setShowDateModal(false)} 920 + > 921 + <View style={styles.modalOverlay}> 922 + <View 923 + style={[ 924 + styles.modalContent, 925 + { backgroundColor: colors.surfaceContainerHigh }, 926 + ]} 104 927 > 105 - <Text style={{ color: colors.onPrimary }}>Mark watched</Text> 106 - </Button> 107 - <Button 108 - variant="outlined" 109 - onPress={() => 110 - unmarkMutation.mutate({ 111 - path: { showId: id }, 112 - query: { 113 - mode: "all", 114 - seasonNumber, 115 - episodeNumber, 116 - }, 117 - }) 118 - } 928 + <View style={styles.modalHeader}> 929 + <Text style={[styles.modalTitle, { color: colors.onSurface }]}> 930 + Watch Again 931 + </Text> 932 + <Pressable onPress={() => setShowDateModal(false)}> 933 + <Ionicons name="close" size={24} color={colors.onSurface} /> 934 + </Pressable> 935 + </View> 936 + <Text 937 + style={[ 938 + styles.modalDescription, 939 + { color: colors.onSurfaceVariant }, 940 + ]} 941 + > 942 + When did you watch this? 943 + </Text> 944 + 945 + <View style={styles.dateTimeContainer}> 946 + <TouchableOpacity 947 + onPress={() => setShowDatePicker(true)} 948 + style={styles.dateTimeButton} 949 + activeOpacity={0.7} 950 + > 951 + <Ionicons 952 + name="calendar-outline" 953 + size={20} 954 + color={colors.onSurfaceVariant} 955 + /> 956 + <Text 957 + style={[styles.dateTimeText, { color: colors.onSurface }]} 958 + > 959 + {customDate.toLocaleDateString("en-US", { 960 + year: "numeric", 961 + month: "short", 962 + day: "numeric", 963 + })} 964 + </Text> 965 + </TouchableOpacity> 966 + 967 + <TouchableOpacity 968 + onPress={() => setShowTimePicker(true)} 969 + style={styles.dateTimeButton} 970 + activeOpacity={0.7} 971 + > 972 + <Ionicons 973 + name="time-outline" 974 + size={20} 975 + color={colors.onSurfaceVariant} 976 + /> 977 + <Text 978 + style={[styles.dateTimeText, { color: colors.onSurface }]} 979 + > 980 + {customDate.toLocaleTimeString("en-US", { 981 + hour: "2-digit", 982 + minute: "2-digit", 983 + hour12: !is24Hour, 984 + })} 985 + </Text> 986 + </TouchableOpacity> 987 + </View> 988 + 989 + <DatePickerModal 990 + visible={showDatePicker} 991 + mode="single" 992 + date={customDate} 993 + locale="en" 994 + onDismiss={() => setShowDatePicker(false)} 995 + onConfirm={(params) => { 996 + setShowDatePicker(false); 997 + if (params.date) { 998 + const newDate = new Date(customDate); 999 + newDate.setFullYear(params.date.getFullYear()); 1000 + newDate.setMonth(params.date.getMonth()); 1001 + newDate.setDate(params.date.getDate()); 1002 + setCustomDate(newDate); 1003 + setShowTimePicker(true); 1004 + } 1005 + }} 1006 + /> 1007 + 1008 + <TimePickerModal 1009 + visible={showTimePicker} 1010 + hours={customDate.getHours()} 1011 + minutes={customDate.getMinutes()} 1012 + locale="en" 1013 + use24HourClock={is24Hour} 1014 + onDismiss={() => setShowTimePicker(false)} 1015 + onConfirm={(params) => { 1016 + const newDate = new Date(customDate); 1017 + newDate.setHours(params.hours); 1018 + newDate.setMinutes(params.minutes); 1019 + setCustomDate(newDate); 1020 + setShowTimePicker(false); 1021 + }} 1022 + /> 1023 + 1024 + <View style={styles.modalActionsSplit}> 1025 + <Button 1026 + variant="outlined" 1027 + onPress={() => setShowDateModal(false)} 1028 + > 1029 + <Text 1030 + style={[ 1031 + styles.modalCancelText, 1032 + { color: colors.onSurfaceVariant }, 1033 + ]} 1034 + > 1035 + Cancel 1036 + </Text> 1037 + </Button> 1038 + <Button 1039 + onPress={handleMarkWatchedWithDate} 1040 + isLoading={markMutation.isPending} 1041 + style={{ backgroundColor: colors.primary }} 1042 + > 1043 + <Text 1044 + style={[styles.modalConfirmText, { color: colors.onPrimary }]} 1045 + > 1046 + Add Watch 1047 + </Text> 1048 + </Button> 1049 + </View> 1050 + </View> 1051 + </View> 1052 + </Modal> 1053 + 1054 + <Modal 1055 + visible={showHistoryModal} 1056 + animationType="fade" 1057 + transparent={true} 1058 + onRequestClose={() => setShowHistoryModal(false)} 1059 + > 1060 + <View style={styles.modalOverlay}> 1061 + <View 1062 + style={[ 1063 + styles.modalContent, 1064 + { backgroundColor: colors.surfaceContainerHigh }, 1065 + ]} 119 1066 > 120 - <Text style={{ color: colors.onBackground }}>Unmark episode</Text> 121 - </Button> 1067 + <View style={styles.modalHeader}> 1068 + <Text style={[styles.modalTitle, { color: colors.onSurface }]}> 1069 + Watch History 1070 + </Text> 1071 + <Pressable onPress={() => setShowHistoryModal(false)}> 1072 + <Ionicons name="close" size={24} color={colors.onSurface} /> 1073 + </Pressable> 1074 + </View> 1075 + <Text 1076 + style={[ 1077 + styles.modalDescription, 1078 + { color: colors.onSurfaceVariant }, 1079 + ]} 1080 + > 1081 + All watches for this episode 1082 + </Text> 1083 + 1084 + <ScrollView style={styles.historyList}> 1085 + {episodeWatchHistory.length ? ( 1086 + episodeWatchHistory.map((watch: EpisodeHistoryItemDto) => ( 1087 + <View 1088 + key={watch.id} 1089 + style={[ 1090 + styles.historyItem, 1091 + { 1092 + backgroundColor: colors.surfaceContainer, 1093 + borderColor: colors.outline, 1094 + }, 1095 + ]} 1096 + > 1097 + <Text 1098 + style={[styles.historyDate, { color: colors.onSurface }]} 1099 + > 1100 + {formatWatchDate( 1101 + watch.watchedDate, 1102 + userTimezone, 1103 + is24Hour, 1104 + )} 1105 + </Text> 1106 + <TouchableOpacity 1107 + onPress={() => 1108 + deleteWatchEntryMutation.mutate({ 1109 + path: { trackedEpisodeId: watch.id }, 1110 + }) 1111 + } 1112 + disabled={deleteWatchEntryMutation.isPending} 1113 + activeOpacity={0.7} 1114 + > 1115 + {deleteWatchEntryMutation.isPending && 1116 + deleteWatchEntryMutation.variables?.path 1117 + ?.trackedEpisodeId === watch.id ? ( 1118 + <ActivityIndicator 1119 + size="small" 1120 + color={colors.onSurfaceVariant} 1121 + /> 1122 + ) : ( 1123 + <Ionicons 1124 + name="trash-outline" 1125 + size={18} 1126 + color={colors.error} 1127 + /> 1128 + )} 1129 + </TouchableOpacity> 1130 + </View> 1131 + )) 1132 + ) : ( 1133 + <Text 1134 + style={[ 1135 + styles.emptyHistory, 1136 + { color: colors.onSurfaceVariant }, 1137 + ]} 1138 + > 1139 + No watch history found 1140 + </Text> 1141 + )} 1142 + </ScrollView> 1143 + 1144 + <Button 1145 + variant="outlined" 1146 + onPress={() => setShowHistoryModal(false)} 1147 + > 1148 + <Text 1149 + style={[ 1150 + styles.modalCancelText, 1151 + { color: colors.onSurfaceVariant }, 1152 + ]} 1153 + > 1154 + Close 1155 + </Text> 1156 + </Button> 1157 + </View> 122 1158 </View> 123 - </View> 124 - </SafeAreaView> 1159 + </Modal> 1160 + 1161 + <AddToListModal 1162 + visible={showAddToListModal} 1163 + onClose={() => setShowAddToListModal(false)} 1164 + mediaType="show" 1165 + mediaId={id} 1166 + mediaTitle={showData?.name || title || "Show"} 1167 + /> 1168 + </> 125 1169 ); 126 1170 } 127 1171 128 1172 const styles = StyleSheet.create({ 129 1173 container: { flex: 1 }, 130 - content: { padding: 16 }, 131 - title: { fontSize: 26, fontWeight: "700" }, 132 - subtitle: { fontSize: 18, marginTop: 4, marginBottom: 10 }, 133 - overview: { fontSize: 15, lineHeight: 22, marginBottom: 12 }, 134 - count: { fontSize: 14, marginBottom: 16 }, 135 - actions: { gap: 10 }, 1174 + scrollContent: { 1175 + paddingBottom: spacing.xxl, 1176 + }, 1177 + heroWrapper: { 1178 + height: 280, 1179 + position: "relative", 1180 + }, 1181 + backdrop: { 1182 + width: "100%", 1183 + height: "100%", 1184 + }, 1185 + backdropOverlay: { 1186 + ...StyleSheet.absoluteFillObject, 1187 + }, 1188 + backButton: { 1189 + position: "absolute", 1190 + top: 48, 1191 + left: 16, 1192 + zIndex: 10, 1193 + padding: 8, 1194 + borderRadius: borderRadius.full, 1195 + backgroundColor: "rgba(0, 0, 0, 0.5)", 1196 + }, 1197 + heroOverlay: { 1198 + position: "absolute", 1199 + bottom: -52, 1200 + left: 16, 1201 + right: 16, 1202 + flexDirection: "row", 1203 + alignItems: "flex-end", 1204 + }, 1205 + posterWrapper: { 1206 + borderRadius: borderRadius.lg, 1207 + overflow: "hidden", 1208 + shadowOffset: { width: 0, height: 4 }, 1209 + shadowOpacity: 0.35, 1210 + shadowRadius: 8, 1211 + elevation: 8, 1212 + }, 1213 + poster: { 1214 + width: 96, 1215 + height: 144, 1216 + }, 1217 + noPoster: { 1218 + alignItems: "center", 1219 + justifyContent: "center", 1220 + }, 1221 + noPosterText: { 1222 + fontSize: 11, 1223 + }, 1224 + titleWrapper: { 1225 + marginLeft: spacing.md, 1226 + marginBottom: spacing.sm, 1227 + flex: 1, 1228 + }, 1229 + content: { 1230 + marginTop: 80, 1231 + paddingHorizontal: 16, 1232 + gap: spacing.md, 1233 + }, 1234 + title: { 1235 + fontSize: 28, 1236 + fontWeight: "700", 1237 + color: "#f9fafb", 1238 + textShadowOffset: { width: 0, height: 2 }, 1239 + textShadowRadius: 10, 1240 + }, 1241 + subtitle: { fontSize: 17, fontWeight: "700" }, 1242 + heroEpisodeName: { fontSize: 14, marginTop: 2 }, 1243 + metaRow: { 1244 + flexDirection: "row", 1245 + flexWrap: "wrap", 1246 + gap: spacing.sm, 1247 + }, 1248 + metaPill: { 1249 + borderWidth: 1, 1250 + borderRadius: borderRadius.full, 1251 + paddingHorizontal: spacing.sm, 1252 + paddingVertical: 6, 1253 + flexDirection: "row", 1254 + alignItems: "center", 1255 + gap: 6, 1256 + }, 1257 + metaText: { fontSize: 13 }, 1258 + overview: { fontSize: 15, lineHeight: 22 }, 1259 + actions: { 1260 + gap: spacing.sm, 1261 + }, 1262 + primaryAction: { 1263 + borderRadius: borderRadius.md, 1264 + paddingVertical: 14, 1265 + paddingHorizontal: spacing.md, 1266 + alignItems: "center", 1267 + justifyContent: "center", 1268 + flexDirection: "row", 1269 + gap: spacing.xs, 1270 + }, 1271 + primaryActionText: { 1272 + fontSize: 16, 1273 + fontWeight: "600", 1274 + }, 1275 + secondaryAction: { 1276 + borderRadius: borderRadius.md, 1277 + borderWidth: 1, 1278 + paddingVertical: 12, 1279 + paddingHorizontal: spacing.md, 1280 + alignItems: "center", 1281 + justifyContent: "center", 1282 + flexDirection: "row", 1283 + gap: spacing.xs, 1284 + }, 1285 + secondaryActionText: { 1286 + fontSize: 15, 1287 + fontWeight: "500", 1288 + }, 1289 + watchedCard: { 1290 + marginTop: spacing.sm, 1291 + borderRadius: borderRadius.md, 1292 + borderWidth: 1, 1293 + padding: spacing.md, 1294 + gap: spacing.xs, 1295 + }, 1296 + watchedHeader: { 1297 + flexDirection: "row", 1298 + alignItems: "center", 1299 + gap: spacing.xs, 1300 + }, 1301 + watchedTitle: { fontSize: 16, fontWeight: "600" }, 1302 + watchedDate: { fontSize: 14 }, 1303 + linkRow: { 1304 + marginTop: spacing.xs, 1305 + flexDirection: "row", 1306 + alignItems: "center", 1307 + gap: spacing.xs, 1308 + }, 1309 + linkText: { fontSize: 14, fontWeight: "500" }, 1310 + contextSection: { marginTop: spacing.sm, gap: spacing.sm }, 1311 + sectionTitle: { fontSize: 18, fontWeight: "600" }, 1312 + contextList: { gap: spacing.sm }, 1313 + contextCard: { 1314 + borderRadius: borderRadius.md, 1315 + borderWidth: 1, 1316 + padding: spacing.md, 1317 + gap: 6, 1318 + }, 1319 + contextLabelRow: { 1320 + flexDirection: "row", 1321 + alignItems: "center", 1322 + gap: 6, 1323 + }, 1324 + contextLabel: { 1325 + fontSize: 11, 1326 + textTransform: "uppercase", 1327 + letterSpacing: 0.3, 1328 + }, 1329 + contextTitle: { 1330 + fontSize: 15, 1331 + fontWeight: "600", 1332 + }, 1333 + contextDate: { fontSize: 13 }, 1334 + modalOverlay: { 1335 + flex: 1, 1336 + backgroundColor: "rgba(0, 0, 0, 0.7)", 1337 + justifyContent: "center", 1338 + padding: spacing.lg, 1339 + }, 1340 + modalContent: { 1341 + borderRadius: borderRadius.lg, 1342 + padding: spacing.md, 1343 + maxHeight: "80%", 1344 + }, 1345 + modalHeader: { 1346 + flexDirection: "row", 1347 + justifyContent: "space-between", 1348 + alignItems: "center", 1349 + marginBottom: spacing.sm, 1350 + }, 1351 + modalTitle: { 1352 + fontSize: 20, 1353 + fontWeight: "700", 1354 + }, 1355 + modalDescription: { 1356 + fontSize: 14, 1357 + marginBottom: spacing.md, 1358 + }, 1359 + dateTimeContainer: { 1360 + gap: spacing.sm, 1361 + marginBottom: spacing.md, 1362 + }, 1363 + dateTimeButton: { 1364 + padding: spacing.md, 1365 + borderRadius: borderRadius.md, 1366 + backgroundColor: "rgba(255, 255, 255, 0.05)", 1367 + flexDirection: "row", 1368 + alignItems: "center", 1369 + gap: spacing.sm, 1370 + }, 1371 + dateTimeText: { 1372 + fontSize: 15, 1373 + fontWeight: "500", 1374 + }, 1375 + modalActionsSplit: { 1376 + flexDirection: "row", 1377 + gap: spacing.sm, 1378 + justifyContent: "space-between", 1379 + }, 1380 + modalCancelText: { 1381 + fontSize: 14, 1382 + fontWeight: "600", 1383 + }, 1384 + modalConfirmText: { 1385 + fontSize: 14, 1386 + fontWeight: "600", 1387 + }, 1388 + historyList: { 1389 + maxHeight: 320, 1390 + marginBottom: spacing.md, 1391 + }, 1392 + historyItem: { 1393 + padding: spacing.md, 1394 + borderRadius: borderRadius.md, 1395 + borderWidth: 1, 1396 + marginBottom: spacing.sm, 1397 + flexDirection: "row", 1398 + alignItems: "center", 1399 + justifyContent: "space-between", 1400 + gap: spacing.sm, 1401 + }, 1402 + historyDate: { 1403 + fontSize: 14, 1404 + flex: 1, 1405 + }, 1406 + emptyHistory: { 1407 + fontSize: 14, 1408 + textAlign: "center", 1409 + paddingVertical: spacing.xl, 1410 + }, 1411 + section: { 1412 + marginBottom: 24, 1413 + }, 1414 + castContainer: { 1415 + position: "relative", 1416 + }, 1417 + castScrollContent: { 1418 + paddingRight: 16, 1419 + gap: 12, 1420 + }, 1421 + castGradient: { 1422 + position: "absolute", 1423 + right: 0, 1424 + top: 0, 1425 + bottom: 16, 1426 + width: 48, 1427 + pointerEvents: "none", 1428 + }, 1429 + castCard: { 1430 + width: 100, 1431 + }, 1432 + castImageContainer: { 1433 + borderRadius: borderRadius.md, 1434 + overflow: "hidden", 1435 + marginBottom: 8, 1436 + backgroundColor: "#1f2937", 1437 + }, 1438 + castImage: { 1439 + width: 100, 1440 + height: 140, 1441 + }, 1442 + castImagePlaceholder: { 1443 + width: 100, 1444 + height: 140, 1445 + backgroundColor: "#1f2937", 1446 + justifyContent: "center", 1447 + alignItems: "center", 1448 + }, 1449 + castImagePlaceholderText: { 1450 + fontSize: 12, 1451 + color: "#6b7280", 1452 + textAlign: "center", 1453 + paddingHorizontal: 8, 1454 + }, 1455 + castName: { 1456 + fontSize: 13, 1457 + fontWeight: "500", 1458 + color: "#e5e7eb", 1459 + marginBottom: 2, 1460 + }, 1461 + castCharacter: { 1462 + fontSize: 11, 1463 + color: "#6b7280", 1464 + }, 1465 + crewGrid: { 1466 + flexDirection: "row", 1467 + flexWrap: "wrap", 1468 + gap: 8, 1469 + }, 1470 + crewCard: { 1471 + backgroundColor: "#111827", 1472 + borderRadius: borderRadius.md, 1473 + padding: 12, 1474 + flex: 1, 1475 + minWidth: "45%", 1476 + }, 1477 + crewName: { 1478 + fontSize: 14, 1479 + fontWeight: "500", 1480 + color: "#e5e7eb", 1481 + marginBottom: 2, 1482 + }, 1483 + crewJob: { 1484 + fontSize: 12, 1485 + color: "#6b7280", 1486 + }, 136 1487 });
+681 -37
apps/mobile/app/show/[id]/season/[seasonNumber]/index.tsx
··· 1 + import { Ionicons } from "@expo/vector-icons"; 1 2 import { 3 + authControllerMeOptions, 2 4 showsControllerGetSeasonDetailsOptions, 5 + showsControllerGetShowDetailsOptions, 6 + showsControllerGetShowWatchHistoryOptions, 7 + type TmdbEpisodeDto, 3 8 type TmdbSeasonDetailDto, 9 + type TmdbShowDetailDto, 4 10 } from "@opnshelf/api"; 5 11 import { useQuery } from "@tanstack/react-query"; 12 + import { Image } from "expo-image"; 13 + import { LinearGradient } from "expo-linear-gradient"; 6 14 import { useLocalSearchParams, useRouter } from "expo-router"; 15 + import { useMemo } from "react"; 7 16 import { 8 17 ScrollView, 9 18 StyleSheet, ··· 12 21 View, 13 22 } from "react-native"; 14 23 import { SafeAreaView } from "react-native-safe-area-context"; 24 + import { borderRadius, spacing } from "@/constants/spacing"; 15 25 import { useTheme } from "@/contexts/theme"; 26 + import { 27 + getTmdbBackdropUrl, 28 + getTmdbPosterUrl, 29 + getTmdbProfileUrl, 30 + } from "@/lib/utils"; 31 + 32 + function formatDateOnly(dateString?: string): string { 33 + if (!dateString) return "Unknown"; 34 + return new Date(dateString).toLocaleDateString("en-US", { 35 + year: "numeric", 36 + month: "short", 37 + day: "numeric", 38 + }); 39 + } 16 40 17 41 export default function ShowSeasonScreen() { 18 42 const { id, seasonNumber, title } = useLocalSearchParams<{ ··· 23 47 const router = useRouter(); 24 48 const { colors } = useTheme(); 25 49 50 + const { data: user } = useQuery({ 51 + ...authControllerMeOptions(), 52 + staleTime: 5 * 60 * 1000, 53 + retry: false, 54 + }); 55 + const resolvedUserDid = user?.did || ""; 56 + 57 + const { data: showData } = useQuery({ 58 + ...showsControllerGetShowDetailsOptions({ 59 + path: { showId: id }, 60 + }), 61 + }); 62 + const show = showData as TmdbShowDetailDto | undefined; 63 + 26 64 const { data } = useQuery({ 27 65 ...showsControllerGetSeasonDetailsOptions({ 28 66 path: { showId: id, seasonNumber }, ··· 30 68 }); 31 69 const season = data as TmdbSeasonDetailDto | undefined; 32 70 71 + const { data: history } = useQuery({ 72 + ...showsControllerGetShowWatchHistoryOptions({ 73 + path: { userDid: resolvedUserDid, showId: id }, 74 + }), 75 + enabled: !!resolvedUserDid, 76 + }); 77 + 78 + const showColors = show?.colors || { 79 + primary: colors.primary, 80 + secondary: colors.secondary, 81 + accent: colors.tertiary, 82 + muted: colors.surfaceContainer, 83 + }; 84 + 85 + const backdropUrl = getTmdbBackdropUrl(show?.backdrop_path); 86 + const posterUrl = season?.poster_path 87 + ? getTmdbPosterUrl(season.poster_path, "w500") 88 + : getTmdbPosterUrl(show?.poster_path, "w500"); 89 + 90 + const episodeWatchCounts = useMemo(() => { 91 + if (!history?.length) return new Map<number, number>(); 92 + 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); 97 + } 98 + } 99 + return counts; 100 + }, [history, seasonNumber]); 101 + 33 102 return ( 34 103 <SafeAreaView 35 104 style={[styles.container, { backgroundColor: colors.background }]} 36 105 > 37 - <ScrollView contentContainerStyle={styles.content}> 38 - <Text style={[styles.title, { color: colors.onBackground }]}> 39 - {title} 40 - </Text> 41 - <Text style={[styles.subtitle, { color: colors.onSurfaceVariant }]}> 42 - Season {seasonNumber} 43 - </Text> 44 - <View style={styles.list}> 45 - {(season?.episodes || []).map((episode) => ( 46 - <TouchableOpacity 47 - key={episode.id} 106 + <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 48 116 style={[ 49 - styles.episodeCard, 117 + styles.backdrop, 50 118 { 51 - borderColor: colors.outline, 52 - backgroundColor: colors.surfaceContainer, 119 + backgroundColor: showColors.muted || colors.surfaceVariant, 53 120 }, 54 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} 127 + /> 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 + ]} 55 141 onPress={() => 56 - router.push({ 57 - pathname: 58 - "/show/[id]/season/[seasonNumber]/episode/[episodeNumber]", 59 - params: { 60 - id, 61 - seasonNumber, 62 - episodeNumber: String(episode.episode_number), 63 - title: title || "", 64 - }, 65 - }) 142 + router.push({ pathname: "/show/[id]", params: { id } }) 66 143 } 144 + activeOpacity={0.8} 67 145 > 68 - <Text style={[styles.episodeTitle, { color: colors.onSurface }]}> 69 - Episode {episode.episode_number} 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> 182 + 183 + <View style={styles.content}> 184 + <View style={styles.infoCards}> 185 + <View 186 + style={[ 187 + styles.infoCard, 188 + { backgroundColor: colors.surfaceContainer }, 189 + ]} 190 + > 191 + <Text 192 + style={[styles.infoLabel, { color: colors.onSurfaceVariant }]} 193 + > 194 + Air Date 195 + </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 + <Text 207 + style={[styles.infoLabel, { color: colors.onSurfaceVariant }]} 208 + > 209 + Episodes 210 + </Text> 211 + <Text style={[styles.infoValue, { color: colors.onSurface }]}> 212 + {season?.episodes?.length || 0} 213 + </Text> 214 + </View> 215 + </View> 216 + 217 + {season?.overview && ( 218 + <View style={styles.section}> 219 + <Text 220 + style={[ 221 + styles.sectionTitle, 222 + { color: showColors.primary || colors.primary }, 223 + ]} 224 + > 225 + Overview 70 226 </Text> 71 227 <Text 72 - style={[styles.episodeName, { color: colors.onSurfaceVariant }]} 228 + style={[styles.overview, { color: colors.onSurfaceVariant }]} 229 + > 230 + {season.overview} 231 + </Text> 232 + </View> 233 + )} 234 + 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) => ( 248 + <EpisodeCard 249 + key={episode.id} 250 + episode={episode} 251 + watchCount={ 252 + episodeWatchCounts.get(episode.episode_number) || 0 253 + } 254 + isAuthenticated={!!resolvedUserDid} 255 + onPress={() => 256 + router.push({ 257 + pathname: 258 + "/show/[id]/season/[seasonNumber]/episode/[episodeNumber]", 259 + params: { 260 + id, 261 + seasonNumber, 262 + episodeNumber: String(episode.episode_number), 263 + title: show?.name || title || "", 264 + }, 265 + }) 266 + } 267 + /> 268 + ))} 269 + </View> 270 + </View> 271 + 272 + {show?.credits?.cast && show.credits.cast.length > 0 ? ( 273 + <View style={styles.section}> 274 + <Text 275 + style={[ 276 + styles.sectionTitle, 277 + { color: showColors.primary || colors.primary }, 278 + ]} 279 + > 280 + Cast 281 + </Text> 282 + <View style={styles.castContainer}> 283 + <ScrollView 284 + horizontal 285 + showsHorizontalScrollIndicator={false} 286 + contentContainerStyle={styles.castScrollContent} 287 + > 288 + {show.credits.cast.map((person) => { 289 + const profileUrl = getTmdbProfileUrl(person.profile_path); 290 + return ( 291 + <TouchableOpacity 292 + key={person.id} 293 + style={styles.castCard} 294 + activeOpacity={0.8} 295 + > 296 + <View style={styles.castImageContainer}> 297 + {profileUrl ? ( 298 + <Image 299 + source={{ uri: profileUrl }} 300 + style={styles.castImage} 301 + contentFit="cover" 302 + /> 303 + ) : ( 304 + <View 305 + style={[ 306 + styles.castImagePlaceholder, 307 + { backgroundColor: colors.surfaceContainer }, 308 + ]} 309 + > 310 + <Text 311 + style={[ 312 + styles.castImagePlaceholderText, 313 + { color: colors.onSurfaceVariant }, 314 + ]} 315 + > 316 + No photo 317 + </Text> 318 + </View> 319 + )} 320 + </View> 321 + <Text 322 + style={[styles.castName, { color: colors.onSurface }]} 323 + numberOfLines={2} 324 + > 325 + {person.name} 326 + </Text> 327 + {person.character ? ( 328 + <Text 329 + style={[ 330 + styles.castCharacter, 331 + { color: colors.onSurfaceVariant }, 332 + ]} 333 + numberOfLines={2} 334 + > 335 + as {person.character} 336 + </Text> 337 + ) : null} 338 + </TouchableOpacity> 339 + ); 340 + })} 341 + </ScrollView> 342 + <LinearGradient 343 + colors={["rgba(3, 7, 18, 0)", "rgba(3, 7, 18, 1)"]} 344 + start={{ x: 0, y: 0.5 }} 345 + end={{ x: 1, y: 0.5 }} 346 + style={styles.castGradient} 347 + /> 348 + </View> 349 + </View> 350 + ) : null} 351 + 352 + {show?.credits?.crew && show.credits.crew.length > 0 ? ( 353 + <View style={styles.section}> 354 + <Text 355 + style={[ 356 + styles.sectionTitle, 357 + { color: showColors.primary || colors.primary }, 358 + ]} 73 359 > 74 - {episode.name} 360 + Crew 75 361 </Text> 76 - </TouchableOpacity> 77 - ))} 362 + <View style={styles.crewGrid}> 363 + {show.credits.crew.map((person) => ( 364 + <TouchableOpacity 365 + key={`${person.id}-${person.job || "crew"}`} 366 + style={[ 367 + styles.crewCard, 368 + { backgroundColor: colors.surfaceContainer }, 369 + ]} 370 + activeOpacity={0.8} 371 + > 372 + <Text 373 + style={[styles.crewName, { color: colors.onSurface }]} 374 + numberOfLines={1} 375 + > 376 + {person.name} 377 + </Text> 378 + <Text 379 + style={[ 380 + styles.crewJob, 381 + { color: colors.onSurfaceVariant }, 382 + ]} 383 + numberOfLines={1} 384 + > 385 + {person.job || person.department || "Crew"} 386 + </Text> 387 + </TouchableOpacity> 388 + ))} 389 + </View> 390 + </View> 391 + ) : null} 78 392 </View> 79 393 </ScrollView> 80 394 </SafeAreaView> 81 395 ); 82 396 } 83 397 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 + 84 492 const styles = StyleSheet.create({ 85 493 container: { flex: 1 }, 86 - content: { padding: 16 }, 87 - title: { fontSize: 24, fontWeight: "700" }, 88 - subtitle: { fontSize: 16, marginTop: 4, marginBottom: 16 }, 89 - list: { gap: 10 }, 90 - episodeCard: { borderWidth: 1, borderRadius: 12, padding: 12 }, 91 - episodeTitle: { fontSize: 16, fontWeight: "600" }, 92 - episodeName: { fontSize: 14, marginTop: 4 }, 494 + scrollContent: { 495 + paddingBottom: spacing.xxl, 496 + }, 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: 48, 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 + 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", 586 + }, 587 + section: { 588 + marginTop: spacing.sm, 589 + }, 590 + sectionTitle: { 591 + fontSize: 18, 592 + fontWeight: "600", 593 + marginBottom: spacing.md, 594 + }, 595 + overview: { 596 + fontSize: 15, 597 + lineHeight: 22, 598 + }, 599 + episodesList: { 600 + gap: spacing.sm, 601 + }, 602 + episodeCard: { 603 + borderWidth: 1, 604 + borderRadius: borderRadius.lg, 605 + overflow: "hidden", 606 + }, 607 + episodeRow: { 608 + flexDirection: "row", 609 + gap: spacing.md, 610 + }, 611 + episodeThumbnail: { 612 + width: 120, 613 + aspectRatio: 16 / 9, 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: { 633 + fontSize: 14, 634 + fontWeight: "500", 635 + flex: 1, 636 + }, 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, 672 + }, 673 + castContainer: { 674 + position: "relative", 675 + }, 676 + castScrollContent: { 677 + gap: 12, 678 + }, 679 + castGradient: { 680 + position: "absolute", 681 + right: 0, 682 + top: 0, 683 + bottom: 16, 684 + width: 48, 685 + pointerEvents: "none", 686 + }, 687 + castCard: { 688 + width: 100, 689 + }, 690 + castImageContainer: { 691 + borderRadius: borderRadius.md, 692 + overflow: "hidden", 693 + marginBottom: 8, 694 + }, 695 + castImage: { 696 + width: 100, 697 + height: 140, 698 + }, 699 + castImagePlaceholder: { 700 + width: 100, 701 + height: 140, 702 + justifyContent: "center", 703 + alignItems: "center", 704 + }, 705 + castImagePlaceholderText: { 706 + fontSize: 12, 707 + textAlign: "center", 708 + paddingHorizontal: 8, 709 + }, 710 + castName: { 711 + fontSize: 13, 712 + fontWeight: "500", 713 + marginBottom: 2, 714 + }, 715 + castCharacter: { 716 + fontSize: 11, 717 + }, 718 + crewGrid: { 719 + flexDirection: "row", 720 + flexWrap: "wrap", 721 + gap: 8, 722 + }, 723 + crewCard: { 724 + padding: spacing.md, 725 + borderRadius: borderRadius.md, 726 + flex: 1, 727 + minWidth: "45%", 728 + }, 729 + crewName: { 730 + fontSize: 14, 731 + fontWeight: "500", 732 + marginBottom: 2, 733 + }, 734 + crewJob: { 735 + fontSize: 12, 736 + }, 93 737 });
+20 -18
apps/mobile/components/AddToListModal.tsx
··· 1 1 import { 2 2 listsControllerAddItemToListMutation, 3 - listsControllerGetListsForMovieOptions, 4 - listsControllerGetListsForMovieQueryKey, 3 + listsControllerGetListsForItemOptions, 4 + listsControllerGetListsForItemQueryKey, 5 5 listsControllerGetListQueryKey, 6 - listsControllerRemoveFromListMutation, 6 + listsControllerRemoveItemFromListMutation, 7 7 type MovieListsForItemDto, 8 8 } from "@opnshelf/api"; 9 9 import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; ··· 24 24 interface AddToListModalProps { 25 25 visible: boolean; 26 26 onClose: () => void; 27 - movieId: string; 28 - movieTitle: string; 27 + mediaType: "movie" | "show"; 28 + mediaId: string; 29 + mediaTitle: string; 29 30 } 30 31 31 32 export const AddToListModal = memo(function AddToListModal({ 32 33 visible, 33 34 onClose, 34 - movieId, 35 - movieTitle, 35 + mediaType, 36 + mediaId, 37 + mediaTitle, 36 38 }: AddToListModalProps) { 37 39 const queryClient = useQueryClient(); 38 40 const { colors } = useTheme(); 39 41 40 42 const { data: listsForMovie, isLoading } = useQuery({ 41 - ...listsControllerGetListsForMovieOptions({ 42 - path: { movieId }, 43 + ...listsControllerGetListsForItemOptions({ 44 + path: { mediaType, mediaId }, 43 45 }), 44 46 enabled: visible, 45 47 }); ··· 50 52 onSuccess: (_, variables) => { 51 53 const slug = variables.path.slug; 52 54 queryClient.invalidateQueries({ 53 - queryKey: listsControllerGetListsForMovieQueryKey({ 54 - path: { movieId }, 55 + queryKey: listsControllerGetListsForItemQueryKey({ 56 + path: { mediaType, mediaId }, 55 57 }), 56 58 }); 57 59 queryClient.invalidateQueries({ ··· 61 63 }); 62 64 63 65 const removeMutation = useMutation({ 64 - ...listsControllerRemoveFromListMutation(), 66 + ...listsControllerRemoveItemFromListMutation(), 65 67 onSuccess: (_, variables) => { 66 68 const slug = variables.path.slug; 67 69 queryClient.invalidateQueries({ 68 - queryKey: listsControllerGetListsForMovieQueryKey({ 69 - path: { movieId }, 70 + queryKey: listsControllerGetListsForItemQueryKey({ 71 + path: { mediaType, mediaId }, 70 72 }), 71 73 }); 72 74 queryClient.invalidateQueries({ ··· 79 81 (slug: string, isInList: boolean) => { 80 82 if (isInList) { 81 83 removeMutation.mutate({ 82 - path: { slug, movieId }, 84 + path: { slug, mediaType, mediaId }, 83 85 }); 84 86 } else { 85 87 addMutation.mutate({ 86 88 path: { slug }, 87 - body: { mediaType: "movie", mediaId: movieId }, 89 + body: { mediaType, mediaId }, 88 90 }); 89 91 } 90 92 }, 91 - [addMutation, removeMutation, movieId], 93 + [addMutation, removeMutation, mediaType, mediaId], 92 94 ); 93 95 94 96 return ( ··· 107 109 </Pressable> 108 110 </View> 109 111 <Text style={[styles.description, { color: colors.onSurfaceVariant }]}> 110 - Add or remove "{movieTitle}" from your lists 112 + Add or remove "{mediaTitle}" from your lists 111 113 </Text> 112 114 113 115 <ScrollView style={styles.listContainer}>
+119
apps/mobile/components/ShowItem.tsx
··· 1 + import { useMemo } from "react"; 2 + import { Image } from "expo-image"; 3 + import { Pressable, StyleSheet, Text, View } from "react-native"; 4 + import { borderRadius, spacing } from "@/constants/spacing"; 5 + import { useTheme } from "@/contexts/theme"; 6 + import { getTmdbPosterUrl } from "@/lib/utils"; 7 + 8 + export type ShowItemData = { 9 + id: number | string; 10 + name: string; 11 + poster_path?: string | null; 12 + posterPath?: string | null; 13 + first_air_date?: string | null; 14 + firstAirDate?: string | null; 15 + }; 16 + 17 + interface ShowItemProps { 18 + show: ShowItemData; 19 + onPress: () => void; 20 + metaText?: string; 21 + } 22 + 23 + export function ShowItem({ show, onPress, metaText }: ShowItemProps) { 24 + const { colors } = useTheme(); 25 + const posterUrl = getTmdbPosterUrl( 26 + show.poster_path ?? show.posterPath ?? null, 27 + ); 28 + const firstAirDate = show.first_air_date ?? show.firstAirDate ?? null; 29 + const year = firstAirDate ? firstAirDate.split("-")[0] : undefined; 30 + 31 + const styles = useMemo( 32 + () => 33 + StyleSheet.create({ 34 + showItem: { 35 + flex: 1, 36 + marginBottom: spacing.lg, 37 + marginHorizontal: spacing.sm, 38 + minWidth: 140, 39 + maxWidth: "47%", 40 + }, 41 + posterContainer: { 42 + aspectRatio: 2 / 3, 43 + borderRadius: borderRadius.lg, 44 + overflow: "hidden", 45 + backgroundColor: colors.surfaceContainer, 46 + shadowColor: "#000", 47 + shadowOffset: { width: 0, height: 4 }, 48 + shadowOpacity: 0.3, 49 + shadowRadius: 8, 50 + elevation: 8, 51 + }, 52 + poster: { 53 + width: "100%", 54 + height: "100%", 55 + }, 56 + noPoster: { 57 + justifyContent: "center", 58 + alignItems: "center", 59 + backgroundColor: colors.surfaceContainerHigh, 60 + }, 61 + noPosterText: { 62 + color: colors.onSurfaceVariant, 63 + fontSize: 12, 64 + fontWeight: "500", 65 + }, 66 + titleContainer: { 67 + marginTop: spacing.sm, 68 + minHeight: 40, 69 + }, 70 + showTitle: { 71 + fontSize: 15, 72 + fontWeight: "600", 73 + color: colors.onSurface, 74 + letterSpacing: -0.2, 75 + lineHeight: 20, 76 + flexWrap: "wrap", 77 + }, 78 + showYear: { 79 + marginTop: spacing.xs, 80 + fontSize: 12, 81 + color: colors.onSurfaceVariant, 82 + fontWeight: "500", 83 + letterSpacing: 0.5, 84 + }, 85 + }), 86 + [ 87 + colors.surfaceContainer, 88 + colors.surfaceContainerHigh, 89 + colors.onSurfaceVariant, 90 + colors.onSurface, 91 + ], 92 + ); 93 + 94 + return ( 95 + <View style={styles.showItem}> 96 + <Pressable onPress={onPress} style={styles.posterContainer}> 97 + {posterUrl ? ( 98 + <Image 99 + source={{ uri: posterUrl }} 100 + style={styles.poster} 101 + contentFit="cover" 102 + transition={200} 103 + /> 104 + ) : ( 105 + <View style={[styles.poster, styles.noPoster]}> 106 + <Text style={styles.noPosterText}>No poster</Text> 107 + </View> 108 + )} 109 + </Pressable> 110 + <Pressable onPress={onPress} style={styles.titleContainer}> 111 + <Text style={styles.showTitle} numberOfLines={2}> 112 + {show.name} 113 + </Text> 114 + {metaText ? <Text style={styles.showYear}>{metaText}</Text> : null} 115 + {!metaText && year ? <Text style={styles.showYear}>{year}</Text> : null} 116 + </Pressable> 117 + </View> 118 + ); 119 + }
+63 -4
apps/mobile/contexts/auth.tsx
··· 16 16 17 17 const AuthContext = createContext<AuthContextType | null>(null); 18 18 19 + function isUnauthorizedError(error: unknown): boolean { 20 + if (!error || typeof error !== "object") { 21 + return false; 22 + } 23 + 24 + const statusCode = (error as { statusCode?: unknown }).statusCode; 25 + return statusCode === 401; 26 + } 27 + 19 28 export function AuthProvider({ children }: { children: ReactNode }) { 20 29 const [isInitialized, setIsInitialized] = useState(false); 30 + const [hasSessionToken, setHasSessionToken] = useState(false); 31 + const [hasResolvedInitialAuth, setHasResolvedInitialAuth] = useState(false); 21 32 const queryClient = useQueryClient(); 22 33 23 - const { data: user, isLoading: isUserLoading } = useQuery({ 34 + const { 35 + data: user, 36 + isLoading: isUserLoading, 37 + isError: isUserError, 38 + error: userError, 39 + status: userStatus, 40 + fetchStatus: userFetchStatus, 41 + } = useQuery({ 24 42 ...authControllerMeOptions(), 25 43 staleTime: 5 * 60 * 1000, 26 44 retry: false, 27 - enabled: isInitialized, 45 + enabled: isInitialized && hasSessionToken, 46 + refetchOnMount: false, 47 + refetchOnReconnect: false, 48 + refetchOnWindowFocus: false, 28 49 }); 29 50 30 51 useEffect(() => { 31 - loadSessionToken().then(() => { 52 + loadSessionToken().then((token) => { 53 + setHasSessionToken(!!token); 32 54 setIsInitialized(true); 33 55 }); 34 56 }, []); 35 57 58 + useEffect(() => { 59 + if (user) { 60 + setHasSessionToken(true); 61 + } 62 + }, [user]); 63 + 64 + useEffect(() => { 65 + if (!isInitialized) { 66 + return; 67 + } 68 + 69 + if (!hasSessionToken) { 70 + setHasResolvedInitialAuth(true); 71 + return; 72 + } 73 + 74 + if (userStatus === "success" || userStatus === "error") { 75 + setHasResolvedInitialAuth(true); 76 + } 77 + }, [isInitialized, hasSessionToken, userStatus]); 78 + 79 + useEffect(() => { 80 + if (isUserError) { 81 + if (isUnauthorizedError(userError)) { 82 + void saveSessionToken(null); 83 + setHasSessionToken(false); 84 + const meQueryKey = authControllerMeQueryKey(); 85 + queryClient.setQueryData(meQueryKey, null); 86 + queryClient.removeQueries({ queryKey: meQueryKey }); 87 + } 88 + } 89 + }, [isUserError, queryClient, userError]); 90 + 36 91 const login = useCallback(async (handle?: string) => { 37 92 const loginUrl = getLoginUrl(handle); 38 93 const result = await WebBrowser.openAuthSessionAsync( ··· 51 106 52 107 const logout = useCallback(async () => { 53 108 await saveSessionToken(null); 109 + setHasSessionToken(false); 54 110 // Set user to null immediately to update UI, then remove queries 55 111 // Use the exact query key structure created by authControllerMeQueryKey() 56 112 const meQueryKey = authControllerMeQueryKey(); ··· 61 117 62 118 const handleAuthCallback = useCallback(async (token: string) => { 63 119 await saveSessionToken(token); 120 + setHasSessionToken(true); 64 121 // Refetch user to update auth state 65 122 await queryClient.invalidateQueries({ queryKey: authControllerMeQueryKey() }); 66 123 }, [queryClient]); 67 124 125 + const isLoading = !isInitialized || (hasSessionToken && !hasResolvedInitialAuth); 126 + 68 127 const value: AuthContextType = { 69 128 user: user ?? null, 70 - isLoading: !isInitialized || isUserLoading, 129 + isLoading, 71 130 isAuthenticated: !!user, 72 131 login, 73 132 logout,
+1 -1
apps/web/src/components/AddToShelfButton.tsx
··· 1 - import type { ReactNode } from "react"; 2 1 import { Loader2 } from "lucide-react"; 2 + import type { ReactNode } from "react"; 3 3 4 4 type AddToShelfButtonProps = { 5 5 onClick: () => void;
+1 -1
apps/web/src/components/MovieGrid.tsx
··· 1 1 import type { UserDto } from "@opnshelf/api"; 2 - import { cn } from "@/lib/utils"; 3 2 import { Skeleton } from "@/components/ui/skeleton"; 3 + import { cn } from "@/lib/utils"; 4 4 import type { MovieCardData } from "./MovieCard"; 5 5 import { MovieCard } from "./MovieCard"; 6 6
+9 -4
apps/web/src/components/ShowCard.tsx
··· 7 7 } 8 8 9 9 export function ShowCard({ show }: ShowCardProps) { 10 + const compatShow = show as TmdbShowResultDto & { 11 + posterPath?: string | null; 12 + firstAirDate?: string | null; 13 + }; 10 14 const showId = show.id.toString(); 11 - const posterUrl = getTmdbPosterUrl(show.poster_path); 12 - const year = show.first_air_date 13 - ? show.first_air_date.split("-")[0] 14 - : undefined; 15 + const posterUrl = getTmdbPosterUrl( 16 + show.poster_path ?? compatShow.posterPath ?? null, 17 + ); 18 + const firstAirDate = show.first_air_date ?? compatShow.firstAirDate ?? null; 19 + const year = firstAirDate ? firstAirDate.split("-")[0] : undefined; 15 20 16 21 return ( 17 22 <div className="group">
+7 -7
apps/web/src/routes/index.tsx
··· 93 93 <M3Button variant="filled" size="lg" asChild> 94 94 <Link to="/search" search={{ q: "", type: "all" }}> 95 95 <Search className="w-5 h-5 mr-2" /> 96 - Search Movies 96 + Search 97 97 </Link> 98 98 </M3Button> 99 99 </div> ··· 211 211 <M3Button variant="filled" asChild> 212 212 <Link to="/search" search={{ q: "", type: "all" }}> 213 213 <Search className="w-5 h-5 mr-2" /> 214 - Search Movies 214 + Search 215 215 </Link> 216 216 </M3Button> 217 217 </div> ··· 265 265 <M3CardContent> 266 266 <p className="md-display-small">{listCount}</p> 267 267 <M3CardDescription> 268 - {totalMoviesInLists} total movie 268 + {totalMoviesInLists} total item 269 269 {totalMoviesInLists !== 1 ? "s" : ""} in lists 270 270 </M3CardDescription> 271 271 </M3CardContent> ··· 306 306 ) : ( 307 307 <M3Card variant="elevated"> 308 308 <M3CardHeader> 309 - <M3CardTitle>No movies watched yet</M3CardTitle> 309 + <M3CardTitle>No items watched yet</M3CardTitle> 310 310 <M3CardDescription> 311 - Start adding watched movies and your activity appears here. 311 + Start adding watched items and your activity appears here. 312 312 </M3CardDescription> 313 313 </M3CardHeader> 314 314 <M3CardContent> 315 315 <M3Button variant="filled" asChild> 316 316 <Link to="/search" search={{ q: "", type: "all" }}> 317 - Search movies 317 + Search 318 318 </Link> 319 319 </M3Button> 320 320 </M3CardContent> ··· 356 356 <M3CardHeader> 357 357 <M3CardTitle>No lists yet</M3CardTitle> 358 358 <M3CardDescription> 359 - Create your first list to organize movies. 359 + Create your first list to organize items. 360 360 </M3CardDescription> 361 361 </M3CardHeader> 362 362 </M3Card>
+2 -1
apps/web/src/routes/lists.$slug.tsx
··· 231 231 }} 232 232 isRemoving={ 233 233 removeMutation.isPending && 234 - removeMutation.variables?.path?.mediaType === item.mediaType && 234 + removeMutation.variables?.path?.mediaType === 235 + item.mediaType && 235 236 removeMutation.variables?.path?.mediaId === item.mediaId 236 237 } 237 238 />
+21 -8
apps/web/src/routes/search.tsx
··· 71 71 if (trimmed !== searchQuery) { 72 72 debounceRef.current = setTimeout(() => { 73 73 lastNavigatedQueryRef.current = trimmed; 74 - navigate({ search: { q: trimmed, type } }); 74 + navigate({ 75 + search: { q: trimmed, type }, 76 + replace: true, 77 + resetScroll: false, 78 + }); 75 79 }, DEBOUNCE_MS); 76 80 } 77 81 ··· 115 119 enabled: !hasQuery && (isAll || isMovies), 116 120 }); 117 121 118 - const { data: discoverShowsData, isLoading: isDiscoverShowsLoading } = useQuery( 119 - { 122 + const { data: discoverShowsData, isLoading: isDiscoverShowsLoading } = 123 + useQuery({ 120 124 ...showsControllerDiscoverShowsOptions({}), 121 125 enabled: !hasQuery && (isAll || isShows), 122 - }, 123 - ); 126 + }); 124 127 125 128 const movieResults: TmdbMovieResultDto[] = hasQuery 126 129 ? (movieSearchData?.results ?? []) ··· 136 139 ? (showSearchData?.total_results ?? showResults.length) 137 140 : showResults.length; 138 141 139 - const movieLoading = hasQuery ? isMovieSearchLoading : isDiscoverMoviesLoading; 142 + const movieLoading = hasQuery 143 + ? isMovieSearchLoading 144 + : isDiscoverMoviesLoading; 140 145 const showLoading = hasQuery ? isShowSearchLoading : isDiscoverShowsLoading; 141 146 const primaryError = movieSearchError || showSearchError; 142 147 143 148 const switchType = (nextType: "all" | "movies" | "shows") => { 144 149 const trimmed = query.trim(); 145 150 lastNavigatedQueryRef.current = trimmed; 146 - navigate({ search: { q: trimmed, type: nextType } }); 151 + navigate({ 152 + search: { q: trimmed, type: nextType }, 153 + replace: true, 154 + resetScroll: false, 155 + }); 147 156 }; 148 157 149 158 return ( ··· 173 182 onClick={() => { 174 183 setQuery(""); 175 184 lastNavigatedQueryRef.current = ""; 176 - navigate({ search: { q: "", type } }); 185 + navigate({ 186 + search: { q: "", type }, 187 + replace: true, 188 + resetScroll: false, 189 + }); 177 190 }} 178 191 className="absolute right-3 top-1/2 -translate-y-1/2 p-1 rounded-full transition-colors hover:bg-[var(--md-sys-color-on-surface)]/10" 179 192 style={{ color: "var(--md-sys-color-on-surface-variant)" }}
+60 -26
apps/web/src/routes/shows.$showId.$title.seasons.$seasonNumber.episodes.$episodeNumber.tsx
··· 56 56 export const Route = createFileRoute( 57 57 "/shows/$showId/$title/seasons/$seasonNumber/episodes/$episodeNumber", 58 58 )({ 59 + loader: async ({ params, context }) => { 60 + const { showId, seasonNumber, episodeNumber } = params; 61 + const { queryClient } = context; 62 + 63 + const showData = await queryClient.fetchQuery({ 64 + ...showsControllerGetShowDetailsOptions({ 65 + path: { showId }, 66 + }), 67 + }); 68 + 69 + const episodeData = await queryClient.fetchQuery({ 70 + ...showsControllerGetEpisodeDetailsOptions({ 71 + path: { showId, seasonNumber, episodeNumber }, 72 + }), 73 + }); 74 + 75 + return { show: showData, episode: episodeData }; 76 + }, 77 + head: ({ loaderData }) => { 78 + const showName = loaderData?.show?.name; 79 + const episodeName = loaderData?.episode?.name; 80 + const title = 81 + showName && episodeName 82 + ? `${showName}: ${episodeName} | OpnShelf` 83 + : "Episode | OpnShelf"; 84 + 85 + return { 86 + meta: [{ title }], 87 + }; 88 + }, 59 89 component: ShowEpisodePage, 60 90 }); 61 91 ··· 320 350 <div className="absolute bottom-0 left-0 right-0 p-4 md:p-8"> 321 351 <div className="container mx-auto max-w-6xl"> 322 352 <div className="flex items-end gap-4 md:gap-8"> 323 - <div 324 - className="w-24 md:w-40 rounded-lg overflow-hidden shadow-2xl" 353 + <Link 354 + to="/shows/$showId/$title" 355 + params={{ showId, title }} 356 + className="w-24 md:w-40 rounded-lg overflow-hidden shadow-2xl cursor-pointer transition-transform hover:scale-105" 325 357 style={{ boxShadow: `0 25px 50px -12px ${colors.primary}40` }} 326 358 > 327 359 {showPoster ? ( ··· 335 367 No poster 336 368 </div> 337 369 )} 338 - </div> 370 + </Link> 339 371 <div className="pb-2"> 340 372 <h1 341 373 className="text-2xl md:text-5xl font-bold mb-2" ··· 537 569 </div> 538 570 539 571 <div className="space-y-6 min-w-0"> 540 - <div className="flex flex-wrap gap-3"> 541 - <Link 542 - to="/shows/$showId/$title/seasons/$seasonNumber" 543 - params={{ showId, title, seasonNumber }} 544 - className="rounded-full border border-(--md-sys-color-outline) px-3 py-1.5 text-sm text-gray-300 flex items-center gap-2 hover:bg-gray-900/40 transition-colors" 545 - > 546 - <Layers className="w-4 h-4" />S{seasonNumber} 547 - </Link> 548 - <div className="rounded-full border border-(--md-sys-color-outline) px-3 py-1.5 text-sm text-gray-300 flex items-center gap-2"> 549 - <Film className="w-4 h-4" />E{episodeNumber} 550 - </div> 551 - <div className="rounded-full border border-(--md-sys-color-outline) px-3 py-1.5 text-sm text-gray-300 flex items-center gap-2"> 552 - <Calendar className="w-4 h-4" /> 553 - {episode?.air_date 554 - ? formatDateOnly(episode.air_date) 555 - : "Air date unknown"} 556 - </div> 557 - <div className="rounded-full border border-(--md-sys-color-outline) px-3 py-1.5 text-sm text-gray-300 flex items-center gap-2"> 558 - <Star className="w-4 h-4" /> 559 - {episode?.vote_average 560 - ? `${episode.vote_average.toFixed(1)}/10` 561 - : "Not rated"} 562 - </div> 572 + <div className="flex flex-wrap gap-3"> 573 + <Link 574 + to="/shows/$showId/$title/seasons/$seasonNumber" 575 + params={{ showId, title, seasonNumber }} 576 + className="rounded-full border border-(--md-sys-color-outline) px-3 py-1.5 text-sm text-gray-300 flex items-center gap-2 hover:bg-gray-900/40 transition-colors" 577 + > 578 + <Layers className="w-4 h-4" /> 579 + Season {seasonNumber} 580 + </Link> 581 + <div className="rounded-full border border-(--md-sys-color-outline) px-3 py-1.5 text-sm text-gray-300 flex items-center gap-2"> 582 + <Film className="w-4 h-4" /> 583 + Episode {episodeNumber} 584 + </div> 585 + <div className="rounded-full border border-(--md-sys-color-outline) px-3 py-1.5 text-sm text-gray-300 flex items-center gap-2"> 586 + <Calendar className="w-4 h-4" /> 587 + {episode?.air_date 588 + ? formatDateOnly(episode.air_date) 589 + : "Air date unknown"} 590 + </div> 591 + <div className="rounded-full border border-(--md-sys-color-outline) px-3 py-1.5 text-sm text-gray-300 flex items-center gap-2"> 592 + <Star className="w-4 h-4" /> 593 + {episode?.vote_average 594 + ? `${episode.vote_average.toFixed(1)}/10` 595 + : "Not rated"} 563 596 </div> 597 + </div> 564 598 <section> 565 599 <h2 566 600 className="text-xl font-semibold mb-3"
+85 -30
apps/web/src/routes/shows.$showId.$title.seasons.$seasonNumber.tsx
··· 16 16 import { ArrowLeft, Calendar, Star } from "lucide-react"; 17 17 import { CastSection } from "@/components/CastSection"; 18 18 import { CrewSection } from "@/components/CrewSection"; 19 - import { formatDateOnly, getTmdbBackdropUrl, getTmdbPosterUrl } from "@/lib/utils"; 19 + import { 20 + formatDateOnly, 21 + getTmdbBackdropUrl, 22 + getTmdbPosterUrl, 23 + } from "@/lib/utils"; 20 24 21 25 export const Route = createFileRoute( 22 26 "/shows/$showId/$title/seasons/$seasonNumber", 23 27 )({ 28 + loader: async ({ params, context }) => { 29 + const { showId, seasonNumber } = params; 30 + const { queryClient } = context; 31 + 32 + const showData = await queryClient.fetchQuery({ 33 + ...showsControllerGetShowDetailsOptions({ 34 + path: { showId }, 35 + }), 36 + }); 37 + 38 + const seasonData = await queryClient.fetchQuery({ 39 + ...showsControllerGetSeasonDetailsOptions({ 40 + path: { showId, seasonNumber }, 41 + }), 42 + }); 43 + 44 + return { show: showData, season: seasonData }; 45 + }, 46 + head: ({ loaderData, params }) => { 47 + const showName = loaderData?.show?.name; 48 + const seasonNumber = params.seasonNumber; 49 + const title = showName 50 + ? `${showName}: Season ${seasonNumber} | OpnShelf` 51 + : `Season ${seasonNumber} | OpnShelf`; 52 + 53 + return { 54 + meta: [{ title }], 55 + }; 56 + }, 24 57 component: ShowSeasonPage, 25 58 }); 26 59 ··· 72 105 <div className="relative h-[45vh] md:h-[55vh] overflow-hidden"> 73 106 {backdropUrl ? ( 74 107 <> 75 - <img src={backdropUrl} alt="" className="w-full h-full object-cover" /> 108 + <img 109 + src={backdropUrl} 110 + alt="" 111 + className="w-full h-full object-cover" 112 + /> 76 113 <div 77 114 className="absolute inset-0" 78 115 style={{ ··· 101 138 <div className="absolute bottom-0 left-0 right-0 p-4 md:p-8"> 102 139 <div className="container mx-auto max-w-6xl"> 103 140 <div className="flex items-end gap-4 md:gap-8"> 104 - <div 105 - className="w-24 md:w-40 rounded-lg overflow-hidden shadow-2xl" 106 - style={{ boxShadow: `0 25px 50px -12px ${colors.primary}40` }} 141 + <Link 142 + to="/shows/$showId/$title" 143 + params={{ showId, title }} 144 + className="w-24 md:w-40 rounded-lg overflow-hidden shadow-2xl cursor-pointer transition-transform hover:scale-105" 145 + style={{ 146 + boxShadow: `0 25px 50px -12px ${colors.primary}40`, 147 + }} 107 148 > 108 149 {seasonPoster ? ( 109 150 <img ··· 116 157 No poster 117 158 </div> 118 159 )} 119 - </div> 160 + </Link> 120 161 <div className="pb-2"> 121 - <h1 className="text-2xl md:text-5xl font-bold mb-2" style={{ textShadow: `0 4px 30px ${colors.primary}60` }}> 162 + <h1 163 + className="text-2xl md:text-5xl font-bold mb-2" 164 + style={{ textShadow: `0 4px 30px ${colors.primary}60` }} 165 + > 122 166 {showData?.name || title.replace(/-/g, " ")} 123 167 </h1> 124 - <h2 className="text-lg md:text-2xl text-gray-200">Season {seasonNumber}</h2> 168 + <h2 className="text-lg md:text-2xl text-gray-200"> 169 + Season {seasonNumber} 170 + </h2> 125 171 </div> 126 172 </div> 127 173 </div> ··· 130 176 131 177 <div className="container mx-auto px-4 py-6 max-w-6xl"> 132 178 <div className="grid grid-cols-1 md:grid-cols-[300px_1fr] gap-8 min-w-0"> 133 - <div className="space-y-4"> 134 - <div className="p-4 rounded-lg bg-gray-900/50"> 135 - <span className="text-gray-500 text-sm block mb-1">Air Date</span> 136 - <span className="font-medium" style={{ color: colors.accent }}> 137 - {season?.air_date ? formatDateOnly(season.air_date) : "Unknown"} 138 - </span> 139 - </div> 140 - <div className="p-4 rounded-lg bg-gray-900/50"> 141 - <span className="text-gray-500 text-sm block mb-1">Episodes</span> 142 - <span className="font-medium" style={{ color: colors.accent }}> 143 - {seasonEpisodes.length} 144 - </span> 179 + <div className="space-y-4" /> 180 + 181 + <div className="space-y-6 min-w-0"> 182 + <div className="flex flex-wrap gap-3"> 183 + {season?.air_date && ( 184 + <div className="rounded-full border border-(--md-sys-color-outline) px-3 py-1.5 text-sm text-gray-300 flex items-center gap-2"> 185 + <Calendar className="w-4 h-4" /> 186 + {formatDateOnly(season.air_date)} 187 + </div> 188 + )} 189 + <div className="rounded-full border border-(--md-sys-color-outline) px-3 py-1.5 text-sm text-gray-300 flex items-center gap-2"> 190 + <span>{seasonEpisodes.length} episodes</span> 191 + </div> 145 192 </div> 146 - </div> 147 193 148 - <div className="space-y-6 min-w-0"> 149 194 <section> 150 - <h2 className="text-xl font-semibold mb-3" style={{ color: colors.primary }}> 195 + <h2 196 + className="text-xl font-semibold mb-3" 197 + style={{ color: colors.primary }} 198 + > 151 199 Overview 152 200 </h2> 153 201 <p className="text-gray-300 leading-relaxed"> ··· 156 204 </section> 157 205 158 206 <section> 159 - <h2 className="text-xl font-semibold mb-4" style={{ color: colors.primary }}> 207 + <h2 208 + className="text-xl font-semibold mb-4" 209 + style={{ color: colors.primary }} 210 + > 160 211 Episodes 161 212 </h2> 162 213 <div className="grid grid-cols-1 gap-3"> 163 214 {seasonEpisodes.map((episode) => { 164 215 const episodeWatches = 165 216 history?.filter( 166 - (h) => 167 - h.seasonNumber === episode.season_number && 168 - h.episodeNumber === episode.episode_number, 169 - ).length || 0; 217 + (h) => 218 + h.seasonNumber === episode.season_number && 219 + h.episodeNumber === episode.episode_number, 220 + ).length || 0; 170 221 171 222 return ( 172 223 <Link ··· 209 260 <div className="mt-2 flex items-center gap-3 text-xs text-gray-400"> 210 261 <span className="flex items-center gap-1"> 211 262 <Calendar className="w-3 h-3" /> 212 - {episode.air_date ? formatDateOnly(episode.air_date) : "TBA"} 263 + {episode.air_date 264 + ? formatDateOnly(episode.air_date) 265 + : "TBA"} 213 266 </span> 214 - {user ? <span>{episodeWatches} watched</span> : null} 267 + {user ? ( 268 + <span>{episodeWatches} watched</span> 269 + ) : null} 215 270 </div> 216 271 </div> 217 272 </div>
+65 -47
apps/web/src/routes/shows.$showId.$title.tsx
··· 15 15 import { CastSection } from "@/components/CastSection"; 16 16 import { CrewSection } from "@/components/CrewSection"; 17 17 import { GenresSection } from "@/components/GenresSection"; 18 - import { formatDateOnly, getTmdbBackdropUrl, getTmdbPosterUrl } from "@/lib/utils"; 18 + import { getTmdbBackdropUrl, getTmdbPosterUrl } from "@/lib/utils"; 19 19 20 20 export const Route = createFileRoute("/shows/$showId/$title")({ 21 + loader: async ({ params, context }) => { 22 + const { showId } = params; 23 + const { queryClient } = context; 24 + 25 + const showData = await queryClient.fetchQuery({ 26 + ...showsControllerGetShowDetailsOptions({ 27 + path: { showId }, 28 + }), 29 + }); 30 + 31 + return showData; 32 + }, 33 + head: ({ loaderData }) => { 34 + const showName = loaderData?.name; 35 + const title = showName ? `${showName} | OpnShelf` : "Show | OpnShelf"; 36 + 37 + return { 38 + meta: [{ title }], 39 + }; 40 + }, 21 41 component: ShowDetailPage, 22 - head: ({ params }) => ({ 23 - meta: [ 24 - { 25 - title: `${params.title.replace(/-/g, " ")} | OpnShelf`, 26 - }, 27 - ], 28 - }), 29 42 }); 30 43 31 44 function ShowDetailPage() { ··· 64 77 <div className="relative h-[50vh] md:h-[60vh] overflow-hidden"> 65 78 {backdropUrl ? ( 66 79 <> 67 - <img src={backdropUrl} alt="" className="w-full h-full object-cover" /> 80 + <img 81 + src={backdropUrl} 82 + alt="" 83 + className="w-full h-full object-cover" 84 + /> 68 85 <div 69 86 className="absolute inset-0" 70 87 style={{ ··· 103 120 <div className="shrink-0"> 104 121 <div 105 122 className="w-28 md:w-48 lg:w-64 rounded-lg overflow-hidden shadow-2xl" 106 - style={{ boxShadow: `0 25px 50px -12px ${colors.primary}40` }} 123 + style={{ 124 + boxShadow: `0 25px 50px -12px ${colors.primary}40`, 125 + }} 107 126 > 108 127 {posterUrl ? ( 109 128 <img ··· 124 143 className="text-2xl md:text-5xl lg:text-6xl font-bold mb-2" 125 144 style={{ textShadow: `0 4px 30px ${colors.primary}60` }} 126 145 > 127 - {isLoading ? "Loading..." : (show?.name ?? title.replace(/-/g, " "))} 146 + {isLoading 147 + ? "Loading..." 148 + : (show?.name ?? title.replace(/-/g, " "))} 128 149 </h1> 129 - <div className="flex flex-wrap items-center gap-4 text-sm md:text-base text-gray-300"> 130 - {show?.first_air_date && ( 131 - <span className="flex items-center gap-2"> 132 - <Calendar className="w-4 h-4" style={{ color: colors.accent }} /> 133 - {new Date(show.first_air_date).getFullYear()} 134 - </span> 135 - )} 136 - <span className="flex items-center gap-2"> 137 - <Tv className="w-4 h-4" style={{ color: colors.accent }} /> 138 - {episodeCount} episodes 139 - </span> 140 - </div> 141 150 </div> 142 151 </div> 143 152 </div> ··· 146 155 147 156 <div className="container mx-auto px-4 py-6 max-w-6xl"> 148 157 <div className="grid grid-cols-1 md:grid-cols-[300px_1fr] gap-8 min-w-0"> 149 - <div className="space-y-4"> 150 - <div className="p-4 rounded-lg bg-gray-900/50"> 151 - <span className="text-gray-500 text-sm block mb-1">First Air Date</span> 152 - <span className="font-medium" style={{ color: colors.accent }}> 153 - {show?.first_air_date ? formatDateOnly(show.first_air_date) : "Unknown"} 154 - </span> 155 - </div> 156 - <div className="p-4 rounded-lg bg-gray-900/50"> 157 - <span className="text-gray-500 text-sm block mb-1">Seasons</span> 158 - <span className="font-medium" style={{ color: colors.accent }}> 159 - {seasonCount} 160 - </span> 161 - </div> 162 - <div className="p-4 rounded-lg bg-gray-900/50"> 163 - <span className="text-gray-500 text-sm block mb-1">Episodes</span> 164 - <span className="font-medium" style={{ color: colors.accent }}> 165 - {episodeCount} 166 - </span> 167 - </div> 168 - </div> 158 + <div className="space-y-4" /> 169 159 170 160 <div className="space-y-6 min-w-0"> 161 + <div className="flex flex-wrap gap-3"> 162 + {show?.first_air_date && ( 163 + <div className="rounded-full border border-(--md-sys-color-outline) px-3 py-1.5 text-sm text-gray-300 flex items-center gap-2"> 164 + <Calendar className="w-4 h-4" /> 165 + {new Date(show.first_air_date).getFullYear()} 166 + </div> 167 + )} 168 + <div className="rounded-full border border-(--md-sys-color-outline) px-3 py-1.5 text-sm text-gray-300 flex items-center gap-2"> 169 + <Tv className="w-4 h-4" /> 170 + {episodeCount} episodes 171 + </div> 172 + <div className="rounded-full border border-(--md-sys-color-outline) px-3 py-1.5 text-sm text-gray-300 flex items-center gap-2"> 173 + <span> 174 + {seasonCount} season{seasonCount !== 1 ? "s" : ""} 175 + </span> 176 + </div> 177 + </div> 178 + 171 179 <section> 172 - <h2 className="text-xl font-semibold mb-3" style={{ color: colors.primary }}> 180 + <h2 181 + className="text-xl font-semibold mb-3" 182 + style={{ color: colors.primary }} 183 + > 173 184 Overview 174 185 </h2> 175 186 <p className="text-gray-300 leading-relaxed"> ··· 180 191 <GenresSection genres={show?.genres} colors={colors} /> 181 192 182 193 <section className="pt-2"> 183 - <h2 className="text-xl font-semibold mb-4" style={{ color: colors.primary }}> 194 + <h2 195 + className="text-xl font-semibold mb-4" 196 + style={{ color: colors.primary }} 197 + > 184 198 Seasons 185 199 </h2> 186 200 <div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-3"> ··· 198 212 className="rounded-xl p-4 border hover:bg-gray-900/40 transition-colors" 199 213 style={{ borderColor: "var(--md-sys-color-outline)" }} 200 214 > 201 - <div className="font-medium">Season {seasonNumber}</div> 215 + <div className="font-medium"> 216 + Season {seasonNumber} 217 + </div> 202 218 {user && ( 203 - <div className="text-xs mt-1 text-gray-400">Open details</div> 219 + <div className="text-xs mt-1 text-gray-400"> 220 + Open details 221 + </div> 204 222 )} 205 223 </Link> 206 224 );
+2 -2
backend/src/lists/lists.service.ts
··· 36 36 { 37 37 name: "Watchlist", 38 38 slug: "watchlist", 39 - description: "Movies you want to watch", 39 + description: "Items you want to watch", 40 40 }, 41 41 { 42 42 name: "Favorites", 43 43 slug: "favorites", 44 - description: "Your favorite movies", 44 + description: "Your favorite items", 45 45 }, 46 46 ]; 47 47