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: update dashboard

+1972 -480
+67 -40
apps/mobile/app/(tabs)/index.tsx
··· 1 1 import { 2 2 listsControllerGetUserListsOptions, 3 - moviesControllerGetUserMoviesOptions, 4 - type TrackedMovieDto, 3 + shelfControllerGetUserShelfOptions, 5 4 type UserDto, 6 5 } from "@opnshelf/api"; 7 6 import { useQuery } from "@tanstack/react-query"; ··· 56 55 const [range, setRange] = useState<"week" | "month">("week"); 57 56 const [showCreateModal, setShowCreateModal] = useState(false); 58 57 59 - const { data: trackedMovies, isLoading: isMoviesLoading } = useQuery({ 60 - ...moviesControllerGetUserMoviesOptions({ 58 + const { data: shelfData, isLoading: isShelfLoading } = useQuery({ 59 + ...shelfControllerGetUserShelfOptions({ 61 60 path: { userDid: user?.did || "" }, 61 + query: { limit: 20 }, 62 62 }), 63 63 enabled: !!user?.did && isAuthenticated, 64 64 }); ··· 68 68 enabled: !!user?.did && isAuthenticated, 69 69 }); 70 70 71 - const { watchedInRangeCount, totalMoviesTracked, recentWatched } = 72 - useMemo(() => { 73 - const now = Date.now(); 74 - const days = range === "week" ? 7 : 30; 75 - const cutoff = now - days * 24 * 60 * 60 * 1000; 71 + const { watchedInRangeCount, totalTracked, recentWatched } = useMemo(() => { 72 + const now = Date.now(); 73 + const days = range === "week" ? 7 : 30; 74 + const cutoff = now - days * 24 * 60 * 60 * 1000; 75 + 76 + const items = shelfData?.items ?? []; 76 77 77 - const sorted = [...(trackedMovies ?? [])].sort((a, b) => { 78 - return getTrackedMovieTimestamp(b) - getTrackedMovieTimestamp(a); 79 - }); 78 + const sorted = items.sort((a, b) => { 79 + const dateA = a.watchedDate 80 + ? new Date(a.watchedDate).getTime() 81 + : new Date(a.createdAt).getTime(); 82 + const dateB = b.watchedDate 83 + ? new Date(b.watchedDate).getTime() 84 + : new Date(b.createdAt).getTime(); 85 + return dateB - dateA; 86 + }); 80 87 81 - const inRange = sorted.filter((movie) => { 82 - return getTrackedMovieTimestamp(movie) >= cutoff; 83 - }); 88 + const inRange = sorted.filter((item) => { 89 + const date = item.watchedDate 90 + ? new Date(item.watchedDate).getTime() 91 + : new Date(item.createdAt).getTime(); 92 + return date >= cutoff; 93 + }); 84 94 85 - return { 86 - watchedInRangeCount: inRange.length, 87 - totalMoviesTracked: sorted.length, 88 - recentWatched: sorted.slice(0, 5), 89 - }; 90 - }, [trackedMovies, range]); 95 + return { 96 + watchedInRangeCount: inRange.length, 97 + totalTracked: shelfData?.total ?? 0, 98 + recentWatched: sorted.slice(0, 5), 99 + }; 100 + }, [shelfData, range]); 91 101 92 102 const { listCount, totalMoviesInLists, recentLists } = useMemo(() => { 93 103 const items = lists ?? []; ··· 259 269 </CardHeader> 260 270 <CardContent> 261 271 <Text style={[styles.metricValue, { color: colors.onSurface }]}> 262 - {totalMoviesTracked} 272 + {totalTracked} 263 273 </Text> 264 274 </CardContent> 265 275 </Card> ··· 307 317 </Text> 308 318 </Pressable> 309 319 </View> 310 - {isMoviesLoading ? ( 320 + {isShelfLoading ? ( 311 321 <View style={styles.sectionSkeleton}> 312 322 {[1, 2, 3].map((i) => ( 313 323 <Skeleton ··· 325 335 const formattedDate = formatDate(watchDate, { 326 336 includeTime: false, 327 337 }); 328 - const posterUrl = getTmdbPosterUrl(tracked.movie.posterPath); 338 + const posterUrl = getTmdbPosterUrl(tracked.posterPath); 339 + 340 + const isEpisode = tracked.type === "episode"; 329 341 330 342 return ( 331 343 <Pressable 332 344 key={tracked.id} 333 345 onPress={() => 334 - router.push({ 335 - pathname: "/movie/[id]", 336 - params: { 337 - id: tracked.movieId, 338 - title: createTitleSlug(tracked.movie.title), 339 - }, 340 - }) 346 + isEpisode 347 + ? router.push({ 348 + pathname: "/episode/[id]", 349 + params: { 350 + id: tracked.showId, 351 + seasonNumber: ( 352 + tracked as unknown as { seasonNumber: number } 353 + ).seasonNumber.toString(), 354 + episodeNumber: ( 355 + tracked as unknown as { episodeNumber: number } 356 + ).episodeNumber.toString(), 357 + }, 358 + }) 359 + : router.push({ 360 + pathname: "/movie/[id]", 361 + params: { 362 + id: tracked.movieId, 363 + title: createTitleSlug( 364 + (tracked as unknown as { title: string }).title, 365 + ), 366 + }, 367 + }) 341 368 } 342 369 style={[ 343 370 styles.recentItem, ··· 379 406 ]} 380 407 numberOfLines={2} 381 408 > 382 - {tracked.movie.title} 409 + {isEpisode 410 + ? (tracked as unknown as { showTitle: string }) 411 + .showTitle 412 + : (tracked as unknown as { title: string }).title} 383 413 </Text> 384 414 <Text 385 415 style={[ ··· 387 417 { color: colors.onSurfaceVariant }, 388 418 ]} 389 419 > 420 + {isEpisode 421 + ? `S${(tracked as unknown as { seasonNumber: number }).seasonNumber} E${(tracked as unknown as { episodeNumber: number }).episodeNumber} • ` 422 + : ""} 390 423 Watched {formattedDate} 391 424 </Text> 392 425 </View> ··· 398 431 <Card> 399 432 <CardHeader> 400 433 <Text style={[styles.emptyTitle, { color: colors.onSurface }]}> 401 - No movies watched yet 434 + No items watched yet 402 435 </Text> 403 436 </CardHeader> 404 437 <CardContent> ··· 408 441 { color: colors.onSurfaceVariant }, 409 442 ]} 410 443 > 411 - Start adding watched movies and your activity appears here. 444 + Start adding watched items and your activity appears here. 412 445 </Text> 413 446 <Button 414 447 onPress={() => router.push("/(tabs)/search")} ··· 589 622 </ScrollView> 590 623 </SafeAreaView> 591 624 ); 592 - } 593 - 594 - function getTrackedMovieTimestamp(tracked: TrackedMovieDto): number { 595 - const dateValue = tracked.watchedDate ?? tracked.createdAt; 596 - const parsed = new Date(dateValue).getTime(); 597 - return Number.isNaN(parsed) ? 0 : parsed; 598 625 } 599 626 600 627 function resolveDisplayName(user: UserDto): string {
+183 -238
apps/mobile/app/(tabs)/profile/shelf.tsx
··· 1 - import type { TrackedMovieDto } from "@opnshelf/api"; 2 1 import { 3 - moviesControllerGetUserMoviesOptions, 4 - moviesControllerGetUserMoviesQueryKey, 5 - moviesControllerUnmarkWatchedMutation, 6 - showsControllerGetUserShowsOptions, 2 + moviesControllerDeleteWatchHistoryEntryMutation, 3 + shelfControllerGetUserShelfInfiniteOptions, 4 + showsControllerDeleteEpisodeWatchHistoryEntryMutation, 7 5 } from "@opnshelf/api"; 8 6 import { FlashList } from "@shopify/flash-list"; 9 - import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; 10 - import { Image } from "expo-image"; 7 + import { 8 + useInfiniteQuery, 9 + useMutation, 10 + useQueryClient, 11 + } from "@tanstack/react-query"; 11 12 import { router } from "expo-router"; 12 13 import { ArrowLeft, BookOpen } from "lucide-react-native"; 13 - import { useCallback, useMemo } from "react"; 14 - import { StyleSheet, Text, TouchableOpacity, View } from "react-native"; 14 + import { useCallback } from "react"; 15 + import { 16 + ActivityIndicator, 17 + StyleSheet, 18 + Text, 19 + TouchableOpacity, 20 + View, 21 + } from "react-native"; 15 22 import { SafeAreaView } from "react-native-safe-area-context"; 23 + import { EpisodeCard } from "@/components/EpisodeCard"; 16 24 import { MovieCard } from "@/components/MovieCard"; 17 25 import { Button } from "@/components/ui/Button"; 18 26 import { Card, CardContent, CardHeader } from "@/components/ui/Card"; ··· 22 30 import { useTheme } from "@/contexts/theme"; 23 31 import { useToast } from "@/contexts/toast"; 24 32 import { useUserSettings } from "@/hooks/useUserSettings"; 25 - import { createTitleSlug, getTmdbPosterUrl } from "@/lib/utils"; 33 + import { createTitleSlug } from "@/lib/utils"; 26 34 27 35 export default function ShelfScreen() { 28 36 const { user } = useAuth(); ··· 31 39 const { timezone, is24Hour } = useUserSettings(); 32 40 const { colors } = useTheme(); 33 41 34 - const { data: trackedMovies, isLoading: isMoviesLoading } = useQuery({ 35 - ...moviesControllerGetUserMoviesOptions({ 36 - path: { userDid: user?.did || "" }, 37 - }), 38 - enabled: !!user?.did, 39 - }); 40 - const { data: trackedShows } = useQuery({ 41 - ...showsControllerGetUserShowsOptions({ 42 - path: { userDid: user?.did || "" }, 42 + const userDid = user?.did || ""; 43 + 44 + const shelfQuery = useInfiniteQuery({ 45 + ...shelfControllerGetUserShelfInfiniteOptions({ 46 + path: { userDid }, 47 + query: { limit: 20 }, 43 48 }), 44 - enabled: !!user?.did, 49 + enabled: !!userDid, 50 + getNextPageParam: (lastPage) => { 51 + const cursor = lastPage.nextCursor; 52 + return cursor && typeof cursor === "string" ? cursor : undefined; 53 + }, 45 54 }); 46 55 47 - const unmarkMutation = useMutation({ 48 - ...moviesControllerUnmarkWatchedMutation(), 56 + const deleteMovieMutation = useMutation({ 57 + ...moviesControllerDeleteWatchHistoryEntryMutation(), 49 58 onSuccess: () => { 50 - queryClient.invalidateQueries({ 51 - queryKey: moviesControllerGetUserMoviesQueryKey({ 52 - path: { userDid: user?.did || "" }, 53 - }), 54 - }); 59 + queryClient.invalidateQueries({ queryKey: ["shelf", "user", userDid] }); 55 60 showToast("Removed from your shelf", "success"); 56 61 }, 57 62 onError: () => { 58 - showToast("Failed to remove from shelf. Please try again.", "error"); 63 + showToast("Failed to remove. Please try again.", "error"); 64 + }, 65 + }); 66 + 67 + const deleteEpisodeMutation = useMutation({ 68 + ...showsControllerDeleteEpisodeWatchHistoryEntryMutation(), 69 + onSuccess: () => { 70 + queryClient.invalidateQueries({ queryKey: ["shelf", "user", userDid] }); 71 + showToast("Episode removed from history", "success"); 72 + }, 73 + onError: () => { 74 + showToast("Failed to remove episode. Please try again.", "error"); 59 75 }, 60 76 }); 61 77 62 - const handleRemove = useCallback( 78 + const handleMovieRemove = useCallback( 63 79 (movieId: string) => { 64 - unmarkMutation.mutate({ path: { movieId } }); 80 + deleteMovieMutation.mutate({ path: { trackedMovieId: movieId } }); 81 + }, 82 + [deleteMovieMutation], 83 + ); 84 + 85 + const handleEpisodeRemove = useCallback( 86 + (episodeId: string) => { 87 + deleteEpisodeMutation.mutate({ path: { trackedEpisodeId: episodeId } }); 88 + }, 89 + [deleteEpisodeMutation], 90 + ); 91 + 92 + const handleMoviePress = useCallback( 93 + (item: { movieId: string; title: string }) => { 94 + router.push({ 95 + pathname: "/movie/[id]", 96 + params: { 97 + id: item.movieId, 98 + title: createTitleSlug(item.title), 99 + }, 100 + }); 101 + }, 102 + [], 103 + ); 104 + 105 + const handleEpisodePress = useCallback( 106 + (item: { 107 + showId: string; 108 + seasonNumber: number; 109 + episodeNumber: number; 110 + showTitle: string; 111 + }) => { 112 + router.push({ 113 + pathname: "/show/[id]/season/[seasonNumber]/episode/[episodeNumber]", 114 + params: { 115 + id: item.showId, 116 + seasonNumber: String(item.seasonNumber), 117 + episodeNumber: String(item.episodeNumber), 118 + }, 119 + }); 65 120 }, 66 - [unmarkMutation], 121 + [], 67 122 ); 68 123 69 - const handleMoviePress = useCallback((tracked: TrackedMovieDto) => { 70 - router.push({ 71 - pathname: "/movie/[id]", 72 - params: { 73 - id: tracked.movieId, 74 - title: createTitleSlug(tracked.movie.title), 75 - }, 76 - }); 77 - }, []); 124 + const items = shelfQuery.data?.pages.flatMap((page) => page.items) ?? []; 125 + const totalCount = shelfQuery.data?.pages[0]?.total ?? 0; 126 + 127 + const isLoading = shelfQuery.isLoading; 128 + const isFetchingNextPage = shelfQuery.isFetchingNextPage; 78 129 79 130 const renderItem = useCallback( 80 - ({ item }: { item: TrackedMovieDto }) => { 131 + ({ item }: { item: (typeof items)[0] }) => { 132 + if (item.type === "movie") { 133 + const isRemoving = 134 + deleteMovieMutation.isPending && 135 + deleteMovieMutation.variables?.path?.trackedMovieId === item.movieId; 136 + 137 + return ( 138 + <MovieCard 139 + tracked={item as never} 140 + isRemoving={isRemoving} 141 + onRemove={() => handleMovieRemove(item.movieId)} 142 + onPress={() => handleMoviePress(item as never)} 143 + timezone={timezone} 144 + is24Hour={is24Hour} 145 + /> 146 + ); 147 + } 81 148 const isRemoving = 82 - unmarkMutation.isPending && 83 - unmarkMutation.variables?.path?.movieId === item.movieId; 149 + deleteEpisodeMutation.isPending && 150 + deleteEpisodeMutation.variables?.path?.trackedEpisodeId === item.id; 84 151 85 152 return ( 86 - <MovieCard 87 - tracked={item} 153 + <EpisodeCard 154 + tracked={item as never} 88 155 isRemoving={isRemoving} 89 - onRemove={handleRemove} 90 - onPress={() => handleMoviePress(item)} 156 + onRemove={() => handleEpisodeRemove(item.id)} 157 + onPress={() => handleEpisodePress(item as never)} 91 158 timezone={timezone} 92 159 is24Hour={is24Hour} 93 160 /> 94 161 ); 95 162 }, 96 - [unmarkMutation, handleRemove, handleMoviePress, timezone, is24Hour], 163 + [ 164 + deleteMovieMutation, 165 + deleteEpisodeMutation, 166 + handleMovieRemove, 167 + handleEpisodeRemove, 168 + handleMoviePress, 169 + handleEpisodePress, 170 + timezone, 171 + is24Hour, 172 + ], 97 173 ); 98 174 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 - ); 175 + const keyExtractor = useCallback((item: (typeof items)[0]) => item.id, []); 176 + 177 + const onEndReached = useCallback(() => { 178 + if (shelfQuery.hasNextPage && !shelfQuery.isFetchingNextPage) { 179 + shelfQuery.fetchNextPage(); 180 + } 181 + }, [ 182 + shelfQuery.hasNextPage, 183 + shelfQuery.isFetchingNextPage, 184 + shelfQuery.fetchNextPage, 185 + ]); 186 + 187 + const renderFooter = useCallback(() => { 188 + if (!isFetchingNextPage) return null; 189 + return ( 190 + <View style={styles.footerLoader}> 191 + <ActivityIndicator size="small" color={colors.primary} /> 192 + </View> 193 + ); 194 + }, [isFetchingNextPage, colors.primary]); 111 195 112 - if (isMoviesLoading) { 196 + if (isLoading) { 113 197 return ( 114 198 <SafeAreaView 115 199 style={[styles.container, { backgroundColor: colors.background }]} ··· 161 245 My Shelf 162 246 </Text> 163 247 </View> 164 - {trackedMovies && trackedMovies.length > 0 && ( 248 + 249 + {items.length > 0 ? ( 165 250 <> 166 251 <Text 167 252 style={[styles.resultsCount, { color: colors.onSurfaceVariant }]} 168 253 > 169 - {trackedMovies.length} movie{trackedMovies.length !== 1 ? "s" : ""}{" "} 170 - watched 254 + {totalCount} item{totalCount !== 1 ? "s" : ""} watched 171 255 </Text> 172 256 <FlashList 173 - data={trackedMovies} 257 + data={items} 174 258 renderItem={renderItem} 175 259 keyExtractor={keyExtractor} 176 260 contentContainerStyle={styles.listContent} 177 261 ItemSeparatorComponent={() => <View style={styles.itemSeparator} />} 262 + onEndReached={onEndReached} 263 + onEndReachedThreshold={0.5} 264 + ListFooterComponent={renderFooter} 178 265 /> 179 266 </> 180 - )} 181 - 182 - {trackedShows && trackedShows.length > 0 && ( 183 - <View style={styles.showsSection}> 184 - <Text 185 - style={[styles.resultsCount, { color: colors.onSurfaceVariant }]} 186 - > 187 - {trackedShows.length} show{trackedShows.length !== 1 ? "s" : ""}{" "} 188 - tracked 189 - </Text> 190 - <View style={styles.showGrid}> 191 - {trackedShowItems.map((item) => ( 192 - <ShelfShowCard 193 - key={item.id} 194 - name={item.name} 195 - posterPath={item.posterPath} 196 - watchCount={item.watchCount} 197 - onPress={() => 198 - router.push({ 199 - pathname: "/show/[id]", 200 - params: { 201 - id: item.id.toString(), 202 - title: createTitleSlug(item.name), 203 - }, 204 - }) 205 - } 267 + ) : ( 268 + <View style={styles.centerContent}> 269 + <Card style={styles.emptyCard}> 270 + <CardHeader style={styles.emptyCardHeader}> 271 + <BookOpen 272 + size={64} 273 + color={colors.onSurfaceVariant} 274 + style={styles.emptyIcon} 206 275 /> 207 - ))} 208 - </View> 276 + <Text style={[styles.emptyTitle, { color: colors.onSurface }]}> 277 + Your shelf is empty 278 + </Text> 279 + <Text 280 + style={[ 281 + styles.emptyDescription, 282 + { color: colors.onSurfaceVariant }, 283 + ]} 284 + > 285 + Start tracking movies and shows you&apos;ve watched 286 + </Text> 287 + </CardHeader> 288 + <CardContent> 289 + <Button onPress={() => router.push("/(tabs)/search")}> 290 + <Text style={[styles.buttonText, { color: colors.onPrimary }]}> 291 + Search for movies or shows 292 + </Text> 293 + </Button> 294 + </CardContent> 295 + </Card> 209 296 </View> 210 297 )} 211 - 212 - {trackedMovies && 213 - trackedMovies.length === 0 && 214 - (!trackedShows || trackedShows.length === 0) && ( 215 - <View style={styles.centerContent}> 216 - <Card style={styles.emptyCard}> 217 - <CardHeader style={styles.emptyCardHeader}> 218 - <BookOpen 219 - size={64} 220 - color={colors.onSurfaceVariant} 221 - style={styles.emptyIcon} 222 - /> 223 - <Text style={[styles.emptyTitle, { color: colors.onSurface }]}> 224 - Your shelf is empty 225 - </Text> 226 - <Text 227 - style={[ 228 - styles.emptyDescription, 229 - { color: colors.onSurfaceVariant }, 230 - ]} 231 - > 232 - Start tracking movies and shows you&apos;ve watched 233 - </Text> 234 - </CardHeader> 235 - <CardContent> 236 - <Button onPress={() => router.push("/(tabs)/search")}> 237 - <Text 238 - style={[styles.buttonText, { color: colors.onPrimary }]} 239 - > 240 - Search for movies or shows 241 - </Text> 242 - </Button> 243 - </CardContent> 244 - </Card> 245 - </View> 246 - )} 247 298 </SafeAreaView> 248 299 ); 249 300 } ··· 277 328 itemSeparator: { 278 329 height: spacing.md, 279 330 }, 280 - showsSection: { 281 - paddingHorizontal: spacing.lg, 282 - paddingBottom: spacing.lg, 283 - }, 284 - showGrid: { 285 - paddingTop: spacing.md, 286 - }, 287 - showCard: { 288 - flexDirection: "row", 289 - borderRadius: borderRadius.lg, 290 - overflow: "hidden", 291 - borderWidth: 1, 292 - marginBottom: spacing.md, 293 - }, 294 - showPosterContainer: { 295 - width: 80, 296 - aspectRatio: 2 / 3, 297 - }, 298 - showPoster: { 299 - width: "100%", 300 - height: "100%", 301 - }, 302 - showCardContent: { 303 - flex: 1, 304 - padding: spacing.md, 305 - justifyContent: "center", 306 - }, 307 - showTitle: { 308 - fontSize: 16, 309 - fontWeight: "600", 310 - marginBottom: spacing.xs, 311 - lineHeight: 22, 312 - }, 313 - showMeta: { 314 - fontSize: 14, 331 + footerLoader: { 332 + paddingVertical: spacing.lg, 333 + alignItems: "center", 315 334 }, 316 335 centerContent: { 317 336 flex: 1, ··· 362 381 padding: spacing.md, 363 382 justifyContent: "center", 364 383 }, 365 - noPoster: { 366 - justifyContent: "center", 367 - alignItems: "center", 368 - }, 369 - noPosterText: { 370 - fontSize: 12, 371 - fontWeight: "500", 372 - }, 373 384 }); 374 - 375 - interface ShelfShowCardProps { 376 - name: string; 377 - posterPath?: string | null; 378 - watchCount: number; 379 - onPress: () => void; 380 - } 381 - 382 - function ShelfShowCard({ 383 - name, 384 - posterPath, 385 - watchCount, 386 - onPress, 387 - }: ShelfShowCardProps) { 388 - const { colors } = useTheme(); 389 - const posterUrl = getTmdbPosterUrl(posterPath); 390 - const watchLabel = `${watchCount} watched episode${watchCount === 1 ? "" : "s"}`; 391 - 392 - return ( 393 - <TouchableOpacity 394 - onPress={onPress} 395 - style={[ 396 - styles.showCard, 397 - { 398 - backgroundColor: colors.surfaceContainer, 399 - borderColor: colors.outline, 400 - }, 401 - ]} 402 - activeOpacity={0.8} 403 - > 404 - <View 405 - style={[ 406 - styles.showPosterContainer, 407 - { backgroundColor: colors.surfaceContainerHigh }, 408 - ]} 409 - > 410 - {posterUrl ? ( 411 - <Image 412 - source={{ uri: posterUrl }} 413 - style={styles.showPoster} 414 - contentFit="cover" 415 - /> 416 - ) : ( 417 - <View style={[styles.showPoster, styles.noPoster]}> 418 - <Text 419 - style={[styles.noPosterText, { color: colors.onSurfaceVariant }]} 420 - > 421 - No poster 422 - </Text> 423 - </View> 424 - )} 425 - </View> 426 - <View style={styles.showCardContent}> 427 - <Text 428 - style={[styles.showTitle, { color: colors.onSurface }]} 429 - numberOfLines={2} 430 - > 431 - {name} 432 - </Text> 433 - <Text style={[styles.showMeta, { color: colors.onSurfaceVariant }]}> 434 - {watchLabel} 435 - </Text> 436 - </View> 437 - </TouchableOpacity> 438 - ); 439 - }
+5
apps/mobile/app/_layout.tsx
··· 2 2 import { Stack } from "expo-router"; 3 3 import { StatusBar } from "expo-status-bar"; 4 4 import { useEffect, useState } from "react"; 5 + 6 + import { DevToolsBubble } from "react-native-react-query-devtools"; 5 7 import { LoadingScreen } from "@/components/LoadingScreen"; 6 8 import { M3SnackbarProvider } from "@/components/ui/m3/M3Snackbar"; 7 9 import { AuthProvider, useAuth } from "@/contexts/auth"; 8 10 import { ThemeProvider } from "@/contexts/theme"; 9 11 import { initializeApiClient } from "@/lib/api"; 10 12 import { queryClient } from "@/lib/query-client"; 13 + 14 + const isDev = process.env.NODE_ENV === "development"; 11 15 12 16 function LocaleInitializer({ children }: { children: React.ReactNode }) { 13 17 const [isReady, setIsReady] = useState(false); ··· 80 84 81 85 return ( 82 86 <QueryClientProvider client={queryClient}> 87 + {isDev && <DevToolsBubble queryClient={queryClient} />} 83 88 <ThemeProvider> 84 89 <AuthProvider> 85 90 <LocaleInitializer>
+261
apps/mobile/components/EpisodeCard.tsx
··· 1 + import { Trash2 } from "lucide-react-native"; 2 + import { Image } from "expo-image"; 3 + import { StyleSheet, Text, TouchableOpacity, View } from "react-native"; 4 + import { borderRadius, spacing } from "@/constants/spacing"; 5 + import { useTheme } from "@/contexts/theme"; 6 + import { getTmdbPosterUrl } from "@/lib/utils"; 7 + import { SpinningLoader } from "./SpinningLoader"; 8 + 9 + export interface ShelfEpisodeItem { 10 + id: string; 11 + type: "episode"; 12 + showId: string; 13 + showTitle: string; 14 + seasonNumber: number; 15 + episodeNumber: number; 16 + posterPath?: string; 17 + backdropPath?: string; 18 + firstAirYear?: number; 19 + overview?: string; 20 + colors?: unknown; 21 + watchedDate?: string; 22 + createdAt: string; 23 + } 24 + 25 + interface EpisodeCardProps { 26 + tracked: ShelfEpisodeItem; 27 + isRemoving: boolean; 28 + onRemove: (trackedEpisodeId: string) => void; 29 + onPress: () => void; 30 + timezone: string; 31 + is24Hour: boolean; 32 + } 33 + 34 + export function EpisodeCard({ 35 + tracked, 36 + isRemoving, 37 + onRemove, 38 + onPress, 39 + timezone, 40 + is24Hour, 41 + }: EpisodeCardProps) { 42 + const { colors } = useTheme(); 43 + const formattedWatchedDate = tracked.watchedDate 44 + ? new Date(tracked.watchedDate).toLocaleString("en-US", { 45 + month: "short", 46 + day: "numeric", 47 + year: "numeric", 48 + hour: "2-digit", 49 + minute: "2-digit", 50 + hour12: !is24Hour, 51 + timeZone: timezone, 52 + }) 53 + : null; 54 + 55 + const posterUrl = getTmdbPosterUrl(tracked.posterPath); 56 + 57 + return ( 58 + <TouchableOpacity 59 + onPress={onPress} 60 + style={[ 61 + styles.card, 62 + { backgroundColor: colors.surfaceContainer, borderColor: colors.outline }, 63 + ]} 64 + activeOpacity={0.8} 65 + > 66 + <View 67 + style={[ 68 + styles.posterContainer, 69 + { backgroundColor: colors.surfaceContainerHigh }, 70 + ]} 71 + > 72 + {posterUrl ? ( 73 + <Image 74 + source={{ uri: posterUrl }} 75 + style={styles.poster} 76 + contentFit="cover" 77 + transition={200} 78 + /> 79 + ) : ( 80 + <View 81 + style={[ 82 + styles.poster, 83 + styles.noPoster, 84 + { backgroundColor: colors.surfaceContainerHigh }, 85 + ]} 86 + > 87 + <Text 88 + style={[styles.noPosterText, { color: colors.onSurfaceVariant }]} 89 + > 90 + No poster 91 + </Text> 92 + </View> 93 + )} 94 + <View style={[styles.episodeBadge, { backgroundColor: colors.primary }]}> 95 + <Text style={[styles.episodeBadgeText, { color: colors.onPrimary }]}> 96 + S{tracked.seasonNumber} E{tracked.episodeNumber} 97 + </Text> 98 + </View> 99 + </View> 100 + 101 + <View style={styles.cardContent}> 102 + <View style={styles.info}> 103 + <Text 104 + style={[styles.showTitle, { color: colors.onSurface }]} 105 + numberOfLines={2} 106 + > 107 + {tracked.showTitle} 108 + </Text> 109 + <View style={styles.meta}> 110 + <Text style={[styles.episodeInfo, { color: colors.onSurfaceVariant }]}> 111 + S{tracked.seasonNumber} E{tracked.episodeNumber} 112 + </Text> 113 + {formattedWatchedDate && ( 114 + <> 115 + <Text 116 + style={[ 117 + styles.metaDot, 118 + { color: colors.onSurfaceVariant }, 119 + ]} 120 + > 121 + 122 + </Text> 123 + <Text 124 + style={[ 125 + styles.watchedDate, 126 + { color: colors.onSurfaceVariant }, 127 + ]} 128 + > 129 + {formattedWatchedDate} 130 + </Text> 131 + </> 132 + )} 133 + </View> 134 + </View> 135 + 136 + <TouchableOpacity 137 + onPress={(e) => { 138 + e.stopPropagation(); 139 + onRemove(tracked.id); 140 + }} 141 + disabled={isRemoving} 142 + style={[styles.removeButton, { backgroundColor: colors.error }]} 143 + activeOpacity={0.7} 144 + > 145 + {isRemoving ? ( 146 + <View style={styles.removeButtonContent}> 147 + <SpinningLoader size={14} color={colors.onError} /> 148 + <Text 149 + style={[ 150 + styles.removeButtonText, 151 + { color: colors.onError }, 152 + ]} 153 + > 154 + Loading 155 + </Text> 156 + </View> 157 + ) : ( 158 + <> 159 + <Trash2 size={14} color={colors.onError} /> 160 + <Text 161 + style={[ 162 + styles.removeButtonText, 163 + { color: colors.onError }, 164 + ]} 165 + > 166 + Remove 167 + </Text> 168 + </> 169 + )} 170 + </TouchableOpacity> 171 + </View> 172 + </TouchableOpacity> 173 + ); 174 + } 175 + 176 + const styles = StyleSheet.create({ 177 + card: { 178 + flexDirection: "row", 179 + borderRadius: borderRadius.lg, 180 + overflow: "hidden", 181 + borderWidth: 1, 182 + }, 183 + posterContainer: { 184 + width: 80, 185 + aspectRatio: 2 / 3, 186 + position: "relative", 187 + }, 188 + poster: { 189 + width: "100%", 190 + height: "100%", 191 + }, 192 + episodeBadge: { 193 + position: "absolute", 194 + bottom: 0, 195 + left: 0, 196 + right: 0, 197 + paddingVertical: 4, 198 + alignItems: "center", 199 + }, 200 + episodeBadgeText: { 201 + fontSize: 11, 202 + fontWeight: "600", 203 + }, 204 + cardContent: { 205 + flex: 1, 206 + padding: spacing.md, 207 + justifyContent: "space-between", 208 + }, 209 + info: { 210 + flex: 1, 211 + }, 212 + showTitle: { 213 + fontSize: 16, 214 + fontWeight: "600", 215 + marginBottom: spacing.xs, 216 + lineHeight: 22, 217 + }, 218 + meta: { 219 + flexDirection: "row", 220 + alignItems: "center", 221 + flexWrap: "wrap", 222 + gap: spacing.xs, 223 + }, 224 + episodeInfo: { 225 + fontSize: 14, 226 + fontWeight: "500", 227 + }, 228 + watchedDate: { 229 + fontSize: 14, 230 + }, 231 + removeButton: { 232 + flexDirection: "row", 233 + alignItems: "center", 234 + gap: spacing.xs, 235 + paddingHorizontal: spacing.md, 236 + paddingVertical: spacing.sm, 237 + borderRadius: borderRadius.full, 238 + alignSelf: "flex-start", 239 + marginTop: spacing.sm, 240 + }, 241 + removeButtonText: { 242 + fontSize: 14, 243 + fontWeight: "600", 244 + }, 245 + removeButtonContent: { 246 + flexDirection: "row", 247 + alignItems: "center", 248 + gap: 6, 249 + }, 250 + metaDot: { 251 + fontSize: 12, 252 + }, 253 + noPoster: { 254 + justifyContent: "center", 255 + alignItems: "center", 256 + }, 257 + noPosterText: { 258 + fontSize: 12, 259 + fontWeight: "500", 260 + }, 261 + });
+80 -15
apps/mobile/components/MovieCard.tsx
··· 1 - import type { TrackedMovieDto } from "@opnshelf/api"; 2 1 import { CheckCircle2, Trash2 } from "lucide-react-native"; 3 2 import { Image } from "expo-image"; 4 3 import { StyleSheet, Text, TouchableOpacity, View } from "react-native"; ··· 7 6 import { getTmdbPosterUrl } from "@/lib/utils"; 8 7 import { SpinningLoader } from "./SpinningLoader"; 9 8 9 + export interface ShelfMovieItem { 10 + id: string; 11 + type: "movie"; 12 + movieId: string; 13 + title: string; 14 + posterPath?: string; 15 + backdropPath?: string; 16 + releaseYear?: number; 17 + overview?: string; 18 + colors?: unknown; 19 + watchedDate?: string; 20 + createdAt: string; 21 + } 22 + 10 23 interface MovieCardProps { 11 - tracked: TrackedMovieDto; 24 + tracked: ShelfMovieItem; 12 25 isRemoving: boolean; 13 26 onRemove: (movieId: string) => void; 14 27 onPress: () => void; ··· 37 50 }) 38 51 : null; 39 52 40 - const posterUrl = getTmdbPosterUrl(tracked.movie.posterPath); 53 + const posterUrl = getTmdbPosterUrl(tracked.posterPath); 41 54 42 55 return ( 43 - <TouchableOpacity onPress={onPress} style={[styles.card, { backgroundColor: colors.surfaceContainer, borderColor: colors.outline }]} activeOpacity={0.8}> 44 - <View style={[styles.posterContainer, { backgroundColor: colors.surfaceContainerHigh }]}> 56 + <TouchableOpacity 57 + onPress={onPress} 58 + style={[ 59 + styles.card, 60 + { backgroundColor: colors.surfaceContainer, borderColor: colors.outline }, 61 + ]} 62 + activeOpacity={0.8} 63 + > 64 + <View 65 + style={[ 66 + styles.posterContainer, 67 + { backgroundColor: colors.surfaceContainerHigh }, 68 + ]} 69 + > 45 70 {posterUrl ? ( 46 71 <Image 47 72 source={{ uri: posterUrl }} ··· 50 75 transition={200} 51 76 /> 52 77 ) : ( 53 - <View style={[styles.poster, styles.noPoster, { backgroundColor: colors.surfaceContainerHigh }]}> 54 - <Text style={[styles.noPosterText, { color: colors.onSurfaceVariant }]}>No poster</Text> 78 + <View 79 + style={[ 80 + styles.poster, 81 + styles.noPoster, 82 + { backgroundColor: colors.surfaceContainerHigh }, 83 + ]} 84 + > 85 + <Text 86 + style={[styles.noPosterText, { color: colors.onSurfaceVariant }]} 87 + > 88 + No poster 89 + </Text> 55 90 </View> 56 91 )} 57 92 </View> 58 93 59 94 <View style={styles.cardContent}> 60 95 <View style={styles.info}> 61 - <Text style={[styles.movieTitle, { color: colors.onSurface }]} numberOfLines={2}> 62 - {tracked.movie.title} 96 + <Text 97 + style={[styles.movieTitle, { color: colors.onSurface }]} 98 + numberOfLines={2} 99 + > 100 + {tracked.title} 63 101 </Text> 64 102 <View style={styles.meta}> 65 - {tracked.movie.releaseYear && ( 66 - <Text style={[styles.year, { color: colors.onSurfaceVariant }]}>{tracked.movie.releaseYear}</Text> 103 + {tracked.releaseYear && ( 104 + <Text style={[styles.year, { color: colors.onSurfaceVariant }]}> 105 + {tracked.releaseYear} 106 + </Text> 67 107 )} 68 108 {formattedWatchedDate && ( 69 109 <> 70 - <Text style={[styles.metaDot, { color: colors.onSurfaceVariant }]}>•</Text> 110 + <Text 111 + style={[styles.metaDot, { color: colors.onSurfaceVariant }]} 112 + > 113 + 114 + </Text> 71 115 <View style={styles.watchedRow}> 72 116 <CheckCircle2 size={12} color={colors.primary} /> 73 - <Text style={[styles.watchedDate, { color: colors.primary }]}>{formattedWatchedDate}</Text> 117 + <Text 118 + style={[ 119 + styles.watchedDate, 120 + { color: colors.primary }, 121 + ]} 122 + > 123 + {formattedWatchedDate} 124 + </Text> 74 125 </View> 75 126 </> 76 127 )} ··· 89 140 {isRemoving ? ( 90 141 <View style={styles.removeButtonContent}> 91 142 <SpinningLoader size={14} color={colors.onError} /> 92 - <Text style={[styles.removeButtonText, { color: colors.onError }]}>Loading</Text> 143 + <Text 144 + style={[ 145 + styles.removeButtonText, 146 + { color: colors.onError }, 147 + ]} 148 + > 149 + Loading 150 + </Text> 93 151 </View> 94 152 ) : ( 95 153 <> 96 154 <Trash2 size={14} color={colors.onError} /> 97 - <Text style={[styles.removeButtonText, { color: colors.onError }]}>Remove</Text> 155 + <Text 156 + style={[ 157 + styles.removeButtonText, 158 + { color: colors.onError }, 159 + ]} 160 + > 161 + Remove 162 + </Text> 98 163 </> 99 164 )} 100 165 </TouchableOpacity>
+5 -3
apps/mobile/package.json
··· 8 8 "android": "expo run:android", 9 9 "ios": "expo run:ios", 10 10 "web": "expo start --web", 11 - "lint": "biome check .", 12 - "format": "biome format .", 13 - "check": "biome check .", 11 + "lint": "biome check .", 12 + "format": "biome format .", 13 + "check": "biome check .", 14 14 "typecheck": "tsc --noEmit" 15 15 }, 16 16 "dependencies": { ··· 46 46 "react-native": "0.81.5", 47 47 "react-native-gesture-handler": "~2.28.0", 48 48 "react-native-paper-dates": "^0.23.3", 49 + "react-native-react-query-devtools": "^1.5.1", 49 50 "react-native-reanimated": "~4.1.1", 50 51 "react-native-safe-area-context": "~5.6.0", 51 52 "react-native-screens": "~4.16.0", 53 + "react-native-svg": "^15.15.3", 52 54 "react-native-web": "~0.21.0", 53 55 "react-native-worklets": "0.5.1" 54 56 },
+1 -1
apps/web/src/components/ListCard.tsx
··· 66 66 className="md-body-medium" 67 67 style={{ color: "var(--md-sys-color-on-surface-variant)" }} 68 68 > 69 - {list.movieCount} movie{list.movieCount !== 1 ? "s" : ""} 69 + {list.movieCount} item{list.movieCount !== 1 ? "s" : ""} 70 70 </p> 71 71 </M3CardContent> 72 72 </M3Card>
+128
apps/web/src/components/ShelfEpisodeCard.tsx
··· 1 + import { 2 + showsControllerDeleteEpisodeWatchHistoryEntryMutation, 3 + type UserDto, 4 + } from "@opnshelf/api"; 5 + import { useMutation, useQueryClient } from "@tanstack/react-query"; 6 + import { Link } from "@tanstack/react-router"; 7 + import { Loader2, Trash2 } from "lucide-react"; 8 + import { useMemo } from "react"; 9 + import { toast } from "sonner"; 10 + import { Button } from "@/components/ui/button"; 11 + import { useFormattedDate } from "@/hooks/useFormattedDate"; 12 + import { createTitleSlug, getTmdbPosterUrl } from "@/lib/utils"; 13 + 14 + export interface ShelfEpisodeItem { 15 + id: string; 16 + type: "episode"; 17 + showId: string; 18 + showTitle: string; 19 + seasonNumber: number; 20 + episodeNumber: number; 21 + posterPath?: string; 22 + backdropPath?: string; 23 + firstAirYear?: number; 24 + overview?: string; 25 + colors?: unknown; 26 + watchedDate?: string; 27 + createdAt: string; 28 + } 29 + 30 + interface ShelfEpisodeCardProps { 31 + tracked: ShelfEpisodeItem; 32 + user: UserDto | undefined; 33 + } 34 + 35 + export function ShelfEpisodeCard({ tracked, user }: ShelfEpisodeCardProps) { 36 + const queryClient = useQueryClient(); 37 + const { formatDate } = useFormattedDate(); 38 + 39 + const deleteMutation = useMutation({ 40 + ...showsControllerDeleteEpisodeWatchHistoryEntryMutation(), 41 + onSuccess: () => { 42 + queryClient.invalidateQueries({ queryKey: ["shelf", "user", user?.did] }); 43 + toast.success("Episode removed from history"); 44 + }, 45 + onError: () => { 46 + toast.error("Failed to remove episode. Please try again."); 47 + }, 48 + }); 49 + 50 + const posterUrl = getTmdbPosterUrl(tracked.posterPath); 51 + const formattedDate = useMemo(() => { 52 + if (!tracked.watchedDate) return null; 53 + return formatDate(tracked.watchedDate); 54 + }, [tracked.watchedDate, formatDate]); 55 + 56 + return ( 57 + <div className="group relative"> 58 + <Link 59 + to="/shows/$showId/$title/seasons/$seasonNumber/episodes/$episodeNumber" 60 + params={{ 61 + showId: tracked.showId, 62 + title: createTitleSlug(tracked.showTitle), 63 + seasonNumber: String(tracked.seasonNumber), 64 + episodeNumber: String(tracked.episodeNumber), 65 + }} 66 + className="block relative aspect-2/3 bg-gray-900 rounded-lg overflow-hidden mb-2" 67 + > 68 + {posterUrl ? ( 69 + <img 70 + src={posterUrl} 71 + alt={tracked.showTitle} 72 + className="w-full h-full object-cover" 73 + /> 74 + ) : ( 75 + <div className="w-full h-full flex items-center justify-center text-gray-600"> 76 + No poster 77 + </div> 78 + )} 79 + <div className="absolute inset-x-0 bottom-0 bg-gradient-to-t from-black/80 to-transparent p-3"> 80 + <div className="text-white text-sm font-medium"> 81 + S{tracked.seasonNumber} E{tracked.episodeNumber} 82 + </div> 83 + </div> 84 + <Button 85 + type="button" 86 + size="icon" 87 + variant="destructive" 88 + onClick={(e) => { 89 + e.preventDefault(); 90 + e.stopPropagation(); 91 + deleteMutation.mutate({ 92 + path: { trackedEpisodeId: tracked.id }, 93 + }); 94 + }} 95 + disabled={deleteMutation.isPending} 96 + className="absolute top-2 right-2 [@media(hover:hover)]:opacity-0 [@media(hover:hover)]:group-hover:opacity-100 transition-opacity" 97 + title="Remove from history" 98 + > 99 + {deleteMutation.isPending ? ( 100 + <Loader2 className="w-4 h-4 animate-spin" /> 101 + ) : ( 102 + <Trash2 className="w-4 h-4" /> 103 + )} 104 + </Button> 105 + </Link> 106 + <Link 107 + to="/shows/$showId/$title/seasons/$seasonNumber/episodes/$episodeNumber" 108 + params={{ 109 + showId: tracked.showId, 110 + title: createTitleSlug(tracked.showTitle), 111 + seasonNumber: String(tracked.seasonNumber), 112 + episodeNumber: String(tracked.episodeNumber), 113 + }} 114 + className="block" 115 + > 116 + <h3 className="font-semibold text-sm line-clamp-2 mb-1 hover:text-purple-400 transition-colors"> 117 + {tracked.showTitle} 118 + </h3> 119 + <p className="text-gray-500 text-sm"> 120 + S{tracked.seasonNumber} E{tracked.episodeNumber} 121 + </p> 122 + {formattedDate && ( 123 + <p className="text-gray-400 text-xs mt-1">Watched {formattedDate}</p> 124 + )} 125 + </Link> 126 + </div> 127 + ); 128 + }
+24 -25
apps/web/src/components/ShelfMovieCard.tsx
··· 1 1 import { 2 - moviesControllerGetUserMoviesQueryKey, 3 2 moviesControllerUnmarkWatchedMutation, 4 - type TrackedMovieDto, 5 3 type UserDto, 6 4 } from "@opnshelf/api"; 7 5 import { useMutation, useQueryClient } from "@tanstack/react-query"; ··· 9 7 import { Loader2, Trash2 } from "lucide-react"; 10 8 import { useMemo } from "react"; 11 9 import { toast } from "sonner"; 12 - import { Badge } from "@/components/ui/badge"; 13 10 import { Button } from "@/components/ui/button"; 14 11 import { useFormattedDate } from "@/hooks/useFormattedDate"; 15 12 import { createTitleSlug, getTmdbPosterUrl } from "@/lib/utils"; 16 13 14 + export interface ShelfMovieItem { 15 + id: string; 16 + type: "movie"; 17 + movieId: string; 18 + title: string; 19 + posterPath?: string; 20 + backdropPath?: string; 21 + releaseYear?: number; 22 + overview?: string; 23 + colors?: unknown; 24 + watchedDate?: string; 25 + createdAt: string; 26 + } 27 + 17 28 interface ShelfMovieCardProps { 18 - tracked: TrackedMovieDto; 29 + tracked: ShelfMovieItem; 19 30 user: UserDto | undefined; 20 31 } 21 32 ··· 26 37 const unmarkMutation = useMutation({ 27 38 ...moviesControllerUnmarkWatchedMutation(), 28 39 onSuccess: () => { 29 - queryClient.invalidateQueries({ 30 - queryKey: moviesControllerGetUserMoviesQueryKey({ 31 - path: { userDid: user?.did || "" }, 32 - }), 33 - }); 40 + queryClient.invalidateQueries({ queryKey: ["shelf", "user", user?.did] }); 34 41 toast.success("Removed from your shelf"); 35 42 }, 36 43 onError: () => { ··· 38 45 }, 39 46 }); 40 47 41 - const posterUrl = getTmdbPosterUrl(tracked.movie.posterPath); 48 + const posterUrl = getTmdbPosterUrl(tracked.posterPath); 42 49 const formattedDate = useMemo(() => { 43 50 if (!tracked.watchedDate) return null; 44 51 return formatDate(tracked.watchedDate); ··· 50 57 to="/movies/$movieId/$title" 51 58 params={{ 52 59 movieId: tracked.movieId, 53 - title: createTitleSlug(tracked.movie.title), 60 + title: createTitleSlug(tracked.title), 54 61 }} 55 62 className="block relative aspect-2/3 bg-gray-900 rounded-lg overflow-hidden mb-2" 56 63 > 57 64 {posterUrl ? ( 58 65 <img 59 66 src={posterUrl} 60 - alt={tracked.movie.title} 67 + alt={tracked.title} 61 68 className="w-full h-full object-cover" 62 69 /> 63 70 ) : ( ··· 95 102 to="/movies/$movieId/$title" 96 103 params={{ 97 104 movieId: tracked.movieId, 98 - title: createTitleSlug(tracked.movie.title), 105 + title: createTitleSlug(tracked.title), 99 106 }} 100 107 className="block" 101 108 > 102 109 <h3 className="font-semibold text-sm line-clamp-2 mb-1 hover:text-purple-400 transition-colors"> 103 - {tracked.movie.title} 110 + {tracked.title} 104 111 </h3> 105 - {tracked.movie.releaseYear && ( 106 - <p className="text-gray-500 text-sm">{tracked.movie.releaseYear}</p> 112 + {tracked.releaseYear && ( 113 + <p className="text-gray-500 text-sm">{tracked.releaseYear}</p> 107 114 )} 108 115 {formattedDate && ( 109 - <p className="text-gray-400 text-xs mt-1"> 110 - Watched {formattedDate} 111 - {((tracked as unknown as { watchCount?: number }).watchCount ?? 0) > 112 - 1 ? ( 113 - <Badge variant="secondary" className="ml-2 text-xs"> 114 - {(tracked as unknown as { watchCount?: number }).watchCount}× 115 - </Badge> 116 - ) : null} 117 - </p> 116 + <p className="text-gray-400 text-xs mt-1">Watched {formattedDate}</p> 118 117 )} 119 118 </Link> 120 119 </div>
+2 -2
apps/web/src/components/ui/m3-card.tsx
··· 99 99 <h3 100 100 ref={ref} 101 101 className={cn( 102 - "md-title-medium text-[var(--md-sys-color-on-surface)]", 102 + "md-title-medium text-(--md-sys-color-on-surface)", 103 103 className, 104 104 )} 105 105 {...props} ··· 117 117 <p 118 118 ref={ref} 119 119 className={cn( 120 - "md-body-medium text-[var(--md-sys-color-on-surface-variant)]", 120 + "md-body-medium text-(--md-sys-color-on-surface-variant)", 121 121 className, 122 122 )} 123 123 {...props}
+43 -41
apps/web/src/routes/index.tsx
··· 1 1 import { 2 2 authControllerMeOptions, 3 3 listsControllerGetUserListsOptions, 4 - moviesControllerGetUserMoviesOptions, 5 - type TrackedMovieDto, 4 + shelfControllerGetUserShelfOptions, 6 5 type UserDto, 7 6 } from "@opnshelf/api"; 8 7 import { useQuery } from "@tanstack/react-query"; ··· 17 16 import { useMemo, useState } from "react"; 18 17 import { CreateListDialog } from "@/components/CreateListDialog"; 19 18 import { ListCard } from "@/components/ListCard"; 19 + import { ShelfEpisodeCard } from "@/components/ShelfEpisodeCard"; 20 20 import { ShelfMovieCard } from "@/components/ShelfMovieCard"; 21 21 import { useTheme } from "@/components/theme-provider"; 22 22 import { M3Button } from "@/components/ui/m3-button"; ··· 144 144 (user as unknown as { displayName?: string | null }).displayName ?? 145 145 user.handle; 146 146 147 - const { data: trackedMovies, isLoading: isMoviesLoading } = useQuery({ 148 - ...moviesControllerGetUserMoviesOptions({ 147 + const { data: shelfData, isLoading } = useQuery({ 148 + ...shelfControllerGetUserShelfOptions({ 149 149 path: { userDid: user.did }, 150 + query: { limit: 20 }, 150 151 }), 151 152 enabled: !!user.did, 152 153 }); ··· 156 157 enabled: !!user.did, 157 158 }); 158 159 159 - const { recentWatched, watchedInRangeCount, totalMoviesTracked } = 160 - useMemo(() => { 161 - const now = Date.now(); 162 - const days = range === "week" ? 7 : 30; 163 - const cutoff = now - days * 24 * 60 * 60 * 1000; 160 + const { recentWatched, watchedInRangeCount, totalTracked } = useMemo(() => { 161 + const now = Date.now(); 162 + const days = range === "week" ? 7 : 30; 163 + const cutoff = now - days * 24 * 60 * 60 * 1000; 164 164 165 - const sorted = [...(trackedMovies ?? [])].sort((a, b) => { 166 - return getTrackedMovieTimestamp(b) - getTrackedMovieTimestamp(a); 167 - }); 165 + const items = shelfData?.items ?? []; 168 166 169 - const inRange = sorted.filter((movie) => { 170 - return getTrackedMovieTimestamp(movie) >= cutoff; 171 - }); 167 + const sorted = items.sort((a, b) => { 168 + const dateA = a.watchedDate ? new Date(a.watchedDate).getTime() : 0; 169 + const dateB = b.watchedDate ? new Date(b.watchedDate).getTime() : 0; 170 + return dateB - dateA; 171 + }); 172 172 173 - return { 174 - recentWatched: sorted.slice(0, 8), 175 - watchedInRangeCount: inRange.length, 176 - totalMoviesTracked: sorted.length, 177 - }; 178 - }, [trackedMovies, range]); 173 + const inRange = sorted.filter((item) => { 174 + const date = item.watchedDate ? new Date(item.watchedDate).getTime() : 0; 175 + return date >= cutoff; 176 + }); 177 + 178 + return { 179 + recentWatched: sorted.slice(0, 8), 180 + watchedInRangeCount: inRange.length, 181 + totalTracked: shelfData?.total ?? 0, 182 + }; 183 + }, [shelfData, range]); 179 184 180 185 const { listCount, totalMoviesInLists, recentLists } = useMemo(() => { 181 186 const listItems = lists ?? []; ··· 252 257 </M3CardTitle> 253 258 </M3CardHeader> 254 259 <M3CardContent> 255 - <p className="md-display-small">{totalMoviesTracked}</p> 260 + <p className="md-display-small">{totalTracked}</p> 256 261 </M3CardContent> 257 262 </M3Card> 258 263 <M3Card variant="elevated"> ··· 280 285 <Link to="/profile/shelf">View shelf</Link> 281 286 </M3Button> 282 287 </div> 283 - {isMoviesLoading ? ( 288 + {isLoading ? ( 284 289 <div className="grid grid-cols-2 sm:grid-cols-3 gap-4"> 285 290 {[1, 2, 3, 4, 5, 6].map((i) => ( 286 291 <div ··· 295 300 </div> 296 301 ) : recentWatched.length > 0 ? ( 297 302 <div className="grid grid-cols-2 sm:grid-cols-3 gap-4"> 298 - {recentWatched.map((tracked) => ( 299 - <ShelfMovieCard 300 - key={tracked.id} 301 - tracked={tracked} 302 - user={user} 303 - /> 304 - ))} 303 + {recentWatched.map((item) => 304 + item.type === "movie" ? ( 305 + <ShelfMovieCard 306 + key={item.id} 307 + tracked={item as never} 308 + user={user} 309 + /> 310 + ) : ( 311 + <ShelfEpisodeCard 312 + key={item.id} 313 + tracked={item as never} 314 + user={user} 315 + /> 316 + ), 317 + )} 305 318 </div> 306 319 ) : ( 307 320 <M3Card variant="elevated"> ··· 366 379 </div> 367 380 ); 368 381 } 369 - 370 - function getTrackedMovieTimestamp(tracked: TrackedMovieDto): number { 371 - const dateValue = tracked.watchedDate ?? tracked.createdAt; 372 - const parsed = new Date(dateValue).getTime(); 373 - 374 - if (Number.isNaN(parsed)) { 375 - return 0; 376 - } 377 - 378 - return parsed; 379 - }
+63 -93
apps/web/src/routes/profile.shelf.tsx
··· 1 1 import { 2 2 authControllerMeOptions, 3 - moviesControllerGetUserMoviesOptions, 4 - showsControllerGetUserShowsOptions, 3 + shelfControllerGetUserShelfOptions, 5 4 } from "@opnshelf/api"; 6 5 import { useQuery } from "@tanstack/react-query"; 7 6 import { createFileRoute, Link } from "@tanstack/react-router"; 8 - import { BookOpen } from "lucide-react"; 9 - import { MovieGridSkeleton } from "@/components/MovieGrid"; 7 + import { BookOpen, Loader2 } from "lucide-react"; 8 + import { ShelfEpisodeCard } from "@/components/ShelfEpisodeCard"; 10 9 import { ShelfMovieCard } from "@/components/ShelfMovieCard"; 11 10 import { M3Button } from "@/components/ui/m3-button"; 12 11 import { ··· 31 30 retry: false, 32 31 }); 33 32 34 - const { data: trackedMovies, isLoading: isMoviesLoading } = useQuery({ 35 - ...moviesControllerGetUserMoviesOptions({ 36 - path: { userDid: user?.did || "" }, 33 + const userDid = user?.did || ""; 34 + 35 + const shelfQuery = useQuery({ 36 + ...shelfControllerGetUserShelfOptions({ 37 + path: { userDid }, 38 + query: { limit: 100 }, 37 39 }), 38 - enabled: !!user?.did, 40 + enabled: !!userDid, 39 41 }); 40 - const { data: trackedShows } = useQuery({ 41 - ...showsControllerGetUserShowsOptions({ 42 - path: { userDid: user?.did || "" }, 43 - }), 44 - enabled: !!user?.did, 45 - }); 42 + 43 + const items = shelfQuery.data?.items ?? []; 44 + const totalCount = shelfQuery.data?.total ?? 0; 46 45 47 - if (isMoviesLoading) { 48 - return <MovieGridSkeleton />; 46 + if (shelfQuery.isLoading) { 47 + return ( 48 + <div className="flex justify-center py-12"> 49 + <Loader2 className="w-8 h-8 animate-spin" /> 50 + </div> 51 + ); 52 + } 53 + 54 + if (items.length === 0) { 55 + return ( 56 + <M3Card variant="elevated" className="text-center max-w-md mx-auto"> 57 + <M3CardHeader> 58 + <BookOpen 59 + className="w-16 h-16 mx-auto mb-4" 60 + style={{ color: "var(--md-sys-color-outline)" }} 61 + /> 62 + <M3CardTitle className="md-headline-small"> 63 + Your shelf is empty 64 + </M3CardTitle> 65 + <M3CardDescription> 66 + Start tracking movies and shows you&apos;ve watched 67 + </M3CardDescription> 68 + </M3CardHeader> 69 + <M3CardContent> 70 + <M3Button variant="filled" asChild> 71 + <Link to="/search" search={{ q: "", type: "all" }}> 72 + Search for movies or shows 73 + </Link> 74 + </M3Button> 75 + </M3CardContent> 76 + </M3Card> 77 + ); 49 78 } 50 79 51 80 return ( 52 81 <div> 53 - {trackedMovies && trackedMovies.length > 0 && ( 54 - <div> 55 - <p 56 - className="mb-6 md-body-large" 57 - style={{ color: "var(--md-sys-color-on-surface-variant)" }} 58 - > 59 - {trackedMovies.length} movie 60 - {trackedMovies.length !== 1 ? "s" : ""} watched 61 - </p> 62 - <div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-4"> 63 - {trackedMovies.map((tracked) => ( 64 - <ShelfMovieCard key={tracked.id} tracked={tracked} user={user} /> 65 - ))} 66 - </div> 67 - </div> 68 - )} 69 - 70 - {trackedShows && trackedShows.length > 0 && ( 71 - <div className="mt-10"> 72 - <p 73 - className="mb-4 md-body-large" 74 - style={{ color: "var(--md-sys-color-on-surface-variant)" }} 75 - > 76 - {trackedShows.length} show 77 - {trackedShows.length !== 1 ? "s" : ""} tracked 78 - </p> 79 - <div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-3"> 80 - {trackedShows.map((tracked) => ( 81 - <Link 82 - key={tracked.showId} 83 - to="/shows/$showId/$title" 84 - params={{ 85 - showId: tracked.showId, 86 - title: tracked.show.title 87 - .toLowerCase() 88 - .replace(/[^a-z0-9]+/g, "-"), 89 - }} 90 - className="rounded-xl border p-4" 91 - style={{ borderColor: "var(--md-sys-color-outline)" }} 92 - > 93 - <div className="font-semibold">{tracked.show.title}</div> 94 - <div 95 - className="text-sm mt-1" 96 - style={{ color: "var(--md-sys-color-on-surface-variant)" }} 97 - > 98 - {tracked.watchCount} watched episode 99 - {tracked.watchCount === 1 ? "" : "s"} 100 - </div> 101 - </Link> 102 - ))} 103 - </div> 104 - </div> 105 - )} 82 + <p 83 + className="mb-6 md-body-large" 84 + style={{ color: "var(--md-sys-color-on-surface-variant)" }} 85 + > 86 + {totalCount} item{totalCount !== 1 ? "s" : ""} watched 87 + </p> 106 88 107 - {trackedMovies && 108 - trackedMovies.length === 0 && 109 - (!trackedShows || trackedShows.length === 0) && ( 110 - <M3Card variant="elevated" className="text-center max-w-md mx-auto"> 111 - <M3CardHeader> 112 - <BookOpen 113 - className="w-16 h-16 mx-auto mb-4" 114 - style={{ color: "var(--md-sys-color-outline)" }} 115 - /> 116 - <M3CardTitle className="md-headline-small"> 117 - Your shelf is empty 118 - </M3CardTitle> 119 - <M3CardDescription> 120 - Start tracking movies and shows you&apos;ve watched 121 - </M3CardDescription> 122 - </M3CardHeader> 123 - <M3CardContent> 124 - <M3Button variant="filled" asChild> 125 - <Link to="/search" search={{ q: "", type: "all" }}> 126 - Search for movies or shows 127 - </Link> 128 - </M3Button> 129 - </M3CardContent> 130 - </M3Card> 89 + <div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-4"> 90 + {items.map((item) => 91 + item.type === "movie" ? ( 92 + <ShelfMovieCard key={item.id} tracked={item as never} user={user} /> 93 + ) : ( 94 + <ShelfEpisodeCard 95 + key={item.id} 96 + tracked={item as never} 97 + user={user} 98 + /> 99 + ), 131 100 )} 101 + </div> 132 102 </div> 133 103 ); 134 104 }
+2
backend/src/app.module.ts
··· 5 5 import { ListsModule } from "./lists/lists.module"; 6 6 import { MoviesModule } from "./movies/movies.module"; 7 7 import { PrismaModule } from "./prisma/prisma.module"; 8 + import { ShelfModule } from "./shelf/shelf.module"; 8 9 import { ShowsModule } from "./shows/shows.module"; 9 10 import { UsersModule } from "./users/users.module"; 10 11 ··· 18 19 UsersModule, 19 20 ListsModule, 20 21 ShowsModule, 22 + ShelfModule, 21 23 ], 22 24 }) 23 25 export class AppModule {}
+29
backend/src/movies/dto/movie.dto.ts
··· 280 280 @ApiProperty() 281 281 watchedDate: string; 282 282 } 283 + 284 + export class PaginatedMoviesQueryDto { 285 + @ApiPropertyOptional({ 286 + description: "Number of items to return", 287 + default: 20, 288 + }) 289 + @IsOptional() 290 + @Type(() => Number) 291 + @IsInt() 292 + limit?: number; 293 + 294 + @ApiPropertyOptional({ 295 + description: "Cursor for pagination (last item ID from previous page)", 296 + }) 297 + @IsOptional() 298 + @IsString() 299 + cursor?: string; 300 + } 301 + 302 + export class PaginatedMoviesResponseDto { 303 + @ApiProperty({ type: [TrackedMovieDto] }) 304 + items: TrackedMovieDto[]; 305 + 306 + @ApiProperty({ description: "Cursor for next page (null if no more items)" }) 307 + nextCursor: string | null; 308 + 309 + @ApiProperty({ description: "Total count of items" }) 310 + total: number; 311 + }
+42
backend/src/movies/movies.controller.ts
··· 25 25 import { 26 26 type DiscoverMoviesDto, 27 27 MovieDto, 28 + PaginatedMoviesQueryDto, 29 + PaginatedMoviesResponseDto, 28 30 SearchResultsDto, 29 31 TMDBMovieDetailDto, 30 32 TrackedMovieDto, ··· 104 106 ); 105 107 106 108 return moviesWithColors; 109 + } 110 + 111 + @Get("user/:userDid/paginated") 112 + @ApiOperation({ summary: "Get paginated tracked movies for a user" }) 113 + @ApiResponse({ status: 200, type: PaginatedMoviesResponseDto }) 114 + async getUserMoviesPaginated( 115 + @Param("userDid") userDid: string, 116 + @Query() query: PaginatedMoviesQueryDto, 117 + ) { 118 + const limit = query.limit ?? 20; 119 + const result = await this.moviesService.getUserMoviesPaginated( 120 + userDid, 121 + limit, 122 + query.cursor, 123 + ); 124 + 125 + // Ensure colors for all movies 126 + const itemsWithColors = await Promise.all( 127 + result.items.map(async (item) => { 128 + const colors = await this.moviesService.ensureMovieHasColors( 129 + item.movieId, 130 + ); 131 + return { 132 + ...item, 133 + watchedDate: item.watchedDate?.toISOString(), 134 + createdAt: item.createdAt.toISOString(), 135 + updatedAt: item.updatedAt.toISOString(), 136 + movie: { 137 + ...item.movie, 138 + colors: colors ?? undefined, 139 + }, 140 + }; 141 + }), 142 + ); 143 + 144 + return { 145 + items: itemsWithColors, 146 + nextCursor: result.nextCursor, 147 + total: result.total, 148 + }; 107 149 } 108 150 109 151 @Post("watched")
+34
backend/src/movies/movies.service.ts
··· 174 174 return Array.from(movieMap.values()); 175 175 } 176 176 177 + async getUserMoviesPaginated( 178 + userDid: string, 179 + limit: number = 20, 180 + cursor?: string, 181 + ) { 182 + const take = limit + 1; // Take one extra to determine if there's a next page 183 + 184 + const movies = await this.prisma.trackedMovie.findMany({ 185 + where: { userDid }, 186 + include: { movie: true }, 187 + orderBy: { watchedDate: "desc" }, 188 + take, 189 + ...(cursor && { 190 + skip: 1, 191 + cursor: { id: cursor }, 192 + }), 193 + }); 194 + 195 + const hasMore = movies.length > limit; 196 + const items = hasMore ? movies.slice(0, limit) : movies; 197 + const nextCursor = hasMore ? items[items.length - 1]?.id : null; 198 + 199 + // Get total count 200 + const total = await this.prisma.trackedMovie.count({ 201 + where: { userDid }, 202 + }); 203 + 204 + return { 205 + items, 206 + nextCursor, 207 + total, 208 + }; 209 + } 210 + 177 211 async getMovieWatchHistory(userDid: string, movieId: string) { 178 212 return this.prisma.trackedMovie.findMany({ 179 213 where: { userDid, movieId },
+107
backend/src/shelf/shelf.controller.ts
··· 1 + import { Controller, Get, Param, Query } from "@nestjs/common"; 2 + import { ApiOperation, ApiResponse, ApiTags } from "@nestjs/swagger"; 3 + import { ShelfQueryDto, ShelfResponseDto } from "./shelf.dto"; 4 + import { ShelfService } from "./shelf.service"; 5 + 6 + @ApiTags("shelf") 7 + @Controller("shelf") 8 + export class ShelfController { 9 + constructor(private readonly shelfService: ShelfService) {} 10 + 11 + @Get("user/:userDid") 12 + @ApiOperation({ 13 + summary: "Get paginated shelf items for a user (movies and episodes)", 14 + }) 15 + @ApiResponse({ status: 200, type: ShelfResponseDto }) 16 + async getUserShelf( 17 + @Param("userDid") userDid: string, 18 + @Query() query: ShelfQueryDto, 19 + ): Promise<ShelfResponseDto> { 20 + const limit = query.limit ?? 20; 21 + const result = await this.shelfService.getUserShelf( 22 + userDid, 23 + limit, 24 + query.cursor, 25 + ); 26 + 27 + // Transform items to DTO format 28 + const items = result.items.map((item) => { 29 + if (item.type === "movie") { 30 + const movieData = item.data as { 31 + id: string; 32 + movieId: string; 33 + title: string; 34 + posterPath?: string; 35 + backdropPath?: string; 36 + releaseYear?: number; 37 + overview?: string; 38 + colors?: unknown; 39 + watchedDate: Date | null; 40 + createdAt: Date; 41 + }; 42 + return { 43 + id: movieData.id, 44 + type: "movie" as const, 45 + movieId: movieData.movieId, 46 + title: movieData.title, 47 + posterPath: movieData.posterPath, 48 + backdropPath: movieData.backdropPath, 49 + releaseYear: movieData.releaseYear, 50 + overview: movieData.overview, 51 + colors: movieData.colors as 52 + | { 53 + primary?: string; 54 + secondary?: string; 55 + accent?: string; 56 + muted?: string; 57 + } 58 + | undefined, 59 + watchedDate: movieData.watchedDate?.toISOString(), 60 + createdAt: movieData.createdAt.toISOString(), 61 + }; 62 + } 63 + const episodeData = item.data as { 64 + id: string; 65 + showId: string; 66 + showTitle: string; 67 + seasonNumber: number; 68 + episodeNumber: number; 69 + posterPath?: string; 70 + backdropPath?: string; 71 + firstAirYear?: number; 72 + overview?: string; 73 + colors?: unknown; 74 + watchedDate: Date | null; 75 + createdAt: Date; 76 + }; 77 + return { 78 + id: episodeData.id, 79 + type: "episode" as const, 80 + showId: episodeData.showId, 81 + showTitle: episodeData.showTitle, 82 + seasonNumber: episodeData.seasonNumber, 83 + episodeNumber: episodeData.episodeNumber, 84 + posterPath: episodeData.posterPath, 85 + backdropPath: episodeData.backdropPath, 86 + firstAirYear: episodeData.firstAirYear, 87 + overview: episodeData.overview, 88 + colors: episodeData.colors as 89 + | { 90 + primary?: string; 91 + secondary?: string; 92 + accent?: string; 93 + muted?: string; 94 + } 95 + | undefined, 96 + watchedDate: episodeData.watchedDate?.toISOString(), 97 + createdAt: episodeData.createdAt.toISOString(), 98 + }; 99 + }); 100 + 101 + return { 102 + items, 103 + nextCursor: result.nextCursor, 104 + total: result.total, 105 + }; 106 + } 107 + }
+161
backend/src/shelf/shelf.dto.ts
··· 1 + import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger"; 2 + import { Type } from "class-transformer"; 3 + import { IsInt, IsOptional, IsString } from "class-validator"; 4 + import { MovieColorsDto } from "../movies/dto/movie.dto"; 5 + 6 + export class ShelfItemMovieDto { 7 + @ApiProperty() 8 + id: string; 9 + 10 + @ApiProperty({ enum: ["movie"] }) 11 + type: "movie"; 12 + 13 + @ApiProperty() 14 + movieId: string; 15 + 16 + @ApiProperty() 17 + title: string; 18 + 19 + @ApiPropertyOptional() 20 + posterPath?: string; 21 + 22 + @ApiPropertyOptional() 23 + backdropPath?: string; 24 + 25 + @ApiPropertyOptional() 26 + releaseYear?: number; 27 + 28 + @ApiPropertyOptional() 29 + overview?: string; 30 + 31 + @ApiPropertyOptional({ type: MovieColorsDto }) 32 + colors?: MovieColorsDto; 33 + 34 + @ApiPropertyOptional() 35 + watchedDate?: string; 36 + 37 + @ApiProperty() 38 + createdAt: string; 39 + } 40 + 41 + export class ShelfItemEpisodeDto { 42 + @ApiProperty() 43 + id: string; 44 + 45 + @ApiProperty({ enum: ["episode"] }) 46 + type: "episode"; 47 + 48 + @ApiProperty() 49 + showId: string; 50 + 51 + @ApiProperty() 52 + showTitle: string; 53 + 54 + @ApiProperty() 55 + seasonNumber: number; 56 + 57 + @ApiProperty() 58 + episodeNumber: number; 59 + 60 + @ApiPropertyOptional() 61 + posterPath?: string; 62 + 63 + @ApiPropertyOptional() 64 + backdropPath?: string; 65 + 66 + @ApiPropertyOptional() 67 + firstAirYear?: number; 68 + 69 + @ApiPropertyOptional() 70 + overview?: string; 71 + 72 + @ApiPropertyOptional({ type: MovieColorsDto }) 73 + colors?: MovieColorsDto; 74 + 75 + @ApiPropertyOptional() 76 + watchedDate?: string; 77 + 78 + @ApiProperty() 79 + createdAt: string; 80 + } 81 + 82 + export const SHELF_ITEM_MOVIE_SCHEMA = { 83 + type: "object", 84 + properties: { 85 + id: { type: "string" }, 86 + type: { type: "string", enum: ["movie"] }, 87 + movieId: { type: "string" }, 88 + title: { type: "string" }, 89 + posterPath: { type: "string" }, 90 + backdropPath: { type: "string" }, 91 + releaseYear: { type: "number" }, 92 + overview: { type: "string" }, 93 + colors: { type: "object" }, 94 + watchedDate: { type: "string" }, 95 + createdAt: { type: "string" }, 96 + }, 97 + required: ["id", "type", "movieId", "title", "createdAt"], 98 + }; 99 + 100 + export const SHELF_ITEM_EPISODE_SCHEMA = { 101 + type: "object", 102 + properties: { 103 + id: { type: "string" }, 104 + type: { type: "string", enum: ["episode"] }, 105 + showId: { type: "string" }, 106 + showTitle: { type: "string" }, 107 + seasonNumber: { type: "number" }, 108 + episodeNumber: { type: "number" }, 109 + posterPath: { type: "string" }, 110 + backdropPath: { type: "string" }, 111 + firstAirYear: { type: "number" }, 112 + overview: { type: "string" }, 113 + colors: { type: "object" }, 114 + watchedDate: { type: "string" }, 115 + createdAt: { type: "string" }, 116 + }, 117 + required: [ 118 + "id", 119 + "type", 120 + "showId", 121 + "showTitle", 122 + "seasonNumber", 123 + "episodeNumber", 124 + "createdAt", 125 + ], 126 + }; 127 + 128 + export class ShelfQueryDto { 129 + @ApiPropertyOptional({ 130 + description: "Number of items to return", 131 + default: 20, 132 + }) 133 + @IsOptional() 134 + @Type(() => Number) 135 + @IsInt() 136 + limit?: number; 137 + 138 + @ApiPropertyOptional({ 139 + description: 140 + "Cursor for pagination (last item watchedDate from previous page)", 141 + }) 142 + @IsOptional() 143 + @IsString() 144 + cursor?: string; 145 + } 146 + 147 + export class ShelfResponseDto { 148 + @ApiProperty({ 149 + type: "array", 150 + items: { 151 + oneOf: [SHELF_ITEM_MOVIE_SCHEMA, SHELF_ITEM_EPISODE_SCHEMA], 152 + }, 153 + }) 154 + items: Array<ShelfItemMovieDto | ShelfItemEpisodeDto>; 155 + 156 + @ApiProperty({ description: "Cursor for next page (null if no more items)" }) 157 + nextCursor: string | null; 158 + 159 + @ApiProperty({ description: "Total count of items" }) 160 + total: number; 161 + }
+12
backend/src/shelf/shelf.module.ts
··· 1 + import { Module } from "@nestjs/common"; 2 + import { ColorExtractionService } from "../movies/color-extraction.service"; 3 + import { PrismaModule } from "../prisma/prisma.module"; 4 + import { ShelfController } from "./shelf.controller"; 5 + import { ShelfService } from "./shelf.service"; 6 + 7 + @Module({ 8 + imports: [PrismaModule], 9 + controllers: [ShelfController], 10 + providers: [ShelfService, ColorExtractionService], 11 + }) 12 + export class ShelfModule {}
+253
backend/src/shelf/shelf.service.ts
··· 1 + import { Injectable } from "@nestjs/common"; 2 + import { PrismaService } from "../prisma/prisma.service"; 3 + import { ColorExtractionService } from "../movies/color-extraction.service"; 4 + 5 + interface ShelfItem { 6 + id: string; 7 + type: "movie" | "episode"; 8 + watchedDate: Date | null; 9 + createdAt: Date; 10 + data: MovieData | EpisodeData; 11 + } 12 + 13 + interface MovieData { 14 + id: string; 15 + movieId: string; 16 + title: string; 17 + posterPath?: string; 18 + backdropPath?: string; 19 + releaseYear?: number; 20 + releaseDate?: Date; 21 + overview?: string; 22 + colors?: unknown; 23 + watchedDate: Date | null; 24 + createdAt: Date; 25 + } 26 + 27 + interface EpisodeData { 28 + id: string; 29 + showId: string; 30 + showTitle: string; 31 + seasonNumber: number; 32 + episodeNumber: number; 33 + posterPath?: string; 34 + backdropPath?: string; 35 + firstAirYear?: number; 36 + firstAirDate?: Date; 37 + overview?: string; 38 + colors?: unknown; 39 + watchedDate: Date | null; 40 + createdAt: Date; 41 + } 42 + 43 + @Injectable() 44 + export class ShelfService { 45 + constructor( 46 + private prisma: PrismaService, 47 + private colorExtraction: ColorExtractionService, 48 + ) {} 49 + 50 + async getUserShelf( 51 + userDid: string, 52 + limit: number = 20, 53 + cursor?: string, 54 + ): Promise<{ 55 + items: ShelfItem[]; 56 + nextCursor: string | null; 57 + total: number; 58 + }> { 59 + // Fetch all tracked movies with colors 60 + const trackedMovies = await this.prisma.trackedMovie.findMany({ 61 + where: { userDid }, 62 + include: { movie: true }, 63 + orderBy: { watchedDate: "desc" }, 64 + }); 65 + 66 + // Fetch all tracked episodes with colors 67 + const trackedEpisodes = await this.prisma.trackedEpisode.findMany({ 68 + where: { userDid }, 69 + include: { show: true }, 70 + orderBy: { watchedDate: "desc" }, 71 + }); 72 + 73 + // Convert to shelf items with ensured colors 74 + const movieItems: ShelfItem[] = await Promise.all( 75 + trackedMovies.map(async (tracked) => { 76 + const colors = await this.ensureMovieHasColors(tracked.movieId); 77 + return { 78 + id: `movie-${tracked.id}`, 79 + type: "movie" as const, 80 + watchedDate: tracked.watchedDate, 81 + createdAt: tracked.createdAt, 82 + data: { 83 + id: tracked.id, 84 + movieId: tracked.movieId, 85 + title: tracked.movie.title, 86 + posterPath: tracked.movie.posterPath ?? undefined, 87 + backdropPath: tracked.movie.backdropPath ?? undefined, 88 + releaseYear: tracked.movie.releaseYear ?? undefined, 89 + releaseDate: tracked.movie.releaseDate ?? undefined, 90 + overview: tracked.movie.overview ?? undefined, 91 + colors, 92 + watchedDate: tracked.watchedDate, 93 + createdAt: tracked.createdAt, 94 + }, 95 + }; 96 + }), 97 + ); 98 + 99 + const episodeItems: ShelfItem[] = await Promise.all( 100 + trackedEpisodes.map(async (tracked) => { 101 + const colors = await this.ensureShowHasColors(tracked.showId); 102 + return { 103 + id: `episode-${tracked.id}`, 104 + type: "episode" as const, 105 + watchedDate: tracked.watchedDate, 106 + createdAt: tracked.createdAt, 107 + data: { 108 + id: tracked.id, 109 + showId: tracked.showId, 110 + showTitle: tracked.show.title, 111 + seasonNumber: tracked.seasonNumber, 112 + episodeNumber: tracked.episodeNumber, 113 + posterPath: tracked.show.posterPath ?? undefined, 114 + backdropPath: tracked.show.backdropPath ?? undefined, 115 + firstAirYear: tracked.show.firstAirYear ?? undefined, 116 + firstAirDate: tracked.show.firstAirDate ?? undefined, 117 + overview: tracked.show.overview ?? undefined, 118 + colors, 119 + watchedDate: tracked.watchedDate, 120 + createdAt: tracked.createdAt, 121 + }, 122 + }; 123 + }), 124 + ); 125 + 126 + // Merge and sort by watchedDate (newest first) 127 + const allItems = [...movieItems, ...episodeItems].sort((a, b) => { 128 + const dateA = a.watchedDate?.getTime() ?? a.createdAt.getTime(); 129 + const dateB = b.watchedDate?.getTime() ?? b.createdAt.getTime(); 130 + return dateB - dateA; 131 + }); 132 + 133 + const total = allItems.length; 134 + 135 + // Apply cursor-based pagination 136 + let startIndex = 0; 137 + if (cursor) { 138 + const cursorIndex = allItems.findIndex((item) => { 139 + // cursor is the watchedDate timestamp of the last item from previous page 140 + const itemDate = 141 + item.watchedDate?.toISOString() ?? item.createdAt.toISOString(); 142 + return itemDate === cursor; 143 + }); 144 + if (cursorIndex !== -1) { 145 + startIndex = cursorIndex + 1; 146 + } 147 + } 148 + 149 + const paginatedItems = allItems.slice(startIndex, startIndex + limit); 150 + const hasMore = startIndex + limit < allItems.length; 151 + 152 + // Generate next cursor from the last item's watchedDate 153 + const nextCursor = 154 + hasMore && paginatedItems.length > 0 155 + ? (paginatedItems[ 156 + paginatedItems.length - 1 157 + ].watchedDate?.toISOString() ?? 158 + paginatedItems[paginatedItems.length - 1].createdAt.toISOString()) 159 + : null; 160 + 161 + return { 162 + items: paginatedItems, 163 + nextCursor, 164 + total, 165 + }; 166 + } 167 + 168 + private async ensureMovieHasColors(movieId: string): Promise<{ 169 + primary?: string; 170 + secondary?: string; 171 + accent?: string; 172 + muted?: string; 173 + } | null> { 174 + const movie = await this.prisma.movie.findUnique({ 175 + where: { movieId }, 176 + select: { 177 + posterPath: true, 178 + colors: true, 179 + }, 180 + }); 181 + 182 + if (!movie) { 183 + return null; 184 + } 185 + 186 + const existingColors = movie.colors as { 187 + primary?: string; 188 + secondary?: string; 189 + accent?: string; 190 + muted?: string; 191 + } | null; 192 + 193 + if (existingColors?.primary) { 194 + return existingColors; 195 + } 196 + 197 + const colors = await this.colorExtraction.extractColorsFromPoster( 198 + movie.posterPath, 199 + ); 200 + 201 + if (colors) { 202 + await this.prisma.movie.update({ 203 + where: { movieId }, 204 + data: { colors }, 205 + }); 206 + } 207 + 208 + return colors ?? existingColors ?? null; 209 + } 210 + 211 + private async ensureShowHasColors(showId: string): Promise<{ 212 + primary?: string; 213 + secondary?: string; 214 + accent?: string; 215 + muted?: string; 216 + } | null> { 217 + const show = await this.prisma.show.findUnique({ 218 + where: { showId }, 219 + select: { 220 + posterPath: true, 221 + colors: true, 222 + }, 223 + }); 224 + 225 + if (!show) { 226 + return null; 227 + } 228 + 229 + const existingColors = show.colors as { 230 + primary?: string; 231 + secondary?: string; 232 + accent?: string; 233 + muted?: string; 234 + } | null; 235 + 236 + if (existingColors?.primary) { 237 + return existingColors; 238 + } 239 + 240 + const colors = await this.colorExtraction.extractColorsFromPoster( 241 + show.posterPath, 242 + ); 243 + 244 + if (colors) { 245 + await this.prisma.show.update({ 246 + where: { showId }, 247 + data: { colors }, 248 + }); 249 + } 250 + 251 + return colors ?? existingColors ?? null; 252 + } 253 + }
+29
backend/src/shows/dto/show.dto.ts
··· 282 282 @ApiProperty() 283 283 episodeNumber: number; 284 284 } 285 + 286 + export class PaginatedEpisodesQueryDto { 287 + @ApiPropertyOptional({ 288 + description: "Number of items to return", 289 + default: 20, 290 + }) 291 + @IsOptional() 292 + @Type(() => Number) 293 + @IsInt() 294 + limit?: number; 295 + 296 + @ApiPropertyOptional({ 297 + description: "Cursor for pagination (last item ID from previous page)", 298 + }) 299 + @IsOptional() 300 + @IsString() 301 + cursor?: string; 302 + } 303 + 304 + export class PaginatedEpisodesResponseDto { 305 + @ApiProperty({ type: [TrackedEpisodeDto] }) 306 + items: TrackedEpisodeDto[]; 307 + 308 + @ApiProperty({ description: "Cursor for next page (null if no more items)" }) 309 + nextCursor: string | null; 310 + 311 + @ApiProperty({ description: "Total count of items" }) 312 + total: number; 313 + }
+40
backend/src/shows/shows.controller.ts
··· 26 26 type DiscoverShowsDto, 27 27 EpisodeHistoryItemDto, 28 28 MarkEpisodeWatchedDto, 29 + PaginatedEpisodesQueryDto, 30 + PaginatedEpisodesResponseDto, 29 31 SearchShowsResultsDto, 30 32 TMDBEpisodeDto, 31 33 TMDBSeasonDetailDto, ··· 124 126 }), 125 127 ); 126 128 return showsWithColors; 129 + } 130 + 131 + @Get("user/:userDid/episodes") 132 + @ApiOperation({ summary: "Get paginated watched episodes for a user" }) 133 + @ApiResponse({ status: 200, type: PaginatedEpisodesResponseDto }) 134 + async getUserEpisodesPaginated( 135 + @Param("userDid") userDid: string, 136 + @Query() query: PaginatedEpisodesQueryDto, 137 + ) { 138 + const limit = query.limit ?? 20; 139 + const result = await this.showsService.getUserEpisodesPaginated( 140 + userDid, 141 + limit, 142 + query.cursor, 143 + ); 144 + 145 + // Ensure colors for all shows 146 + const itemsWithColors = await Promise.all( 147 + result.items.map(async (item) => { 148 + const colors = await this.showsService.ensureShowHasColors(item.showId); 149 + return { 150 + ...item, 151 + watchedDate: item.watchedDate?.toISOString(), 152 + createdAt: item.createdAt.toISOString(), 153 + updatedAt: item.updatedAt.toISOString(), 154 + show: { 155 + ...item.show, 156 + colors: colors ?? undefined, 157 + }, 158 + }; 159 + }), 160 + ); 161 + 162 + return { 163 + items: itemsWithColors, 164 + nextCursor: result.nextCursor, 165 + total: result.total, 166 + }; 127 167 } 128 168 129 169 @Post("watched")
+34
backend/src/shows/shows.service.ts
··· 303 303 return Array.from(showMap.values()); 304 304 } 305 305 306 + async getUserEpisodesPaginated( 307 + userDid: string, 308 + limit: number = 20, 309 + cursor?: string, 310 + ) { 311 + const take = limit + 1; // Take one extra to determine if there's a next page 312 + 313 + const episodes = await this.prisma.trackedEpisode.findMany({ 314 + where: { userDid }, 315 + include: { show: true }, 316 + orderBy: { watchedDate: "desc" }, 317 + take, 318 + ...(cursor && { 319 + skip: 1, 320 + cursor: { id: cursor }, 321 + }), 322 + }); 323 + 324 + const hasMore = episodes.length > limit; 325 + const items = hasMore ? episodes.slice(0, limit) : episodes; 326 + const nextCursor = hasMore ? items[items.length - 1]?.id : null; 327 + 328 + // Get total count 329 + const total = await this.prisma.trackedEpisode.count({ 330 + where: { userDid }, 331 + }); 332 + 333 + return { 334 + items, 335 + nextCursor, 336 + total, 337 + }; 338 + } 339 + 306 340 async getEpisodeWatchHistory(userDid: string, showId: string) { 307 341 return this.prisma.trackedEpisode.findMany({ 308 342 where: { userDid, showId },
+167 -3
packages/api/src/generated/@tanstack/react-query.gen.ts
··· 1 1 // This file is auto-generated by @hey-api/openapi-ts 2 2 3 - import { type DefaultError, queryOptions, type UseMutationOptions } from '@tanstack/react-query'; 3 + import { type DefaultError, type InfiniteData, infiniteQueryOptions, queryOptions, type UseMutationOptions } from '@tanstack/react-query'; 4 4 5 5 import { client } from '../client.gen'; 6 - import { authControllerCallback, authControllerGetClientMetadata, authControllerLogin, authControllerLogout, authControllerMe, authControllerSuggestions, listsControllerAddItemToList, listsControllerAddToList, listsControllerCreateList, listsControllerDeleteList, listsControllerGetList, listsControllerGetListsForItem, listsControllerGetListsForMovie, listsControllerGetUserLists, listsControllerInitDefaultLists, listsControllerRemoveFromList, listsControllerRemoveItemFromList, listsControllerUpdateList, moviesControllerDeleteWatchHistoryEntry, moviesControllerDiscoverMovies, moviesControllerGetMovie, moviesControllerGetMovieDetails, moviesControllerGetMovieWatchHistory, moviesControllerGetUserMovies, moviesControllerMarkWatched, moviesControllerSearchMovies, moviesControllerUnmarkWatched, type Options, showsControllerDeleteEpisodeWatchHistoryEntry, showsControllerDiscoverShows, showsControllerGetEpisodeDetails, showsControllerGetSeasonDetails, showsControllerGetShow, showsControllerGetShowDetails, showsControllerGetShowWatchHistory, showsControllerGetUserShows, showsControllerMarkWatched, showsControllerSearchShows, showsControllerUnmarkWatched, usersControllerDeleteMyAccount, usersControllerGetMySettings, usersControllerUpdateMySettings } from '../sdk.gen'; 7 - import type { AuthControllerCallbackData, AuthControllerGetClientMetadataData, AuthControllerLoginData, AuthControllerLogoutData, AuthControllerMeData, AuthControllerMeResponse, AuthControllerSuggestionsData, ListsControllerAddItemToListData, ListsControllerAddToListData, ListsControllerCreateListData, ListsControllerCreateListResponse, ListsControllerDeleteListData, ListsControllerGetListData, ListsControllerGetListResponse, ListsControllerGetListsForItemData, ListsControllerGetListsForItemResponse, ListsControllerGetListsForMovieData, ListsControllerGetUserListsData, ListsControllerGetUserListsResponse, ListsControllerInitDefaultListsData, ListsControllerInitDefaultListsResponse, ListsControllerRemoveFromListData, ListsControllerRemoveItemFromListData, ListsControllerUpdateListData, ListsControllerUpdateListResponse, MoviesControllerDeleteWatchHistoryEntryData, MoviesControllerDeleteWatchHistoryEntryResponse, MoviesControllerDiscoverMoviesData, MoviesControllerDiscoverMoviesResponse, MoviesControllerGetMovieData, MoviesControllerGetMovieDetailsData, MoviesControllerGetMovieDetailsResponse, MoviesControllerGetMovieResponse, MoviesControllerGetMovieWatchHistoryData, MoviesControllerGetMovieWatchHistoryResponse, MoviesControllerGetUserMoviesData, MoviesControllerGetUserMoviesResponse, MoviesControllerMarkWatchedData, MoviesControllerMarkWatchedResponse, MoviesControllerSearchMoviesData, MoviesControllerSearchMoviesResponse, MoviesControllerUnmarkWatchedData, MoviesControllerUnmarkWatchedResponse, ShowsControllerDeleteEpisodeWatchHistoryEntryData, ShowsControllerDeleteEpisodeWatchHistoryEntryResponse, ShowsControllerDiscoverShowsData, ShowsControllerDiscoverShowsResponse, ShowsControllerGetEpisodeDetailsData, ShowsControllerGetEpisodeDetailsResponse, ShowsControllerGetSeasonDetailsData, ShowsControllerGetSeasonDetailsResponse, ShowsControllerGetShowData, ShowsControllerGetShowDetailsData, ShowsControllerGetShowDetailsResponse, ShowsControllerGetShowResponse, ShowsControllerGetShowWatchHistoryData, ShowsControllerGetShowWatchHistoryResponse, ShowsControllerGetUserShowsData, ShowsControllerGetUserShowsResponse, ShowsControllerMarkWatchedData, ShowsControllerMarkWatchedResponse, ShowsControllerSearchShowsData, ShowsControllerSearchShowsResponse, ShowsControllerUnmarkWatchedData, ShowsControllerUnmarkWatchedResponse, UsersControllerDeleteMyAccountData, UsersControllerDeleteMyAccountResponse, UsersControllerGetMySettingsData, UsersControllerGetMySettingsResponse, UsersControllerUpdateMySettingsData, UsersControllerUpdateMySettingsResponse } from '../types.gen'; 6 + import { authControllerCallback, authControllerGetClientMetadata, authControllerLogin, authControllerLogout, authControllerMe, authControllerSuggestions, listsControllerAddItemToList, listsControllerAddToList, listsControllerCreateList, listsControllerDeleteList, listsControllerGetList, listsControllerGetListsForItem, listsControllerGetListsForMovie, listsControllerGetUserLists, listsControllerInitDefaultLists, listsControllerRemoveFromList, listsControllerRemoveItemFromList, listsControllerUpdateList, moviesControllerDeleteWatchHistoryEntry, moviesControllerDiscoverMovies, moviesControllerGetMovie, moviesControllerGetMovieDetails, moviesControllerGetMovieWatchHistory, moviesControllerGetUserMovies, moviesControllerGetUserMoviesPaginated, moviesControllerMarkWatched, moviesControllerSearchMovies, moviesControllerUnmarkWatched, type Options, shelfControllerGetUserShelf, showsControllerDeleteEpisodeWatchHistoryEntry, showsControllerDiscoverShows, showsControllerGetEpisodeDetails, showsControllerGetSeasonDetails, showsControllerGetShow, showsControllerGetShowDetails, showsControllerGetShowWatchHistory, showsControllerGetUserEpisodesPaginated, showsControllerGetUserShows, showsControllerMarkWatched, showsControllerSearchShows, showsControllerUnmarkWatched, usersControllerDeleteMyAccount, usersControllerGetMySettings, usersControllerUpdateMySettings } from '../sdk.gen'; 7 + import type { AuthControllerCallbackData, AuthControllerGetClientMetadataData, AuthControllerLoginData, AuthControllerLogoutData, AuthControllerMeData, AuthControllerMeResponse, AuthControllerSuggestionsData, ListsControllerAddItemToListData, ListsControllerAddToListData, ListsControllerCreateListData, ListsControllerCreateListResponse, ListsControllerDeleteListData, ListsControllerGetListData, ListsControllerGetListResponse, ListsControllerGetListsForItemData, ListsControllerGetListsForItemResponse, ListsControllerGetListsForMovieData, ListsControllerGetUserListsData, ListsControllerGetUserListsResponse, ListsControllerInitDefaultListsData, ListsControllerInitDefaultListsResponse, ListsControllerRemoveFromListData, ListsControllerRemoveItemFromListData, ListsControllerUpdateListData, ListsControllerUpdateListResponse, MoviesControllerDeleteWatchHistoryEntryData, MoviesControllerDeleteWatchHistoryEntryResponse, MoviesControllerDiscoverMoviesData, MoviesControllerDiscoverMoviesResponse, MoviesControllerGetMovieData, MoviesControllerGetMovieDetailsData, MoviesControllerGetMovieDetailsResponse, MoviesControllerGetMovieResponse, MoviesControllerGetMovieWatchHistoryData, MoviesControllerGetMovieWatchHistoryResponse, MoviesControllerGetUserMoviesData, MoviesControllerGetUserMoviesPaginatedData, MoviesControllerGetUserMoviesPaginatedResponse, MoviesControllerGetUserMoviesResponse, MoviesControllerMarkWatchedData, MoviesControllerMarkWatchedResponse, MoviesControllerSearchMoviesData, MoviesControllerSearchMoviesResponse, MoviesControllerUnmarkWatchedData, MoviesControllerUnmarkWatchedResponse, ShelfControllerGetUserShelfData, ShelfControllerGetUserShelfResponse, ShowsControllerDeleteEpisodeWatchHistoryEntryData, ShowsControllerDeleteEpisodeWatchHistoryEntryResponse, ShowsControllerDiscoverShowsData, ShowsControllerDiscoverShowsResponse, ShowsControllerGetEpisodeDetailsData, ShowsControllerGetEpisodeDetailsResponse, ShowsControllerGetSeasonDetailsData, ShowsControllerGetSeasonDetailsResponse, ShowsControllerGetShowData, ShowsControllerGetShowDetailsData, ShowsControllerGetShowDetailsResponse, ShowsControllerGetShowResponse, ShowsControllerGetShowWatchHistoryData, ShowsControllerGetShowWatchHistoryResponse, ShowsControllerGetUserEpisodesPaginatedData, ShowsControllerGetUserEpisodesPaginatedResponse, ShowsControllerGetUserShowsData, ShowsControllerGetUserShowsResponse, ShowsControllerMarkWatchedData, ShowsControllerMarkWatchedResponse, ShowsControllerSearchShowsData, ShowsControllerSearchShowsResponse, ShowsControllerUnmarkWatchedData, ShowsControllerUnmarkWatchedResponse, UsersControllerDeleteMyAccountData, UsersControllerDeleteMyAccountResponse, UsersControllerGetMySettingsData, UsersControllerGetMySettingsResponse, UsersControllerUpdateMySettingsData, UsersControllerUpdateMySettingsResponse } from '../types.gen'; 8 8 9 9 export type QueryKey<TOptions extends Options> = [ 10 10 Pick<TOptions, 'baseUrl' | 'body' | 'headers' | 'path' | 'query'> & { ··· 109 109 return data; 110 110 }, 111 111 queryKey: moviesControllerGetUserMoviesQueryKey(options) 112 + }); 113 + 114 + export const moviesControllerGetUserMoviesPaginatedQueryKey = (options: Options<MoviesControllerGetUserMoviesPaginatedData>) => createQueryKey('moviesControllerGetUserMoviesPaginated', options); 115 + 116 + /** 117 + * Get paginated tracked movies for a user 118 + */ 119 + export const moviesControllerGetUserMoviesPaginatedOptions = (options: Options<MoviesControllerGetUserMoviesPaginatedData>) => queryOptions<MoviesControllerGetUserMoviesPaginatedResponse, DefaultError, MoviesControllerGetUserMoviesPaginatedResponse, ReturnType<typeof moviesControllerGetUserMoviesPaginatedQueryKey>>({ 120 + queryFn: async ({ queryKey, signal }) => { 121 + const { data } = await moviesControllerGetUserMoviesPaginated({ 122 + ...options, 123 + ...queryKey[0], 124 + signal, 125 + throwOnError: true 126 + }); 127 + return data; 128 + }, 129 + queryKey: moviesControllerGetUserMoviesPaginatedQueryKey(options) 130 + }); 131 + 132 + const createInfiniteParams = <K extends Pick<QueryKey<Options>[0], 'body' | 'headers' | 'path' | 'query'>>(queryKey: QueryKey<Options>, page: K) => { 133 + const params = { ...queryKey[0] }; 134 + if (page.body) { 135 + params.body = { 136 + ...queryKey[0].body as any, 137 + ...page.body as any 138 + }; 139 + } 140 + if (page.headers) { 141 + params.headers = { 142 + ...queryKey[0].headers, 143 + ...page.headers 144 + }; 145 + } 146 + if (page.path) { 147 + params.path = { 148 + ...queryKey[0].path as any, 149 + ...page.path as any 150 + }; 151 + } 152 + if (page.query) { 153 + params.query = { 154 + ...queryKey[0].query as any, 155 + ...page.query as any 156 + }; 157 + } 158 + return params as unknown as typeof page; 159 + }; 160 + 161 + export const moviesControllerGetUserMoviesPaginatedInfiniteQueryKey = (options: Options<MoviesControllerGetUserMoviesPaginatedData>): QueryKey<Options<MoviesControllerGetUserMoviesPaginatedData>> => createQueryKey('moviesControllerGetUserMoviesPaginated', options, true); 162 + 163 + /** 164 + * Get paginated tracked movies for a user 165 + */ 166 + export const moviesControllerGetUserMoviesPaginatedInfiniteOptions = (options: Options<MoviesControllerGetUserMoviesPaginatedData>) => infiniteQueryOptions<MoviesControllerGetUserMoviesPaginatedResponse, DefaultError, InfiniteData<MoviesControllerGetUserMoviesPaginatedResponse>, QueryKey<Options<MoviesControllerGetUserMoviesPaginatedData>>, string | Pick<QueryKey<Options<MoviesControllerGetUserMoviesPaginatedData>>[0], 'body' | 'headers' | 'path' | 'query'>>( 167 + // @ts-ignore 168 + { 169 + queryFn: async ({ pageParam, queryKey, signal }) => { 170 + // @ts-ignore 171 + const page: Pick<QueryKey<Options<MoviesControllerGetUserMoviesPaginatedData>>[0], 'body' | 'headers' | 'path' | 'query'> = typeof pageParam === 'object' ? pageParam : { 172 + query: { 173 + cursor: pageParam 174 + } 175 + }; 176 + const params = createInfiniteParams(queryKey, page); 177 + const { data } = await moviesControllerGetUserMoviesPaginated({ 178 + ...options, 179 + ...params, 180 + signal, 181 + throwOnError: true 182 + }); 183 + return data; 184 + }, 185 + queryKey: moviesControllerGetUserMoviesPaginatedInfiniteQueryKey(options) 112 186 }); 113 187 114 188 /** ··· 413 487 queryKey: showsControllerGetUserShowsQueryKey(options) 414 488 }); 415 489 490 + export const showsControllerGetUserEpisodesPaginatedQueryKey = (options: Options<ShowsControllerGetUserEpisodesPaginatedData>) => createQueryKey('showsControllerGetUserEpisodesPaginated', options); 491 + 492 + /** 493 + * Get paginated watched episodes for a user 494 + */ 495 + export const showsControllerGetUserEpisodesPaginatedOptions = (options: Options<ShowsControllerGetUserEpisodesPaginatedData>) => queryOptions<ShowsControllerGetUserEpisodesPaginatedResponse, DefaultError, ShowsControllerGetUserEpisodesPaginatedResponse, ReturnType<typeof showsControllerGetUserEpisodesPaginatedQueryKey>>({ 496 + queryFn: async ({ queryKey, signal }) => { 497 + const { data } = await showsControllerGetUserEpisodesPaginated({ 498 + ...options, 499 + ...queryKey[0], 500 + signal, 501 + throwOnError: true 502 + }); 503 + return data; 504 + }, 505 + queryKey: showsControllerGetUserEpisodesPaginatedQueryKey(options) 506 + }); 507 + 508 + export const showsControllerGetUserEpisodesPaginatedInfiniteQueryKey = (options: Options<ShowsControllerGetUserEpisodesPaginatedData>): QueryKey<Options<ShowsControllerGetUserEpisodesPaginatedData>> => createQueryKey('showsControllerGetUserEpisodesPaginated', options, true); 509 + 510 + /** 511 + * Get paginated watched episodes for a user 512 + */ 513 + export const showsControllerGetUserEpisodesPaginatedInfiniteOptions = (options: Options<ShowsControllerGetUserEpisodesPaginatedData>) => infiniteQueryOptions<ShowsControllerGetUserEpisodesPaginatedResponse, DefaultError, InfiniteData<ShowsControllerGetUserEpisodesPaginatedResponse>, QueryKey<Options<ShowsControllerGetUserEpisodesPaginatedData>>, string | Pick<QueryKey<Options<ShowsControllerGetUserEpisodesPaginatedData>>[0], 'body' | 'headers' | 'path' | 'query'>>( 514 + // @ts-ignore 515 + { 516 + queryFn: async ({ pageParam, queryKey, signal }) => { 517 + // @ts-ignore 518 + const page: Pick<QueryKey<Options<ShowsControllerGetUserEpisodesPaginatedData>>[0], 'body' | 'headers' | 'path' | 'query'> = typeof pageParam === 'object' ? pageParam : { 519 + query: { 520 + cursor: pageParam 521 + } 522 + }; 523 + const params = createInfiniteParams(queryKey, page); 524 + const { data } = await showsControllerGetUserEpisodesPaginated({ 525 + ...options, 526 + ...params, 527 + signal, 528 + throwOnError: true 529 + }); 530 + return data; 531 + }, 532 + queryKey: showsControllerGetUserEpisodesPaginatedInfiniteQueryKey(options) 533 + }); 534 + 416 535 /** 417 536 * Mark an episode as watched 418 537 */ ··· 759 878 }; 760 879 return mutationOptions; 761 880 }; 881 + 882 + export const shelfControllerGetUserShelfQueryKey = (options: Options<ShelfControllerGetUserShelfData>) => createQueryKey('shelfControllerGetUserShelf', options); 883 + 884 + /** 885 + * Get paginated shelf items for a user (movies and episodes) 886 + */ 887 + export const shelfControllerGetUserShelfOptions = (options: Options<ShelfControllerGetUserShelfData>) => queryOptions<ShelfControllerGetUserShelfResponse, DefaultError, ShelfControllerGetUserShelfResponse, ReturnType<typeof shelfControllerGetUserShelfQueryKey>>({ 888 + queryFn: async ({ queryKey, signal }) => { 889 + const { data } = await shelfControllerGetUserShelf({ 890 + ...options, 891 + ...queryKey[0], 892 + signal, 893 + throwOnError: true 894 + }); 895 + return data; 896 + }, 897 + queryKey: shelfControllerGetUserShelfQueryKey(options) 898 + }); 899 + 900 + export const shelfControllerGetUserShelfInfiniteQueryKey = (options: Options<ShelfControllerGetUserShelfData>): QueryKey<Options<ShelfControllerGetUserShelfData>> => createQueryKey('shelfControllerGetUserShelf', options, true); 901 + 902 + /** 903 + * Get paginated shelf items for a user (movies and episodes) 904 + */ 905 + export const shelfControllerGetUserShelfInfiniteOptions = (options: Options<ShelfControllerGetUserShelfData>) => infiniteQueryOptions<ShelfControllerGetUserShelfResponse, DefaultError, InfiniteData<ShelfControllerGetUserShelfResponse>, QueryKey<Options<ShelfControllerGetUserShelfData>>, string | Pick<QueryKey<Options<ShelfControllerGetUserShelfData>>[0], 'body' | 'headers' | 'path' | 'query'>>( 906 + // @ts-ignore 907 + { 908 + queryFn: async ({ pageParam, queryKey, signal }) => { 909 + // @ts-ignore 910 + const page: Pick<QueryKey<Options<ShelfControllerGetUserShelfData>>[0], 'body' | 'headers' | 'path' | 'query'> = typeof pageParam === 'object' ? pageParam : { 911 + query: { 912 + cursor: pageParam 913 + } 914 + }; 915 + const params = createInfiniteParams(queryKey, page); 916 + const { data } = await shelfControllerGetUserShelf({ 917 + ...options, 918 + ...params, 919 + signal, 920 + throwOnError: true 921 + }); 922 + return data; 923 + }, 924 + queryKey: shelfControllerGetUserShelfInfiniteQueryKey(options) 925 + });
+2 -2
packages/api/src/generated/index.ts
··· 1 1 // This file is auto-generated by @hey-api/openapi-ts 2 2 3 - export { authControllerCallback, authControllerGetClientMetadata, authControllerLogin, authControllerLogout, authControllerMe, authControllerSuggestions, listsControllerAddItemToList, listsControllerAddToList, listsControllerCreateList, listsControllerDeleteList, listsControllerGetList, listsControllerGetListsForItem, listsControllerGetListsForMovie, listsControllerGetUserLists, listsControllerInitDefaultLists, listsControllerRemoveFromList, listsControllerRemoveItemFromList, listsControllerUpdateList, moviesControllerDeleteWatchHistoryEntry, moviesControllerDiscoverMovies, moviesControllerGetMovie, moviesControllerGetMovieDetails, moviesControllerGetMovieWatchHistory, moviesControllerGetUserMovies, moviesControllerMarkWatched, moviesControllerSearchMovies, moviesControllerUnmarkWatched, type Options, showsControllerDeleteEpisodeWatchHistoryEntry, showsControllerDiscoverShows, showsControllerGetEpisodeDetails, showsControllerGetSeasonDetails, showsControllerGetShow, showsControllerGetShowDetails, showsControllerGetShowWatchHistory, showsControllerGetUserShows, showsControllerMarkWatched, showsControllerSearchShows, showsControllerUnmarkWatched, usersControllerDeleteMyAccount, usersControllerGetMySettings, usersControllerUpdateMySettings } from './sdk.gen'; 4 - export type { AddToListDto, AuthControllerCallbackData, AuthControllerGetClientMetadataData, AuthControllerGetClientMetadataResponses, AuthControllerLoginData, AuthControllerLogoutData, AuthControllerLogoutResponses, AuthControllerMeData, AuthControllerMeErrors, AuthControllerMeResponse, AuthControllerMeResponses, AuthControllerSuggestionsData, AuthControllerSuggestionsResponses, ClientOptions, CreateListDto, DeleteUserAccountDto, EpisodeHistoryItemDto, ListsControllerAddItemToListData, ListsControllerAddItemToListErrors, ListsControllerAddItemToListResponses, ListsControllerAddToListData, ListsControllerAddToListResponses, ListsControllerCreateListData, ListsControllerCreateListErrors, ListsControllerCreateListResponse, ListsControllerCreateListResponses, ListsControllerDeleteListData, ListsControllerDeleteListErrors, ListsControllerDeleteListResponses, ListsControllerGetListData, ListsControllerGetListErrors, ListsControllerGetListResponse, ListsControllerGetListResponses, ListsControllerGetListsForItemData, ListsControllerGetListsForItemErrors, ListsControllerGetListsForItemResponse, ListsControllerGetListsForItemResponses, ListsControllerGetListsForMovieData, ListsControllerGetListsForMovieResponses, ListsControllerGetUserListsData, ListsControllerGetUserListsErrors, ListsControllerGetUserListsResponse, ListsControllerGetUserListsResponses, ListsControllerInitDefaultListsData, ListsControllerInitDefaultListsErrors, ListsControllerInitDefaultListsResponse, ListsControllerInitDefaultListsResponses, ListsControllerRemoveFromListData, ListsControllerRemoveFromListResponses, ListsControllerRemoveItemFromListData, ListsControllerRemoveItemFromListErrors, ListsControllerRemoveItemFromListResponses, ListsControllerUpdateListData, ListsControllerUpdateListErrors, ListsControllerUpdateListResponse, ListsControllerUpdateListResponses, MarkEpisodeWatchedDto, MediaInListDto, MovieColorsDto, MovieDto, MovieListDto, MovieListsForItemDto, MovieListSummaryDto, MovieListWithMoviesDto, MoviesControllerDeleteWatchHistoryEntryData, MoviesControllerDeleteWatchHistoryEntryErrors, MoviesControllerDeleteWatchHistoryEntryResponse, MoviesControllerDeleteWatchHistoryEntryResponses, MoviesControllerDiscoverMoviesData, MoviesControllerDiscoverMoviesResponse, MoviesControllerDiscoverMoviesResponses, MoviesControllerGetMovieData, MoviesControllerGetMovieDetailsData, MoviesControllerGetMovieDetailsResponse, MoviesControllerGetMovieDetailsResponses, MoviesControllerGetMovieResponse, MoviesControllerGetMovieResponses, MoviesControllerGetMovieWatchHistoryData, MoviesControllerGetMovieWatchHistoryErrors, MoviesControllerGetMovieWatchHistoryResponse, MoviesControllerGetMovieWatchHistoryResponses, MoviesControllerGetUserMoviesData, MoviesControllerGetUserMoviesResponse, MoviesControllerGetUserMoviesResponses, MoviesControllerMarkWatchedData, MoviesControllerMarkWatchedErrors, MoviesControllerMarkWatchedResponse, MoviesControllerMarkWatchedResponses, MoviesControllerSearchMoviesData, MoviesControllerSearchMoviesResponse, MoviesControllerSearchMoviesResponses, MoviesControllerUnmarkWatchedData, MoviesControllerUnmarkWatchedErrors, MoviesControllerUnmarkWatchedResponse, MoviesControllerUnmarkWatchedResponses, SearchResultsDto, SearchShowsResultsDto, ShowDto, ShowsControllerDeleteEpisodeWatchHistoryEntryData, ShowsControllerDeleteEpisodeWatchHistoryEntryErrors, ShowsControllerDeleteEpisodeWatchHistoryEntryResponse, ShowsControllerDeleteEpisodeWatchHistoryEntryResponses, ShowsControllerDiscoverShowsData, ShowsControllerDiscoverShowsResponse, ShowsControllerDiscoverShowsResponses, ShowsControllerGetEpisodeDetailsData, ShowsControllerGetEpisodeDetailsResponse, ShowsControllerGetEpisodeDetailsResponses, ShowsControllerGetSeasonDetailsData, ShowsControllerGetSeasonDetailsResponse, ShowsControllerGetSeasonDetailsResponses, ShowsControllerGetShowData, ShowsControllerGetShowDetailsData, ShowsControllerGetShowDetailsResponse, ShowsControllerGetShowDetailsResponses, ShowsControllerGetShowResponse, ShowsControllerGetShowResponses, ShowsControllerGetShowWatchHistoryData, ShowsControllerGetShowWatchHistoryErrors, ShowsControllerGetShowWatchHistoryResponse, ShowsControllerGetShowWatchHistoryResponses, ShowsControllerGetUserShowsData, ShowsControllerGetUserShowsResponse, ShowsControllerGetUserShowsResponses, ShowsControllerMarkWatchedData, ShowsControllerMarkWatchedErrors, ShowsControllerMarkWatchedResponse, ShowsControllerMarkWatchedResponses, ShowsControllerSearchShowsData, ShowsControllerSearchShowsResponse, ShowsControllerSearchShowsResponses, ShowsControllerUnmarkWatchedData, ShowsControllerUnmarkWatchedResponse, ShowsControllerUnmarkWatchedResponses, TmdbCastDto, TmdbCreditsDto, TmdbCrewDto, TmdbEpisodeDto, TmdbGenreDto, TmdbMovieDetailDto, TmdbMovieResultDto, TmdbSeasonDetailDto, TmdbShowDetailDto, TmdbShowResultDto, TrackedEpisodeDto, TrackedMovieDto, TrackedShowSummaryDto, UpdateListDto, UpdateUserSettingsDto, UserDto, UsersControllerDeleteMyAccountData, UsersControllerDeleteMyAccountErrors, UsersControllerDeleteMyAccountResponse, UsersControllerDeleteMyAccountResponses, UsersControllerGetMySettingsData, UsersControllerGetMySettingsErrors, UsersControllerGetMySettingsResponse, UsersControllerGetMySettingsResponses, UsersControllerUpdateMySettingsData, UsersControllerUpdateMySettingsErrors, UsersControllerUpdateMySettingsResponse, UsersControllerUpdateMySettingsResponses, UserSettingsDto, WatchHistoryItemDto } from './types.gen'; 3 + export { authControllerCallback, authControllerGetClientMetadata, authControllerLogin, authControllerLogout, authControllerMe, authControllerSuggestions, listsControllerAddItemToList, listsControllerAddToList, listsControllerCreateList, listsControllerDeleteList, listsControllerGetList, listsControllerGetListsForItem, listsControllerGetListsForMovie, listsControllerGetUserLists, listsControllerInitDefaultLists, listsControllerRemoveFromList, listsControllerRemoveItemFromList, listsControllerUpdateList, moviesControllerDeleteWatchHistoryEntry, moviesControllerDiscoverMovies, moviesControllerGetMovie, moviesControllerGetMovieDetails, moviesControllerGetMovieWatchHistory, moviesControllerGetUserMovies, moviesControllerGetUserMoviesPaginated, moviesControllerMarkWatched, moviesControllerSearchMovies, moviesControllerUnmarkWatched, type Options, shelfControllerGetUserShelf, showsControllerDeleteEpisodeWatchHistoryEntry, showsControllerDiscoverShows, showsControllerGetEpisodeDetails, showsControllerGetSeasonDetails, showsControllerGetShow, showsControllerGetShowDetails, showsControllerGetShowWatchHistory, showsControllerGetUserEpisodesPaginated, showsControllerGetUserShows, showsControllerMarkWatched, showsControllerSearchShows, showsControllerUnmarkWatched, usersControllerDeleteMyAccount, usersControllerGetMySettings, usersControllerUpdateMySettings } from './sdk.gen'; 4 + export type { AddToListDto, AuthControllerCallbackData, AuthControllerGetClientMetadataData, AuthControllerGetClientMetadataResponses, AuthControllerLoginData, AuthControllerLogoutData, AuthControllerLogoutResponses, AuthControllerMeData, AuthControllerMeErrors, AuthControllerMeResponse, AuthControllerMeResponses, AuthControllerSuggestionsData, AuthControllerSuggestionsResponses, ClientOptions, CreateListDto, DeleteUserAccountDto, EpisodeHistoryItemDto, ListsControllerAddItemToListData, ListsControllerAddItemToListErrors, ListsControllerAddItemToListResponses, ListsControllerAddToListData, ListsControllerAddToListResponses, ListsControllerCreateListData, ListsControllerCreateListErrors, ListsControllerCreateListResponse, ListsControllerCreateListResponses, ListsControllerDeleteListData, ListsControllerDeleteListErrors, ListsControllerDeleteListResponses, ListsControllerGetListData, ListsControllerGetListErrors, ListsControllerGetListResponse, ListsControllerGetListResponses, ListsControllerGetListsForItemData, ListsControllerGetListsForItemErrors, ListsControllerGetListsForItemResponse, ListsControllerGetListsForItemResponses, ListsControllerGetListsForMovieData, ListsControllerGetListsForMovieResponses, ListsControllerGetUserListsData, ListsControllerGetUserListsErrors, ListsControllerGetUserListsResponse, ListsControllerGetUserListsResponses, ListsControllerInitDefaultListsData, ListsControllerInitDefaultListsErrors, ListsControllerInitDefaultListsResponse, ListsControllerInitDefaultListsResponses, ListsControllerRemoveFromListData, ListsControllerRemoveFromListResponses, ListsControllerRemoveItemFromListData, ListsControllerRemoveItemFromListErrors, ListsControllerRemoveItemFromListResponses, ListsControllerUpdateListData, ListsControllerUpdateListErrors, ListsControllerUpdateListResponse, ListsControllerUpdateListResponses, MarkEpisodeWatchedDto, MediaInListDto, MovieColorsDto, MovieDto, MovieListDto, MovieListsForItemDto, MovieListSummaryDto, MovieListWithMoviesDto, MoviesControllerDeleteWatchHistoryEntryData, MoviesControllerDeleteWatchHistoryEntryErrors, MoviesControllerDeleteWatchHistoryEntryResponse, MoviesControllerDeleteWatchHistoryEntryResponses, MoviesControllerDiscoverMoviesData, MoviesControllerDiscoverMoviesResponse, MoviesControllerDiscoverMoviesResponses, MoviesControllerGetMovieData, MoviesControllerGetMovieDetailsData, MoviesControllerGetMovieDetailsResponse, MoviesControllerGetMovieDetailsResponses, MoviesControllerGetMovieResponse, MoviesControllerGetMovieResponses, MoviesControllerGetMovieWatchHistoryData, MoviesControllerGetMovieWatchHistoryErrors, MoviesControllerGetMovieWatchHistoryResponse, MoviesControllerGetMovieWatchHistoryResponses, MoviesControllerGetUserMoviesData, MoviesControllerGetUserMoviesPaginatedData, MoviesControllerGetUserMoviesPaginatedResponse, MoviesControllerGetUserMoviesPaginatedResponses, MoviesControllerGetUserMoviesResponse, MoviesControllerGetUserMoviesResponses, MoviesControllerMarkWatchedData, MoviesControllerMarkWatchedErrors, MoviesControllerMarkWatchedResponse, MoviesControllerMarkWatchedResponses, MoviesControllerSearchMoviesData, MoviesControllerSearchMoviesResponse, MoviesControllerSearchMoviesResponses, MoviesControllerUnmarkWatchedData, MoviesControllerUnmarkWatchedErrors, MoviesControllerUnmarkWatchedResponse, MoviesControllerUnmarkWatchedResponses, PaginatedEpisodesResponseDto, PaginatedMoviesResponseDto, SearchResultsDto, SearchShowsResultsDto, ShelfControllerGetUserShelfData, ShelfControllerGetUserShelfResponse, ShelfControllerGetUserShelfResponses, ShelfResponseDto, ShowDto, ShowsControllerDeleteEpisodeWatchHistoryEntryData, ShowsControllerDeleteEpisodeWatchHistoryEntryErrors, ShowsControllerDeleteEpisodeWatchHistoryEntryResponse, ShowsControllerDeleteEpisodeWatchHistoryEntryResponses, ShowsControllerDiscoverShowsData, ShowsControllerDiscoverShowsResponse, ShowsControllerDiscoverShowsResponses, ShowsControllerGetEpisodeDetailsData, ShowsControllerGetEpisodeDetailsResponse, ShowsControllerGetEpisodeDetailsResponses, ShowsControllerGetSeasonDetailsData, ShowsControllerGetSeasonDetailsResponse, ShowsControllerGetSeasonDetailsResponses, ShowsControllerGetShowData, ShowsControllerGetShowDetailsData, ShowsControllerGetShowDetailsResponse, ShowsControllerGetShowDetailsResponses, ShowsControllerGetShowResponse, ShowsControllerGetShowResponses, ShowsControllerGetShowWatchHistoryData, ShowsControllerGetShowWatchHistoryErrors, ShowsControllerGetShowWatchHistoryResponse, ShowsControllerGetShowWatchHistoryResponses, ShowsControllerGetUserEpisodesPaginatedData, ShowsControllerGetUserEpisodesPaginatedResponse, ShowsControllerGetUserEpisodesPaginatedResponses, ShowsControllerGetUserShowsData, ShowsControllerGetUserShowsResponse, ShowsControllerGetUserShowsResponses, ShowsControllerMarkWatchedData, ShowsControllerMarkWatchedErrors, ShowsControllerMarkWatchedResponse, ShowsControllerMarkWatchedResponses, ShowsControllerSearchShowsData, ShowsControllerSearchShowsResponse, ShowsControllerSearchShowsResponses, ShowsControllerUnmarkWatchedData, ShowsControllerUnmarkWatchedResponse, ShowsControllerUnmarkWatchedResponses, TmdbCastDto, TmdbCreditsDto, TmdbCrewDto, TmdbEpisodeDto, TmdbGenreDto, TmdbMovieDetailDto, TmdbMovieResultDto, TmdbSeasonDetailDto, TmdbShowDetailDto, TmdbShowResultDto, TrackedEpisodeDto, TrackedMovieDto, TrackedShowSummaryDto, UpdateListDto, UpdateUserSettingsDto, UserDto, UsersControllerDeleteMyAccountData, UsersControllerDeleteMyAccountErrors, UsersControllerDeleteMyAccountResponse, UsersControllerDeleteMyAccountResponses, UsersControllerGetMySettingsData, UsersControllerGetMySettingsErrors, UsersControllerGetMySettingsResponse, UsersControllerGetMySettingsResponses, UsersControllerUpdateMySettingsData, UsersControllerUpdateMySettingsErrors, UsersControllerUpdateMySettingsResponse, UsersControllerUpdateMySettingsResponses, UserSettingsDto, WatchHistoryItemDto } from './types.gen';
+16 -1
packages/api/src/generated/sdk.gen.ts
··· 2 2 3 3 import type { Client, Options as Options2, TDataShape } from './client'; 4 4 import { client } from './client.gen'; 5 - import type { AuthControllerCallbackData, AuthControllerGetClientMetadataData, AuthControllerGetClientMetadataResponses, AuthControllerLoginData, AuthControllerLogoutData, AuthControllerLogoutResponses, AuthControllerMeData, AuthControllerMeErrors, AuthControllerMeResponses, AuthControllerSuggestionsData, AuthControllerSuggestionsResponses, ListsControllerAddItemToListData, ListsControllerAddItemToListErrors, ListsControllerAddItemToListResponses, ListsControllerAddToListData, ListsControllerAddToListResponses, ListsControllerCreateListData, ListsControllerCreateListErrors, ListsControllerCreateListResponses, ListsControllerDeleteListData, ListsControllerDeleteListErrors, ListsControllerDeleteListResponses, ListsControllerGetListData, ListsControllerGetListErrors, ListsControllerGetListResponses, ListsControllerGetListsForItemData, ListsControllerGetListsForItemErrors, ListsControllerGetListsForItemResponses, ListsControllerGetListsForMovieData, ListsControllerGetListsForMovieResponses, ListsControllerGetUserListsData, ListsControllerGetUserListsErrors, ListsControllerGetUserListsResponses, ListsControllerInitDefaultListsData, ListsControllerInitDefaultListsErrors, ListsControllerInitDefaultListsResponses, ListsControllerRemoveFromListData, ListsControllerRemoveFromListResponses, ListsControllerRemoveItemFromListData, ListsControllerRemoveItemFromListErrors, ListsControllerRemoveItemFromListResponses, ListsControllerUpdateListData, ListsControllerUpdateListErrors, ListsControllerUpdateListResponses, MoviesControllerDeleteWatchHistoryEntryData, MoviesControllerDeleteWatchHistoryEntryErrors, MoviesControllerDeleteWatchHistoryEntryResponses, MoviesControllerDiscoverMoviesData, MoviesControllerDiscoverMoviesResponses, MoviesControllerGetMovieData, MoviesControllerGetMovieDetailsData, MoviesControllerGetMovieDetailsResponses, MoviesControllerGetMovieResponses, MoviesControllerGetMovieWatchHistoryData, MoviesControllerGetMovieWatchHistoryErrors, MoviesControllerGetMovieWatchHistoryResponses, MoviesControllerGetUserMoviesData, MoviesControllerGetUserMoviesResponses, MoviesControllerMarkWatchedData, MoviesControllerMarkWatchedErrors, MoviesControllerMarkWatchedResponses, MoviesControllerSearchMoviesData, MoviesControllerSearchMoviesResponses, MoviesControllerUnmarkWatchedData, MoviesControllerUnmarkWatchedErrors, MoviesControllerUnmarkWatchedResponses, ShowsControllerDeleteEpisodeWatchHistoryEntryData, ShowsControllerDeleteEpisodeWatchHistoryEntryErrors, ShowsControllerDeleteEpisodeWatchHistoryEntryResponses, ShowsControllerDiscoverShowsData, ShowsControllerDiscoverShowsResponses, ShowsControllerGetEpisodeDetailsData, ShowsControllerGetEpisodeDetailsResponses, ShowsControllerGetSeasonDetailsData, ShowsControllerGetSeasonDetailsResponses, ShowsControllerGetShowData, ShowsControllerGetShowDetailsData, ShowsControllerGetShowDetailsResponses, ShowsControllerGetShowResponses, ShowsControllerGetShowWatchHistoryData, ShowsControllerGetShowWatchHistoryErrors, ShowsControllerGetShowWatchHistoryResponses, ShowsControllerGetUserShowsData, ShowsControllerGetUserShowsResponses, ShowsControllerMarkWatchedData, ShowsControllerMarkWatchedErrors, ShowsControllerMarkWatchedResponses, ShowsControllerSearchShowsData, ShowsControllerSearchShowsResponses, ShowsControllerUnmarkWatchedData, ShowsControllerUnmarkWatchedResponses, UsersControllerDeleteMyAccountData, UsersControllerDeleteMyAccountErrors, UsersControllerDeleteMyAccountResponses, UsersControllerGetMySettingsData, UsersControllerGetMySettingsErrors, UsersControllerGetMySettingsResponses, UsersControllerUpdateMySettingsData, UsersControllerUpdateMySettingsErrors, UsersControllerUpdateMySettingsResponses } from './types.gen'; 5 + import type { AuthControllerCallbackData, AuthControllerGetClientMetadataData, AuthControllerGetClientMetadataResponses, AuthControllerLoginData, AuthControllerLogoutData, AuthControllerLogoutResponses, AuthControllerMeData, AuthControllerMeErrors, AuthControllerMeResponses, AuthControllerSuggestionsData, AuthControllerSuggestionsResponses, ListsControllerAddItemToListData, ListsControllerAddItemToListErrors, ListsControllerAddItemToListResponses, ListsControllerAddToListData, ListsControllerAddToListResponses, ListsControllerCreateListData, ListsControllerCreateListErrors, ListsControllerCreateListResponses, ListsControllerDeleteListData, ListsControllerDeleteListErrors, ListsControllerDeleteListResponses, ListsControllerGetListData, ListsControllerGetListErrors, ListsControllerGetListResponses, ListsControllerGetListsForItemData, ListsControllerGetListsForItemErrors, ListsControllerGetListsForItemResponses, ListsControllerGetListsForMovieData, ListsControllerGetListsForMovieResponses, ListsControllerGetUserListsData, ListsControllerGetUserListsErrors, ListsControllerGetUserListsResponses, ListsControllerInitDefaultListsData, ListsControllerInitDefaultListsErrors, ListsControllerInitDefaultListsResponses, ListsControllerRemoveFromListData, ListsControllerRemoveFromListResponses, ListsControllerRemoveItemFromListData, ListsControllerRemoveItemFromListErrors, ListsControllerRemoveItemFromListResponses, ListsControllerUpdateListData, ListsControllerUpdateListErrors, ListsControllerUpdateListResponses, MoviesControllerDeleteWatchHistoryEntryData, MoviesControllerDeleteWatchHistoryEntryErrors, MoviesControllerDeleteWatchHistoryEntryResponses, MoviesControllerDiscoverMoviesData, MoviesControllerDiscoverMoviesResponses, MoviesControllerGetMovieData, MoviesControllerGetMovieDetailsData, MoviesControllerGetMovieDetailsResponses, MoviesControllerGetMovieResponses, MoviesControllerGetMovieWatchHistoryData, MoviesControllerGetMovieWatchHistoryErrors, MoviesControllerGetMovieWatchHistoryResponses, MoviesControllerGetUserMoviesData, MoviesControllerGetUserMoviesPaginatedData, MoviesControllerGetUserMoviesPaginatedResponses, MoviesControllerGetUserMoviesResponses, MoviesControllerMarkWatchedData, MoviesControllerMarkWatchedErrors, MoviesControllerMarkWatchedResponses, MoviesControllerSearchMoviesData, MoviesControllerSearchMoviesResponses, MoviesControllerUnmarkWatchedData, MoviesControllerUnmarkWatchedErrors, MoviesControllerUnmarkWatchedResponses, ShelfControllerGetUserShelfData, ShelfControllerGetUserShelfResponses, ShowsControllerDeleteEpisodeWatchHistoryEntryData, ShowsControllerDeleteEpisodeWatchHistoryEntryErrors, ShowsControllerDeleteEpisodeWatchHistoryEntryResponses, ShowsControllerDiscoverShowsData, ShowsControllerDiscoverShowsResponses, ShowsControllerGetEpisodeDetailsData, ShowsControllerGetEpisodeDetailsResponses, ShowsControllerGetSeasonDetailsData, ShowsControllerGetSeasonDetailsResponses, ShowsControllerGetShowData, ShowsControllerGetShowDetailsData, ShowsControllerGetShowDetailsResponses, ShowsControllerGetShowResponses, ShowsControllerGetShowWatchHistoryData, ShowsControllerGetShowWatchHistoryErrors, ShowsControllerGetShowWatchHistoryResponses, ShowsControllerGetUserEpisodesPaginatedData, ShowsControllerGetUserEpisodesPaginatedResponses, ShowsControllerGetUserShowsData, ShowsControllerGetUserShowsResponses, ShowsControllerMarkWatchedData, ShowsControllerMarkWatchedErrors, ShowsControllerMarkWatchedResponses, ShowsControllerSearchShowsData, ShowsControllerSearchShowsResponses, ShowsControllerUnmarkWatchedData, ShowsControllerUnmarkWatchedResponses, UsersControllerDeleteMyAccountData, UsersControllerDeleteMyAccountErrors, UsersControllerDeleteMyAccountResponses, UsersControllerGetMySettingsData, UsersControllerGetMySettingsErrors, UsersControllerGetMySettingsResponses, UsersControllerUpdateMySettingsData, UsersControllerUpdateMySettingsErrors, UsersControllerUpdateMySettingsResponses } from './types.gen'; 6 6 7 7 export type Options<TData extends TDataShape = TDataShape, ThrowOnError extends boolean = boolean> = Options2<TData, ThrowOnError> & { 8 8 /** ··· 37 37 * Get tracked movies for a user 38 38 */ 39 39 export const moviesControllerGetUserMovies = <ThrowOnError extends boolean = false>(options: Options<MoviesControllerGetUserMoviesData, ThrowOnError>) => (options.client ?? client).get<MoviesControllerGetUserMoviesResponses, unknown, ThrowOnError>({ url: '/movies/user/{userDid}', ...options }); 40 + 41 + /** 42 + * Get paginated tracked movies for a user 43 + */ 44 + export const moviesControllerGetUserMoviesPaginated = <ThrowOnError extends boolean = false>(options: Options<MoviesControllerGetUserMoviesPaginatedData, ThrowOnError>) => (options.client ?? client).get<MoviesControllerGetUserMoviesPaginatedResponses, unknown, ThrowOnError>({ url: '/movies/user/{userDid}/paginated', ...options }); 40 45 41 46 /** 42 47 * Mark a movie as watched ··· 129 134 * Get tracked shows for a user 130 135 */ 131 136 export const showsControllerGetUserShows = <ThrowOnError extends boolean = false>(options: Options<ShowsControllerGetUserShowsData, ThrowOnError>) => (options.client ?? client).get<ShowsControllerGetUserShowsResponses, unknown, ThrowOnError>({ url: '/shows/user/{userDid}', ...options }); 137 + 138 + /** 139 + * Get paginated watched episodes for a user 140 + */ 141 + export const showsControllerGetUserEpisodesPaginated = <ThrowOnError extends boolean = false>(options: Options<ShowsControllerGetUserEpisodesPaginatedData, ThrowOnError>) => (options.client ?? client).get<ShowsControllerGetUserEpisodesPaginatedResponses, unknown, ThrowOnError>({ url: '/shows/user/{userDid}/episodes', ...options }); 132 142 133 143 /** 134 144 * Mark an episode as watched ··· 271 281 ...options.headers 272 282 } 273 283 }); 284 + 285 + /** 286 + * Get paginated shelf items for a user (movies and episodes) 287 + */ 288 + export const shelfControllerGetUserShelf = <ThrowOnError extends boolean = false>(options: Options<ShelfControllerGetUserShelfData, ThrowOnError>) => (options.client ?? client).get<ShelfControllerGetUserShelfResponses, unknown, ThrowOnError>({ url: '/shelf/user/{userDid}', ...options });
+160 -16
packages/api/src/generated/types.gen.ts
··· 92 92 movie: MovieDto; 93 93 }; 94 94 95 + export type PaginatedMoviesResponseDto = { 96 + items: Array<TrackedMovieDto>; 97 + /** 98 + * Cursor for next page (null if no more items) 99 + */ 100 + nextCursor: { 101 + [key: string]: unknown; 102 + }; 103 + /** 104 + * Total count of items 105 + */ 106 + total: number; 107 + }; 108 + 95 109 export type WatchHistoryItemDto = { 96 110 id: string; 97 111 watchedDate: string; ··· 186 200 watchCount: number; 187 201 latestWatchedDate?: string; 188 202 show: ShowDto; 203 + }; 204 + 205 + export type TrackedEpisodeDto = { 206 + id: string; 207 + rkey: string; 208 + uri: string; 209 + cid: string; 210 + userDid: string; 211 + showId: string; 212 + seasonNumber: number; 213 + episodeNumber: number; 214 + status: string; 215 + watchedDate?: string; 216 + createdAt: string; 217 + updatedAt: string; 218 + show: ShowDto; 219 + }; 220 + 221 + export type PaginatedEpisodesResponseDto = { 222 + items: Array<TrackedEpisodeDto>; 223 + /** 224 + * Cursor for next page (null if no more items) 225 + */ 226 + nextCursor: { 227 + [key: string]: unknown; 228 + }; 229 + /** 230 + * Total count of items 231 + */ 232 + total: number; 189 233 }; 190 234 191 235 export type MarkEpisodeWatchedDto = { ··· 207 251 watchedAt?: string; 208 252 }; 209 253 210 - export type TrackedEpisodeDto = { 211 - id: string; 212 - rkey: string; 213 - uri: string; 214 - cid: string; 215 - userDid: string; 216 - showId: string; 217 - seasonNumber: number; 218 - episodeNumber: number; 219 - status: string; 220 - watchedDate?: string; 221 - createdAt: string; 222 - updatedAt: string; 223 - show: ShowDto; 224 - }; 225 - 226 254 export type EpisodeHistoryItemDto = { 227 255 id: string; 228 256 watchedDate: string; ··· 367 395 deletePDSData: boolean; 368 396 }; 369 397 398 + export type ShelfResponseDto = { 399 + items: Array<{ 400 + id: string; 401 + type: 'movie'; 402 + movieId: string; 403 + title: string; 404 + posterPath?: string; 405 + backdropPath?: string; 406 + releaseYear?: number; 407 + overview?: string; 408 + colors?: { 409 + [key: string]: unknown; 410 + }; 411 + watchedDate?: string; 412 + createdAt: string; 413 + } | { 414 + id: string; 415 + type: 'episode'; 416 + showId: string; 417 + showTitle: string; 418 + seasonNumber: number; 419 + episodeNumber: number; 420 + posterPath?: string; 421 + backdropPath?: string; 422 + firstAirYear?: number; 423 + overview?: string; 424 + colors?: { 425 + [key: string]: unknown; 426 + }; 427 + watchedDate?: string; 428 + createdAt: string; 429 + }>; 430 + /** 431 + * Cursor for next page (null if no more items) 432 + */ 433 + nextCursor: { 434 + [key: string]: unknown; 435 + }; 436 + /** 437 + * Total count of items 438 + */ 439 + total: number; 440 + }; 441 + 370 442 export type MoviesControllerSearchMoviesData = { 371 443 body?: never; 372 444 path?: never; ··· 427 499 }; 428 500 429 501 export type MoviesControllerGetUserMoviesResponse = MoviesControllerGetUserMoviesResponses[keyof MoviesControllerGetUserMoviesResponses]; 502 + 503 + export type MoviesControllerGetUserMoviesPaginatedData = { 504 + body?: never; 505 + path: { 506 + userDid: string; 507 + }; 508 + query?: { 509 + /** 510 + * Number of items to return 511 + */ 512 + limit?: number; 513 + /** 514 + * Cursor for pagination (last item ID from previous page) 515 + */ 516 + cursor?: string; 517 + }; 518 + url: '/movies/user/{userDid}/paginated'; 519 + }; 520 + 521 + export type MoviesControllerGetUserMoviesPaginatedResponses = { 522 + 200: PaginatedMoviesResponseDto; 523 + }; 524 + 525 + export type MoviesControllerGetUserMoviesPaginatedResponse = MoviesControllerGetUserMoviesPaginatedResponses[keyof MoviesControllerGetUserMoviesPaginatedResponses]; 430 526 431 527 export type MoviesControllerMarkWatchedData = { 432 528 body: { ··· 750 846 }; 751 847 752 848 export type ShowsControllerGetUserShowsResponse = ShowsControllerGetUserShowsResponses[keyof ShowsControllerGetUserShowsResponses]; 849 + 850 + export type ShowsControllerGetUserEpisodesPaginatedData = { 851 + body?: never; 852 + path: { 853 + userDid: string; 854 + }; 855 + query?: { 856 + /** 857 + * Number of items to return 858 + */ 859 + limit?: number; 860 + /** 861 + * Cursor for pagination (last item ID from previous page) 862 + */ 863 + cursor?: string; 864 + }; 865 + url: '/shows/user/{userDid}/episodes'; 866 + }; 867 + 868 + export type ShowsControllerGetUserEpisodesPaginatedResponses = { 869 + 200: PaginatedEpisodesResponseDto; 870 + }; 871 + 872 + export type ShowsControllerGetUserEpisodesPaginatedResponse = ShowsControllerGetUserEpisodesPaginatedResponses[keyof ShowsControllerGetUserEpisodesPaginatedResponses]; 753 873 754 874 export type ShowsControllerMarkWatchedData = { 755 875 body: MarkEpisodeWatchedDto; ··· 1246 1366 }; 1247 1367 1248 1368 export type UsersControllerDeleteMyAccountResponse = UsersControllerDeleteMyAccountResponses[keyof UsersControllerDeleteMyAccountResponses]; 1369 + 1370 + export type ShelfControllerGetUserShelfData = { 1371 + body?: never; 1372 + path: { 1373 + userDid: string; 1374 + }; 1375 + query?: { 1376 + /** 1377 + * Number of items to return 1378 + */ 1379 + limit?: number; 1380 + /** 1381 + * Cursor for pagination (last item watchedDate from previous page) 1382 + */ 1383 + cursor?: string; 1384 + }; 1385 + url: '/shelf/user/{userDid}'; 1386 + }; 1387 + 1388 + export type ShelfControllerGetUserShelfResponses = { 1389 + 200: ShelfResponseDto; 1390 + }; 1391 + 1392 + export type ShelfControllerGetUserShelfResponse = ShelfControllerGetUserShelfResponses[keyof ShelfControllerGetUserShelfResponses];
+22
pnpm-lock.yaml
··· 110 110 react-native-paper-dates: 111 111 specifier: ^0.23.3 112 112 version: 0.23.3(react-native-paper@5.15.0(react-native-safe-area-context@5.6.2(react-native@0.81.5(@babel/core@7.28.6)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.28.6)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native-safe-area-context@5.6.2(react-native@0.81.5(@babel/core@7.28.6)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.28.6)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) 113 + react-native-react-query-devtools: 114 + specifier: ^1.5.1 115 + version: 1.5.1(@tanstack/react-query@5.90.20(react@19.1.0))(react-native-svg@15.15.3(react-native@0.81.5(@babel/core@7.28.6)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.28.6)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) 113 116 react-native-reanimated: 114 117 specifier: ~4.1.1 115 118 version: 4.1.6(@babel/core@7.28.6)(react-native-worklets@0.5.1(@babel/core@7.28.6)(react-native@0.81.5(@babel/core@7.28.6)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.28.6)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) ··· 119 122 react-native-screens: 120 123 specifier: ~4.16.0 121 124 version: 4.16.0(react-native@0.81.5(@babel/core@7.28.6)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) 125 + react-native-svg: 126 + specifier: ^15.15.3 127 + version: 15.15.3(react-native@0.81.5(@babel/core@7.28.6)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) 122 128 react-native-web: 123 129 specifier: ~0.21.0 124 130 version: 0.21.2(react-dom@19.1.0(react@19.1.0))(react@19.1.0) ··· 7374 7380 react: '*' 7375 7381 react-native: '*' 7376 7382 react-native-safe-area-context: '*' 7383 + 7384 + react-native-react-query-devtools@1.5.1: 7385 + resolution: {integrity: sha512-3WDN0/4euB/uP7XqO816hQHp9Mi2oNekYVbYIU3GqjgrFz069fWBLafjuLWACm9uxesfRmdLMoy73N4H8FbP6g==} 7386 + peerDependencies: 7387 + '@tanstack/react-query': ^5.77.2 7388 + react: ^18 || ^19 7389 + react-native: '>=0.78.0' 7390 + react-native-svg: ^15.12.0 7377 7391 7378 7392 react-native-reanimated@4.1.6: 7379 7393 resolution: {integrity: sha512-F+ZJBYiok/6Jzp1re75F/9aLzkgoQCOh4yxrnwATa8392RvM3kx+fiXXFvwcgE59v48lMwd9q0nzF1oJLXpfxQ==} ··· 17315 17329 react-native: 0.81.5(@babel/core@7.28.6)(@types/react@19.1.17)(react@19.1.0) 17316 17330 react-native-safe-area-context: 5.6.2(react-native@0.81.5(@babel/core@7.28.6)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) 17317 17331 use-latest-callback: 0.2.6(react@19.1.0) 17332 + 17333 + react-native-react-query-devtools@1.5.1(@tanstack/react-query@5.90.20(react@19.1.0))(react-native-svg@15.15.3(react-native@0.81.5(@babel/core@7.28.6)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.28.6)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0): 17334 + dependencies: 17335 + '@tanstack/react-query': 5.90.20(react@19.1.0) 17336 + fast-deep-equal: 3.1.3 17337 + react: 19.1.0 17338 + react-native: 0.81.5(@babel/core@7.28.6)(@types/react@19.1.17)(react@19.1.0) 17339 + react-native-svg: 15.15.3(react-native@0.81.5(@babel/core@7.28.6)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) 17318 17340 17319 17341 react-native-reanimated@4.1.6(@babel/core@7.28.6)(react-native-worklets@0.5.1(@babel/core@7.28.6)(react-native@0.81.5(@babel/core@7.28.6)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.28.6)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0): 17320 17342 dependencies: