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: use poster accent color for all movie detail values

Apply colors.accent to release date, runtime, and vote count
for visual consistency with vote average

+545
+545
apps/web/src/routes/movies.$movieId.$title.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, useState } from "react"; 13 + import { toast } from "sonner"; 14 + import { usePosterColors } from "../hooks/usePosterColors"; 15 + 16 + // TMDB Movie Detail type based on API response 17 + interface TMDBMovieDetail { 18 + id: number; 19 + title: string; 20 + poster_path?: string; 21 + backdrop_path?: string; 22 + release_date?: string; 23 + overview?: string; 24 + runtime?: number; 25 + vote_average?: number; 26 + vote_count?: number; 27 + genres?: Array<{ id: number; name: string }>; 28 + } 29 + 30 + export const Route = createFileRoute("/movies/$movieId/$title")({ 31 + loader: async ({ params, context }) => { 32 + const { movieId } = params; 33 + const { queryClient } = context; 34 + 35 + const data = await queryClient.fetchQuery({ 36 + ...moviesControllerGetMovieDetailsOptions({ 37 + path: { movieId }, 38 + }), 39 + }); 40 + 41 + return data as TMDBMovieDetail; 42 + }, 43 + head: ({ loaderData }) => ({ 44 + meta: [ 45 + { 46 + title: loaderData 47 + ? `${loaderData.title} | OpnShelf` 48 + : "Movie | OpnShelf", 49 + }, 50 + ], 51 + }), 52 + component: MovieDetailPage, 53 + }); 54 + 55 + function MovieDetailPage() { 56 + const { movieId } = Route.useParams(); 57 + const queryClient = useQueryClient(); 58 + const [showHours, setShowHours] = useState(false); 59 + 60 + const formatRuntime = (minutes: number, useHours: boolean) => { 61 + if (!useHours) return `${minutes} min`; 62 + const hours = Math.floor(minutes / 60); 63 + const mins = minutes % 60; 64 + if (mins === 0) return `${hours} hours`; 65 + return `${hours} hours ${mins} minutes`; 66 + }; 67 + 68 + // Fetch auth state 69 + const { data: user } = useQuery({ 70 + ...authControllerMeOptions(), 71 + staleTime: 5 * 60 * 1000, 72 + retry: false, 73 + }); 74 + 75 + // Fetch movie details 76 + const { data: movieData, isLoading: isMovieLoading } = useQuery({ 77 + ...moviesControllerGetMovieDetailsOptions({ 78 + path: { movieId }, 79 + }), 80 + }); 81 + 82 + const movie = movieData as TMDBMovieDetail | undefined; 83 + 84 + // Fetch user's tracked movies 85 + const { data: trackedMovies } = useQuery({ 86 + ...moviesControllerGetUserMoviesOptions({ 87 + path: { userDid: user?.did || "" }, 88 + }), 89 + enabled: !!user?.did, 90 + }); 91 + 92 + // Check if this movie is in user's watched list 93 + const isWatched = useMemo(() => { 94 + if (!trackedMovies) return false; 95 + return trackedMovies.some((tm) => tm.movieId === movieId); 96 + }, [trackedMovies, movieId]); 97 + 98 + // Find the tracked movie entry to get watched date 99 + const trackedMovie = useMemo(() => { 100 + if (!trackedMovies) return null; 101 + return trackedMovies.find((tm) => tm.movieId === movieId) || null; 102 + }, [trackedMovies, movieId]); 103 + 104 + // Format the watched date 105 + const formattedWatchedDate = useMemo(() => { 106 + if (!trackedMovie?.watchedDate) return null; 107 + return new Date(trackedMovie.watchedDate).toLocaleDateString("en-US", { 108 + year: "numeric", 109 + month: "short", 110 + day: "numeric", 111 + }); 112 + }, [trackedMovie]); 113 + 114 + // Extract accent colors from poster 115 + const colors = usePosterColors(movie?.poster_path); 116 + 117 + // Mutations for watchlist 118 + const markMutation = useMutation({ 119 + ...moviesControllerMarkWatchedMutation(), 120 + onSuccess: () => { 121 + queryClient.invalidateQueries({ 122 + queryKey: moviesControllerGetUserMoviesQueryKey({ 123 + path: { userDid: user?.did || "" }, 124 + }), 125 + }); 126 + toast.success("Added to your shelf"); 127 + }, 128 + onError: () => { 129 + toast.error("Failed to update. Please try again."); 130 + }, 131 + }); 132 + 133 + const unmarkMutation = useMutation({ 134 + ...moviesControllerUnmarkWatchedMutation(), 135 + onSuccess: () => { 136 + queryClient.invalidateQueries({ 137 + queryKey: moviesControllerGetUserMoviesQueryKey({ 138 + path: { userDid: user?.did || "" }, 139 + }), 140 + }); 141 + toast.success("Removed from your shelf"); 142 + }, 143 + onError: () => { 144 + toast.error("Failed to update. Please try again."); 145 + }, 146 + }); 147 + 148 + const handleToggleWatched = () => { 149 + if (isWatched) { 150 + unmarkMutation.mutate({ path: { movieId } }); 151 + } else { 152 + markMutation.mutate({ body: { movieId } }); 153 + } 154 + }; 155 + 156 + const isPending = 157 + (markMutation.isPending && 158 + markMutation.variables?.body?.movieId === movieId) || 159 + (unmarkMutation.isPending && 160 + unmarkMutation.variables?.path?.movieId === movieId); 161 + 162 + const releaseYear = movie?.release_date 163 + ? new Date(movie.release_date).getFullYear() 164 + : null; 165 + 166 + const backdropUrl = movie?.backdrop_path 167 + ? `https://image.tmdb.org/t/p/w1280${movie.backdrop_path}` 168 + : null; 169 + 170 + const posterUrl = movie?.poster_path 171 + ? `https://image.tmdb.org/t/p/w500${movie.poster_path}` 172 + : null; 173 + 174 + return ( 175 + <div className="min-h-screen bg-gray-950 text-gray-50"> 176 + {/* Hero Section with Backdrop */} 177 + <div className="relative h-[50vh] md:h-[60vh] overflow-hidden"> 178 + {backdropUrl ? ( 179 + <> 180 + <img 181 + src={backdropUrl} 182 + alt="" 183 + className="w-full h-full object-cover" 184 + /> 185 + {/* Gradient overlays */} 186 + <div 187 + className="absolute inset-0" 188 + style={{ 189 + background: `linear-gradient(to bottom, transparent 0%, rgba(3, 7, 18, 0.6) 60%, rgb(3, 7, 18) 100%)`, 190 + }} 191 + /> 192 + <div 193 + className="absolute inset-0" 194 + style={{ 195 + background: `linear-gradient(to right, rgba(3, 7, 18, 0.8) 0%, transparent 50%)`, 196 + }} 197 + /> 198 + </> 199 + ) : ( 200 + <div 201 + className="w-full h-full" 202 + style={{ 203 + background: `linear-gradient(135deg, ${colors.muted} 0%, rgb(3, 7, 18) 100%)`, 204 + }} 205 + /> 206 + )} 207 + 208 + {/* Back button */} 209 + <Link 210 + to="/search" 211 + search={{ q: "" }} 212 + className="absolute top-4 left-4 z-10 p-2 rounded-full bg-black/50 hover:bg-black/70 transition-colors" 213 + > 214 + <ArrowLeft className="w-5 h-5" /> 215 + </Link> 216 + 217 + {/* Hero Content */} 218 + <div className="absolute bottom-0 left-0 right-0 p-4 md:p-8"> 219 + <div className="container mx-auto max-w-6xl"> 220 + <div className="flex items-end gap-4 md:gap-8"> 221 + {/* Poster */} 222 + <div className="hidden md:block flex-shrink-0"> 223 + <div 224 + className="w-48 lg:w-64 rounded-lg overflow-hidden shadow-2xl" 225 + style={{ 226 + boxShadow: `0 25px 50px -12px ${colors.primary}40`, 227 + }} 228 + > 229 + {posterUrl ? ( 230 + <img 231 + src={posterUrl} 232 + alt={movie?.title} 233 + className="w-full aspect-2/3 object-cover" 234 + /> 235 + ) : ( 236 + <div className="w-full aspect-2/3 bg-gray-900 flex items-center justify-center"> 237 + <span className="text-gray-600">No poster</span> 238 + </div> 239 + )} 240 + </div> 241 + </div> 242 + 243 + {/* Title and Meta */} 244 + <div className="flex-1 pb-2"> 245 + <h1 246 + className="text-3xl md:text-5xl lg:text-6xl font-bold mb-2" 247 + style={{ 248 + textShadow: `0 4px 30px ${colors.primary}60`, 249 + }} 250 + > 251 + {movie?.title} 252 + </h1> 253 + {releaseYear && ( 254 + <div className="flex items-center gap-4 text-lg text-gray-300"> 255 + <span className="flex items-center gap-2"> 256 + <Calendar 257 + className="w-4 h-4" 258 + style={{ color: colors.accent }} 259 + /> 260 + {releaseYear} 261 + </span> 262 + {movie?.runtime && ( 263 + <button 264 + type="button" 265 + onClick={() => setShowHours(!showHours)} 266 + className="flex items-center gap-2 cursor-pointer hover:text-white transition-colors" 267 + > 268 + <Clock 269 + className="w-4 h-4" 270 + style={{ color: colors.accent }} 271 + /> 272 + {formatRuntime(movie.runtime, showHours)} 273 + </button> 274 + )} 275 + </div> 276 + )} 277 + </div> 278 + </div> 279 + </div> 280 + </div> 281 + </div> 282 + 283 + {/* Main Content */} 284 + <div className="container mx-auto px-4 py-8 max-w-6xl"> 285 + <div className="grid grid-cols-1 md:grid-cols-[300px_1fr] gap-8"> 286 + {/* Left Column - Poster (mobile) & Actions */} 287 + <div className="md:hidden"> 288 + <div className="flex gap-4"> 289 + {posterUrl && ( 290 + <div 291 + className="w-32 flex-shrink-0 rounded-lg overflow-hidden" 292 + style={{ 293 + boxShadow: `0 20px 40px -10px ${colors.primary}40`, 294 + }} 295 + > 296 + <img 297 + src={posterUrl} 298 + alt={movie?.title} 299 + className="w-full aspect-2/3 object-cover" 300 + /> 301 + </div> 302 + )} 303 + <div className="flex-1 flex flex-col justify-center"> 304 + {user ? ( 305 + <button 306 + type="button" 307 + onClick={handleToggleWatched} 308 + disabled={isPending} 309 + 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" 310 + style={{ 311 + background: isWatched 312 + ? `linear-gradient(135deg, ${colors.muted} 0%, ${colors.primary} 100%)` 313 + : `linear-gradient(135deg, ${colors.primary} 0%, ${colors.secondary} 100%)`, 314 + boxShadow: `0 10px 30px -10px ${colors.primary}60`, 315 + }} 316 + > 317 + {isPending ? ( 318 + <Loader2 className="w-5 h-5 animate-spin" /> 319 + ) : isWatched ? ( 320 + <div className="flex flex-col items-center"> 321 + <span className="flex items-center gap-2"> 322 + <Check className="w-5 h-5" /> 323 + On Your Shelf 324 + </span> 325 + {formattedWatchedDate && ( 326 + <span className="text-xs font-normal opacity-80"> 327 + Watched on {formattedWatchedDate} 328 + </span> 329 + )} 330 + </div> 331 + ) : ( 332 + <> 333 + <Plus className="w-5 h-5" /> 334 + Add to Shelf 335 + </> 336 + )} 337 + </button> 338 + ) : ( 339 + <Link 340 + to="/login" 341 + className="w-full py-3 px-6 rounded-xl font-semibold text-white text-center transition-all duration-200" 342 + style={{ 343 + background: `linear-gradient(135deg, ${colors.primary} 0%, ${colors.secondary} 100%)`, 344 + boxShadow: `0 10px 30px -10px ${colors.primary}60`, 345 + }} 346 + > 347 + Sign in to Track 348 + </Link> 349 + )} 350 + </div> 351 + </div> 352 + </div> 353 + 354 + {/* Desktop Actions */} 355 + <div className="hidden md:block space-y-4"> 356 + {user ? ( 357 + <button 358 + type="button" 359 + onClick={handleToggleWatched} 360 + disabled={isPending} 361 + 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]" 362 + style={{ 363 + background: isWatched 364 + ? `linear-gradient(135deg, ${colors.muted} 0%, ${colors.primary} 100%)` 365 + : `linear-gradient(135deg, ${colors.primary} 0%, ${colors.secondary} 100%)`, 366 + boxShadow: `0 15px 35px -10px ${colors.primary}60`, 367 + }} 368 + > 369 + {isPending ? ( 370 + <Loader2 className="w-5 h-5 animate-spin" /> 371 + ) : isWatched ? ( 372 + <div className="flex flex-col items-center"> 373 + <span className="flex items-center gap-2"> 374 + <Check className="w-5 h-5" /> 375 + On Your Shelf 376 + </span> 377 + {formattedWatchedDate && ( 378 + <span className="text-sm font-normal opacity-80"> 379 + Watched on {formattedWatchedDate} 380 + </span> 381 + )} 382 + </div> 383 + ) : ( 384 + <> 385 + <Plus className="w-5 h-5" /> 386 + Add to Shelf 387 + </> 388 + )} 389 + </button> 390 + ) : ( 391 + <Link 392 + to="/login" 393 + 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]" 394 + style={{ 395 + background: `linear-gradient(135deg, ${colors.primary} 0%, ${colors.secondary} 100%)`, 396 + boxShadow: `0 15px 35px -10px ${colors.primary}60`, 397 + }} 398 + > 399 + Sign in to Track 400 + </Link> 401 + )} 402 + 403 + {/* Color preview (subtle) */} 404 + <div className="pt-4 border-t border-gray-800"> 405 + <div className="flex gap-2"> 406 + <div 407 + className="w-8 h-8 rounded-full" 408 + style={{ backgroundColor: colors.primary }} 409 + title="Primary" 410 + /> 411 + <div 412 + className="w-8 h-8 rounded-full" 413 + style={{ backgroundColor: colors.secondary }} 414 + title="Secondary" 415 + /> 416 + <div 417 + className="w-8 h-8 rounded-full" 418 + style={{ backgroundColor: colors.accent }} 419 + title="Accent" 420 + /> 421 + </div> 422 + </div> 423 + </div> 424 + 425 + {/* Right Column - Details */} 426 + <div className="space-y-6"> 427 + {/* Overview */} 428 + <section> 429 + <h2 430 + className="text-xl font-semibold mb-3" 431 + style={{ color: colors.primary }} 432 + > 433 + Overview 434 + </h2> 435 + <p className="text-gray-300 leading-relaxed text-lg"> 436 + {movie?.overview || "No overview available."} 437 + </p> 438 + </section> 439 + 440 + {/* Additional Info */} 441 + <section className="grid grid-cols-2 gap-4"> 442 + {movie?.release_date && ( 443 + <div className="p-4 rounded-lg bg-gray-900/50"> 444 + <span className="text-gray-500 text-sm block mb-1"> 445 + Release Date 446 + </span> 447 + <span 448 + className="font-medium" 449 + style={{ color: colors.accent }} 450 + > 451 + {new Date(movie.release_date).toLocaleDateString("en-US", { 452 + year: "numeric", 453 + month: "long", 454 + day: "numeric", 455 + })} 456 + </span> 457 + </div> 458 + )} 459 + {movie?.runtime && ( 460 + <button 461 + type="button" 462 + onClick={() => setShowHours(!showHours)} 463 + className="p-4 rounded-lg bg-gray-900/50 text-left cursor-pointer hover:bg-gray-800/50 transition-colors w-full" 464 + > 465 + <span className="text-gray-500 text-sm block mb-1"> 466 + Runtime 467 + </span> 468 + <span 469 + className="font-medium" 470 + style={{ color: colors.accent }} 471 + > 472 + {formatRuntime(movie.runtime, showHours)} 473 + </span> 474 + </button> 475 + )} 476 + {movie?.vote_average && ( 477 + <div className="p-4 rounded-lg bg-gray-900/50"> 478 + <span className="text-gray-500 text-sm block mb-1"> 479 + Rating 480 + </span> 481 + <span 482 + className="font-medium" 483 + style={{ color: colors.accent }} 484 + > 485 + {movie.vote_average.toFixed(1)}/10 486 + </span> 487 + </div> 488 + )} 489 + {movie?.vote_count && ( 490 + <div className="p-4 rounded-lg bg-gray-900/50"> 491 + <span className="text-gray-500 text-sm block mb-1"> 492 + Votes 493 + </span> 494 + <span 495 + className="font-medium" 496 + style={{ color: colors.accent }} 497 + > 498 + {movie.vote_count.toLocaleString()} 499 + </span> 500 + </div> 501 + )} 502 + </section> 503 + 504 + {/* Genres */} 505 + {movie?.genres && movie.genres.length > 0 && ( 506 + <section> 507 + <h2 508 + className="text-xl font-semibold mb-3" 509 + style={{ color: colors.primary }} 510 + > 511 + Genres 512 + </h2> 513 + <div className="flex flex-wrap gap-2"> 514 + {movie.genres.map((genre) => ( 515 + <span 516 + key={genre.id} 517 + className="px-4 py-2 rounded-full text-sm font-medium" 518 + style={{ 519 + backgroundColor: `${colors.primary}20`, 520 + color: colors.accent, 521 + border: `1px solid ${colors.primary}40`, 522 + }} 523 + > 524 + {genre.name} 525 + </span> 526 + ))} 527 + </div> 528 + </section> 529 + )} 530 + </div> 531 + </div> 532 + </div> 533 + 534 + {/* Loading State */} 535 + {isMovieLoading && ( 536 + <div className="fixed inset-0 bg-gray-950 flex items-center justify-center z-50"> 537 + <div 538 + className="animate-spin rounded-full h-16 w-16 border-b-2" 539 + style={{ borderColor: colors.primary }} 540 + /> 541 + </div> 542 + )} 543 + </div> 544 + ); 545 + }