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: implement usePosterColors hook for dynamic color extraction from movie posters and integrate it into MovieDetailPage for enhanced UI

+661 -14
+186
apps/web/src/hooks/usePosterColors.ts
··· 1 + import { useEffect, useState } from "react"; 2 + 3 + interface PosterColors { 4 + primary: string; 5 + secondary: string; 6 + accent: string; 7 + muted: string; 8 + } 9 + 10 + function getImageUrl(path: string | null | undefined): string | null { 11 + if (!path) return null; 12 + return `https://image.tmdb.org/t/p/w342${path}`; 13 + } 14 + 15 + function rgbToHex(r: number, g: number, b: number): string { 16 + return `#${[r, g, b].map((x) => x.toString(16).padStart(2, "0")).join("")}`; 17 + } 18 + 19 + function getBrightness(r: number, g: number, b: number): number { 20 + return (r * 299 + g * 587 + b * 114) / 1000; 21 + } 22 + 23 + function getSaturation(r: number, g: number, b: number): number { 24 + const max = Math.max(r, g, b); 25 + const min = Math.min(r, g, b); 26 + if (max === 0) return 0; 27 + return (max - min) / max; 28 + } 29 + 30 + function adjustColorForBoldness( 31 + r: number, 32 + g: number, 33 + b: number, 34 + ): [number, number, number] { 35 + // Increase saturation and ensure vibrancy 36 + const avg = (r + g + b) / 3; 37 + const saturationBoost = 1.4; 38 + 39 + let nr = Math.min(255, avg + (r - avg) * saturationBoost); 40 + let ng = Math.min(255, avg + (g - avg) * saturationBoost); 41 + let nb = Math.min(255, avg + (b - avg) * saturationBoost); 42 + 43 + // Ensure minimum brightness for visibility 44 + const brightness = getBrightness(nr, ng, nb); 45 + if (brightness < 80) { 46 + const boost = (80 - brightness) / 2; 47 + nr = Math.min(255, nr + boost); 48 + ng = Math.min(255, ng + boost); 49 + nb = Math.min(255, nb + boost); 50 + } 51 + 52 + return [Math.round(nr), Math.round(ng), Math.round(nb)]; 53 + } 54 + 55 + export function usePosterColors( 56 + posterPath: string | null | undefined, 57 + ): PosterColors { 58 + const [colors, setColors] = useState<PosterColors>({ 59 + primary: "#8b5cf6", // Default purple 60 + secondary: "#6366f1", // Default indigo 61 + accent: "#a855f7", // Default purple 62 + muted: "#4c1d95", // Default dark purple 63 + }); 64 + 65 + useEffect(() => { 66 + const url = getImageUrl(posterPath); 67 + if (!url) return; 68 + 69 + const img = new Image(); 70 + img.crossOrigin = "anonymous"; 71 + 72 + img.onload = () => { 73 + try { 74 + const canvas = document.createElement("canvas"); 75 + const ctx = canvas.getContext("2d"); 76 + if (!ctx) return; 77 + 78 + // Resize for performance while keeping enough detail 79 + canvas.width = 100; 80 + canvas.height = 150; 81 + ctx.drawImage(img, 0, 0, canvas.width, canvas.height); 82 + 83 + const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height); 84 + const data = imageData.data; 85 + 86 + // Collect color samples with their frequency and saturation 87 + const colorMap = new Map< 88 + string, 89 + { r: number; g: number; b: number; count: number; saturation: number } 90 + >(); 91 + 92 + for (let i = 0; i < data.length; i += 4 * 3) { 93 + // Sample every 3rd pixel for performance 94 + const r = data[i]; 95 + const g = data[i + 1]; 96 + const b = data[i + 2]; 97 + 98 + // Skip very dark or very light colors 99 + const brightness = getBrightness(r, g, b); 100 + if (brightness < 30 || brightness > 240) continue; 101 + 102 + // Quantize colors slightly for grouping 103 + const qr = Math.round(r / 8) * 8; 104 + const qg = Math.round(g / 8) * 8; 105 + const qb = Math.round(b / 8) * 8; 106 + const key = `${qr},${qg},${qb}`; 107 + 108 + const saturation = getSaturation(qr, qg, qb); 109 + const existing = colorMap.get(key); 110 + if (existing) { 111 + existing.count++; 112 + } else { 113 + colorMap.set(key, { 114 + r: qr, 115 + g: qg, 116 + b: qb, 117 + count: 1, 118 + saturation, 119 + }); 120 + } 121 + } 122 + 123 + // Sort by a combination of count and saturation (weighted toward saturation for boldness) 124 + const sortedColors = Array.from(colorMap.values()).sort((a, b) => { 125 + const scoreA = a.count * (1 + a.saturation * 2); 126 + const scoreB = b.count * (1 + b.saturation * 2); 127 + return scoreB - scoreA; 128 + }); 129 + 130 + if (sortedColors.length > 0) { 131 + // Primary: Most prominent vibrant color 132 + const primary = sortedColors[0]; 133 + const [pr, pg, pb] = adjustColorForBoldness( 134 + primary.r, 135 + primary.g, 136 + primary.b, 137 + ); 138 + 139 + // Secondary: Different hue from sorted colors 140 + let secondary = sortedColors[Math.min(2, sortedColors.length - 1)]; 141 + for (const color of sortedColors.slice(1)) { 142 + const hueDiff = Math.abs( 143 + primary.r - primary.g - (color.r - color.g), 144 + ); 145 + if (hueDiff > 30) { 146 + secondary = color; 147 + break; 148 + } 149 + } 150 + const [sr, sg, sb] = adjustColorForBoldness( 151 + secondary.r, 152 + secondary.g, 153 + secondary.b, 154 + ); 155 + 156 + // Accent: Blend of primary and secondary 157 + const [ar, ag, ab] = [ 158 + Math.round((pr + sr * 2) / 3), 159 + Math.round((pg + sg * 2) / 3), 160 + Math.round((pb + sb * 2) / 3), 161 + ]; 162 + 163 + // Muted: Darker version of primary 164 + const [mr, mg, mb] = [ 165 + Math.round(pr * 0.4), 166 + Math.round(pg * 0.4), 167 + Math.round(pb * 0.4), 168 + ]; 169 + 170 + setColors({ 171 + primary: rgbToHex(pr, pg, pb), 172 + secondary: rgbToHex(sr, sg, sb), 173 + accent: rgbToHex(ar, ag, ab), 174 + muted: rgbToHex(mr, mg, mb), 175 + }); 176 + } 177 + } catch { 178 + // Keep defaults on error 179 + } 180 + }; 181 + 182 + img.src = url; 183 + }, [posterPath]); 184 + 185 + return colors; 186 + }
+449
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 + export const Route = createFileRoute("/movies/$movieId")({ 16 + component: MovieDetailPage, 17 + }); 18 + 19 + function MovieDetailPage() { 20 + const { movieId } = Route.useParams(); 21 + const queryClient = useQueryClient(); 22 + 23 + // Fetch auth state 24 + const { data: user } = useQuery({ 25 + ...authControllerMeOptions(), 26 + staleTime: 5 * 60 * 1000, 27 + retry: false, 28 + }); 29 + 30 + // Fetch movie details 31 + const { data: movie, isLoading: isMovieLoading } = useQuery({ 32 + ...moviesControllerGetMovieDetailsOptions({ 33 + path: { movieId }, 34 + }), 35 + }); 36 + 37 + // Fetch user's tracked movies 38 + const { data: trackedMovies } = useQuery({ 39 + ...moviesControllerGetUserMoviesOptions({ 40 + path: { userDid: user?.did || "" }, 41 + }), 42 + enabled: !!user?.did, 43 + }); 44 + 45 + // Check if this movie is in user's watched list 46 + const isWatched = useMemo(() => { 47 + if (!trackedMovies) return false; 48 + return trackedMovies.some((tm) => tm.movieId === movieId); 49 + }, [trackedMovies, movieId]); 50 + 51 + // Extract accent colors from poster 52 + const colors = usePosterColors(movie?.poster_path as string); 53 + 54 + // Mutations for watchlist 55 + const markMutation = useMutation({ 56 + ...moviesControllerMarkWatchedMutation(), 57 + onSuccess: () => { 58 + queryClient.invalidateQueries({ 59 + queryKey: moviesControllerGetUserMoviesQueryKey({ 60 + path: { userDid: user?.did || "" }, 61 + }), 62 + }); 63 + }, 64 + }); 65 + 66 + const unmarkMutation = useMutation({ 67 + ...moviesControllerUnmarkWatchedMutation(), 68 + onSuccess: () => { 69 + queryClient.invalidateQueries({ 70 + queryKey: moviesControllerGetUserMoviesQueryKey({ 71 + path: { userDid: user?.did || "" }, 72 + }), 73 + }); 74 + }, 75 + }); 76 + 77 + const handleToggleWatched = () => { 78 + if (isWatched) { 79 + unmarkMutation.mutate({ path: { movieId } }); 80 + } else { 81 + markMutation.mutate({ body: { movieId } }); 82 + } 83 + }; 84 + 85 + const isPending = 86 + (markMutation.isPending && 87 + markMutation.variables?.body?.movieId === movieId) || 88 + (unmarkMutation.isPending && 89 + unmarkMutation.variables?.path?.movieId === movieId); 90 + 91 + const releaseYear = movie?.release_date 92 + ? new Date(movie.release_date as string).getFullYear() 93 + : null; 94 + 95 + const backdropUrl = movie?.backdrop_path 96 + ? `https://image.tmdb.org/t/p/w1280${movie.backdrop_path}` 97 + : null; 98 + 99 + const posterUrl = movie?.poster_path 100 + ? `https://image.tmdb.org/t/p/w500${movie.poster_path}` 101 + : null; 102 + 103 + return ( 104 + <div className="min-h-screen bg-gray-950 text-gray-50"> 105 + {/* Hero Section with Backdrop */} 106 + <div className="relative h-[50vh] md:h-[60vh] overflow-hidden"> 107 + {backdropUrl ? ( 108 + <> 109 + <img 110 + src={backdropUrl} 111 + alt="" 112 + className="w-full h-full object-cover" 113 + /> 114 + {/* Gradient overlays */} 115 + <div 116 + className="absolute inset-0" 117 + style={{ 118 + background: `linear-gradient(to bottom, transparent 0%, rgba(3, 7, 18, 0.6) 60%, rgb(3, 7, 18) 100%)`, 119 + }} 120 + /> 121 + <div 122 + className="absolute inset-0" 123 + style={{ 124 + background: `linear-gradient(to right, rgba(3, 7, 18, 0.8) 0%, transparent 50%)`, 125 + }} 126 + /> 127 + </> 128 + ) : ( 129 + <div 130 + className="w-full h-full" 131 + style={{ 132 + background: `linear-gradient(135deg, ${colors.muted} 0%, rgb(3, 7, 18) 100%)`, 133 + }} 134 + /> 135 + )} 136 + 137 + {/* Back button */} 138 + <Link 139 + to="/search" 140 + className="absolute top-4 left-4 z-10 p-2 rounded-full bg-black/50 hover:bg-black/70 transition-colors" 141 + > 142 + <ArrowLeft className="w-5 h-5" /> 143 + </Link> 144 + 145 + {/* Hero Content */} 146 + <div className="absolute bottom-0 left-0 right-0 p-4 md:p-8"> 147 + <div className="container mx-auto max-w-6xl"> 148 + <div className="flex items-end gap-4 md:gap-8"> 149 + {/* Poster */} 150 + <div className="hidden md:block flex-shrink-0"> 151 + <div 152 + className="w-48 lg:w-64 rounded-lg overflow-hidden shadow-2xl" 153 + style={{ 154 + boxShadow: `0 25px 50px -12px ${colors.primary}40`, 155 + }} 156 + > 157 + {posterUrl ? ( 158 + <img 159 + src={posterUrl} 160 + alt={movie?.title as string} 161 + className="w-full aspect-2/3 object-cover" 162 + /> 163 + ) : ( 164 + <div className="w-full aspect-2/3 bg-gray-900 flex items-center justify-center"> 165 + <span className="text-gray-600">No poster</span> 166 + </div> 167 + )} 168 + </div> 169 + </div> 170 + 171 + {/* Title and Meta */} 172 + <div className="flex-1 pb-2"> 173 + <h1 174 + className="text-3xl md:text-5xl lg:text-6xl font-bold mb-2" 175 + style={{ 176 + textShadow: `0 4px 30px ${colors.primary}60`, 177 + }} 178 + > 179 + {movie?.title} 180 + </h1> 181 + {releaseYear && ( 182 + <div className="flex items-center gap-4 text-lg text-gray-300"> 183 + <span className="flex items-center gap-2"> 184 + <Calendar 185 + className="w-4 h-4" 186 + style={{ color: colors.accent }} 187 + /> 188 + {releaseYear} 189 + </span> 190 + {movie?.runtime && ( 191 + <span className="flex items-center gap-2"> 192 + <Clock 193 + className="w-4 h-4" 194 + style={{ color: colors.accent }} 195 + /> 196 + {movie.runtime} min 197 + </span> 198 + )} 199 + </div> 200 + )} 201 + </div> 202 + </div> 203 + </div> 204 + </div> 205 + </div> 206 + 207 + {/* Main Content */} 208 + <div className="container mx-auto px-4 py-8 max-w-6xl"> 209 + <div className="grid grid-cols-1 md:grid-cols-[300px_1fr] gap-8"> 210 + {/* Left Column - Poster (mobile) & Actions */} 211 + <div className="md:hidden"> 212 + <div className="flex gap-4"> 213 + {posterUrl && ( 214 + <div 215 + className="w-32 flex-shrink-0 rounded-lg overflow-hidden" 216 + style={{ 217 + boxShadow: `0 20px 40px -10px ${colors.primary}40`, 218 + }} 219 + > 220 + <img 221 + src={posterUrl} 222 + alt={movie?.title as string} 223 + className="w-full aspect-2/3 object-cover" 224 + /> 225 + </div> 226 + )} 227 + <div className="flex-1 flex flex-col justify-center"> 228 + {user ? ( 229 + <button 230 + type="button" 231 + onClick={handleToggleWatched} 232 + disabled={isPending} 233 + 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" 234 + style={{ 235 + background: isWatched 236 + ? `linear-gradient(135deg, ${colors.muted} 0%, ${colors.primary} 100%)` 237 + : `linear-gradient(135deg, ${colors.primary} 0%, ${colors.secondary} 100%)`, 238 + boxShadow: `0 10px 30px -10px ${colors.primary}60`, 239 + }} 240 + > 241 + {isPending ? ( 242 + <Loader2 className="w-5 h-5 animate-spin" /> 243 + ) : isWatched ? ( 244 + <> 245 + <Check className="w-5 h-5" /> 246 + On Your Shelf 247 + </> 248 + ) : ( 249 + <> 250 + <Plus className="w-5 h-5" /> 251 + Add to Shelf 252 + </> 253 + )} 254 + </button> 255 + ) : ( 256 + <Link 257 + to="/login" 258 + className="w-full py-3 px-6 rounded-xl font-semibold text-white text-center transition-all duration-200" 259 + style={{ 260 + background: `linear-gradient(135deg, ${colors.primary} 0%, ${colors.secondary} 100%)`, 261 + boxShadow: `0 10px 30px -10px ${colors.primary}60`, 262 + }} 263 + > 264 + Sign in to Track 265 + </Link> 266 + )} 267 + </div> 268 + </div> 269 + </div> 270 + 271 + {/* Desktop Actions */} 272 + <div className="hidden md:block space-y-4"> 273 + {user ? ( 274 + <button 275 + type="button" 276 + onClick={handleToggleWatched} 277 + disabled={isPending} 278 + 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]" 279 + style={{ 280 + background: isWatched 281 + ? `linear-gradient(135deg, ${colors.muted} 0%, ${colors.primary} 100%)` 282 + : `linear-gradient(135deg, ${colors.primary} 0%, ${colors.secondary} 100%)`, 283 + boxShadow: `0 15px 35px -10px ${colors.primary}60`, 284 + }} 285 + > 286 + {isPending ? ( 287 + <Loader2 className="w-5 h-5 animate-spin" /> 288 + ) : isWatched ? ( 289 + <> 290 + <Check className="w-5 h-5" /> 291 + On Your Shelf 292 + </> 293 + ) : ( 294 + <> 295 + <Plus className="w-5 h-5" /> 296 + Add to Shelf 297 + </> 298 + )} 299 + </button> 300 + ) : ( 301 + <Link 302 + to="/login" 303 + 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]" 304 + style={{ 305 + background: `linear-gradient(135deg, ${colors.primary} 0%, ${colors.secondary} 100%)`, 306 + boxShadow: `0 15px 35px -10px ${colors.primary}60`, 307 + }} 308 + > 309 + Sign in to Track 310 + </Link> 311 + )} 312 + 313 + {/* Color preview (subtle) */} 314 + <div className="pt-4 border-t border-gray-800"> 315 + <div className="flex gap-2"> 316 + <div 317 + className="w-8 h-8 rounded-full" 318 + style={{ backgroundColor: colors.primary }} 319 + title="Primary" 320 + /> 321 + <div 322 + className="w-8 h-8 rounded-full" 323 + style={{ backgroundColor: colors.secondary }} 324 + title="Secondary" 325 + /> 326 + <div 327 + className="w-8 h-8 rounded-full" 328 + style={{ backgroundColor: colors.accent }} 329 + title="Accent" 330 + /> 331 + </div> 332 + </div> 333 + </div> 334 + 335 + {/* Right Column - Details */} 336 + <div className="space-y-6"> 337 + {/* Overview */} 338 + <section> 339 + <h2 340 + className="text-xl font-semibold mb-3" 341 + style={{ color: colors.primary }} 342 + > 343 + Overview 344 + </h2> 345 + <p className="text-gray-300 leading-relaxed text-lg"> 346 + {movie?.overview || "No overview available."} 347 + </p> 348 + </section> 349 + 350 + {/* Additional Info */} 351 + <section className="grid grid-cols-2 gap-4"> 352 + {movie?.release_date && ( 353 + <div className="p-4 rounded-lg bg-gray-900/50"> 354 + <span className="text-gray-500 text-sm block mb-1"> 355 + Release Date 356 + </span> 357 + <span className="text-gray-200 font-medium"> 358 + {new Date(movie.release_date as string).toLocaleDateString( 359 + "en-US", 360 + { 361 + year: "numeric", 362 + month: "long", 363 + day: "numeric", 364 + }, 365 + )} 366 + </span> 367 + </div> 368 + )} 369 + {movie?.runtime && ( 370 + <div className="p-4 rounded-lg bg-gray-900/50"> 371 + <span className="text-gray-500 text-sm block mb-1"> 372 + Runtime 373 + </span> 374 + <span className="text-gray-200 font-medium"> 375 + {movie.runtime} minutes 376 + </span> 377 + </div> 378 + )} 379 + {movie?.vote_average && ( 380 + <div className="p-4 rounded-lg bg-gray-900/50"> 381 + <span className="text-gray-500 text-sm block mb-1"> 382 + Rating 383 + </span> 384 + <span 385 + className="font-medium" 386 + style={{ color: colors.accent }} 387 + > 388 + {(movie.vote_average as number).toFixed(1)}/10 389 + </span> 390 + </div> 391 + )} 392 + {movie?.vote_count && ( 393 + <div className="p-4 rounded-lg bg-gray-900/50"> 394 + <span className="text-gray-500 text-sm block mb-1"> 395 + Votes 396 + </span> 397 + <span className="text-gray-200 font-medium"> 398 + {(movie.vote_count as number).toLocaleString()} 399 + </span> 400 + </div> 401 + )} 402 + </section> 403 + 404 + {/* Genres */} 405 + {movie?.genres && 406 + (movie.genres as Array<{ id: number; name: string }>).length > 407 + 0 && ( 408 + <section> 409 + <h2 410 + className="text-xl font-semibold mb-3" 411 + style={{ color: colors.primary }} 412 + > 413 + Genres 414 + </h2> 415 + <div className="flex flex-wrap gap-2"> 416 + {(movie.genres as Array<{ id: number; name: string }>).map( 417 + (genre) => ( 418 + <span 419 + key={genre.id} 420 + className="px-4 py-2 rounded-full text-sm font-medium" 421 + style={{ 422 + backgroundColor: `${colors.primary}20`, 423 + color: colors.accent, 424 + border: `1px solid ${colors.primary}40`, 425 + }} 426 + > 427 + {genre.name} 428 + </span> 429 + ), 430 + )} 431 + </div> 432 + </section> 433 + )} 434 + </div> 435 + </div> 436 + </div> 437 + 438 + {/* Loading State */} 439 + {isMovieLoading && ( 440 + <div className="fixed inset-0 bg-gray-950 flex items-center justify-center z-50"> 441 + <div 442 + className="animate-spin rounded-full h-16 w-16 border-b-2" 443 + style={{ borderColor: colors.primary }} 444 + /> 445 + </div> 446 + )} 447 + </div> 448 + ); 449 + }
+26 -14
apps/web/src/routes/search.tsx
··· 7 7 moviesControllerUnmarkWatchedMutation, 8 8 } from "@opnshelf/api"; 9 9 import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; 10 - import { createFileRoute, useNavigate } from "@tanstack/react-router"; 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 13 ··· 147 147 148 148 return ( 149 149 <div key={movie.id} className="group"> 150 - <div className="relative aspect-2/3 bg-gray-900 rounded-lg overflow-hidden mb-2"> 150 + <Link 151 + to="/movies/$movieId" 152 + params={{ movieId: movieId }} 153 + className="block relative aspect-2/3 bg-gray-900 rounded-lg overflow-hidden mb-2" 154 + > 151 155 {movie.poster_path ? ( 152 156 <img 153 157 src={`https://image.tmdb.org/t/p/w342${movie.poster_path}`} 154 158 alt={movie.title} 155 - className="w-full h-full object-cover" 159 + className="w-full h-full object-cover transition-transform duration-200 group-hover:scale-105" 156 160 /> 157 161 ) : ( 158 162 <div className="w-full h-full flex items-center justify-center text-gray-600"> ··· 162 166 {user && ( 163 167 <button 164 168 type="button" 165 - onClick={() => { 169 + onClick={(e) => { 170 + e.preventDefault(); 171 + e.stopPropagation(); 166 172 if (isWatched) { 167 173 unmarkMutation.mutate({ path: { movieId } }); 168 174 } else { ··· 177 183 unmarkMutation.variables?.path?.movieId === 178 184 movieId) 179 185 } 180 - className={`absolute top-2 right-2 p-2 rounded-full transition-opacity disabled:opacity-50 ${ 186 + className={`absolute top-2 right-2 p-2 rounded-full transition-opacity disabled:opacity-50 z-10 ${ 181 187 isWatched 182 188 ? "bg-green-600 hover:bg-red-600 opacity-100" 183 189 : "bg-purple-600 hover:bg-purple-700 [@media(hover:hover)]:opacity-0 [@media(hover:hover)]:group-hover:opacity-100" ··· 200 206 )} 201 207 </button> 202 208 )} 203 - </div> 204 - <h3 className="font-semibold text-sm line-clamp-2 mb-1"> 205 - {movie.title} 206 - </h3> 207 - {movie.release_date && ( 208 - <p className="text-gray-500 text-sm"> 209 - {movie.release_date.split("-")[0]} 210 - </p> 211 - )} 209 + </Link> 210 + <Link 211 + to="/movies/$movieId" 212 + params={{ movieId: movieId }} 213 + className="block" 214 + > 215 + <h3 className="font-semibold text-sm line-clamp-2 mb-1 hover:text-purple-400 transition-colors"> 216 + {movie.title} 217 + </h3> 218 + {movie.release_date && ( 219 + <p className="text-gray-500 text-sm"> 220 + {movie.release_date.split("-")[0]} 221 + </p> 222 + )} 223 + </Link> 212 224 </div> 213 225 ); 214 226 })}