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: enhance movie item interactions and layout

- Added animated feedback for the quick add button in MovieItem, improving user experience with haptic feedback and visual scaling effects.
- Introduced a spinning loader component for better loading indication when adding/removing movies.
- Updated styles for action buttons and movie cards to enhance visual appeal and usability.
- Adjusted layout in TabLayout to hide headers for better navigation experience.
- Refined styles across various components for consistency and improved aesthetics.

+651 -445
+3
apps/mobile/app/(tabs)/_layout.tsx
··· 23 23 options={{ 24 24 title: "Home", 25 25 tabBarIcon: ({ color, size }) => <Home size={size} color={color} />, 26 + headerShown: false, 26 27 }} 27 28 /> 28 29 <Tabs.Screen ··· 30 31 options={{ 31 32 title: "Search", 32 33 tabBarIcon: ({ color, size }) => <Search size={size} color={color} />, 34 + headerShown: false, 33 35 }} 34 36 /> 35 37 <Tabs.Screen ··· 37 39 options={{ 38 40 title: "Shelf", 39 41 tabBarIcon: ({ color, size }) => <Library size={size} color={color} />, 42 + headerShown: false, 40 43 }} 41 44 /> 42 45 </Tabs>
+120 -24
apps/mobile/app/(tabs)/search.tsx
··· 16 16 StyleSheet, 17 17 Text, 18 18 View, 19 + Platform, 19 20 } from "react-native"; 20 21 import { SafeAreaView } from "react-native-safe-area-context"; 21 22 import { useAuth } from "@/contexts/auth"; ··· 24 25 import { Skeleton } from "@/components/ui/Skeleton"; 25 26 import { colors, spacing, borderRadius } from "@/constants/theme"; 26 27 import { Image } from "expo-image"; 28 + import * as Haptics from "expo-haptics"; 29 + import Animated, { 30 + useAnimatedStyle, 31 + useSharedValue, 32 + withSpring, 33 + withTiming, 34 + withRepeat, 35 + Easing, 36 + } from "react-native-reanimated"; 27 37 28 38 const DEBOUNCE_MS = 300; 29 39 const POSTER_BASE_URL = "https://image.tmdb.org/t/p/w342"; ··· 44 54 onPress: () => void; 45 55 } 46 56 57 + const AnimatedPressable = Animated.createAnimatedComponent(Pressable); 58 + 59 + // Spinning loader component 60 + const SpinningLoader = ({ size, color }: { size: number; color: string }) => { 61 + const rotation = useSharedValue(0); 62 + 63 + rotation.value = withRepeat( 64 + withTiming(360, { duration: 1000, easing: Easing.linear }), 65 + -1, 66 + false 67 + ); 68 + 69 + const animatedStyle = useAnimatedStyle(() => ({ 70 + transform: [{ rotate: `${rotation.value}deg` }], 71 + })); 72 + 73 + return ( 74 + <Animated.View style={animatedStyle}> 75 + <Loader2 size={size} color={color} /> 76 + </Animated.View> 77 + ); 78 + }; 79 + 47 80 const MovieItem = ({ movie, isWatched, isMarking, isUnmarking, onToggle, onPress }: MovieItemProps) => { 48 - const handleToggle = useCallback(() => { 81 + const scale = useSharedValue(1); 82 + const opacity = useSharedValue(1); 83 + 84 + const handleToggle = useCallback((e: any) => { 85 + e.stopPropagation(); 86 + 87 + if (Platform.OS !== "web") { 88 + Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium); 89 + } 90 + 49 91 onToggle(movie.id.toString(), isWatched); 50 92 }, [movie.id, isWatched, onToggle]); 51 93 94 + const handlePressIn = useCallback(() => { 95 + scale.value = withSpring(0.95, { damping: 15, stiffness: 300 }); 96 + opacity.value = withTiming(0.8, { duration: 100 }); 97 + }, [scale, opacity]); 98 + 99 + const handlePressOut = useCallback(() => { 100 + scale.value = withSpring(1, { damping: 15, stiffness: 300 }); 101 + opacity.value = withTiming(1, { duration: 100 }); 102 + }, [scale, opacity]); 103 + 104 + const animatedButtonStyle = useAnimatedStyle(() => ({ 105 + transform: [{ scale: scale.value }], 106 + opacity: opacity.value, 107 + })); 108 + 52 109 const isPending = isMarking || isUnmarking; 53 110 54 111 return ( ··· 66 123 <Text style={styles.noPosterText}>No poster</Text> 67 124 </View> 68 125 )} 69 - {/* Quick add button */} 70 - <Pressable 126 + 127 + {/* Quick add button - icon only, top-right with expanded touch area */} 128 + <AnimatedPressable 71 129 onPress={handleToggle} 130 + onPressIn={handlePressIn} 131 + onPressOut={handlePressOut} 72 132 disabled={isPending} 133 + hitSlop={{ top: 12, bottom: 12, left: 12, right: 12 }} 73 134 style={[ 74 - styles.quickAddButton, 75 - isWatched && styles.quickAddButtonWatched, 135 + styles.actionButton, 136 + isWatched && styles.actionButtonWatched, 137 + animatedButtonStyle, 76 138 ]} 77 139 > 78 - {isPending ? ( 79 - <Loader2 size={16} color={colors.text} /> 80 - ) : isWatched ? ( 81 - <Check size={16} color={colors.text} /> 82 - ) : ( 83 - <Plus size={16} color={colors.text} /> 84 - )} 85 - </Pressable> 140 + <View style={styles.iconContainer}> 141 + {isPending ? ( 142 + <SpinningLoader size={22} color={colors.text} /> 143 + ) : isWatched ? ( 144 + <Check size={22} color={colors.text} strokeWidth={2.5} /> 145 + ) : ( 146 + <Plus size={22} color={colors.text} strokeWidth={2.5} /> 147 + )} 148 + </View> 149 + </AnimatedPressable> 86 150 </Pressable> 87 - <Pressable onPress={onPress}> 151 + <Pressable onPress={onPress} style={styles.titleContainer}> 88 152 <Text style={styles.movieTitle} numberOfLines={2}> 89 153 {movie.title} 90 154 </Text> 91 155 {movie.release_date && ( 92 - <Text style={styles.movieYear}> 93 - {movie.release_date.split("-")[0]} 94 - </Text> 156 + <View style={styles.yearBadge}> 157 + <Text style={styles.movieYear}> 158 + {movie.release_date.split("-")[0]} 159 + </Text> 160 + </View> 95 161 )} 96 162 </Pressable> 97 163 </View> ··· 309 375 fontSize: 28, 310 376 fontWeight: "bold", 311 377 color: colors.text, 378 + letterSpacing: -0.5, 312 379 }, 313 380 searchInput: { 314 381 marginHorizontal: spacing.lg, ··· 330 397 overflow: "hidden", 331 398 backgroundColor: colors.card, 332 399 position: "relative", 400 + shadowColor: "#000", 401 + shadowOffset: { width: 0, height: 4 }, 402 + shadowOpacity: 0.3, 403 + shadowRadius: 8, 404 + elevation: 8, 333 405 }, 334 406 poster: { 335 407 width: "100%", ··· 338 410 noPoster: { 339 411 justifyContent: "center", 340 412 alignItems: "center", 413 + backgroundColor: colors.cardMuted, 341 414 }, 342 415 noPosterText: { 343 416 color: colors.textSecondary, 344 417 fontSize: 12, 418 + fontWeight: "500", 345 419 }, 346 - quickAddButton: { 420 + // Icon-only action button - top right 421 + actionButton: { 347 422 position: "absolute", 348 423 top: spacing.sm, 349 424 right: spacing.sm, 350 - width: 32, 351 - height: 32, 425 + width: 44, 426 + height: 44, 352 427 borderRadius: borderRadius.full, 353 428 backgroundColor: colors.primary, 354 429 justifyContent: "center", 355 430 alignItems: "center", 431 + shadowColor: "#000", 432 + shadowOffset: { width: 0, height: 3 }, 433 + shadowOpacity: 0.4, 434 + shadowRadius: 5, 435 + elevation: 5, 356 436 }, 357 - quickAddButtonWatched: { 437 + actionButtonWatched: { 358 438 backgroundColor: colors.success, 359 439 }, 440 + iconContainer: { 441 + width: 22, 442 + height: 22, 443 + justifyContent: "center", 444 + alignItems: "center", 445 + }, 446 + titleContainer: { 447 + marginTop: spacing.sm, 448 + minHeight: 40, 449 + }, 360 450 movieTitle: { 361 - fontSize: 14, 451 + fontSize: 15, 362 452 fontWeight: "600", 363 453 color: colors.text, 364 - marginTop: spacing.sm, 454 + letterSpacing: -0.2, 455 + lineHeight: 20, 456 + flexWrap: "wrap", 457 + }, 458 + yearBadge: { 459 + marginTop: spacing.xs, 365 460 }, 366 461 movieYear: { 367 462 fontSize: 12, 368 463 color: colors.textMuted, 369 - marginTop: 2, 464 + fontWeight: "500", 465 + letterSpacing: 0.5, 370 466 }, 371 467 resultsCount: { 372 468 fontSize: 14,
+524 -417
apps/mobile/app/(tabs)/shelf.tsx
··· 1 1 import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; 2 2 import { 3 - moviesControllerGetUserMoviesOptions, 4 - moviesControllerGetUserMoviesQueryKey, 5 - moviesControllerUnmarkWatchedMutation, 3 + moviesControllerGetUserMoviesOptions, 4 + moviesControllerGetUserMoviesQueryKey, 5 + moviesControllerUnmarkWatchedMutation, 6 6 } from "@opnshelf/api"; 7 7 import type { TrackedMovieDto } from "@opnshelf/api"; 8 8 import { FlashList } from "@shopify/flash-list"; 9 9 import { router } from "expo-router"; 10 - import { BookOpen, Loader2, LogIn, LogOut, Trash2 } from "lucide-react-native"; 10 + import { 11 + BookOpen, 12 + Loader2, 13 + LogIn, 14 + LogOut, 15 + Trash2, 16 + CheckCircle2, 17 + } from "lucide-react-native"; 11 18 import { useCallback } from "react"; 12 - import { Alert, Pressable, StyleSheet, Text, View } from "react-native"; 19 + import { Pressable, StyleSheet, Text, View, TouchableOpacity } from "react-native"; 13 20 import { SafeAreaView } from "react-native-safe-area-context"; 14 21 import { useAuth } from "@/contexts/auth"; 15 22 import { useToast } from "@/contexts/toast"; ··· 19 26 import { colors, spacing, borderRadius } from "@/constants/theme"; 20 27 import { Image } from "expo-image"; 21 28 import { format } from "date-fns"; 29 + import Animated, { 30 + useAnimatedStyle, 31 + useSharedValue, 32 + withRepeat, 33 + withTiming, 34 + Easing, 35 + } from "react-native-reanimated"; 22 36 23 37 const POSTER_BASE_URL = "https://image.tmdb.org/t/p/w342"; 24 38 25 39 function createTitleSlug(title: string): string { 26 - return title 27 - .replace(/[^a-zA-Z0-9\s-]/g, "") 28 - .trim() 29 - .replace(/\s+/g, "-"); 40 + return title 41 + .replace(/[^a-zA-Z0-9\s-]/g, "") 42 + .trim() 43 + .replace(/\s+/g, "-"); 30 44 } 31 45 32 - interface TrackedMovieItemProps { 33 - tracked: TrackedMovieDto; 34 - isRemoving: boolean; 35 - onRemove: (movieId: string) => void; 36 - onPress: () => void; 46 + const AnimatedPressable = Animated.createAnimatedComponent(Pressable); 47 + 48 + // Spinning loader component 49 + const SpinningLoader = ({ size, color }: { size: number; color: string }) => { 50 + const rotation = useSharedValue(0); 51 + 52 + rotation.value = withRepeat( 53 + withTiming(360, { duration: 1000, easing: Easing.linear }), 54 + -1, 55 + false, 56 + ); 57 + 58 + const animatedStyle = useAnimatedStyle(() => ({ 59 + transform: [{ rotate: `${rotation.value}deg` }], 60 + })); 61 + 62 + return ( 63 + <Animated.View style={animatedStyle}> 64 + <Loader2 size={size} color={color} /> 65 + </Animated.View> 66 + ); 67 + }; 68 + 69 + // Movie Card - Horizontal layout with poster on left 70 + interface MovieCardProps { 71 + tracked: TrackedMovieDto; 72 + isRemoving: boolean; 73 + onRemove: (movieId: string) => void; 74 + onPress: () => void; 37 75 } 38 76 39 - const TrackedMovieItem = ({ tracked, isRemoving, onRemove, onPress }: TrackedMovieItemProps) => { 40 - const handleRemove = useCallback(() => { 41 - Alert.alert( 42 - "Remove from Shelf", 43 - `Are you sure you want to remove "${tracked.movie.title}" from your shelf?`, 44 - [ 45 - { text: "Cancel", style: "cancel" }, 46 - { text: "Remove", style: "destructive", onPress: () => onRemove(tracked.movieId) }, 47 - ] 48 - ); 49 - }, [tracked.movie.title, tracked.movieId, onRemove]); 77 + const MovieCard = ({ 78 + tracked, 79 + isRemoving, 80 + onRemove, 81 + onPress, 82 + }: MovieCardProps) => { 83 + const formattedWatchedDate = tracked.watchedDate 84 + ? format(new Date(tracked.watchedDate), "MMM d, yyyy • HH:mm") 85 + : null; 50 86 51 - return ( 52 - <View style={styles.movieItem}> 53 - <Pressable onPress={onPress} style={styles.posterContainer}> 54 - {tracked.movie.posterPath ? ( 55 - <Image 56 - source={{ uri: `${POSTER_BASE_URL}${tracked.movie.posterPath}` }} 57 - style={styles.poster} 58 - contentFit="cover" 59 - transition={200} 60 - /> 61 - ) : ( 62 - <View style={[styles.poster, styles.noPoster]}> 63 - <Text style={styles.noPosterText}>No poster</Text> 64 - </View> 65 - )} 66 - {/* Remove button */} 67 - <Pressable 68 - onPress={handleRemove} 69 - disabled={isRemoving} 70 - style={styles.removeButton} 71 - > 72 - {isRemoving ? ( 73 - <Loader2 size={16} color={colors.text} /> 74 - ) : ( 75 - <Trash2 size={16} color={colors.error} /> 76 - )} 77 - </Pressable> 78 - </Pressable> 79 - <Pressable onPress={onPress}> 80 - <Text style={styles.movieTitle} numberOfLines={2}> 81 - {tracked.movie.title} 82 - </Text> 83 - {tracked.movie.releaseYear && ( 84 - <Text style={styles.movieYear}>{tracked.movie.releaseYear}</Text> 85 - )} 86 - {tracked.watchedDate && ( 87 - <View style={styles.watchedInfo}> 88 - <Text style={styles.watchedDate}> 89 - Watched {format(new Date(tracked.watchedDate), "MMM d, yyyy HH:mm")} 90 - </Text> 91 - </View> 92 - )} 93 - </Pressable> 94 - </View> 95 - ); 87 + return ( 88 + <TouchableOpacity 89 + onPress={onPress} 90 + style={styles.card} 91 + activeOpacity={0.8} 92 + > 93 + {/* Poster on the left */} 94 + <View style={styles.posterContainer}> 95 + {tracked.movie.posterPath ? ( 96 + <Image 97 + source={{ uri: `${POSTER_BASE_URL}${tracked.movie.posterPath}` }} 98 + style={styles.poster} 99 + contentFit="cover" 100 + transition={200} 101 + /> 102 + ) : ( 103 + <View style={[styles.poster, styles.noPoster]}> 104 + <Text style={styles.noPosterText}>No poster</Text> 105 + </View> 106 + )} 107 + </View> 108 + 109 + {/* Content on the right */} 110 + <View style={styles.cardContent}> 111 + <View style={styles.info}> 112 + <Text style={styles.movieTitle} numberOfLines={2}> 113 + {tracked.movie.title} 114 + </Text> 115 + <View style={styles.meta}> 116 + {tracked.movie.releaseYear && ( 117 + <Text style={styles.year}>{tracked.movie.releaseYear}</Text> 118 + )} 119 + {formattedWatchedDate && ( 120 + <> 121 + <Text style={styles.metaDot}>•</Text> 122 + <View style={styles.watchedRow}> 123 + <CheckCircle2 size={12} color={colors.success} /> 124 + <Text style={styles.watchedDate}>{formattedWatchedDate}</Text> 125 + </View> 126 + </> 127 + )} 128 + </View> 129 + </View> 130 + 131 + {/* Remove button */} 132 + <TouchableOpacity 133 + onPress={(e) => { 134 + e.stopPropagation(); 135 + onRemove(tracked.movieId); 136 + }} 137 + disabled={isRemoving} 138 + style={styles.removeButton} 139 + activeOpacity={0.7} 140 + > 141 + {isRemoving ? ( 142 + <SpinningLoader size={14} color={colors.text} /> 143 + ) : ( 144 + <> 145 + <Trash2 size={14} color={colors.text} /> 146 + <Text style={styles.removeButtonText}>Remove</Text> 147 + </> 148 + )} 149 + </TouchableOpacity> 150 + </View> 151 + </TouchableOpacity> 152 + ); 96 153 }; 97 154 98 155 export default function ShelfScreen() { 99 - const { user, isLoading: isAuthLoading, isAuthenticated, logout } = useAuth(); 100 - const { showToast } = useToast(); 101 - const queryClient = useQueryClient(); 156 + const { user, isLoading: isAuthLoading, isAuthenticated, logout } = useAuth(); 157 + const { showToast } = useToast(); 158 + const queryClient = useQueryClient(); 159 + 160 + // Fetch user's tracked movies 161 + const { data: trackedMovies, isLoading: isMoviesLoading } = useQuery({ 162 + ...moviesControllerGetUserMoviesOptions({ 163 + path: { userDid: user?.did || "" }, 164 + }), 165 + enabled: !!user?.did, 166 + }); 102 167 103 - // Fetch user's tracked movies 104 - const { data: trackedMovies, isLoading: isMoviesLoading } = useQuery({ 105 - ...moviesControllerGetUserMoviesOptions({ 106 - path: { userDid: user?.did || "" }, 107 - }), 108 - enabled: !!user?.did, 109 - }); 168 + // Remove from shelf mutation 169 + const unmarkMutation = useMutation({ 170 + ...moviesControllerUnmarkWatchedMutation(), 171 + onSuccess: () => { 172 + queryClient.invalidateQueries({ 173 + queryKey: moviesControllerGetUserMoviesQueryKey({ 174 + path: { userDid: user?.did || "" }, 175 + }), 176 + }); 177 + showToast("Removed from your shelf", "success"); 178 + }, 179 + onError: () => { 180 + showToast("Failed to remove from shelf. Please try again.", "error"); 181 + }, 182 + }); 110 183 111 - // Remove from shelf mutation 112 - const unmarkMutation = useMutation({ 113 - ...moviesControllerUnmarkWatchedMutation(), 114 - onSuccess: () => { 115 - queryClient.invalidateQueries({ 116 - queryKey: moviesControllerGetUserMoviesQueryKey({ 117 - path: { userDid: user?.did || "" }, 118 - }), 119 - }); 120 - showToast("Removed from your shelf", "success"); 121 - }, 122 - onError: () => { 123 - showToast("Failed to remove from shelf. Please try again.", "error"); 124 - }, 125 - }); 184 + const handleRemove = useCallback( 185 + (movieId: string) => { 186 + unmarkMutation.mutate({ path: { movieId } }); 187 + }, 188 + [unmarkMutation], 189 + ); 126 190 127 - const handleRemove = useCallback( 128 - (movieId: string) => { 129 - unmarkMutation.mutate({ path: { movieId } }); 130 - }, 131 - [unmarkMutation] 132 - ); 191 + const handleMoviePress = useCallback((tracked: TrackedMovieDto) => { 192 + router.push({ 193 + pathname: "/movie/[id]", 194 + params: { 195 + id: tracked.movieId, 196 + title: createTitleSlug(tracked.movie.title), 197 + }, 198 + }); 199 + }, []); 133 200 134 - const handleMoviePress = useCallback( 135 - (tracked: TrackedMovieDto) => { 136 - router.push({ 137 - pathname: "/movie/[id]", 138 - params: { 139 - id: tracked.movieId, 140 - title: createTitleSlug(tracked.movie.title), 141 - }, 142 - }); 143 - }, 144 - [] 145 - ); 201 + const renderItem = useCallback( 202 + ({ item }: { item: TrackedMovieDto }) => { 203 + const isRemoving = 204 + unmarkMutation.isPending && 205 + unmarkMutation.variables?.path?.movieId === item.movieId; 146 206 147 - const renderMovieItem = useCallback( 148 - ({ item }: { item: TrackedMovieDto }) => { 149 - const isRemoving = 150 - unmarkMutation.isPending && unmarkMutation.variables?.path?.movieId === item.movieId; 207 + return ( 208 + <MovieCard 209 + tracked={item} 210 + isRemoving={isRemoving} 211 + onRemove={handleRemove} 212 + onPress={() => handleMoviePress(item)} 213 + /> 214 + ); 215 + }, 216 + [unmarkMutation, handleRemove, handleMoviePress], 217 + ); 151 218 152 - return ( 153 - <TrackedMovieItem 154 - tracked={item} 155 - isRemoving={isRemoving} 156 - onRemove={handleRemove} 157 - onPress={() => handleMoviePress(item)} 158 - /> 159 - ); 160 - }, 161 - [unmarkMutation, handleRemove, handleMoviePress] 162 - ); 219 + const keyExtractor = useCallback((item: TrackedMovieDto) => item.id, []); 163 220 164 - const keyExtractor = useCallback((item: TrackedMovieDto) => item.id, []); 221 + const handleAuthAction = useCallback(async () => { 222 + if (isAuthenticated) { 223 + await logout(); 224 + showToast("Logged out successfully", "success"); 225 + } else { 226 + router.push("/login"); 227 + } 228 + }, [isAuthenticated, logout, showToast]); 165 229 166 - // Loading state 167 - if (isAuthLoading) { 168 - return ( 169 - <SafeAreaView style={styles.container} edges={["top"]}> 170 - <View style={styles.header}> 171 - <Text style={styles.title}>My Shelf</Text> 172 - </View> 173 - <View style={styles.skeletonGrid}> 174 - {[...Array(8)].map((_, i) => ( 175 - <View key={i} style={styles.skeletonItem}> 176 - <View style={[styles.skeletonPoster, { backgroundColor: colors.cardMuted }]} /> 177 - <View style={{ marginTop: spacing.sm }}> 178 - <Skeleton width="80%" height={16} /> 179 - </View> 180 - <View style={{ marginTop: spacing.xs }}> 181 - <Skeleton width="50%" height={14} /> 182 - </View> 183 - </View> 184 - ))} 185 - </View> 186 - </SafeAreaView> 187 - ); 188 - } 230 + // Loading state 231 + if (isAuthLoading) { 232 + return ( 233 + <SafeAreaView style={styles.container} edges={["top"]}> 234 + <View style={styles.header}> 235 + <View style={styles.headerLeft}> 236 + <BookOpen size={32} color={colors.primary} /> 237 + <Text style={styles.title}>My Shelf</Text> 238 + </View> 239 + </View> 240 + <View style={styles.skeletonContainer}> 241 + {[...Array(6)].map((_, i) => ( 242 + <View key={i} style={styles.skeleton}> 243 + <View style={[styles.skeletonPoster, { backgroundColor: colors.cardMuted }]} /> 244 + <View style={styles.skeletonContent}> 245 + <Skeleton width="70%" height={18} /> 246 + <Skeleton width="40%" height={14} style={{ marginTop: spacing.sm }} /> 247 + </View> 248 + </View> 249 + ))} 250 + </View> 251 + </SafeAreaView> 252 + ); 253 + } 189 254 190 - // Not authenticated state 191 - if (!isAuthenticated) { 192 - return ( 193 - <SafeAreaView style={styles.container} edges={["top"]}> 194 - <View style={styles.centerContent}> 195 - <Card style={styles.authCard}> 196 - <CardHeader> 197 - <BookOpen size={64} color={colors.primary} style={styles.authIcon} /> 198 - <Text style={styles.authTitle}>My Shelf</Text> 199 - <Text style={styles.authDescription}> 200 - Sign in to track movies you&apos;ve watched 201 - </Text> 202 - </CardHeader> 203 - <CardContent> 204 - <Button size="lg" onPress={() => router.push("/login")}> 205 - <LogIn size={20} color={colors.text} style={styles.buttonIcon} /> 206 - <Text style={styles.buttonText}>Sign in</Text> 207 - </Button> 208 - </CardContent> 209 - </Card> 210 - </View> 211 - </SafeAreaView> 212 - ); 213 - } 255 + // Not authenticated state 256 + if (!isAuthenticated) { 257 + return ( 258 + <SafeAreaView style={styles.container} edges={["top"]}> 259 + <View style={styles.header}> 260 + <View style={styles.headerLeft}> 261 + <BookOpen size={32} color={colors.primary} /> 262 + <Text style={styles.title}>My Shelf</Text> 263 + </View> 264 + <TouchableOpacity onPress={handleAuthAction} style={styles.authButton}> 265 + <LogIn size={20} color={colors.text} /> 266 + <Text style={styles.authButtonText}>Sign in</Text> 267 + </TouchableOpacity> 268 + </View> 269 + <View style={styles.centerContent}> 270 + <Card style={styles.authCard}> 271 + <CardHeader> 272 + <BookOpen 273 + size={64} 274 + color={colors.primary} 275 + style={styles.authIcon} 276 + /> 277 + <Text style={styles.authTitle}>My Shelf</Text> 278 + <Text style={styles.authDescription}> 279 + Sign in to track movies you&apos;ve watched 280 + </Text> 281 + </CardHeader> 282 + <CardContent> 283 + <Button size="lg" onPress={() => router.push("/login")}> 284 + <LogIn 285 + size={20} 286 + color={colors.text} 287 + style={styles.buttonIcon} 288 + /> 289 + <Text style={styles.buttonText}>Sign in</Text> 290 + </Button> 291 + </CardContent> 292 + </Card> 293 + </View> 294 + </SafeAreaView> 295 + ); 296 + } 214 297 215 - return ( 216 - <SafeAreaView style={styles.container} edges={["top"]}> 217 - <View style={styles.header}> 218 - <View style={styles.headerContent}> 219 - <BookOpen size={32} color={colors.primary} /> 220 - <Text style={styles.title}>My Shelf</Text> 221 - </View> 222 - <Pressable 223 - onPress={async () => { 224 - await logout(); 225 - showToast("Logged out successfully", "success"); 226 - }} 227 - style={styles.logoutButton} 228 - > 229 - <LogOut size={20} color={colors.textMuted} /> 230 - <Text style={styles.logoutButtonText}>Logout</Text> 231 - </Pressable> 232 - </View> 298 + return ( 299 + <SafeAreaView style={styles.container} edges={["top"]}> 300 + <View style={styles.header}> 301 + <View style={styles.headerLeft}> 302 + <BookOpen size={32} color={colors.primary} /> 303 + <Text style={styles.title}>My Shelf</Text> 304 + </View> 305 + <TouchableOpacity onPress={handleAuthAction} style={styles.authButton}> 306 + <LogOut size={20} color={colors.text} /> 307 + <Text style={styles.authButtonText}>Logout</Text> 308 + </TouchableOpacity> 309 + </View> 233 310 234 - {isMoviesLoading && ( 235 - <View style={styles.skeletonGrid}> 236 - {[...Array(8)].map((_, i) => ( 237 - <View key={i} style={styles.skeletonItem}> 238 - <View style={[styles.skeletonPoster, { backgroundColor: colors.cardMuted }]} /> 239 - <View style={{ marginTop: spacing.sm }}> 240 - <Skeleton width="80%" height={16} /> 241 - </View> 242 - <View style={{ marginTop: spacing.xs }}> 243 - <Skeleton width="50%" height={14} /> 244 - </View> 245 - </View> 246 - ))} 247 - </View> 248 - )} 311 + {isMoviesLoading && ( 312 + <View style={styles.skeletonContainer}> 313 + {[...Array(6)].map((_, i) => ( 314 + <View key={i} style={styles.skeleton}> 315 + <View style={[styles.skeletonPoster, { backgroundColor: colors.cardMuted }]} /> 316 + <View style={styles.skeletonContent}> 317 + <Skeleton width="70%" height={18} /> 318 + <Skeleton width="40%" height={14} style={{ marginTop: spacing.sm }} /> 319 + </View> 320 + </View> 321 + ))} 322 + </View> 323 + )} 249 324 250 - {trackedMovies && trackedMovies.length > 0 && ( 251 - <FlashList 252 - data={trackedMovies} 253 - renderItem={renderMovieItem} 254 - keyExtractor={keyExtractor} 255 - numColumns={2} 256 - contentContainerStyle={styles.listContent} 257 - ListHeaderComponent={ 258 - <Text style={styles.resultsCount}> 259 - {trackedMovies.length} movie 260 - {trackedMovies.length !== 1 ? "s" : ""} watched 261 - </Text> 262 - } 263 - /> 264 - )} 325 + {trackedMovies && trackedMovies.length > 0 && ( 326 + <> 327 + <Text style={styles.resultsCount}> 328 + {trackedMovies.length} movie{trackedMovies.length !== 1 ? "s" : ""} watched 329 + </Text> 330 + <FlashList 331 + data={trackedMovies} 332 + renderItem={renderItem} 333 + keyExtractor={keyExtractor} 334 + contentContainerStyle={styles.listContent} 335 + ItemSeparatorComponent={() => <View style={styles.itemSeparator} />} 336 + /> 337 + </> 338 + )} 265 339 266 - {trackedMovies && trackedMovies.length === 0 && ( 267 - <View style={styles.centerContent}> 268 - <Card style={styles.emptyCard}> 269 - <CardHeader> 270 - <BookOpen size={64} color={colors.textSecondary} style={styles.emptyIcon} /> 271 - <Text style={styles.emptyTitle}>Your shelf is empty</Text> 272 - <Text style={styles.emptyDescription}> 273 - Start tracking movies you&apos;ve watched 274 - </Text> 275 - </CardHeader> 276 - <CardContent> 277 - <Button onPress={() => router.push("/(tabs)/search")}> 278 - <Text style={styles.buttonText}>Search for movies</Text> 279 - </Button> 280 - </CardContent> 281 - </Card> 282 - </View> 283 - )} 284 - </SafeAreaView> 285 - ); 340 + {trackedMovies && trackedMovies.length === 0 && ( 341 + <View style={styles.centerContent}> 342 + <Card style={styles.emptyCard}> 343 + <CardHeader> 344 + <BookOpen 345 + size={64} 346 + color={colors.textSecondary} 347 + style={styles.emptyIcon} 348 + /> 349 + <Text style={styles.emptyTitle}>Your shelf is empty</Text> 350 + <Text style={styles.emptyDescription}> 351 + Start tracking movies you&apos;ve watched 352 + </Text> 353 + </CardHeader> 354 + <CardContent> 355 + <Button onPress={() => router.push("/(tabs)/search")}> 356 + <Text style={styles.buttonText}>Search for movies</Text> 357 + </Button> 358 + </CardContent> 359 + </Card> 360 + </View> 361 + )} 362 + </SafeAreaView> 363 + ); 286 364 } 287 365 288 366 const styles = StyleSheet.create({ 289 - container: { 290 - flex: 1, 291 - backgroundColor: colors.background, 292 - }, 293 - header: { 294 - paddingHorizontal: spacing.lg, 295 - paddingVertical: spacing.md, 296 - flexDirection: "row", 297 - justifyContent: "space-between", 298 - alignItems: "center", 299 - }, 300 - headerContent: { 301 - flexDirection: "row", 302 - alignItems: "center", 303 - gap: spacing.sm, 304 - }, 305 - logoutButton: { 306 - padding: spacing.sm, 307 - flexDirection: "row", 308 - alignItems: "center", 309 - gap: spacing.sm, 310 - }, 311 - logoutButtonText: { 312 - color: colors.textMuted, 313 - fontSize: 16, 314 - fontWeight: "600", 315 - }, 316 - title: { 317 - fontSize: 28, 318 - fontWeight: "bold", 319 - color: colors.text, 320 - }, 321 - listContent: { 322 - padding: spacing.lg, 323 - }, 324 - movieItem: { 325 - flex: 1, 326 - marginBottom: spacing.lg, 327 - marginHorizontal: spacing.sm, 328 - minWidth: 140, 329 - maxWidth: "47%", 330 - }, 331 - posterContainer: { 332 - aspectRatio: 2 / 3, 333 - borderRadius: borderRadius.lg, 334 - overflow: "hidden", 335 - backgroundColor: colors.card, 336 - position: "relative", 337 - }, 338 - poster: { 339 - width: "100%", 340 - height: "100%", 341 - }, 342 - noPoster: { 343 - justifyContent: "center", 344 - alignItems: "center", 345 - }, 346 - noPosterText: { 347 - color: colors.textSecondary, 348 - fontSize: 12, 349 - }, 350 - removeButton: { 351 - position: "absolute", 352 - top: spacing.sm, 353 - right: spacing.sm, 354 - width: 32, 355 - height: 32, 356 - borderRadius: borderRadius.full, 357 - backgroundColor: colors.card, 358 - justifyContent: "center", 359 - alignItems: "center", 360 - }, 361 - movieTitle: { 362 - fontSize: 14, 363 - fontWeight: "600", 364 - color: colors.text, 365 - marginTop: spacing.sm, 366 - }, 367 - movieYear: { 368 - fontSize: 12, 369 - color: colors.textMuted, 370 - marginTop: 2, 371 - }, 372 - watchedInfo: { 373 - flexDirection: "row", 374 - alignItems: "center", 375 - marginTop: 4, 376 - gap: spacing.xs, 377 - }, 378 - watchedDate: { 379 - fontSize: 11, 380 - color: colors.textMuted, 381 - }, 382 - watchCount: { 383 - paddingHorizontal: 6, 384 - paddingVertical: 2, 385 - }, 386 - resultsCount: { 387 - fontSize: 14, 388 - color: colors.textMuted, 389 - marginBottom: spacing.md, 390 - }, 391 - centerContent: { 392 - flex: 1, 393 - justifyContent: "center", 394 - alignItems: "center", 395 - padding: spacing.xl, 396 - }, 397 - authCard: { 398 - width: "100%", 399 - maxWidth: 400, 400 - alignItems: "center", 401 - }, 402 - authIcon: { 403 - marginBottom: spacing.md, 404 - }, 405 - authTitle: { 406 - fontSize: 24, 407 - fontWeight: "bold", 408 - color: colors.text, 409 - textAlign: "center", 410 - marginBottom: spacing.sm, 411 - }, 412 - authDescription: { 413 - fontSize: 16, 414 - color: colors.textMuted, 415 - textAlign: "center", 416 - }, 417 - emptyCard: { 418 - width: "100%", 419 - maxWidth: 400, 420 - alignItems: "center", 421 - }, 422 - emptyIcon: { 423 - marginBottom: spacing.md, 424 - }, 425 - emptyTitle: { 426 - fontSize: 20, 427 - fontWeight: "bold", 428 - color: colors.text, 429 - textAlign: "center", 430 - marginBottom: spacing.sm, 431 - }, 432 - emptyDescription: { 433 - fontSize: 14, 434 - color: colors.textMuted, 435 - textAlign: "center", 436 - }, 437 - buttonIcon: { 438 - marginRight: spacing.sm, 439 - }, 440 - buttonText: { 441 - color: colors.text, 442 - fontSize: 16, 443 - fontWeight: "600", 444 - }, 445 - skeletonGrid: { 446 - flexDirection: "row", 447 - flexWrap: "wrap", 448 - padding: spacing.lg, 449 - }, 450 - skeletonItem: { 451 - flex: 1, 452 - marginBottom: spacing.lg, 453 - marginHorizontal: spacing.sm, 454 - minWidth: 140, 455 - maxWidth: "47%", 456 - }, 457 - skeletonPoster: { 458 - aspectRatio: 2 / 3, 459 - borderRadius: borderRadius.lg, 460 - overflow: "hidden", 461 - }, 367 + container: { 368 + flex: 1, 369 + backgroundColor: colors.background, 370 + }, 371 + header: { 372 + paddingHorizontal: spacing.lg, 373 + paddingVertical: spacing.md, 374 + flexDirection: "row", 375 + justifyContent: "space-between", 376 + alignItems: "center", 377 + }, 378 + headerLeft: { 379 + flexDirection: "row", 380 + alignItems: "center", 381 + gap: spacing.sm, 382 + }, 383 + title: { 384 + fontSize: 28, 385 + fontWeight: "bold", 386 + color: colors.text, 387 + }, 388 + authButton: { 389 + flexDirection: "row", 390 + alignItems: "center", 391 + gap: spacing.xs, 392 + paddingHorizontal: spacing.md, 393 + paddingVertical: spacing.sm, 394 + backgroundColor: colors.card, 395 + borderRadius: borderRadius.md, 396 + }, 397 + authButtonText: { 398 + fontSize: 14, 399 + fontWeight: "600", 400 + color: colors.text, 401 + }, 402 + resultsCount: { 403 + fontSize: 14, 404 + color: colors.textMuted, 405 + marginHorizontal: spacing.lg, 406 + marginBottom: spacing.sm, 407 + }, 408 + listContent: { 409 + padding: spacing.lg, 410 + }, 411 + card: { 412 + flexDirection: "row", 413 + backgroundColor: colors.card, 414 + borderRadius: borderRadius.lg, 415 + overflow: "hidden", 416 + borderWidth: 1, 417 + borderColor: colors.border, 418 + }, 419 + posterContainer: { 420 + width: 80, 421 + aspectRatio: 2 / 3, 422 + backgroundColor: colors.cardMuted, 423 + }, 424 + poster: { 425 + width: "100%", 426 + height: "100%", 427 + }, 428 + cardContent: { 429 + flex: 1, 430 + padding: spacing.md, 431 + justifyContent: "space-between", 432 + }, 433 + info: { 434 + flex: 1, 435 + }, 436 + movieTitle: { 437 + fontSize: 16, 438 + fontWeight: "600", 439 + color: colors.text, 440 + marginBottom: spacing.xs, 441 + lineHeight: 22, 442 + }, 443 + meta: { 444 + flexDirection: "row", 445 + alignItems: "center", 446 + flexWrap: "wrap", 447 + gap: spacing.xs, 448 + }, 449 + year: { 450 + fontSize: 14, 451 + color: colors.textMuted, 452 + }, 453 + watchedRow: { 454 + flexDirection: "row", 455 + alignItems: "center", 456 + gap: spacing.xs, 457 + }, 458 + watchedDate: { 459 + fontSize: 14, 460 + color: colors.success, 461 + fontWeight: "500", 462 + }, 463 + removeButton: { 464 + flexDirection: "row", 465 + alignItems: "center", 466 + gap: spacing.xs, 467 + paddingHorizontal: spacing.md, 468 + paddingVertical: spacing.sm, 469 + backgroundColor: colors.error, 470 + borderRadius: borderRadius.full, 471 + alignSelf: "flex-start", 472 + marginTop: spacing.sm, 473 + }, 474 + removeButtonText: { 475 + color: colors.text, 476 + fontSize: 14, 477 + fontWeight: "600", 478 + }, 479 + itemSeparator: { 480 + height: spacing.md, 481 + }, 482 + metaDot: { 483 + color: colors.textSecondary, 484 + fontSize: 12, 485 + }, 486 + noPoster: { 487 + justifyContent: "center", 488 + alignItems: "center", 489 + backgroundColor: colors.cardMuted, 490 + }, 491 + noPosterText: { 492 + color: colors.textSecondary, 493 + fontSize: 12, 494 + fontWeight: "500", 495 + }, 496 + centerContent: { 497 + flex: 1, 498 + justifyContent: "center", 499 + alignItems: "center", 500 + padding: spacing.xl, 501 + }, 502 + authCard: { 503 + width: "100%", 504 + maxWidth: 400, 505 + alignItems: "center", 506 + }, 507 + authIcon: { 508 + marginBottom: spacing.md, 509 + }, 510 + authTitle: { 511 + fontSize: 24, 512 + fontWeight: "bold", 513 + color: colors.text, 514 + textAlign: "center", 515 + marginBottom: spacing.sm, 516 + }, 517 + authDescription: { 518 + fontSize: 16, 519 + color: colors.textMuted, 520 + textAlign: "center", 521 + }, 522 + emptyCard: { 523 + width: "100%", 524 + maxWidth: 400, 525 + alignItems: "center", 526 + }, 527 + emptyIcon: { 528 + marginBottom: spacing.md, 529 + }, 530 + emptyTitle: { 531 + fontSize: 20, 532 + fontWeight: "bold", 533 + color: colors.text, 534 + textAlign: "center", 535 + marginBottom: spacing.sm, 536 + }, 537 + emptyDescription: { 538 + fontSize: 14, 539 + color: colors.textMuted, 540 + textAlign: "center", 541 + }, 542 + buttonIcon: { 543 + marginRight: spacing.sm, 544 + }, 545 + buttonText: { 546 + color: colors.text, 547 + fontSize: 16, 548 + fontWeight: "600", 549 + }, 550 + skeletonContainer: { 551 + padding: spacing.lg, 552 + }, 553 + skeleton: { 554 + flexDirection: "row", 555 + marginBottom: spacing.md, 556 + backgroundColor: colors.card, 557 + borderRadius: borderRadius.lg, 558 + overflow: "hidden", 559 + }, 560 + skeletonPoster: { 561 + width: 80, 562 + aspectRatio: 2 / 3, 563 + }, 564 + skeletonContent: { 565 + flex: 1, 566 + padding: spacing.md, 567 + justifyContent: "center", 568 + }, 462 569 });
+1 -1
apps/web/src/routes/movies.$movieId.$title.tsx
··· 403 403 </div> 404 404 405 405 {/* Main Content */} 406 - <div className="container mx-auto px-4 py-8 max-w-6xl"> 406 + <div className="container mx-auto px-4 py-4 max-w-6xl"> 407 407 <div className="grid grid-cols-1 md:grid-cols-[300px_1fr] gap-8"> 408 408 {/* Left Column - Poster (mobile) & Actions */} 409 409 <div className="md:hidden">
+1 -1
apps/web/src/routes/search.tsx
··· 142 142 143 143 return ( 144 144 <div className="min-h-screen bg-gray-950 text-gray-50"> 145 - <div className="container mx-auto px-4 py-8 max-w-7xl"> 145 + <div className="container mx-auto px-4 py-4 max-w-7xl"> 146 146 <h1 className="text-4xl font-bold mb-8">Search Movies</h1> 147 147 148 148 <div className="mb-8">
+2 -2
apps/web/src/routes/shelf.tsx
··· 70 70 if (isAuthLoading) { 71 71 return ( 72 72 <div className="min-h-screen bg-gray-950 text-gray-50"> 73 - <div className="container mx-auto px-4 py-8 max-w-7xl"> 73 + <div className="container mx-auto px-4 py-4 max-w-7xl"> 74 74 <div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-4"> 75 75 {["a", "b", "c", "d", "e", "f", "g", "h", "i", "j"].map((key) => ( 76 76 <Skeleton key={key} className="aspect-2/3 rounded-lg" /> ··· 109 109 110 110 return ( 111 111 <div className="min-h-screen bg-gray-950 text-gray-50"> 112 - <div className="container mx-auto px-4 py-8 max-w-7xl"> 112 + <div className="container mx-auto px-4 py-4 max-w-7xl"> 113 113 <div className="flex items-center gap-3 mb-8"> 114 114 <BookOpen className="w-8 h-8 text-purple-500" /> 115 115 <h1 className="text-4xl font-bold">My Shelf</h1>