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: add sonner for toast notifications and update routes for movie details

- Integrated sonner for user feedback on actions in SearchPage and ShelfPage.
- Updated routes to include movie titles in the URL structure for better SEO and user experience.
- Refactored route handling in AuthCompletePage to redirect to the shelf after login.
- Introduced a new MoviesMovieIdTitleRoute for handling movie details with titles.
- Added meta tags for improved SEO in various routes.

+174 -503
+1
apps/web/package.json
··· 31 31 "nitro": "npm:nitro-nightly@latest", 32 32 "react": "^19.2.0", 33 33 "react-dom": "^19.2.0", 34 + "sonner": "^2.0.7", 34 35 "tailwind-merge": "^3.0.2", 35 36 "tailwindcss": "^4.0.6", 36 37 "tw-animate-css": "^1.3.6",
+21 -21
apps/web/src/routeTree.gen.ts
··· 13 13 import { Route as SearchRouteImport } from './routes/search' 14 14 import { Route as LoginRouteImport } from './routes/login' 15 15 import { Route as IndexRouteImport } from './routes/index' 16 - import { Route as MoviesMovieIdRouteImport } from './routes/movies.$movieId' 17 16 import { Route as AuthCompleteRouteImport } from './routes/auth/complete' 17 + import { Route as MoviesMovieIdTitleRouteImport } from './routes/movies.$movieId.$title' 18 18 19 19 const ShelfRoute = ShelfRouteImport.update({ 20 20 id: '/shelf', ··· 36 36 path: '/', 37 37 getParentRoute: () => rootRouteImport, 38 38 } as any) 39 - const MoviesMovieIdRoute = MoviesMovieIdRouteImport.update({ 40 - id: '/movies/$movieId', 41 - path: '/movies/$movieId', 42 - getParentRoute: () => rootRouteImport, 43 - } as any) 44 39 const AuthCompleteRoute = AuthCompleteRouteImport.update({ 45 40 id: '/auth/complete', 46 41 path: '/auth/complete', 47 42 getParentRoute: () => rootRouteImport, 48 43 } as any) 44 + const MoviesMovieIdTitleRoute = MoviesMovieIdTitleRouteImport.update({ 45 + id: '/movies/$movieId/$title', 46 + path: '/movies/$movieId/$title', 47 + getParentRoute: () => rootRouteImport, 48 + } as any) 49 49 50 50 export interface FileRoutesByFullPath { 51 51 '/': typeof IndexRoute ··· 53 53 '/search': typeof SearchRoute 54 54 '/shelf': typeof ShelfRoute 55 55 '/auth/complete': typeof AuthCompleteRoute 56 - '/movies/$movieId': typeof MoviesMovieIdRoute 56 + '/movies/$movieId/$title': typeof MoviesMovieIdTitleRoute 57 57 } 58 58 export interface FileRoutesByTo { 59 59 '/': typeof IndexRoute ··· 61 61 '/search': typeof SearchRoute 62 62 '/shelf': typeof ShelfRoute 63 63 '/auth/complete': typeof AuthCompleteRoute 64 - '/movies/$movieId': typeof MoviesMovieIdRoute 64 + '/movies/$movieId/$title': typeof MoviesMovieIdTitleRoute 65 65 } 66 66 export interface FileRoutesById { 67 67 __root__: typeof rootRouteImport ··· 70 70 '/search': typeof SearchRoute 71 71 '/shelf': typeof ShelfRoute 72 72 '/auth/complete': typeof AuthCompleteRoute 73 - '/movies/$movieId': typeof MoviesMovieIdRoute 73 + '/movies/$movieId/$title': typeof MoviesMovieIdTitleRoute 74 74 } 75 75 export interface FileRouteTypes { 76 76 fileRoutesByFullPath: FileRoutesByFullPath ··· 80 80 | '/search' 81 81 | '/shelf' 82 82 | '/auth/complete' 83 - | '/movies/$movieId' 83 + | '/movies/$movieId/$title' 84 84 fileRoutesByTo: FileRoutesByTo 85 85 to: 86 86 | '/' ··· 88 88 | '/search' 89 89 | '/shelf' 90 90 | '/auth/complete' 91 - | '/movies/$movieId' 91 + | '/movies/$movieId/$title' 92 92 id: 93 93 | '__root__' 94 94 | '/' ··· 96 96 | '/search' 97 97 | '/shelf' 98 98 | '/auth/complete' 99 - | '/movies/$movieId' 99 + | '/movies/$movieId/$title' 100 100 fileRoutesById: FileRoutesById 101 101 } 102 102 export interface RootRouteChildren { ··· 105 105 SearchRoute: typeof SearchRoute 106 106 ShelfRoute: typeof ShelfRoute 107 107 AuthCompleteRoute: typeof AuthCompleteRoute 108 - MoviesMovieIdRoute: typeof MoviesMovieIdRoute 108 + MoviesMovieIdTitleRoute: typeof MoviesMovieIdTitleRoute 109 109 } 110 110 111 111 declare module '@tanstack/react-router' { ··· 138 138 preLoaderRoute: typeof IndexRouteImport 139 139 parentRoute: typeof rootRouteImport 140 140 } 141 - '/movies/$movieId': { 142 - id: '/movies/$movieId' 143 - path: '/movies/$movieId' 144 - fullPath: '/movies/$movieId' 145 - preLoaderRoute: typeof MoviesMovieIdRouteImport 146 - parentRoute: typeof rootRouteImport 147 - } 148 141 '/auth/complete': { 149 142 id: '/auth/complete' 150 143 path: '/auth/complete' ··· 152 145 preLoaderRoute: typeof AuthCompleteRouteImport 153 146 parentRoute: typeof rootRouteImport 154 147 } 148 + '/movies/$movieId/$title': { 149 + id: '/movies/$movieId/$title' 150 + path: '/movies/$movieId/$title' 151 + fullPath: '/movies/$movieId/$title' 152 + preLoaderRoute: typeof MoviesMovieIdTitleRouteImport 153 + parentRoute: typeof rootRouteImport 154 + } 155 155 } 156 156 } 157 157 ··· 161 161 SearchRoute: SearchRoute, 162 162 ShelfRoute: ShelfRoute, 163 163 AuthCompleteRoute: AuthCompleteRoute, 164 - MoviesMovieIdRoute: MoviesMovieIdRoute, 164 + MoviesMovieIdTitleRoute: MoviesMovieIdTitleRoute, 165 165 } 166 166 export const routeTree = rootRouteImport 167 167 ._addFileChildren(rootRouteChildren)
+2
apps/web/src/routes/__root.tsx
··· 8 8 Scripts, 9 9 } from "@tanstack/react-router"; 10 10 import { TanStackRouterDevtoolsPanel } from "@tanstack/react-router-devtools"; 11 + import { Toaster } from "sonner"; 11 12 import { env } from "@/env"; 12 13 import Header from "../components/Header"; 13 14 import TanStackQueryDevtools from "../integrations/tanstack-query/devtools"; ··· 68 69 TanStackQueryDevtools, 69 70 ]} 70 71 /> 72 + <Toaster /> 71 73 </QueryClientProvider> 72 74 ); 73 75 }
+1 -1
apps/web/src/routes/auth/complete.tsx
··· 38 38 if (storedRedirect && isValidRedirectPath(storedRedirect)) { 39 39 navigate({ to: storedRedirect }); 40 40 } else { 41 - navigate({ to: "/" }); 41 + navigate({ to: "/shelf" }); 42 42 } 43 43 }, [navigate, queryClient]); 44 44
+3
apps/web/src/routes/index.tsx
··· 2 2 import { Film, Search } from "lucide-react"; 3 3 4 4 export const Route = createFileRoute("/")({ 5 + head: () => ({ 6 + meta: [{ title: "OpnShelf" }], 7 + }), 5 8 component: HomePage, 6 9 }); 7 10
+4 -1
apps/web/src/routes/login.tsx
··· 14 14 15 15 export const Route = createFileRoute("/login")({ 16 16 validateSearch: loginSearchSchema, 17 + head: () => ({ 18 + meta: [{ title: "Sign In | OpnShelf" }], 19 + }), 17 20 component: LoginPage, 18 21 }); 19 22 ··· 34 37 // Redirect if already logged in 35 38 useEffect(() => { 36 39 if (user && !isAuthLoading) { 37 - navigate({ to: redirect || "/" }); 40 + navigate({ to: redirect || "/shelf" }); 38 41 } 39 42 }, [user, isAuthLoading, navigate, redirect]); 40 43
-459
apps/web/src/routes/movies.$movieId.tsx
··· 1 - import { 2 - authControllerMeOptions, 3 - moviesControllerGetMovieDetailsOptions, 4 - moviesControllerGetUserMoviesOptions, 5 - moviesControllerGetUserMoviesQueryKey, 6 - moviesControllerMarkWatchedMutation, 7 - moviesControllerUnmarkWatchedMutation, 8 - } from "@opnshelf/api"; 9 - import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; 10 - import { createFileRoute, Link } from "@tanstack/react-router"; 11 - import { ArrowLeft, Calendar, Check, Clock, Loader2, Plus } from "lucide-react"; 12 - import { useMemo } from "react"; 13 - import { usePosterColors } from "../hooks/usePosterColors"; 14 - 15 - // TMDB Movie Detail type based on API response 16 - interface TMDBMovieDetail { 17 - id: number; 18 - title: string; 19 - poster_path?: string; 20 - backdrop_path?: string; 21 - release_date?: string; 22 - overview?: string; 23 - runtime?: number; 24 - vote_average?: number; 25 - vote_count?: number; 26 - genres?: Array<{ id: number; name: string }>; 27 - } 28 - 29 - export const Route = createFileRoute("/movies/$movieId")({ 30 - component: MovieDetailPage, 31 - }); 32 - 33 - function MovieDetailPage() { 34 - const { movieId } = Route.useParams(); 35 - const queryClient = useQueryClient(); 36 - 37 - // Fetch auth state 38 - const { data: user } = useQuery({ 39 - ...authControllerMeOptions(), 40 - staleTime: 5 * 60 * 1000, 41 - retry: false, 42 - }); 43 - 44 - // Fetch movie details 45 - const { data: movieData, isLoading: isMovieLoading } = useQuery({ 46 - ...moviesControllerGetMovieDetailsOptions({ 47 - path: { movieId }, 48 - }), 49 - }); 50 - 51 - const movie = movieData as TMDBMovieDetail | undefined; 52 - 53 - // Fetch user's tracked movies 54 - const { data: trackedMovies } = useQuery({ 55 - ...moviesControllerGetUserMoviesOptions({ 56 - path: { userDid: user?.did || "" }, 57 - }), 58 - enabled: !!user?.did, 59 - }); 60 - 61 - // Check if this movie is in user's watched list 62 - const isWatched = useMemo(() => { 63 - if (!trackedMovies) return false; 64 - return trackedMovies.some((tm) => tm.movieId === movieId); 65 - }, [trackedMovies, movieId]); 66 - 67 - // Extract accent colors from poster 68 - const colors = usePosterColors(movie?.poster_path); 69 - 70 - // Mutations for watchlist 71 - const markMutation = useMutation({ 72 - ...moviesControllerMarkWatchedMutation(), 73 - onSuccess: () => { 74 - queryClient.invalidateQueries({ 75 - queryKey: moviesControllerGetUserMoviesQueryKey({ 76 - path: { userDid: user?.did || "" }, 77 - }), 78 - }); 79 - }, 80 - }); 81 - 82 - const unmarkMutation = useMutation({ 83 - ...moviesControllerUnmarkWatchedMutation(), 84 - onSuccess: () => { 85 - queryClient.invalidateQueries({ 86 - queryKey: moviesControllerGetUserMoviesQueryKey({ 87 - path: { userDid: user?.did || "" }, 88 - }), 89 - }); 90 - }, 91 - }); 92 - 93 - const handleToggleWatched = () => { 94 - if (isWatched) { 95 - unmarkMutation.mutate({ path: { movieId } }); 96 - } else { 97 - markMutation.mutate({ body: { movieId } }); 98 - } 99 - }; 100 - 101 - const isPending = 102 - (markMutation.isPending && 103 - markMutation.variables?.body?.movieId === movieId) || 104 - (unmarkMutation.isPending && 105 - unmarkMutation.variables?.path?.movieId === movieId); 106 - 107 - const releaseYear = movie?.release_date 108 - ? new Date(movie.release_date).getFullYear() 109 - : null; 110 - 111 - const backdropUrl = movie?.backdrop_path 112 - ? `https://image.tmdb.org/t/p/w1280${movie.backdrop_path}` 113 - : null; 114 - 115 - const posterUrl = movie?.poster_path 116 - ? `https://image.tmdb.org/t/p/w500${movie.poster_path}` 117 - : null; 118 - 119 - return ( 120 - <div className="min-h-screen bg-gray-950 text-gray-50"> 121 - {/* Hero Section with Backdrop */} 122 - <div className="relative h-[50vh] md:h-[60vh] overflow-hidden"> 123 - {backdropUrl ? ( 124 - <> 125 - <img 126 - src={backdropUrl} 127 - alt="" 128 - className="w-full h-full object-cover" 129 - /> 130 - {/* Gradient overlays */} 131 - <div 132 - className="absolute inset-0" 133 - style={{ 134 - background: `linear-gradient(to bottom, transparent 0%, rgba(3, 7, 18, 0.6) 60%, rgb(3, 7, 18) 100%)`, 135 - }} 136 - /> 137 - <div 138 - className="absolute inset-0" 139 - style={{ 140 - background: `linear-gradient(to right, rgba(3, 7, 18, 0.8) 0%, transparent 50%)`, 141 - }} 142 - /> 143 - </> 144 - ) : ( 145 - <div 146 - className="w-full h-full" 147 - style={{ 148 - background: `linear-gradient(135deg, ${colors.muted} 0%, rgb(3, 7, 18) 100%)`, 149 - }} 150 - /> 151 - )} 152 - 153 - {/* Back button */} 154 - <Link 155 - to="/search" 156 - search={{ q: "" }} 157 - className="absolute top-4 left-4 z-10 p-2 rounded-full bg-black/50 hover:bg-black/70 transition-colors" 158 - > 159 - <ArrowLeft className="w-5 h-5" /> 160 - </Link> 161 - 162 - {/* Hero Content */} 163 - <div className="absolute bottom-0 left-0 right-0 p-4 md:p-8"> 164 - <div className="container mx-auto max-w-6xl"> 165 - <div className="flex items-end gap-4 md:gap-8"> 166 - {/* Poster */} 167 - <div className="hidden md:block flex-shrink-0"> 168 - <div 169 - className="w-48 lg:w-64 rounded-lg overflow-hidden shadow-2xl" 170 - style={{ 171 - boxShadow: `0 25px 50px -12px ${colors.primary}40`, 172 - }} 173 - > 174 - {posterUrl ? ( 175 - <img 176 - src={posterUrl} 177 - alt={movie?.title} 178 - className="w-full aspect-2/3 object-cover" 179 - /> 180 - ) : ( 181 - <div className="w-full aspect-2/3 bg-gray-900 flex items-center justify-center"> 182 - <span className="text-gray-600">No poster</span> 183 - </div> 184 - )} 185 - </div> 186 - </div> 187 - 188 - {/* Title and Meta */} 189 - <div className="flex-1 pb-2"> 190 - <h1 191 - className="text-3xl md:text-5xl lg:text-6xl font-bold mb-2" 192 - style={{ 193 - textShadow: `0 4px 30px ${colors.primary}60`, 194 - }} 195 - > 196 - {movie?.title} 197 - </h1> 198 - {releaseYear && ( 199 - <div className="flex items-center gap-4 text-lg text-gray-300"> 200 - <span className="flex items-center gap-2"> 201 - <Calendar 202 - className="w-4 h-4" 203 - style={{ color: colors.accent }} 204 - /> 205 - {releaseYear} 206 - </span> 207 - {movie?.runtime && ( 208 - <span className="flex items-center gap-2"> 209 - <Clock 210 - className="w-4 h-4" 211 - style={{ color: colors.accent }} 212 - /> 213 - {movie.runtime} min 214 - </span> 215 - )} 216 - </div> 217 - )} 218 - </div> 219 - </div> 220 - </div> 221 - </div> 222 - </div> 223 - 224 - {/* Main Content */} 225 - <div className="container mx-auto px-4 py-8 max-w-6xl"> 226 - <div className="grid grid-cols-1 md:grid-cols-[300px_1fr] gap-8"> 227 - {/* Left Column - Poster (mobile) & Actions */} 228 - <div className="md:hidden"> 229 - <div className="flex gap-4"> 230 - {posterUrl && ( 231 - <div 232 - className="w-32 flex-shrink-0 rounded-lg overflow-hidden" 233 - style={{ 234 - boxShadow: `0 20px 40px -10px ${colors.primary}40`, 235 - }} 236 - > 237 - <img 238 - src={posterUrl} 239 - alt={movie?.title} 240 - className="w-full aspect-2/3 object-cover" 241 - /> 242 - </div> 243 - )} 244 - <div className="flex-1 flex flex-col justify-center"> 245 - {user ? ( 246 - <button 247 - type="button" 248 - onClick={handleToggleWatched} 249 - disabled={isPending} 250 - className="w-full py-3 px-6 rounded-xl font-semibold text-white transition-all duration-200 flex items-center justify-center gap-2 disabled:opacity-70" 251 - style={{ 252 - background: isWatched 253 - ? `linear-gradient(135deg, ${colors.muted} 0%, ${colors.primary} 100%)` 254 - : `linear-gradient(135deg, ${colors.primary} 0%, ${colors.secondary} 100%)`, 255 - boxShadow: `0 10px 30px -10px ${colors.primary}60`, 256 - }} 257 - > 258 - {isPending ? ( 259 - <Loader2 className="w-5 h-5 animate-spin" /> 260 - ) : isWatched ? ( 261 - <> 262 - <Check className="w-5 h-5" /> 263 - On Your Shelf 264 - </> 265 - ) : ( 266 - <> 267 - <Plus className="w-5 h-5" /> 268 - Add to Shelf 269 - </> 270 - )} 271 - </button> 272 - ) : ( 273 - <Link 274 - to="/login" 275 - className="w-full py-3 px-6 rounded-xl font-semibold text-white text-center transition-all duration-200" 276 - style={{ 277 - background: `linear-gradient(135deg, ${colors.primary} 0%, ${colors.secondary} 100%)`, 278 - boxShadow: `0 10px 30px -10px ${colors.primary}60`, 279 - }} 280 - > 281 - Sign in to Track 282 - </Link> 283 - )} 284 - </div> 285 - </div> 286 - </div> 287 - 288 - {/* Desktop Actions */} 289 - <div className="hidden md:block space-y-4"> 290 - {user ? ( 291 - <button 292 - type="button" 293 - onClick={handleToggleWatched} 294 - disabled={isPending} 295 - className="w-full py-4 px-6 rounded-xl font-semibold text-white text-lg transition-all duration-200 flex items-center justify-center gap-2 disabled:opacity-70 hover:scale-[1.02]" 296 - style={{ 297 - background: isWatched 298 - ? `linear-gradient(135deg, ${colors.muted} 0%, ${colors.primary} 100%)` 299 - : `linear-gradient(135deg, ${colors.primary} 0%, ${colors.secondary} 100%)`, 300 - boxShadow: `0 15px 35px -10px ${colors.primary}60`, 301 - }} 302 - > 303 - {isPending ? ( 304 - <Loader2 className="w-5 h-5 animate-spin" /> 305 - ) : isWatched ? ( 306 - <> 307 - <Check className="w-5 h-5" /> 308 - On Your Shelf 309 - </> 310 - ) : ( 311 - <> 312 - <Plus className="w-5 h-5" /> 313 - Add to Shelf 314 - </> 315 - )} 316 - </button> 317 - ) : ( 318 - <Link 319 - to="/login" 320 - className="w-full py-4 px-6 rounded-xl font-semibold text-white text-lg text-center transition-all duration-200 block hover:scale-[1.02]" 321 - style={{ 322 - background: `linear-gradient(135deg, ${colors.primary} 0%, ${colors.secondary} 100%)`, 323 - boxShadow: `0 15px 35px -10px ${colors.primary}60`, 324 - }} 325 - > 326 - Sign in to Track 327 - </Link> 328 - )} 329 - 330 - {/* Color preview (subtle) */} 331 - <div className="pt-4 border-t border-gray-800"> 332 - <div className="flex gap-2"> 333 - <div 334 - className="w-8 h-8 rounded-full" 335 - style={{ backgroundColor: colors.primary }} 336 - title="Primary" 337 - /> 338 - <div 339 - className="w-8 h-8 rounded-full" 340 - style={{ backgroundColor: colors.secondary }} 341 - title="Secondary" 342 - /> 343 - <div 344 - className="w-8 h-8 rounded-full" 345 - style={{ backgroundColor: colors.accent }} 346 - title="Accent" 347 - /> 348 - </div> 349 - </div> 350 - </div> 351 - 352 - {/* Right Column - Details */} 353 - <div className="space-y-6"> 354 - {/* Overview */} 355 - <section> 356 - <h2 357 - className="text-xl font-semibold mb-3" 358 - style={{ color: colors.primary }} 359 - > 360 - Overview 361 - </h2> 362 - <p className="text-gray-300 leading-relaxed text-lg"> 363 - {movie?.overview || "No overview available."} 364 - </p> 365 - </section> 366 - 367 - {/* Additional Info */} 368 - <section className="grid grid-cols-2 gap-4"> 369 - {movie?.release_date && ( 370 - <div className="p-4 rounded-lg bg-gray-900/50"> 371 - <span className="text-gray-500 text-sm block mb-1"> 372 - Release Date 373 - </span> 374 - <span className="text-gray-200 font-medium"> 375 - {new Date(movie.release_date).toLocaleDateString("en-US", { 376 - year: "numeric", 377 - month: "long", 378 - day: "numeric", 379 - })} 380 - </span> 381 - </div> 382 - )} 383 - {movie?.runtime && ( 384 - <div className="p-4 rounded-lg bg-gray-900/50"> 385 - <span className="text-gray-500 text-sm block mb-1"> 386 - Runtime 387 - </span> 388 - <span className="text-gray-200 font-medium"> 389 - {movie.runtime} minutes 390 - </span> 391 - </div> 392 - )} 393 - {movie?.vote_average && ( 394 - <div className="p-4 rounded-lg bg-gray-900/50"> 395 - <span className="text-gray-500 text-sm block mb-1"> 396 - Rating 397 - </span> 398 - <span 399 - className="font-medium" 400 - style={{ color: colors.accent }} 401 - > 402 - {movie.vote_average.toFixed(1)}/10 403 - </span> 404 - </div> 405 - )} 406 - {movie?.vote_count && ( 407 - <div className="p-4 rounded-lg bg-gray-900/50"> 408 - <span className="text-gray-500 text-sm block mb-1"> 409 - Votes 410 - </span> 411 - <span className="text-gray-200 font-medium"> 412 - {movie.vote_count.toLocaleString()} 413 - </span> 414 - </div> 415 - )} 416 - </section> 417 - 418 - {/* Genres */} 419 - {movie?.genres && movie.genres.length > 0 && ( 420 - <section> 421 - <h2 422 - className="text-xl font-semibold mb-3" 423 - style={{ color: colors.primary }} 424 - > 425 - Genres 426 - </h2> 427 - <div className="flex flex-wrap gap-2"> 428 - {movie.genres.map((genre) => ( 429 - <span 430 - key={genre.id} 431 - className="px-4 py-2 rounded-full text-sm font-medium" 432 - style={{ 433 - backgroundColor: `${colors.primary}20`, 434 - color: colors.accent, 435 - border: `1px solid ${colors.primary}40`, 436 - }} 437 - > 438 - {genre.name} 439 - </span> 440 - ))} 441 - </div> 442 - </section> 443 - )} 444 - </div> 445 - </div> 446 - </div> 447 - 448 - {/* Loading State */} 449 - {isMovieLoading && ( 450 - <div className="fixed inset-0 bg-gray-950 flex items-center justify-center z-50"> 451 - <div 452 - className="animate-spin rounded-full h-16 w-16 border-b-2" 453 - style={{ borderColor: colors.primary }} 454 - /> 455 - </div> 456 - )} 457 - </div> 458 - ); 459 - }
+29 -4
apps/web/src/routes/search.tsx
··· 10 10 import { createFileRoute, Link, useNavigate } from "@tanstack/react-router"; 11 11 import { Check, Loader2, Plus, Search } from "lucide-react"; 12 12 import { useEffect, useMemo, useRef, useState } from "react"; 13 + import { toast } from "sonner"; 14 + 15 + function createTitleSlug(title: string): string { 16 + return title 17 + .replace(/[^a-zA-Z0-9\s-]/g, "") 18 + .trim() 19 + .replace(/\s+/g, "-"); 20 + } 13 21 14 22 export const Route = createFileRoute("/search")({ 15 23 component: SearchPage, 16 24 validateSearch: (search: Record<string, unknown>) => ({ 17 25 q: (search.q as string) || "", 26 + }), 27 + head: () => ({ 28 + meta: [{ title: "Search Movies | OpnShelf" }], 18 29 }), 19 30 }); 20 31 ··· 57 68 path: { userDid: user?.did || "" }, 58 69 }), 59 70 }); 71 + toast.success("Added to your shelf"); 72 + }, 73 + onError: () => { 74 + toast.error("Failed to update. Please try again."); 60 75 }, 61 76 }); 62 77 ··· 69 84 path: { userDid: user?.did || "" }, 70 85 }), 71 86 }); 87 + toast.success("Removed from your shelf"); 88 + }, 89 + onError: () => { 90 + toast.error("Failed to update. Please try again."); 72 91 }, 73 92 }); 74 93 ··· 148 167 return ( 149 168 <div key={movie.id} className="group"> 150 169 <Link 151 - to="/movies/$movieId" 152 - params={{ movieId: movieId }} 170 + to="/movies/$movieId/$title" 171 + params={{ 172 + movieId: movieId, 173 + title: createTitleSlug(movie.title), 174 + }} 153 175 className="block relative aspect-2/3 bg-gray-900 rounded-lg overflow-hidden mb-2" 154 176 > 155 177 {movie.poster_path ? ( ··· 208 230 )} 209 231 </Link> 210 232 <Link 211 - to="/movies/$movieId" 212 - params={{ movieId: movieId }} 233 + to="/movies/$movieId/$title" 234 + params={{ 235 + movieId: movieId, 236 + title: createTitleSlug(movie.title), 237 + }} 213 238 className="block" 214 239 > 215 240 <h3 className="font-semibold text-sm line-clamp-2 mb-1 hover:text-purple-400 transition-colors">
+59 -13
apps/web/src/routes/shelf.tsx
··· 7 7 import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; 8 8 import { createFileRoute, Link } from "@tanstack/react-router"; 9 9 import { BookOpen, Loader2, LogIn, Trash2 } from "lucide-react"; 10 + import { toast } from "sonner"; 11 + 12 + function createTitleSlug(title: string): string { 13 + return title 14 + .replace(/[^a-zA-Z0-9\s-]/g, "") 15 + .trim() 16 + .replace(/\s+/g, "-"); 17 + } 10 18 11 19 export const Route = createFileRoute("/shelf")({ 20 + head: () => ({ 21 + meta: [{ title: "My Shelf | OpnShelf" }], 22 + }), 12 23 component: ShelfPage, 13 24 }); 14 25 ··· 39 50 path: { userDid: user?.did || "" }, 40 51 }), 41 52 }); 53 + toast.success("Removed from your shelf"); 54 + }, 55 + onError: () => { 56 + toast.error("Failed to update. Please try again."); 42 57 }, 43 58 }); 44 59 ··· 100 115 <div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-4"> 101 116 {trackedMovies.map((tracked) => ( 102 117 <div key={tracked.id} className="group relative"> 103 - <div className="relative aspect-2/3 bg-gray-900 rounded-lg overflow-hidden mb-2"> 118 + <Link 119 + to="/movies/$movieId/$title" 120 + params={{ 121 + movieId: tracked.movieId, 122 + title: createTitleSlug(tracked.movie.title), 123 + }} 124 + className="block relative aspect-2/3 bg-gray-900 rounded-lg overflow-hidden mb-2" 125 + > 104 126 {tracked.movie.posterPath ? ( 105 127 <img 106 128 src={`https://image.tmdb.org/t/p/w342${tracked.movie.posterPath}`} ··· 114 136 )} 115 137 <button 116 138 type="button" 117 - onClick={() => 139 + onClick={(e) => { 140 + e.preventDefault(); 141 + e.stopPropagation(); 118 142 unmarkMutation.mutate({ 119 143 path: { movieId: tracked.movieId }, 120 - }) 121 - } 144 + }); 145 + }} 122 146 disabled={ 123 147 unmarkMutation.isPending && 124 148 unmarkMutation.variables?.path?.movieId === ··· 135 159 <Trash2 className="w-4 h-4" /> 136 160 )} 137 161 </button> 138 - </div> 139 - <h3 className="font-semibold text-sm line-clamp-2 mb-1"> 140 - {tracked.movie.title} 141 - </h3> 142 - {tracked.movie.releaseYear && ( 143 - <p className="text-gray-500 text-sm"> 144 - {tracked.movie.releaseYear} 145 - </p> 146 - )} 162 + </Link> 163 + <Link 164 + to="/movies/$movieId/$title" 165 + params={{ 166 + movieId: tracked.movieId, 167 + title: createTitleSlug(tracked.movie.title), 168 + }} 169 + className="block" 170 + > 171 + <h3 className="font-semibold text-sm line-clamp-2 mb-1 hover:text-purple-400 transition-colors"> 172 + {tracked.movie.title} 173 + </h3> 174 + {tracked.movie.releaseYear && ( 175 + <p className="text-gray-500 text-sm"> 176 + {tracked.movie.releaseYear} 177 + </p> 178 + )} 179 + {tracked.watchedDate && ( 180 + <p className="text-gray-400 text-xs mt-1"> 181 + Watched{" "} 182 + {new Date(tracked.watchedDate).toLocaleDateString( 183 + "en-US", 184 + { 185 + month: "short", 186 + day: "numeric", 187 + year: "numeric", 188 + }, 189 + )} 190 + </p> 191 + )} 192 + </Link> 147 193 </div> 148 194 ))} 149 195 </div>
+7 -4
backend/src/auth/auth.service.ts
··· 8 8 import { Agent } from '@atproto/api'; 9 9 import { PrismaService } from '../prisma/prisma.service'; 10 10 11 + const OAUTH_SCOPE = 12 + 'atproto repo:app.opnshelf.movie rpc:app.bsky.actor.getProfile?aud=did:web:api.bsky.app%23bsky_appview'; 13 + 11 14 @Injectable() 12 15 export class AuthService implements OnModuleInit { 13 16 private readonly logger = new Logger(AuthService.name); ··· 39 42 : `${backendUrl}/auth/callback`; 40 43 41 44 const clientId = isLocalhost 42 - ? `http://localhost?redirect_uri=${encodeURIComponent(redirectUri)}&scope=${encodeURIComponent('atproto transition:generic')}` 45 + ? `http://localhost?redirect_uri=${encodeURIComponent(redirectUri)}&scope=${encodeURIComponent(OAUTH_SCOPE)}` 43 46 : `${backendUrl}/.well-known/oauth-client-metadata.json`; 44 47 45 48 this.logger.log(`Initializing OAuth client with client_id: ${clientId}`); ··· 113 116 client_name: 'OpnShelf', 114 117 client_uri: isLocalhost ? `http://127.0.0.1:${port}` : backendUrl, 115 118 redirect_uris: [redirectUri], 116 - scope: 'atproto transition:generic', 119 + scope: OAUTH_SCOPE, 117 120 grant_types: ['authorization_code', 'refresh_token'], 118 121 response_types: ['code'], 119 122 // For localhost: application_type must be 'native' per AT Protocol spec ··· 148 151 async authorize(handle: string): Promise<string> { 149 152 const client = this.getOAuthClient(); 150 153 const url = await client.authorize(handle, { 151 - scope: 'atproto transition:generic', 154 + scope: OAUTH_SCOPE, 152 155 }); 153 156 return url.toString(); 154 157 } ··· 295 298 client_name: 'OpnShelf', 296 299 client_uri: isLocalhost ? `http://127.0.0.1:${port}` : backendUrl, 297 300 redirect_uris: [redirectUri], 298 - scope: 'atproto transition:generic', 301 + scope: OAUTH_SCOPE, 299 302 grant_types: ['authorization_code', 'refresh_token'], 300 303 response_types: ['code'], 301 304 application_type: isLocalhost ? 'native' : 'web',
+33
lexicons/app/opnshelf/movie.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "app.opnshelf.movie", 4 + "defs": { 5 + "main": { 6 + "type": "record", 7 + "description": "A tracked movie record for OpnShelf", 8 + "key": "tid", 9 + "record": { 10 + "type": "object", 11 + "required": ["movieId", "status"], 12 + "properties": { 13 + "movieId": { 14 + "type": "string", 15 + "description": "TMDB movie ID" 16 + }, 17 + "status": { 18 + "type": "string", 19 + "description": "Watch status of the movie (e.g., watched, wantToWatch, watching)" 20 + }, 21 + "watchedDate": { 22 + "type": "datetime", 23 + "description": "When the movie was watched" 24 + }, 25 + "createdAt": { 26 + "type": "datetime", 27 + "description": "Record creation timestamp" 28 + } 29 + } 30 + } 31 + } 32 + } 33 + }
+14
pnpm-lock.yaml
··· 129 129 react-dom: 130 130 specifier: ^19.2.0 131 131 version: 19.2.4(react@19.2.4) 132 + sonner: 133 + specifier: ^2.0.7 134 + version: 2.0.7(react-dom@19.2.4(react@19.2.4))(react@19.2.4) 132 135 tailwind-merge: 133 136 specifier: ^3.0.2 134 137 version: 3.4.0 ··· 6731 6734 6732 6735 sonic-boom@3.8.1: 6733 6736 resolution: {integrity: sha512-y4Z8LCDBuum+PBP3lSV7RHrXscqksve/bi0as7mhwVnBW+/wUqKT/2Kb7um8yqcFy0duYbbPxzt89Zy2nOCaxg==} 6737 + 6738 + sonner@2.0.7: 6739 + resolution: {integrity: sha512-W6ZN4p58k8aDKA4XPcx2hpIQXBRAgyiWVkYhT7CvK6D3iAu7xjvVyhQHg2/iaKJZ1XVJ4r7XuwGL+WGEK37i9w==} 6740 + peerDependencies: 6741 + react: ^18.0.0 || ^19.0.0 || ^19.0.0-rc 6742 + react-dom: ^18.0.0 || ^19.0.0 || ^19.0.0-rc 6734 6743 6735 6744 source-map-js@1.2.1: 6736 6745 resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} ··· 15220 15229 sonic-boom@3.8.1: 15221 15230 dependencies: 15222 15231 atomic-sleep: 1.0.0 15232 + 15233 + sonner@2.0.7(react-dom@19.2.4(react@19.2.4))(react@19.2.4): 15234 + dependencies: 15235 + react: 19.2.4 15236 + react-dom: 19.2.4(react@19.2.4) 15223 15237 15224 15238 source-map-js@1.2.1: {} 15225 15239