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: discover, multi-share, custom pds

+497 -66
+29
apps/mobile/app/(tabs)/search.tsx
··· 1 1 import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; 2 2 import { 3 + moviesControllerDiscoverMoviesOptions, 3 4 moviesControllerGetUserMoviesOptions, 4 5 moviesControllerGetUserMoviesQueryKey, 5 6 moviesControllerMarkWatchedMutation, ··· 211 212 enabled: debouncedQuery.length > 0, 212 213 }); 213 214 215 + // Discover popular movies when no search query 216 + const { data: discoverData, isLoading: isDiscoverLoading } = useQuery({ 217 + ...moviesControllerDiscoverMoviesOptions({ 218 + query: { sortBy: 'popularity.desc', page: 1 }, 219 + }), 220 + enabled: debouncedQuery.length === 0, 221 + }); 222 + 214 223 // Mark watched mutation 215 224 const markMutation = useMutation({ 216 225 ...moviesControllerMarkWatchedMutation(), ··· 356 365 <Text style={styles.emptyText}> 357 366 No results found for &quot;{debouncedQuery}&quot; 358 367 </Text> 368 + </View> 369 + )} 370 + 371 + {/* Popular movies suggestions when no search query */} 372 + {!debouncedQuery && ( 373 + <View style={{ flex: 1 }}> 374 + <View style={styles.header}> 375 + <Text style={styles.title}>Popular Movies</Text> 376 + </View> 377 + {isDiscoverLoading && renderSkeleton()} 378 + {discoverData && discoverData.results.length > 0 && ( 379 + <FlashList 380 + data={discoverData.results} 381 + renderItem={renderMovieItem} 382 + keyExtractor={keyExtractor} 383 + numColumns={2} 384 + contentContainerStyle={styles.listContent} 385 + extraData={watchedMovieIds} 386 + /> 387 + )} 359 388 </View> 360 389 )} 361 390 </SafeAreaView>
+3 -7
apps/mobile/app/login.tsx
··· 1 1 import { useEffect, useState } from "react"; 2 - import { useQuery } from "@tanstack/react-query"; 3 2 import { Ionicons } from "@expo/vector-icons"; 4 3 import { 5 4 ActivityIndicator, ··· 13 12 } from "react-native"; 14 13 import * as WebBrowser from "expo-web-browser"; 15 14 import { useRouter, useLocalSearchParams } from "expo-router"; 16 - import { authControllerMeOptions, getLoginUrl } from "@opnshelf/api"; 15 + import { getLoginUrl } from "@opnshelf/api"; 16 + import { useAuth } from "@/contexts/auth"; 17 17 18 18 export default function LoginScreen() { 19 19 const [handle, setHandle] = useState(""); ··· 26 26 }>(); 27 27 const { error, redirect, reason } = params; 28 28 29 - const { data: user, isLoading: isAuthLoading } = useQuery({ 30 - ...authControllerMeOptions(), 31 - staleTime: 5 * 60 * 1000, 32 - retry: false, 33 - }); 29 + const { user, isLoading: isAuthLoading } = useAuth(); 34 30 35 31 useEffect(() => { 36 32 if (user && !isAuthLoading) {
+77 -45
apps/mobile/app/movie/[id].tsx
··· 20 20 Modal, 21 21 Pressable, 22 22 ScrollView, 23 + Share, 23 24 StyleSheet, 24 25 Text, 25 26 TouchableOpacity, ··· 205 206 [deleteWatchEntryMutation] 206 207 ); 207 208 209 + const handleShare = useCallback(async () => { 210 + const url = `https://opnshelf.xyz/movie/${movieId}/${title || ""}`; 211 + try { 212 + await Share.share({ 213 + url, 214 + }); 215 + } catch { 216 + showToast("Failed to share", "error"); 217 + } 218 + }, [movieId, title, showToast]); 219 + 208 220 const onDateChange = useCallback((event: DateTimePickerEvent, selectedDate?: Date) => { 209 221 setShowDatePicker(false); 210 222 if (selectedDate) { ··· 356 368 )} 357 369 </TouchableOpacity> 358 370 359 - <TouchableOpacity 360 - onPress={openDateModal} 361 - style={styles.secondaryButton} 362 - activeOpacity={0.8} 363 - > 371 + <TouchableOpacity 372 + onPress={openDateModal} 373 + style={styles.secondaryButton} 374 + activeOpacity={0.8} 375 + > 376 + <View style={styles.buttonContent}> 377 + <Ionicons name="calendar" size={18} color="#9ca3af" /> 378 + <Text style={styles.secondaryButtonText}> 379 + Add on Different Date 380 + </Text> 381 + </View> 382 + </TouchableOpacity> 383 + <TouchableOpacity 384 + onPress={handleShare} 385 + style={styles.secondaryButton} 386 + activeOpacity={0.8} 387 + > 388 + <View style={styles.buttonContent}> 389 + <Ionicons name="share-outline" size={18} color="#9ca3af" /> 390 + <Text style={styles.secondaryButtonText}>Share</Text> 391 + </View> 392 + </TouchableOpacity> 393 + </> 394 + ) : ( 395 + <> 396 + <TouchableOpacity 397 + onPress={handleMarkWatched} 398 + disabled={isPending} 399 + style={[ 400 + styles.primaryButton, 401 + { backgroundColor: movieColors.primary, opacity: isPending ? 0.7 : 1 }, 402 + ]} 403 + activeOpacity={0.8} 404 + > 405 + {isPending ? ( 406 + <ActivityIndicator color="#f9fafb" /> 407 + ) : ( 364 408 <View style={styles.buttonContent}> 365 - <Ionicons name="calendar" size={18} color="#9ca3af" /> 366 - <Text style={styles.secondaryButtonText}> 367 - Add on Different Date 368 - </Text> 409 + <Ionicons name="refresh" size={20} color="#f9fafb" /> 410 + <Text style={styles.buttonText}>Watch Now</Text> 369 411 </View> 370 - </TouchableOpacity> 371 - </> 372 - ) : ( 373 - <> 374 - <TouchableOpacity 375 - onPress={handleMarkWatched} 376 - disabled={isPending} 377 - style={[ 378 - styles.primaryButton, 379 - { backgroundColor: movieColors.primary, opacity: isPending ? 0.7 : 1 }, 380 - ]} 381 - activeOpacity={0.8} 382 - > 383 - {isPending ? ( 384 - <ActivityIndicator color="#f9fafb" /> 385 - ) : ( 386 - <View style={styles.buttonContent}> 387 - <Ionicons name="refresh" size={20} color="#f9fafb" /> 388 - <Text style={styles.buttonText}>Watch Now</Text> 389 - </View> 390 - )} 391 - </TouchableOpacity> 412 + )} 413 + </TouchableOpacity> 392 414 393 - <TouchableOpacity 394 - onPress={openDateModal} 395 - style={styles.secondaryButton} 396 - activeOpacity={0.8} 397 - > 398 - <View style={styles.buttonContent}> 399 - <Ionicons name="calendar" size={18} color="#9ca3af" /> 400 - <Text style={styles.secondaryButtonText}> 401 - Watch on Different Date 402 - </Text> 403 - </View> 404 - </TouchableOpacity> 405 - </> 406 - ) 415 + <TouchableOpacity 416 + onPress={openDateModal} 417 + style={styles.secondaryButton} 418 + activeOpacity={0.8} 419 + > 420 + <View style={styles.buttonContent}> 421 + <Ionicons name="calendar" size={18} color="#9ca3af" /> 422 + <Text style={styles.secondaryButtonText}> 423 + Watch on Different Date 424 + </Text> 425 + </View> 426 + </TouchableOpacity> 427 + <TouchableOpacity 428 + onPress={handleShare} 429 + style={styles.secondaryButton} 430 + activeOpacity={0.8} 431 + > 432 + <View style={styles.buttonContent}> 433 + <Ionicons name="share-outline" size={18} color="#9ca3af" /> 434 + <Text style={styles.secondaryButtonText}>Share</Text> 435 + </View> 436 + </TouchableOpacity> 437 + </> 438 + ) 407 439 ) : ( 408 440 <TouchableOpacity 409 441 onPress={() => router.push("/login")}
+1
apps/web/src/routes/login.tsx
··· 58 58 const errorMessages: Record<string, string> = { 59 59 auth_failed: "Authentication failed. Please try again.", 60 60 callback_failed: "Something went wrong during sign in. Please try again.", 61 + handle_required: "Please enter your handle (e.g., username.bsky.social).", 61 62 }; 62 63 63 64 if (isAuthLoading) {
+54
apps/web/src/routes/movies.$movieId.$title.tsx
··· 26 26 Loader2, 27 27 Plus, 28 28 RotateCcw, 29 + Share2, 29 30 Trash2, 30 31 X, 31 32 } from "lucide-react"; ··· 296 297 setShowDateModal(true); 297 298 }; 298 299 300 + const handleShare = async () => { 301 + const url = window.location.href; 302 + if (navigator.share) { 303 + try { 304 + await navigator.share({ 305 + title: movie?.title, 306 + url, 307 + }); 308 + } catch { 309 + // User cancelled share 310 + } 311 + } else { 312 + try { 313 + await navigator.clipboard.writeText(url); 314 + toast.success("Link copied to clipboard"); 315 + } catch { 316 + toast.error("Failed to copy link"); 317 + } 318 + } 319 + }; 320 + 299 321 return ( 300 322 <div className="min-h-screen bg-gray-950 text-gray-50"> 301 323 {/* Hero Section with Backdrop */} ··· 455 477 <Calendar className="w-4 h-4" /> 456 478 Add on Different Date 457 479 </button> 480 + <button 481 + type="button" 482 + onClick={handleShare} 483 + className="w-full py-2 px-4 rounded-xl font-medium text-gray-300 transition-all duration-200 flex items-center justify-center gap-2 hover:bg-gray-800 border border-gray-700" 484 + > 485 + <Share2 className="w-4 h-4" /> 486 + Share 487 + </button> 458 488 </> 459 489 ) : ( 460 490 <> ··· 484 514 > 485 515 <Calendar className="w-4 h-4" /> 486 516 Watch on Different Date 517 + </button> 518 + <button 519 + type="button" 520 + onClick={handleShare} 521 + className="w-full py-2 px-4 rounded-xl font-medium text-gray-300 transition-all duration-200 flex items-center justify-center gap-2 hover:bg-gray-800 border border-gray-700" 522 + > 523 + <Share2 className="w-4 h-4" /> 524 + Share 487 525 </button> 488 526 </> 489 527 ) ··· 581 619 <Calendar className="w-4 h-4" /> 582 620 Add on Different Date 583 621 </button> 622 + <button 623 + type="button" 624 + onClick={handleShare} 625 + className="w-full py-3 px-6 rounded-xl font-medium text-gray-300 transition-all duration-200 flex items-center justify-center gap-2 hover:bg-gray-800 border border-gray-700" 626 + > 627 + <Share2 className="w-4 h-4" /> 628 + Share 629 + </button> 584 630 </div> 585 631 ) : ( 586 632 <div className="space-y-3"> ··· 655 701 > 656 702 <Calendar className="w-4 h-4" /> 657 703 Watch on Different Date 704 + </button> 705 + <button 706 + type="button" 707 + onClick={handleShare} 708 + className="w-full py-3 px-6 rounded-xl font-medium text-gray-300 transition-all duration-200 flex items-center justify-center gap-2 hover:bg-gray-800 border border-gray-700" 709 + > 710 + <Share2 className="w-4 h-4" /> 711 + Share 658 712 </button> 659 713 </div> 660 714 )
+138
apps/web/src/routes/search.tsx
··· 1 1 import { 2 2 authControllerMeOptions, 3 + moviesControllerDiscoverMoviesOptions, 3 4 moviesControllerGetUserMoviesOptions, 4 5 moviesControllerGetUserMoviesQueryKey, 5 6 moviesControllerMarkWatchedMutation, ··· 138 139 query: { query: searchQuery }, 139 140 }), 140 141 enabled: searchQuery.length > 0, 142 + }); 143 + 144 + // Discover popular movies when no search query 145 + const { data: discoverData, isLoading: isDiscoverLoading } = useQuery({ 146 + ...moviesControllerDiscoverMoviesOptions({ 147 + query: { sortBy: "popularity.desc", page: 1 }, 148 + }), 149 + enabled: searchQuery.length === 0, 141 150 }); 142 151 143 152 return ( ··· 296 305 <p className="text-gray-400 text-lg"> 297 306 No results found for &quot;{searchQuery}&quot; 298 307 </p> 308 + </div> 309 + )} 310 + 311 + {/* Popular movies suggestions when no search query */} 312 + {!searchQuery && ( 313 + <div> 314 + <h2 className="text-xl font-semibold text-gray-200 mb-4"> 315 + Popular Movies 316 + </h2> 317 + {isDiscoverLoading && ( 318 + <div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-4"> 319 + {["a", "b", "c", "d", "e", "f", "g", "h", "i", "j"].map((key) => ( 320 + <div key={`discover-loading-${key}`}> 321 + <Skeleton className="aspect-2/3 rounded-lg mb-2" /> 322 + <Skeleton className="h-4 w-3/4 mb-1" /> 323 + <Skeleton className="h-3 w-1/2" /> 324 + </div> 325 + ))} 326 + </div> 327 + )} 328 + {discoverData && discoverData.results.length > 0 && ( 329 + <TooltipProvider> 330 + <div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-4"> 331 + {discoverData.results.map((movie) => { 332 + const movieId = movie.id.toString(); 333 + const isWatched = watchedMovieIds.has(movieId); 334 + 335 + return ( 336 + <div key={movie.id} className="group"> 337 + <Link 338 + to="/movies/$movieId/$title" 339 + params={{ 340 + movieId: movieId, 341 + title: createTitleSlug(movie.title), 342 + }} 343 + className="block relative aspect-2/3 bg-gray-900 rounded-lg overflow-hidden mb-2" 344 + > 345 + {movie.poster_path ? ( 346 + <img 347 + src={`https://image.tmdb.org/t/p/w342${movie.poster_path}`} 348 + alt={movie.title} 349 + className="w-full h-full object-cover transition-transform duration-200 group-hover:scale-105" 350 + /> 351 + ) : ( 352 + <div className="w-full h-full flex items-center justify-center text-gray-600"> 353 + No poster 354 + </div> 355 + )} 356 + {user && ( 357 + <Tooltip> 358 + <TooltipTrigger asChild> 359 + <Button 360 + type="button" 361 + size="icon" 362 + variant={isWatched ? "default" : "default"} 363 + onClick={(e) => { 364 + e.preventDefault(); 365 + e.stopPropagation(); 366 + if (isWatched) { 367 + unmarkMutation.mutate({ 368 + path: { movieId }, 369 + }); 370 + } else { 371 + markMutation.mutate({ 372 + body: { movieId }, 373 + }); 374 + } 375 + }} 376 + disabled={ 377 + (markMutation.isPending && 378 + markMutation.variables?.body?.movieId === 379 + movieId) || 380 + (unmarkMutation.isPending && 381 + unmarkMutation.variables?.path 382 + ?.movieId === movieId) 383 + } 384 + className={`absolute top-2 right-2 z-10 ${ 385 + isWatched 386 + ? "bg-green-600 hover:bg-red-600" 387 + : "bg-purple-600 hover:bg-purple-700 [@media(hover:hover)]:opacity-0 [@media(hover:hover)]:group-hover:opacity-100" 388 + } transition-opacity`} 389 + > 390 + {(markMutation.isPending && 391 + markMutation.variables?.body?.movieId === 392 + movieId) || 393 + (unmarkMutation.isPending && 394 + unmarkMutation.variables?.path?.movieId === 395 + movieId) ? ( 396 + <Loader2 className="w-4 h-4 animate-spin" /> 397 + ) : isWatched ? ( 398 + <Check className="w-4 h-4" /> 399 + ) : ( 400 + <Plus className="w-4 h-4" /> 401 + )} 402 + </Button> 403 + </TooltipTrigger> 404 + <TooltipContent> 405 + <p> 406 + {isWatched 407 + ? "Remove from shelf" 408 + : "Mark as watched"} 409 + </p> 410 + </TooltipContent> 411 + </Tooltip> 412 + )} 413 + </Link> 414 + <Link 415 + to="/movies/$movieId/$title" 416 + params={{ 417 + movieId: movieId, 418 + title: createTitleSlug(movie.title), 419 + }} 420 + className="block" 421 + > 422 + <h3 className="font-semibold text-sm line-clamp-2 mb-1 hover:text-purple-400 transition-colors"> 423 + {movie.title} 424 + </h3> 425 + {movie.release_date && ( 426 + <p className="text-gray-500 text-sm"> 427 + {movie.release_date.split("-")[0]} 428 + </p> 429 + )} 430 + </Link> 431 + </div> 432 + ); 433 + })} 434 + </div> 435 + </TooltipProvider> 436 + )} 299 437 </div> 300 438 )} 301 439 </div>
+5 -4
backend/src/auth/auth.controller.spec.ts
··· 123 123 expect(res.redirect).toHaveBeenCalledWith(authUrl); 124 124 }); 125 125 126 - it('should use bsky.social as default handle', async () => { 127 - const authUrl = 'https://bsky.social/oauth/authorize?state=abc'; 128 - mockAuthService.authorize.mockResolvedValue(authUrl); 126 + it('should redirect with error when handle is not provided', async () => { 129 127 const res = createMockResponse(); 130 128 131 129 await controller.login(undefined, undefined, res); 132 130 133 - expect(mockAuthService.authorize).toHaveBeenCalledWith('bsky.social'); 131 + expect(mockAuthService.authorize).not.toHaveBeenCalled(); 132 + expect(res.redirect).toHaveBeenCalledWith( 133 + 'http://127.0.0.1:3000?error=handle_required', 134 + ); 134 135 }); 135 136 136 137 it('should set platform cookie when platform=mobile', async () => {
+11 -4
backend/src/auth/auth.controller.ts
··· 70 70 @ApiOperation({ summary: 'Start AT Protocol OAuth login' }) 71 71 @ApiQuery({ 72 72 name: 'handle', 73 - required: false, 74 - description: 'User handle (e.g., user.bsky.social)', 73 + required: true, 74 + description: 'User handle (e.g., user.bsky.social or user.custompds.com)', 75 75 }) 76 76 @ApiQuery({ 77 77 name: 'platform', ··· 84 84 @Query('platform') platform: string | undefined, 85 85 @Res() res: Response, 86 86 ) { 87 - // Default to bsky.social if no handle provided 88 - const userHandle = handle || 'bsky.social'; 87 + // Require handle to be provided 88 + if (!handle || handle.trim() === '') { 89 + const frontendUrl = 90 + this.configService.get<string>('FRONTEND_URL') || 91 + 'http://127.0.0.1:3000'; 92 + return res.redirect(`${frontendUrl}?error=handle_required`); 93 + } 94 + 95 + const userHandle = handle.trim(); 89 96 90 97 // Set platform cookie if mobile, so callback knows where to redirect 91 98 if (platform === 'mobile') {
+36
backend/src/movies/dto/movie.dto.ts
··· 1 1 import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; 2 2 import { IsString, IsOptional, IsInt, IsDateString } from 'class-validator'; 3 + import { Type } from 'class-transformer'; 3 4 4 5 export class MovieColorsDto { 5 6 @ApiPropertyOptional() ··· 101 102 @ApiProperty() 102 103 @IsString() 103 104 query: string; 105 + } 106 + 107 + export class DiscoverMoviesDto { 108 + @ApiPropertyOptional({ 109 + description: 'Sort order for results', 110 + enum: [ 111 + 'popularity.desc', 112 + 'popularity.asc', 113 + 'release_date.desc', 114 + 'release_date.asc', 115 + 'vote_average.desc', 116 + 'vote_average.asc', 117 + ], 118 + default: 'popularity.desc', 119 + }) 120 + @IsOptional() 121 + @IsString() 122 + sortBy?: string; 123 + 124 + @ApiPropertyOptional({ 125 + description: 'Filter by release year', 126 + }) 127 + @IsOptional() 128 + @IsInt() 129 + @Type(() => Number) 130 + year?: number; 131 + 132 + @ApiPropertyOptional({ 133 + description: 'Page number', 134 + default: 1, 135 + }) 136 + @IsOptional() 137 + @IsInt() 138 + @Type(() => Number) 139 + page?: number; 104 140 } 105 141 106 142 export class TMDBMovieResultDto {
+12
backend/src/movies/movies.controller.ts
··· 22 22 import { MoviesService } from './movies.service'; 23 23 import { 24 24 SearchMoviesDto, 25 + DiscoverMoviesDto, 25 26 SearchResultsDto, 26 27 TrackedMovieDto, 27 28 MovieDto, ··· 46 47 @ApiResponse({ status: 200, type: SearchResultsDto }) 47 48 async searchMovies(@Query() searchDto: SearchMoviesDto) { 48 49 return this.moviesService.searchMovies(searchDto.query); 50 + } 51 + 52 + @Get('discover') 53 + @ApiOperation({ summary: 'Discover popular movies from TMDB' }) 54 + @ApiResponse({ status: 200, type: SearchResultsDto }) 55 + async discoverMovies(@Query() discoverDto: DiscoverMoviesDto) { 56 + return this.moviesService.discoverMovies( 57 + discoverDto.sortBy, 58 + discoverDto.page ?? 1, 59 + discoverDto.year, 60 + ); 49 61 } 50 62 51 63 @Get('tmdb/:movieId')
+20
backend/src/movies/movies.service.ts
··· 76 76 return response.json() as Promise<TMDBSearchResponse>; 77 77 } 78 78 79 + async discoverMovies( 80 + sortBy: string = 'popularity.desc', 81 + page: number = 1, 82 + year?: number, 83 + ): Promise<TMDBSearchResponse> { 84 + let url = `${this.tmdbBaseUrl}/discover/movie?api_key=${this.tmdbApiKey}&sort_by=${sortBy}&page=${page}`; 85 + 86 + if (year) { 87 + url += `&primary_release_year=${year}`; 88 + } 89 + 90 + const response = await fetch(url); 91 + 92 + if (!response.ok) { 93 + throw new Error('Failed to discover movies'); 94 + } 95 + 96 + return response.json() as Promise<TMDBSearchResponse>; 97 + } 98 + 79 99 async getMovieDetails(movieId: string): Promise<TMDBMovie> { 80 100 const response = await fetch( 81 101 `${this.tmdbBaseUrl}/movie/${movieId}?api_key=${this.tmdbApiKey}`,
+77 -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, moviesControllerDeleteWatchHistoryEntry, moviesControllerGetMovie, moviesControllerGetMovieDetails, moviesControllerGetMovieWatchHistory, moviesControllerGetUserMovies, moviesControllerMarkWatched, moviesControllerSearchMovies, moviesControllerUnmarkWatched, type Options } from '../sdk.gen'; 7 - import type { AuthControllerCallbackData, AuthControllerGetClientMetadataData, AuthControllerLoginData, AuthControllerLogoutData, AuthControllerMeData, AuthControllerMeResponse, MoviesControllerDeleteWatchHistoryEntryData, MoviesControllerDeleteWatchHistoryEntryResponse, MoviesControllerGetMovieData, MoviesControllerGetMovieDetailsData, MoviesControllerGetMovieDetailsResponse, MoviesControllerGetMovieResponse, MoviesControllerGetMovieWatchHistoryData, MoviesControllerGetMovieWatchHistoryResponse, MoviesControllerGetUserMoviesData, MoviesControllerGetUserMoviesResponse, MoviesControllerMarkWatchedData, MoviesControllerMarkWatchedResponse, MoviesControllerSearchMoviesData, MoviesControllerSearchMoviesResponse, MoviesControllerUnmarkWatchedData, MoviesControllerUnmarkWatchedResponse } from '../types.gen'; 6 + import { authControllerCallback, authControllerGetClientMetadata, authControllerLogin, authControllerLogout, authControllerMe, moviesControllerDeleteWatchHistoryEntry, moviesControllerDiscoverMovies, moviesControllerGetMovie, moviesControllerGetMovieDetails, moviesControllerGetMovieWatchHistory, moviesControllerGetUserMovies, moviesControllerMarkWatched, moviesControllerSearchMovies, moviesControllerUnmarkWatched, type Options } from '../sdk.gen'; 7 + import type { AuthControllerCallbackData, AuthControllerGetClientMetadataData, AuthControllerLoginData, AuthControllerLogoutData, AuthControllerMeData, AuthControllerMeResponse, MoviesControllerDeleteWatchHistoryEntryData, MoviesControllerDeleteWatchHistoryEntryResponse, MoviesControllerDiscoverMoviesData, MoviesControllerDiscoverMoviesResponse, MoviesControllerGetMovieData, MoviesControllerGetMovieDetailsData, MoviesControllerGetMovieDetailsResponse, MoviesControllerGetMovieResponse, MoviesControllerGetMovieWatchHistoryData, MoviesControllerGetMovieWatchHistoryResponse, MoviesControllerGetUserMoviesData, MoviesControllerGetUserMoviesResponse, MoviesControllerMarkWatchedData, MoviesControllerMarkWatchedResponse, MoviesControllerSearchMoviesData, MoviesControllerSearchMoviesResponse, MoviesControllerUnmarkWatchedData, MoviesControllerUnmarkWatchedResponse } from '../types.gen'; 8 8 9 9 export type QueryKey<TOptions extends Options> = [ 10 10 Pick<TOptions, 'baseUrl' | 'body' | 'headers' | 'path' | 'query'> & { ··· 55 55 return data; 56 56 }, 57 57 queryKey: moviesControllerSearchMoviesQueryKey(options) 58 + }); 59 + 60 + export const moviesControllerDiscoverMoviesQueryKey = (options?: Options<MoviesControllerDiscoverMoviesData>) => createQueryKey('moviesControllerDiscoverMovies', options); 61 + 62 + /** 63 + * Discover popular movies from TMDB 64 + */ 65 + export const moviesControllerDiscoverMoviesOptions = (options?: Options<MoviesControllerDiscoverMoviesData>) => queryOptions<MoviesControllerDiscoverMoviesResponse, DefaultError, MoviesControllerDiscoverMoviesResponse, ReturnType<typeof moviesControllerDiscoverMoviesQueryKey>>({ 66 + queryFn: async ({ queryKey, signal }) => { 67 + const { data } = await moviesControllerDiscoverMovies({ 68 + ...options, 69 + ...queryKey[0], 70 + signal, 71 + throwOnError: true 72 + }); 73 + return data; 74 + }, 75 + queryKey: moviesControllerDiscoverMoviesQueryKey(options) 76 + }); 77 + 78 + const createInfiniteParams = <K extends Pick<QueryKey<Options>[0], 'body' | 'headers' | 'path' | 'query'>>(queryKey: QueryKey<Options>, page: K) => { 79 + const params = { ...queryKey[0] }; 80 + if (page.body) { 81 + params.body = { 82 + ...queryKey[0].body as any, 83 + ...page.body as any 84 + }; 85 + } 86 + if (page.headers) { 87 + params.headers = { 88 + ...queryKey[0].headers, 89 + ...page.headers 90 + }; 91 + } 92 + if (page.path) { 93 + params.path = { 94 + ...queryKey[0].path as any, 95 + ...page.path as any 96 + }; 97 + } 98 + if (page.query) { 99 + params.query = { 100 + ...queryKey[0].query as any, 101 + ...page.query as any 102 + }; 103 + } 104 + return params as unknown as typeof page; 105 + }; 106 + 107 + export const moviesControllerDiscoverMoviesInfiniteQueryKey = (options?: Options<MoviesControllerDiscoverMoviesData>): QueryKey<Options<MoviesControllerDiscoverMoviesData>> => createQueryKey('moviesControllerDiscoverMovies', options, true); 108 + 109 + /** 110 + * Discover popular movies from TMDB 111 + */ 112 + export const moviesControllerDiscoverMoviesInfiniteOptions = (options?: Options<MoviesControllerDiscoverMoviesData>) => infiniteQueryOptions<MoviesControllerDiscoverMoviesResponse, DefaultError, InfiniteData<MoviesControllerDiscoverMoviesResponse>, QueryKey<Options<MoviesControllerDiscoverMoviesData>>, number | Pick<QueryKey<Options<MoviesControllerDiscoverMoviesData>>[0], 'body' | 'headers' | 'path' | 'query'>>( 113 + // @ts-ignore 114 + { 115 + queryFn: async ({ pageParam, queryKey, signal }) => { 116 + // @ts-ignore 117 + const page: Pick<QueryKey<Options<MoviesControllerDiscoverMoviesData>>[0], 'body' | 'headers' | 'path' | 'query'> = typeof pageParam === 'object' ? pageParam : { 118 + query: { 119 + page: pageParam 120 + } 121 + }; 122 + const params = createInfiniteParams(queryKey, page); 123 + const { data } = await moviesControllerDiscoverMovies({ 124 + ...options, 125 + ...params, 126 + signal, 127 + throwOnError: true 128 + }); 129 + return data; 130 + }, 131 + queryKey: moviesControllerDiscoverMoviesInfiniteQueryKey(options) 58 132 }); 59 133 60 134 export const moviesControllerGetMovieDetailsQueryKey = (options: Options<MoviesControllerGetMovieDetailsData>) => createQueryKey('moviesControllerGetMovieDetails', options);
+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, moviesControllerDeleteWatchHistoryEntry, moviesControllerGetMovie, moviesControllerGetMovieDetails, moviesControllerGetMovieWatchHistory, moviesControllerGetUserMovies, moviesControllerMarkWatched, moviesControllerSearchMovies, moviesControllerUnmarkWatched, type Options } from './sdk.gen'; 4 - export type { AuthControllerCallbackData, AuthControllerGetClientMetadataData, AuthControllerGetClientMetadataResponses, AuthControllerLoginData, AuthControllerLogoutData, AuthControllerLogoutResponses, AuthControllerMeData, AuthControllerMeErrors, AuthControllerMeResponse, AuthControllerMeResponses, ClientOptions, MarkWatchedDto, MovieColorsDto, MovieDto, MoviesControllerDeleteWatchHistoryEntryData, MoviesControllerDeleteWatchHistoryEntryErrors, MoviesControllerDeleteWatchHistoryEntryResponse, MoviesControllerDeleteWatchHistoryEntryResponses, 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, TmdbCastDto, TmdbCreditsDto, TmdbCrewDto, TmdbGenreDto, TmdbMovieDetailDto, TmdbMovieResultDto, TrackedMovieDto, UserDto, WatchHistoryItemDto } from './types.gen'; 3 + export { authControllerCallback, authControllerGetClientMetadata, authControllerLogin, authControllerLogout, authControllerMe, moviesControllerDeleteWatchHistoryEntry, moviesControllerDiscoverMovies, moviesControllerGetMovie, moviesControllerGetMovieDetails, moviesControllerGetMovieWatchHistory, moviesControllerGetUserMovies, moviesControllerMarkWatched, moviesControllerSearchMovies, moviesControllerUnmarkWatched, type Options } from './sdk.gen'; 4 + export type { AuthControllerCallbackData, AuthControllerGetClientMetadataData, AuthControllerGetClientMetadataResponses, AuthControllerLoginData, AuthControllerLogoutData, AuthControllerLogoutResponses, AuthControllerMeData, AuthControllerMeErrors, AuthControllerMeResponse, AuthControllerMeResponses, ClientOptions, MarkWatchedDto, MovieColorsDto, MovieDto, 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, TmdbCastDto, TmdbCreditsDto, TmdbCrewDto, TmdbGenreDto, TmdbMovieDetailDto, TmdbMovieResultDto, TrackedMovieDto, UserDto, WatchHistoryItemDto } from './types.gen';
+6 -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, MoviesControllerDeleteWatchHistoryEntryData, MoviesControllerDeleteWatchHistoryEntryErrors, MoviesControllerDeleteWatchHistoryEntryResponses, MoviesControllerGetMovieData, MoviesControllerGetMovieDetailsData, MoviesControllerGetMovieDetailsResponses, MoviesControllerGetMovieResponses, MoviesControllerGetMovieWatchHistoryData, MoviesControllerGetMovieWatchHistoryErrors, MoviesControllerGetMovieWatchHistoryResponses, MoviesControllerGetUserMoviesData, MoviesControllerGetUserMoviesResponses, MoviesControllerMarkWatchedData, MoviesControllerMarkWatchedErrors, MoviesControllerMarkWatchedResponses, MoviesControllerSearchMoviesData, MoviesControllerSearchMoviesResponses, MoviesControllerUnmarkWatchedData, MoviesControllerUnmarkWatchedErrors, MoviesControllerUnmarkWatchedResponses } from './types.gen'; 5 + import type { AuthControllerCallbackData, AuthControllerGetClientMetadataData, AuthControllerGetClientMetadataResponses, AuthControllerLoginData, AuthControllerLogoutData, AuthControllerLogoutResponses, AuthControllerMeData, AuthControllerMeErrors, AuthControllerMeResponses, MoviesControllerDeleteWatchHistoryEntryData, MoviesControllerDeleteWatchHistoryEntryErrors, MoviesControllerDeleteWatchHistoryEntryResponses, MoviesControllerDiscoverMoviesData, MoviesControllerDiscoverMoviesResponses, MoviesControllerGetMovieData, MoviesControllerGetMovieDetailsData, MoviesControllerGetMovieDetailsResponses, MoviesControllerGetMovieResponses, MoviesControllerGetMovieWatchHistoryData, MoviesControllerGetMovieWatchHistoryErrors, MoviesControllerGetMovieWatchHistoryResponses, MoviesControllerGetUserMoviesData, MoviesControllerGetUserMoviesResponses, MoviesControllerMarkWatchedData, MoviesControllerMarkWatchedErrors, MoviesControllerMarkWatchedResponses, MoviesControllerSearchMoviesData, MoviesControllerSearchMoviesResponses, MoviesControllerUnmarkWatchedData, MoviesControllerUnmarkWatchedErrors, MoviesControllerUnmarkWatchedResponses } from './types.gen'; 6 6 7 7 export type Options<TData extends TDataShape = TDataShape, ThrowOnError extends boolean = boolean> = Options2<TData, ThrowOnError> & { 8 8 /** ··· 22 22 * Search movies from TMDB 23 23 */ 24 24 export const moviesControllerSearchMovies = <ThrowOnError extends boolean = false>(options: Options<MoviesControllerSearchMoviesData, ThrowOnError>) => (options.client ?? client).get<MoviesControllerSearchMoviesResponses, unknown, ThrowOnError>({ url: '/movies/search', ...options }); 25 + 26 + /** 27 + * Discover popular movies from TMDB 28 + */ 29 + export const moviesControllerDiscoverMovies = <ThrowOnError extends boolean = false>(options?: Options<MoviesControllerDiscoverMoviesData, ThrowOnError>) => (options?.client ?? client).get<MoviesControllerDiscoverMoviesResponses, unknown, ThrowOnError>({ url: '/movies/discover', ...options }); 25 30 26 31 /** 27 32 * Get movie details from TMDB
+26
packages/api/src/generated/types.gen.ts
··· 149 149 150 150 export type MoviesControllerSearchMoviesResponse = MoviesControllerSearchMoviesResponses[keyof MoviesControllerSearchMoviesResponses]; 151 151 152 + export type MoviesControllerDiscoverMoviesData = { 153 + body?: never; 154 + path?: never; 155 + query?: { 156 + /** 157 + * Sort order for results 158 + */ 159 + sortBy?: 'popularity.desc' | 'popularity.asc' | 'release_date.desc' | 'release_date.asc' | 'vote_average.desc' | 'vote_average.asc'; 160 + /** 161 + * Filter by release year 162 + */ 163 + year?: number; 164 + /** 165 + * Page number 166 + */ 167 + page?: number; 168 + }; 169 + url: '/movies/discover'; 170 + }; 171 + 172 + export type MoviesControllerDiscoverMoviesResponses = { 173 + 200: SearchResultsDto; 174 + }; 175 + 176 + export type MoviesControllerDiscoverMoviesResponse = MoviesControllerDiscoverMoviesResponses[keyof MoviesControllerDiscoverMoviesResponses]; 177 + 152 178 export type MoviesControllerGetMovieDetailsData = { 153 179 body?: never; 154 180 path: {