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.

fix: various issues

+186 -61
+2 -2
apps/mobile/app/(tabs)/profile/index.tsx
··· 228 228 { color: colors.onSurfaceVariant }, 229 229 ]} 230 230 > 231 - Movies you&apos;ve watched 231 + Items added to your shelf 232 232 </Text> 233 233 </View> 234 234 <Text style={[styles.linkArrow, { color: colors.onSurfaceVariant }]}> ··· 264 264 { color: colors.onSurfaceVariant }, 265 265 ]} 266 266 > 267 - Custom movie collections 267 + Custom lists of items 268 268 </Text> 269 269 </View> 270 270 <Text style={[styles.linkArrow, { color: colors.onSurfaceVariant }]}>
+11 -4
apps/mobile/app/auth/callback.tsx
··· 3 3 import { ActivityIndicator, StyleSheet, Text, View } from "react-native"; 4 4 import { useAuth } from "@/contexts/auth"; 5 5 import { useTheme } from "@/contexts/theme"; 6 + import { useToast } from "@/contexts/toast"; 6 7 7 8 export default function AuthCallbackScreen() { 8 9 const { token } = useLocalSearchParams<{ token?: string }>(); 9 10 const { handleAuthCallback } = useAuth(); 10 11 const router = useRouter(); 11 12 const { colors } = useTheme(); 13 + const { showToast } = useToast(); 12 14 13 15 useEffect(() => { 14 16 if (token) { 15 - handleAuthCallback(token).then(() => { 16 - router.replace("/(tabs)"); 17 - }); 17 + handleAuthCallback(token) 18 + .then(() => { 19 + router.replace("/(tabs)"); 20 + }) 21 + .catch(() => { 22 + showToast("Sign in failed. Please try again."); 23 + router.replace("/login"); 24 + }); 18 25 } 19 - }, [token, handleAuthCallback, router]); 26 + }, [token, handleAuthCallback, router, showToast]); 20 27 21 28 return ( 22 29 <View style={[styles.container, { backgroundColor: colors.background }]}>
+23 -13
apps/mobile/app/auth/complete.tsx
··· 1 - import { Ionicons } from "@expo/vector-icons"; 2 1 import { authControllerMeOptions } from "@opnshelf/api"; 3 2 import { useQueryClient } from "@tanstack/react-query"; 4 3 import { useLocalSearchParams, useRouter } from "expo-router"; 5 4 import { useEffect } from "react"; 6 - import { ActivityIndicator, Text, View } from "react-native"; 5 + import { ActivityIndicator, Image, Text, View } from "react-native"; 6 + import { useToast } from "@/contexts/toast"; 7 7 import { saveSessionToken } from "@/lib/api"; 8 8 9 9 export default function AuthCompleteScreen() { ··· 11 11 const router = useRouter(); 12 12 const params = useLocalSearchParams<{ session?: string }>(); 13 13 const { session } = params; 14 + const { showToast } = useToast(); 14 15 15 16 useEffect(() => { 16 17 async function completeAuth() { 17 - if (session) { 18 - await saveSessionToken(session); 19 - } 18 + try { 19 + if (session) { 20 + await saveSessionToken(session); 21 + } 20 22 21 - await queryClient.fetchQuery({ 22 - ...authControllerMeOptions(), 23 - staleTime: 0, 24 - }); 23 + await queryClient.fetchQuery({ 24 + ...authControllerMeOptions(), 25 + staleTime: 0, 26 + }); 25 27 26 - await new Promise((resolve) => setTimeout(resolve, 100)); 28 + await new Promise((resolve) => setTimeout(resolve, 100)); 27 29 28 - router.replace("/(tabs)"); 30 + router.replace("/(tabs)"); 31 + } catch (error) { 32 + console.error("Auth complete failed:", error); 33 + showToast("Sign in failed. Please try again."); 34 + router.replace("/login"); 35 + } 29 36 } 30 37 31 38 completeAuth(); 32 - }, [router, queryClient, session]); 39 + }, [router, queryClient, session, showToast]); 33 40 34 41 return ( 35 42 <View ··· 41 48 padding: 16, 42 49 }} 43 50 > 44 - <Ionicons name="film" size={48} color="#F59E0B" /> 51 + <Image 52 + source={require("@/assets/images/icon.png")} 53 + style={{ width: 64, height: 64, borderRadius: 16, marginBottom: 16 }} 54 + /> 45 55 <ActivityIndicator 46 56 size="large" 47 57 color="#F59E0B"
+4 -1
apps/mobile/app/login.tsx
··· 216 216 <View style={{ flex: 1, justifyContent: "center" }}> 217 217 <View style={{ alignItems: "center", marginBottom: 32 }}> 218 218 <View style={{ marginBottom: 16 }}> 219 - <Ionicons name="film" size={48} color={colors.primary} /> 219 + <Image 220 + source={require("@/assets/images/icon.png")} 221 + style={{ width: 64, height: 64, borderRadius: 16 }} 222 + /> 220 223 </View> 221 224 <Text 222 225 style={{
+11 -9
apps/mobile/app/movie/[id].tsx
··· 253 253 ); 254 254 255 255 const handleShare = useCallback(async () => { 256 + const displayTitle = movie?.title || title; 256 257 const shareUrl = `https://opnshelf.xyz/movies/${movieId}/${title || ""}`; 257 258 try { 258 259 await Share.share({ 259 - message: `Check out ${title} on OpnShelf!\n\n${shareUrl}`, 260 - title: `Check out ${title} on OpnShelf`, 260 + message: `Check out ${displayTitle} on OpnShelf!\n\n${shareUrl}`, 261 + title: `Check out ${displayTitle} on OpnShelf`, 261 262 }); 262 263 } catch { 263 264 showToast("Failed to share", "error"); 264 265 } 265 - }, [movieId, title, showToast]); 266 + }, [movie?.title, movieId, title, showToast]); 266 267 267 268 const openDateModal = useCallback(() => { 268 269 setCustomDate(new Date()); ··· 336 337 } 337 338 338 339 return ( 339 - <> 340 - <ScrollView 341 - style={[styles.container, { backgroundColor: themeColors.background }]} 342 - contentContainerStyle={styles.scrollContent} 343 - > 340 + <SafeAreaView 341 + style={[styles.container, { backgroundColor: themeColors.background }]} 342 + > 343 + <ScrollView contentContainerStyle={styles.scrollContent}> 344 344 <DetailHero 345 345 title={movie?.title || title || ""} 346 346 subtitle={releaseYear ? String(releaseYear) : undefined} ··· 368 368 onShowListModal={() => setShowAddToListModal(true)} 369 369 onViewHistory={() => setShowHistoryModal(true)} 370 370 onShare={handleShare} 371 + isLoggedIn={!!user} 372 + onLogin={() => router.push("/login")} 371 373 /> 372 374 373 375 <MetadataPills items={metadataItems} /> ··· 756 758 mediaId={movieId} 757 759 mediaTitle={movie?.title || title || ""} 758 760 /> 759 - </> 761 + </SafeAreaView> 760 762 ); 761 763 } 762 764
+5 -5
apps/mobile/components/detail/DetailActions.tsx
··· 115 115 > 116 116 {isPending ? ( 117 117 <View style={styles.buttonContent}> 118 - <ActivityIndicator color="#f9fafb" size="small" /> 118 + <ActivityIndicator color="#3f2e00" size="small" /> 119 119 <Text style={styles.primaryButtonText}>Loading</Text> 120 120 </View> 121 121 ) : ( 122 122 <View style={styles.buttonContent}> 123 - <Ionicons name="refresh" size={18} color="#f9fafb" /> 123 + <Ionicons name="refresh" size={18} color="#3f2e00" /> 124 124 <Text style={styles.primaryButtonText}>Watch Again</Text> 125 125 </View> 126 126 )} ··· 156 156 > 157 157 {isPending ? ( 158 158 <View style={styles.buttonContent}> 159 - <ActivityIndicator color="#f9fafb" size="small" /> 159 + <ActivityIndicator color="#3f2e00" size="small" /> 160 160 <Text style={styles.primaryButtonText}>Loading</Text> 161 161 </View> 162 162 ) : ( 163 163 <View style={styles.buttonContent}> 164 - <Ionicons name="add" size={20} color="#f9fafb" /> 164 + <Ionicons name="add" size={20} color="#3f2e00" /> 165 165 <Text style={styles.primaryButtonText}>Add to Shelf</Text> 166 166 </View> 167 167 )} ··· 261 261 gap: spacing.sm, 262 262 }, 263 263 primaryButtonText: { 264 - color: "#f9fafb", 264 + color: "#3f2e00", 265 265 fontSize: 16, 266 266 fontWeight: "600", 267 267 },
+5 -5
apps/mobile/components/movie/MovieActions.tsx
··· 69 69 > 70 70 {isPending ? ( 71 71 <View style={styles.buttonContent}> 72 - <ActivityIndicator color="#f9fafb" /> 72 + <ActivityIndicator color="#3f2e00" /> 73 73 <Text style={styles.buttonText}>Loading</Text> 74 74 </View> 75 75 ) : ( 76 76 <View style={styles.buttonContent}> 77 - <Ionicons name="add" size={20} color="#f9fafb" /> 77 + <Ionicons name="add" size={20} color="#3f2e00" /> 78 78 <Text style={styles.buttonText}>Add to Shelf</Text> 79 79 </View> 80 80 )} ··· 103 103 > 104 104 {isPending ? ( 105 105 <View style={styles.buttonContent}> 106 - <ActivityIndicator color="#f9fafb" /> 106 + <ActivityIndicator color="#3f2e00" /> 107 107 <Text style={styles.buttonText}>Loading</Text> 108 108 </View> 109 109 ) : ( 110 110 <View style={styles.buttonContent}> 111 - <Ionicons name="checkmark" size={20} color="#f9fafb" /> 111 + <Ionicons name="checkmark" size={20} color="#3f2e00" /> 112 112 <Text style={styles.buttonText}>On Your Shelf</Text> 113 113 </View> 114 114 )} ··· 140 140 gap: spacing.sm, 141 141 }, 142 142 buttonText: { 143 - color: "#f9fafb", 143 + color: "#3f2e00", 144 144 fontSize: 16, 145 145 fontWeight: "600", 146 146 },
+8 -6
apps/mobile/contexts/auth.tsx
··· 115 115 queryClient.removeQueries({ queryKey: ["moviesControllerGetUserMovies"] }); 116 116 }, [queryClient]); 117 117 118 - const handleAuthCallback = useCallback(async (token: string) => { 119 - await saveSessionToken(token); 120 - setHasSessionToken(true); 121 - // Refetch user to update auth state 122 - await queryClient.invalidateQueries({ queryKey: authControllerMeQueryKey() }); 123 - }, [queryClient]); 118 + const handleAuthCallback = useCallback( 119 + async (token: string) => { 120 + await saveSessionToken(token); 121 + setHasSessionToken(true); 122 + await queryClient.invalidateQueries({ queryKey: authControllerMeQueryKey() }); 123 + }, 124 + [queryClient] 125 + ); 124 126 125 127 const isLoading = !isInitialized || (hasSessionToken && !hasResolvedInitialAuth); 126 128
+1 -1
apps/web/src/components/Header.tsx
··· 25 25 mutationKey: ["auth", "logout"], 26 26 ...authControllerLogoutMutation(), 27 27 onSuccess: () => { 28 - queryClient.removeQueries(authControllerMeOptions()); 28 + queryClient.clear(); 29 29 navigate({ to: "/" }); 30 30 }, 31 31 });
+5 -2
apps/web/src/routes/auth/complete.tsx
··· 1 1 import { useQueryClient } from "@tanstack/react-query"; 2 2 import { createFileRoute, useNavigate } from "@tanstack/react-router"; 3 - import { Film } from "lucide-react"; 4 3 import { useEffect } from "react"; 5 4 import { useTheme } from "@/components/theme-provider"; 6 5 ··· 43 42 }} 44 43 > 45 44 <div className="flex-1 flex flex-col items-center justify-center p-4"> 46 - <Film className="w-12 h-12 mb-4" style={{ color: seedColor }} /> 45 + <img 46 + src="/icon.png" 47 + alt="OpnShelf" 48 + className="w-16 h-16 rounded-xl mb-4" 49 + /> 47 50 <div 48 51 className="w-8 h-8 border-4 border-t-transparent rounded-full animate-spin mb-4" 49 52 style={{ borderColor: seedColor }}
+5 -4
apps/web/src/routes/index.tsx
··· 18 18 import { ListCard } from "@/components/ListCard"; 19 19 import { ShelfEpisodeCard } from "@/components/ShelfEpisodeCard"; 20 20 import { ShelfMovieCard } from "@/components/ShelfMovieCard"; 21 - import { useTheme } from "@/components/theme-provider"; 22 21 import { M3Button } from "@/components/ui/m3-button"; 23 22 import { 24 23 M3Card, ··· 68 67 } 69 68 70 69 function LandingHomePage() { 71 - const { seedColor } = useTheme(); 72 - 73 70 return ( 74 71 <div 75 72 className="min-h-screen" ··· 81 78 <div className="container mx-auto px-4 py-16 max-w-4xl"> 82 79 <div className="text-center mb-12"> 83 80 <div className="flex justify-center mb-6"> 84 - <Film className="w-16 h-16" style={{ color: seedColor }} /> 81 + <img 82 + src="/icon.png" 83 + alt="OpnShelf" 84 + className="w-24 h-24 rounded-2xl" 85 + /> 85 86 </div> 86 87 <h1 className="md-display-large mb-4">OpnShelf</h1> 87 88 <p
+6 -2
apps/web/src/routes/login.tsx
··· 1 1 import { authControllerMeOptions, getLoginUrl } from "@opnshelf/api"; 2 2 import { useQuery } from "@tanstack/react-query"; 3 3 import { createFileRoute, useNavigate } from "@tanstack/react-router"; 4 - import { AlertCircle, Film, LogIn } from "lucide-react"; 4 + import { AlertCircle, LogIn } from "lucide-react"; 5 5 import { useEffect, useId, useRef, useState } from "react"; 6 6 import { z } from "zod"; 7 7 import { useTheme } from "@/components/theme-provider"; ··· 170 170 <div className="w-full max-w-md"> 171 171 <div className="text-center mb-8"> 172 172 <div className="flex justify-center mb-4"> 173 - <Film className="w-12 h-12" style={{ color: seedColor }} /> 173 + <img 174 + src="/icon.png" 175 + alt="OpnShelf" 176 + className="w-16 h-16 rounded-xl" 177 + /> 174 178 </div> 175 179 <h1 className="md-headline-medium mb-2">Sign in to OpnShelf</h1> 176 180 <p style={{ color: "var(--md-sys-color-on-surface-variant)" }}>
+37 -3
apps/web/src/routes/shows.$showId.$title.seasons.$seasonNumber.episodes.$episodeNumber.tsx
··· 10 10 showsControllerGetUserShowsQueryKey, 11 11 showsControllerMarkWatchedMutation, 12 12 showsControllerUnmarkWatchedMutation, 13 + type TmdbEpisodeDto, 13 14 type TmdbShowDetailDto, 14 15 usersControllerGetMySettingsOptions, 15 16 } from "@opnshelf/api"; ··· 68 69 return { show: showData, episode: episodeData }; 69 70 }, 70 71 head: ({ loaderData }) => { 71 - const showName = loaderData?.show?.name; 72 - const episodeName = loaderData?.episode?.name; 72 + const show = loaderData?.show as TmdbShowDetailDto | undefined; 73 + const episode = loaderData?.episode as TmdbEpisodeDto | undefined; 74 + const showName = show?.name; 75 + const episodeName = episode?.name; 73 76 const title = 74 77 showName && episodeName 75 78 ? `${showName}: ${episodeName} | OpnShelf` 76 79 : "Episode | OpnShelf"; 80 + const posterUrl = 81 + episode?.still_path 82 + ? `https://image.tmdb.org/t/p/w780${episode.still_path}` 83 + : show?.poster_path 84 + ? `https://image.tmdb.org/t/p/w780${show.poster_path}` 85 + : null; 86 + const url = typeof window !== "undefined" ? window.location.href : ""; 77 87 78 88 return { 79 - meta: [{ title }], 89 + meta: [ 90 + { title }, 91 + { 92 + name: "description", 93 + content: episode?.overview?.slice(0, 160) || show?.overview?.slice(0, 160) || "", 94 + }, 95 + { property: "og:title", content: title }, 96 + { 97 + property: "og:description", 98 + content: episode?.overview?.slice(0, 160) || show?.overview?.slice(0, 160) || "", 99 + }, 100 + { property: "og:type", content: "video.episode" }, 101 + { property: "og:url", content: url }, 102 + ...(posterUrl ? [{ property: "og:image", content: posterUrl }] : []), 103 + { property: "og:image:width", content: "780" }, 104 + { property: "og:image:height", content: "1170" }, 105 + { name: "twitter:card", content: "summary_large_image" }, 106 + { name: "twitter:title", content: title }, 107 + { 108 + name: "twitter:description", 109 + content: episode?.overview?.slice(0, 160) || show?.overview?.slice(0, 160) || "", 110 + }, 111 + ...(posterUrl ? [{ name: "twitter:image", content: posterUrl }] : []), 112 + { name: "twitter:url", content: url }, 113 + ], 80 114 }; 81 115 }, 82 116 component: ShowEpisodePage,
+32 -2
apps/web/src/routes/shows.$showId.$title.seasons.$seasonNumber.tsx
··· 63 63 return { show: showData, season: seasonData }; 64 64 }, 65 65 head: ({ loaderData, params }) => { 66 - const showName = loaderData?.show?.name; 66 + const show = loaderData?.show as TmdbShowDetailDto | undefined; 67 + const season = loaderData?.season as TmdbSeasonDetailDto | undefined; 68 + const showName = show?.name; 67 69 const seasonNumber = params.seasonNumber; 68 70 const title = showName 69 71 ? `${showName}: Season ${seasonNumber} | OpnShelf` 70 72 : `Season ${seasonNumber} | OpnShelf`; 73 + const posterUrl = show?.poster_path 74 + ? `https://image.tmdb.org/t/p/w780${show.poster_path}` 75 + : null; 76 + const url = typeof window !== "undefined" ? window.location.href : ""; 71 77 72 78 return { 73 - meta: [{ title }], 79 + meta: [ 80 + { title }, 81 + { 82 + name: "description", 83 + content: season?.overview?.slice(0, 160) || show?.overview?.slice(0, 160) || "", 84 + }, 85 + { property: "og:title", content: title }, 86 + { 87 + property: "og:description", 88 + content: season?.overview?.slice(0, 160) || show?.overview?.slice(0, 160) || "", 89 + }, 90 + { property: "og:type", content: "video.tv" }, 91 + { property: "og:url", content: url }, 92 + ...(posterUrl ? [{ property: "og:image", content: posterUrl }] : []), 93 + { property: "og:image:width", content: "780" }, 94 + { property: "og:image:height", content: "1170" }, 95 + { name: "twitter:card", content: "summary_large_image" }, 96 + { name: "twitter:title", content: title }, 97 + { 98 + name: "twitter:description", 99 + content: season?.overview?.slice(0, 160) || show?.overview?.slice(0, 160) || "", 100 + }, 101 + ...(posterUrl ? [{ name: "twitter:image", content: posterUrl }] : []), 102 + { name: "twitter:url", content: url }, 103 + ], 74 104 }; 75 105 }, 76 106 component: ShowSeasonPage,
+31 -2
apps/web/src/routes/shows.$showId.$title.tsx
··· 52 52 return showData; 53 53 }, 54 54 head: ({ loaderData }) => { 55 - const showName = loaderData?.name; 55 + const show = loaderData as TmdbShowDetailDto | undefined; 56 + const showName = show?.name; 56 57 const title = showName ? `${showName} | OpnShelf` : "Show | OpnShelf"; 58 + const posterUrl = show?.poster_path 59 + ? `https://image.tmdb.org/t/p/w780${show.poster_path}` 60 + : null; 61 + const url = typeof window !== "undefined" ? window.location.href : ""; 57 62 58 63 return { 59 - meta: [{ title }], 64 + meta: [ 65 + { title }, 66 + { 67 + name: "description", 68 + content: show?.overview?.slice(0, 160) || "", 69 + }, 70 + { property: "og:title", content: title }, 71 + { 72 + property: "og:description", 73 + content: show?.overview?.slice(0, 160) || "", 74 + }, 75 + { property: "og:type", content: "video.tv" }, 76 + { property: "og:url", content: url }, 77 + ...(posterUrl ? [{ property: "og:image", content: posterUrl }] : []), 78 + { property: "og:image:width", content: "780" }, 79 + { property: "og:image:height", content: "1170" }, 80 + { name: "twitter:card", content: "summary_large_image" }, 81 + { name: "twitter:title", content: title }, 82 + { 83 + name: "twitter:description", 84 + content: show?.overview?.slice(0, 160) || "", 85 + }, 86 + ...(posterUrl ? [{ name: "twitter:image", content: posterUrl }] : []), 87 + { name: "twitter:url", content: url }, 88 + ], 60 89 }; 61 90 }, 62 91 component: ShowDetailPage,