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.

Add nested index routes for show details

+865 -828
+43 -2
apps/web/src/routeTree.gen.ts
··· 19 19 import { Route as AuthCompleteRouteImport } from './routes/auth/complete' 20 20 import { Route as ShowsShowIdShowNameRouteImport } from './routes/shows/$showId/$showName' 21 21 import { Route as MoviesMovieIdMovieNameRouteImport } from './routes/movies/$movieId/$movieName' 22 + import { Route as ShowsShowIdShowNameIndexRouteImport } from './routes/shows/$showId/$showName/index' 22 23 import { Route as ShowsShowIdShowNameSeasonsSeasonNumberRouteImport } from './routes/shows/$showId/$showName/seasons.$seasonNumber' 24 + import { Route as ShowsShowIdShowNameSeasonsSeasonNumberIndexRouteImport } from './routes/shows/$showId/$showName/seasons.$seasonNumber/index' 23 25 import { Route as ShowsShowIdShowNameSeasonsSeasonNumberEpisodesEpisodeNumberRouteImport } from './routes/shows/$showId/$showName/seasons.$seasonNumber.episodes.$episodeNumber' 24 26 25 27 const LoginRoute = LoginRouteImport.update({ ··· 72 74 path: '/movies/$movieId/$movieName', 73 75 getParentRoute: () => rootRouteImport, 74 76 } as any) 77 + const ShowsShowIdShowNameIndexRoute = 78 + ShowsShowIdShowNameIndexRouteImport.update({ 79 + id: '/', 80 + path: '/', 81 + getParentRoute: () => ShowsShowIdShowNameRoute, 82 + } as any) 75 83 const ShowsShowIdShowNameSeasonsSeasonNumberRoute = 76 84 ShowsShowIdShowNameSeasonsSeasonNumberRouteImport.update({ 77 85 id: '/seasons/$seasonNumber', 78 86 path: '/seasons/$seasonNumber', 79 87 getParentRoute: () => ShowsShowIdShowNameRoute, 80 88 } as any) 89 + const ShowsShowIdShowNameSeasonsSeasonNumberIndexRoute = 90 + ShowsShowIdShowNameSeasonsSeasonNumberIndexRouteImport.update({ 91 + id: '/', 92 + path: '/', 93 + getParentRoute: () => ShowsShowIdShowNameSeasonsSeasonNumberRoute, 94 + } as any) 81 95 const ShowsShowIdShowNameSeasonsSeasonNumberEpisodesEpisodeNumberRoute = 82 96 ShowsShowIdShowNameSeasonsSeasonNumberEpisodesEpisodeNumberRouteImport.update( 83 97 { ··· 98 112 '/auth/complete': typeof AuthCompleteRoute 99 113 '/movies/$movieId/$movieName': typeof MoviesMovieIdMovieNameRoute 100 114 '/shows/$showId/$showName': typeof ShowsShowIdShowNameRouteWithChildren 115 + '/shows/$showId/$showName/': typeof ShowsShowIdShowNameIndexRoute 101 116 '/shows/$showId/$showName/seasons/$seasonNumber': typeof ShowsShowIdShowNameSeasonsSeasonNumberRouteWithChildren 117 + '/shows/$showId/$showName/seasons/$seasonNumber/': typeof ShowsShowIdShowNameSeasonsSeasonNumberIndexRoute 102 118 '/shows/$showId/$showName/seasons/$seasonNumber/episodes/$episodeNumber': typeof ShowsShowIdShowNameSeasonsSeasonNumberEpisodesEpisodeNumberRoute 103 119 } 104 120 export interface FileRoutesByTo { ··· 111 127 '/login': typeof LoginRoute 112 128 '/auth/complete': typeof AuthCompleteRoute 113 129 '/movies/$movieId/$movieName': typeof MoviesMovieIdMovieNameRoute 114 - '/shows/$showId/$showName': typeof ShowsShowIdShowNameRouteWithChildren 115 - '/shows/$showId/$showName/seasons/$seasonNumber': typeof ShowsShowIdShowNameSeasonsSeasonNumberRouteWithChildren 130 + '/shows/$showId/$showName': typeof ShowsShowIdShowNameIndexRoute 131 + '/shows/$showId/$showName/seasons/$seasonNumber': typeof ShowsShowIdShowNameSeasonsSeasonNumberIndexRoute 116 132 '/shows/$showId/$showName/seasons/$seasonNumber/episodes/$episodeNumber': typeof ShowsShowIdShowNameSeasonsSeasonNumberEpisodesEpisodeNumberRoute 117 133 } 118 134 export interface FileRoutesById { ··· 127 143 '/auth/complete': typeof AuthCompleteRoute 128 144 '/movies/$movieId/$movieName': typeof MoviesMovieIdMovieNameRoute 129 145 '/shows/$showId/$showName': typeof ShowsShowIdShowNameRouteWithChildren 146 + '/shows/$showId/$showName/': typeof ShowsShowIdShowNameIndexRoute 130 147 '/shows/$showId/$showName/seasons/$seasonNumber': typeof ShowsShowIdShowNameSeasonsSeasonNumberRouteWithChildren 148 + '/shows/$showId/$showName/seasons/$seasonNumber/': typeof ShowsShowIdShowNameSeasonsSeasonNumberIndexRoute 131 149 '/shows/$showId/$showName/seasons/$seasonNumber/episodes/$episodeNumber': typeof ShowsShowIdShowNameSeasonsSeasonNumberEpisodesEpisodeNumberRoute 132 150 } 133 151 export interface FileRouteTypes { ··· 143 161 | '/auth/complete' 144 162 | '/movies/$movieId/$movieName' 145 163 | '/shows/$showId/$showName' 164 + | '/shows/$showId/$showName/' 146 165 | '/shows/$showId/$showName/seasons/$seasonNumber' 166 + | '/shows/$showId/$showName/seasons/$seasonNumber/' 147 167 | '/shows/$showId/$showName/seasons/$seasonNumber/episodes/$episodeNumber' 148 168 fileRoutesByTo: FileRoutesByTo 149 169 to: ··· 171 191 | '/auth/complete' 172 192 | '/movies/$movieId/$movieName' 173 193 | '/shows/$showId/$showName' 194 + | '/shows/$showId/$showName/' 174 195 | '/shows/$showId/$showName/seasons/$seasonNumber' 196 + | '/shows/$showId/$showName/seasons/$seasonNumber/' 175 197 | '/shows/$showId/$showName/seasons/$seasonNumber/episodes/$episodeNumber' 176 198 fileRoutesById: FileRoutesById 177 199 } ··· 260 282 preLoaderRoute: typeof MoviesMovieIdMovieNameRouteImport 261 283 parentRoute: typeof rootRouteImport 262 284 } 285 + '/shows/$showId/$showName/': { 286 + id: '/shows/$showId/$showName/' 287 + path: '/' 288 + fullPath: '/shows/$showId/$showName/' 289 + preLoaderRoute: typeof ShowsShowIdShowNameIndexRouteImport 290 + parentRoute: typeof ShowsShowIdShowNameRoute 291 + } 263 292 '/shows/$showId/$showName/seasons/$seasonNumber': { 264 293 id: '/shows/$showId/$showName/seasons/$seasonNumber' 265 294 path: '/seasons/$seasonNumber' ··· 267 296 preLoaderRoute: typeof ShowsShowIdShowNameSeasonsSeasonNumberRouteImport 268 297 parentRoute: typeof ShowsShowIdShowNameRoute 269 298 } 299 + '/shows/$showId/$showName/seasons/$seasonNumber/': { 300 + id: '/shows/$showId/$showName/seasons/$seasonNumber/' 301 + path: '/' 302 + fullPath: '/shows/$showId/$showName/seasons/$seasonNumber/' 303 + preLoaderRoute: typeof ShowsShowIdShowNameSeasonsSeasonNumberIndexRouteImport 304 + parentRoute: typeof ShowsShowIdShowNameSeasonsSeasonNumberRoute 305 + } 270 306 '/shows/$showId/$showName/seasons/$seasonNumber/episodes/$episodeNumber': { 271 307 id: '/shows/$showId/$showName/seasons/$seasonNumber/episodes/$episodeNumber' 272 308 path: '/episodes/$episodeNumber' ··· 278 314 } 279 315 280 316 interface ShowsShowIdShowNameSeasonsSeasonNumberRouteChildren { 317 + ShowsShowIdShowNameSeasonsSeasonNumberIndexRoute: typeof ShowsShowIdShowNameSeasonsSeasonNumberIndexRoute 281 318 ShowsShowIdShowNameSeasonsSeasonNumberEpisodesEpisodeNumberRoute: typeof ShowsShowIdShowNameSeasonsSeasonNumberEpisodesEpisodeNumberRoute 282 319 } 283 320 284 321 const ShowsShowIdShowNameSeasonsSeasonNumberRouteChildren: ShowsShowIdShowNameSeasonsSeasonNumberRouteChildren = 285 322 { 323 + ShowsShowIdShowNameSeasonsSeasonNumberIndexRoute: 324 + ShowsShowIdShowNameSeasonsSeasonNumberIndexRoute, 286 325 ShowsShowIdShowNameSeasonsSeasonNumberEpisodesEpisodeNumberRoute: 287 326 ShowsShowIdShowNameSeasonsSeasonNumberEpisodesEpisodeNumberRoute, 288 327 } ··· 293 332 ) 294 333 295 334 interface ShowsShowIdShowNameRouteChildren { 335 + ShowsShowIdShowNameIndexRoute: typeof ShowsShowIdShowNameIndexRoute 296 336 ShowsShowIdShowNameSeasonsSeasonNumberRoute: typeof ShowsShowIdShowNameSeasonsSeasonNumberRouteWithChildren 297 337 } 298 338 299 339 const ShowsShowIdShowNameRouteChildren: ShowsShowIdShowNameRouteChildren = { 340 + ShowsShowIdShowNameIndexRoute: ShowsShowIdShowNameIndexRoute, 300 341 ShowsShowIdShowNameSeasonsSeasonNumberRoute: 301 342 ShowsShowIdShowNameSeasonsSeasonNumberRouteWithChildren, 302 343 }
+6 -759
apps/web/src/routes/shows/$showId/$showName.tsx
··· 1 - import { 2 - listsControllerGetListsForItemOptions, 3 - showsControllerGetSeasonDetailsOptions, 4 - showsControllerGetShowWatchHistoryOptions, 5 - } from "@opnshelf/api"; 6 - import { useQuery } from "@tanstack/react-query"; 7 - import { 8 - createFileRoute, 9 - Link, 10 - Outlet, 11 - useMatches, 12 - } from "@tanstack/react-router"; 13 - import { 14 - Check, 15 - ChevronDown, 16 - ChevronLeft, 17 - ChevronRight, 18 - Heart, 19 - Loader2, 20 - Play, 21 - Plus, 22 - Share2, 23 - Star, 24 - } from "lucide-react"; 25 - import { useState } from "react"; 26 - import { setupApiClient } from "#/lib/api"; 27 - import { useAuth } from "#/lib/auth-context"; 28 - import { 29 - useDiscoverShows, 30 - useMarkEpisodeWatched, 31 - useShowDetails, 32 - useUserUpNext, 33 - } from "#/lib/hooks"; 34 - import { slugifyName } from "#/lib/url-utils"; 35 - import MediaCard from "../../../components/MediaCard"; 36 - 37 - // Initialize API client 38 - setupApiClient(); 1 + import { createFileRoute, Outlet } from "@tanstack/react-router"; 39 2 3 + // This is a layout route - it only renders the outlet 4 + // The actual show/season/episode content is rendered by child routes 40 5 export const Route = createFileRoute("/shows/$showId/$showName")({ 41 - component: ShowDetailPage, 6 + component: ShowLayout, 42 7 }); 43 8 44 - // Format runtime from minutes to hours and minutes 45 - function formatRuntime(minutes: number): string { 46 - if (!minutes || minutes <= 0) return "N/A"; 47 - const hours = Math.floor(minutes / 60); 48 - const mins = minutes % 60; 49 - if (hours === 0) return `${mins}m`; 50 - return `${hours}h ${mins}m`; 51 - } 52 - 53 - // Format date to readable string 54 - function formatDate(dateString: string): string { 55 - if (!dateString) return "Unknown"; 56 - try { 57 - return new Date(dateString).toLocaleDateString("en-US", { 58 - month: "long", 59 - day: "numeric", 60 - year: "numeric", 61 - }); 62 - } catch { 63 - return dateString; 64 - } 65 - } 66 - 67 - // Hook to fetch season details 68 - function useSeasonDetails(showId: string, seasonNumber: number | null) { 69 - return useQuery({ 70 - ...showsControllerGetSeasonDetailsOptions({ 71 - path: { showId, seasonNumber: seasonNumber?.toString() || "1" }, 72 - }), 73 - enabled: !!showId && !!seasonNumber, 74 - }); 75 - } 76 - 77 - function ShowDetailPage() { 78 - const { showId } = Route.useParams(); 79 - const matches = useMatches(); 80 - const { user, isAuthenticated } = useAuth(); 81 - const userDid = user?.did; 82 - 83 - // Check if we're on a child route (season or episode page) 84 - const isChildRoute = matches.some( 85 - (match) => 86 - match.routeId === "/shows/$showId/$showName/seasons/$seasonNumber" || 87 - match.routeId === 88 - "/shows/$showId/$showName/seasons/$seasonNumber/episodes/$episodeNumber", 89 - ); 90 - 91 - // NOTE: All hooks must be called unconditionally before any early return 92 - // This is a React requirement for hooks 93 - 94 - const [expandedSeason, setExpandedSeason] = useState<number | null>(1); 95 - 96 - // Fetch show details from API 97 - const { 98 - data: show, 99 - isLoading: showLoading, 100 - error: showError, 101 - } = useShowDetails(showId); 102 - 103 - // Fetch user's watch history for this specific show 104 - const { data: watchHistory } = useQuery({ 105 - ...showsControllerGetShowWatchHistoryOptions({ 106 - path: { userDid: userDid || "", showId }, 107 - }), 108 - enabled: !!userDid && !!showId, 109 - }); 110 - 111 - // Fetch lists containing this show 112 - const { data: listsForItem } = useQuery({ 113 - ...listsControllerGetListsForItemOptions({ 114 - path: { mediaType: "show", mediaId: showId }, 115 - }), 116 - enabled: !!showId, 117 - }); 118 - 119 - // Count how many lists this show is actually in 120 - const listsContainingShow = 121 - listsForItem?.filter((list) => list.isInList) || []; 122 - 123 - // Fetch user's up next episodes 124 - const { data: upNextData } = useUserUpNext(userDid || ""); 125 - 126 - // Fetch discover shows for similar recommendations 127 - const { data: discoverShowsData } = useDiscoverShows(1); 128 - 129 - // Fetch season details when expanded 130 - const { data: seasonDetails, isLoading: seasonLoading } = useSeasonDetails( 131 - showId, 132 - expandedSeason, 133 - ); 134 - 135 - // Mark episode watched mutation 136 - const markWatchedMutation = useMarkEpisodeWatched(); 137 - 138 - // If on season or episode page, render only the outlet (child component) 139 - if (isChildRoute) { 140 - return <Outlet />; 141 - } 142 - 143 - // Check if user tracks this show (based on watch history) 144 - const isTracking = !!watchHistory && watchHistory.length > 0; 145 - 146 - // Find up next episode for this show 147 - const upNextForShow = upNextData?.items?.find( 148 - (item) => item.showId === showId, 149 - ); 150 - const nextEpisode = upNextForShow?.nextEpisode; 151 - 152 - // Calculate watched episodes from watch history 153 - const episodesWatched = watchHistory?.length || 0; 154 - const totalEpisodes = show?.number_of_episodes || 0; 155 - const progressPercentage = 156 - totalEpisodes > 0 ? (episodesWatched / totalEpisodes) * 100 : 0; 157 - const episodesRemaining = totalEpisodes - episodesWatched; 158 - 159 - // Loading state 160 - if (showLoading) { 161 - return ( 162 - <div className="flex h-screen items-center justify-center"> 163 - <Loader2 className="h-12 w-12 animate-spin text-[var(--accent)]" /> 164 - </div> 165 - ); 166 - } 167 - 168 - // Error state 169 - if (showError || !show) { 170 - return ( 171 - <div className="container-app py-8"> 172 - <div className="rounded-lg border border-red-200 bg-red-50 p-8 text-center text-red-800"> 173 - <p className="text-lg font-medium">Failed to load show</p> 174 - <p className="mt-2">Please check your connection and try again.</p> 175 - <Link to="/" className="btn btn-primary mt-4 inline-flex"> 176 - Back to Dashboard 177 - </Link> 178 - </div> 179 - </div> 180 - ); 181 - } 182 - 183 - // Transform API data 184 - const backdropUrl = show.backdrop_path 185 - ? `https://image.tmdb.org/t/p/original${show.backdrop_path}` 186 - : show.poster_path 187 - ? `https://image.tmdb.org/t/p/original${show.poster_path}` 188 - : ""; 189 - const posterUrl = show.poster_path 190 - ? `https://image.tmdb.org/t/p/w500${show.poster_path}` 191 - : ""; 192 - 193 - // Get creator from crew (look for executive producer or creator) 194 - const creator = 195 - show.credits?.crew?.find( 196 - (person) => 197 - person.job === "Executive Producer" || person.job === "Creator", 198 - )?.name || "Unknown"; 199 - 200 - // Get cast (limit to 6) 201 - const cast = 202 - show.credits?.cast?.slice(0, 6).map((actor) => ({ 203 - name: actor.name, 204 - character: actor.character || "", 205 - photo: actor.profile_path 206 - ? `https://image.tmdb.org/t/p/w185${actor.profile_path}` 207 - : `https://i.pravatar.cc/150?u=${actor.id}`, 208 - })) || []; 209 - 210 - // Get similar shows from discover API, excluding current show 211 - const similarShows = 212 - discoverShowsData?.results 213 - ?.filter((s) => s.id !== Number(showId)) 214 - ?.slice(0, 4) 215 - ?.map((s) => ({ 216 - id: s.id, 217 - title: s.name, 218 - type: "show" as const, 219 - year: s.first_air_date 220 - ? new Date(s.first_air_date).getFullYear() 221 - : undefined, 222 - posterUrl: s.poster_path 223 - ? `https://image.tmdb.org/t/p/w300${s.poster_path}` 224 - : "", 225 - rating: s.vote_average, 226 - })) || []; 227 - 228 - // Handle mark episode as watched 229 - const handleMarkWatched = (seasonNumber: number, episodeNumber: number) => { 230 - if (!userDid || !isAuthenticated) return; 231 - 232 - markWatchedMutation.mutate({ 233 - body: { 234 - showId, 235 - seasonNumber, 236 - episodeNumber, 237 - }, 238 - }); 239 - }; 240 - 241 - // Get current episode display text 242 - const getCurrentEpisodeText = () => { 243 - if (nextEpisode) { 244 - return `Continue S${nextEpisode.season_number}E${nextEpisode.episode_number}`; 245 - } 246 - if (isTracking && episodesWatched > 0) { 247 - return "Continue Watching"; 248 - } 249 - return "Start Watching"; 250 - }; 251 - 252 - // Check if an episode is the next/current one 253 - const isCurrentEpisode = (seasonNum: number, episodeNum: number) => { 254 - return ( 255 - nextEpisode?.season_number === seasonNum && 256 - nextEpisode?.episode_number === episodeNum 257 - ); 258 - }; 259 - 260 - // Check if an episode has been watched using watch history 261 - const isEpisodeWatched = (seasonNum: number, episodeNum: number) => { 262 - if (!watchHistory || watchHistory.length === 0) return false; 263 - 264 - return watchHistory.some( 265 - (ep) => ep.seasonNumber === seasonNum && ep.episodeNumber === episodeNum, 266 - ); 267 - }; 268 - 269 - return ( 270 - <div className="min-h-screen pb-8"> 271 - {/* Hero Section with Backdrop */} 272 - <div className="relative z-10 min-h-[50vh] overflow-hidden"> 273 - {/* Backdrop Image */} 274 - <div className="absolute inset-0 h-[60vh] overflow-hidden"> 275 - {backdropUrl ? ( 276 - <img 277 - src={backdropUrl} 278 - alt={show.name} 279 - className="h-full w-full object-cover" 280 - /> 281 - ) : ( 282 - <div className="h-full w-full bg-gradient-to-br from-gray-800 to-gray-900" /> 283 - )} 284 - {/* Gradient Overlays */} 285 - <div className="absolute inset-0 bg-gradient-to-t from-[var(--background)] via-[var(--background)]/60 to-transparent" /> 286 - <div className="absolute inset-0 bg-gradient-to-r from-[var(--background)] via-[var(--background)]/40 to-transparent" /> 287 - </div> 288 - 289 - {/* Content */} 290 - <div className="container-app relative pt-8"> 291 - {/* Back Button */} 292 - <Link to="/" className="btn btn-secondary mb-6 inline-flex gap-2"> 293 - <ChevronLeft className="h-4 w-4" /> 294 - Back to Dashboard 295 - </Link> 296 - 297 - {/* Show Info Header */} 298 - <div className="grid gap-8 lg:grid-cols-[300px_1fr] lg:gap-12"> 299 - {/* Poster */} 300 - <div className="hidden lg:block"> 301 - <div className="aspect-[2/3] overflow-hidden rounded-xl shadow-2xl"> 302 - {posterUrl ? ( 303 - <img 304 - src={posterUrl} 305 - alt={show.name} 306 - className="h-full w-full object-cover" 307 - /> 308 - ) : ( 309 - <div className="h-full w-full bg-gradient-to-br from-gray-700 to-gray-800 flex items-center justify-center"> 310 - <span className="text-gray-400">No Poster</span> 311 - </div> 312 - )} 313 - </div> 314 - </div> 315 - 316 - {/* Info */} 317 - <div className="flex flex-col justify-end pb-8 lg:pb-16"> 318 - {/* Mobile Poster */} 319 - <div className="mb-6 flex gap-4 lg:hidden"> 320 - <div className="h-40 w-28 flex-shrink-0 overflow-hidden rounded-lg"> 321 - {posterUrl ? ( 322 - <img 323 - src={posterUrl} 324 - alt={show.name} 325 - className="h-full w-full object-cover" 326 - /> 327 - ) : ( 328 - <div className="h-full w-full bg-gradient-to-br from-gray-700 to-gray-800" /> 329 - )} 330 - </div> 331 - <div className="flex flex-col justify-center"> 332 - <h1 className="text-display-2">{show.name}</h1> 333 - </div> 334 - </div> 335 - 336 - {/* Desktop Title */} 337 - <div className="hidden lg:block"> 338 - <h1 className="text-display-2">{show.name}</h1> 339 - </div> 340 - 341 - {/* Meta Info */} 342 - <div className="mt-4 flex flex-wrap items-center gap-3 text-sm"> 343 - <div className="flex items-center gap-1"> 344 - <Star className="h-4 w-4 fill-yellow-500 text-yellow-500" /> 345 - <span className="font-semibold"> 346 - {show.vote_average?.toFixed(1) || "N/A"} 347 - </span> 348 - <span className="text-[var(--foreground-muted)]">/10</span> 349 - </div> 350 - <span className="text-[var(--border-strong)]">•</span> 351 - <span> 352 - {show.number_of_seasons || 0} Season 353 - {show.number_of_seasons !== 1 ? "s" : ""} 354 - </span> 355 - <span className="text-[var(--border-strong)]">•</span> 356 - <span>{show.number_of_episodes || 0} Episodes</span> 357 - <span className="text-[var(--border-strong)]">•</span> 358 - <span className="badge badge-accent"> 359 - {show.status || "Unknown"} 360 - </span> 361 - <span className="text-[var(--border-strong)]">•</span> 362 - <div className="flex gap-2"> 363 - {show.genres?.slice(0, 3).map((genre) => ( 364 - <span key={genre.id} className="badge badge-subtle"> 365 - {genre.name} 366 - </span> 367 - ))} 368 - </div> 369 - </div> 370 - 371 - {/* Current Progress */} 372 - {isTracking && nextEpisode && ( 373 - <div className="mt-4 flex items-center gap-2 text-sm"> 374 - <div className="flex h-6 w-6 items-center justify-center rounded-full bg-[var(--accent)] text-[#3f2e00] text-xs font-medium"> 375 - {nextEpisode.episode_number} 376 - </div> 377 - <span className="text-[var(--foreground-muted)]"> 378 - Currently at{" "} 379 - <span className="font-medium text-[var(--foreground)]"> 380 - S{nextEpisode.season_number}E{nextEpisode.episode_number} 381 - </span> 382 - </span> 383 - </div> 384 - )} 385 - 386 - {/* Action Buttons */} 387 - <div className="mt-6 flex flex-wrap gap-3"> 388 - <button type="button" className="btn btn-primary gap-2"> 389 - <Play className="h-4 w-4" /> 390 - {getCurrentEpisodeText()} 391 - </button> 392 - 393 - {isTracking ? ( 394 - <button 395 - type="button" 396 - className="btn btn-secondary gap-2 bg-[var(--accent-subtle)] text-[var(--accent)]" 397 - > 398 - <Check className="h-4 w-4" /> 399 - Tracking 400 - </button> 401 - ) : ( 402 - <button type="button" className="btn btn-secondary gap-2"> 403 - <Plus className="h-4 w-4" /> 404 - Track Show 405 - </button> 406 - )} 407 - 408 - <button 409 - type="button" 410 - className="btn btn-secondary h-10 w-10 p-0" 411 - aria-label="Share" 412 - > 413 - <Share2 className="h-4 w-4" /> 414 - </button> 415 - 416 - <button 417 - type="button" 418 - className="btn btn-secondary h-10 w-10 p-0" 419 - aria-label="Like" 420 - > 421 - <Heart className="h-4 w-4" /> 422 - </button> 423 - </div> 424 - </div> 425 - </div> 426 - </div> 427 - </div> 428 - 429 - {/* Main Content */} 430 - <div className="container-app relative z-20 mt-8"> 431 - <div className="grid gap-8 lg:grid-cols-[2fr_1fr] lg:gap-12"> 432 - {/* Left Column */} 433 - <div className="space-y-8"> 434 - {/* Overview */} 435 - <section> 436 - <h2 className="text-display-3 mb-4">Overview</h2> 437 - <p className="text-[var(--foreground-muted)] leading-relaxed"> 438 - {show.overview || "No overview available."} 439 - </p> 440 - </section> 441 - 442 - {/* Episodes */} 443 - {show.seasons && show.seasons.length > 0 && ( 444 - <section> 445 - <h2 className="text-display-3 mb-4">Episodes</h2> 446 - <div className="space-y-3"> 447 - {show.seasons 448 - .filter((season) => season.season_number > 0) // Filter out specials (season 0) 449 - .map((season) => ( 450 - <div key={season.id} className="card overflow-hidden"> 451 - {/* Season Header */} 452 - <Link 453 - to="/shows/$showId/$showName/seasons/$seasonNumber" 454 - params={{ 455 - showId, 456 - showName: slugifyName(show.name), 457 - seasonNumber: season.season_number, 458 - }} 459 - className="flex flex-1 items-center justify-between p-4 text-left transition-colors hover:bg-[var(--background-subtle)]" 460 - > 461 - <div> 462 - <h3 className="font-semibold hover:text-[var(--accent)]"> 463 - {season.name || `Season ${season.season_number}`} 464 - </h3> 465 - <p className="text-sm text-[var(--foreground-muted)]"> 466 - {season.episode_count || 0} episodes 467 - </p> 468 - </div> 469 - </Link> 470 - <button 471 - type="button" 472 - onClick={() => 473 - setExpandedSeason( 474 - expandedSeason === season.season_number 475 - ? null 476 - : season.season_number, 477 - ) 478 - } 479 - className="flex items-center justify-center p-4 text-[var(--foreground-muted)] transition-colors hover:bg-[var(--background-subtle)]" 480 - > 481 - <ChevronDown 482 - className={`h-5 w-5 transition-transform ${ 483 - expandedSeason === season.season_number 484 - ? "rotate-180" 485 - : "" 486 - }`} 487 - /> 488 - </button> 489 - 490 - {/* Episode List */} 491 - {expandedSeason === season.season_number && ( 492 - <div className="border-t border-[var(--border)]"> 493 - {seasonLoading ? ( 494 - <div className="p-4 text-center"> 495 - <Loader2 className="h-6 w-6 animate-spin mx-auto text-[var(--accent)]" /> 496 - </div> 497 - ) : seasonDetails?.episodes ? ( 498 - seasonDetails.episodes.map((episode, index) => { 499 - const isWatched = isEpisodeWatched( 500 - season.season_number, 501 - episode.episode_number, 502 - ); 503 - const isCurrent = isCurrentEpisode( 504 - season.season_number, 505 - episode.episode_number, 506 - ); 507 - 508 - return ( 509 - <Link 510 - key={episode.id} 511 - to="/shows/$showId/$showName/_seasons/$seasonNumber/episodes/$episodeNumber" 512 - params={{ 513 - showId, 514 - showName: slugifyName(show.name), 515 - seasonNumber: season.season_number, 516 - episodeNumber: episode.episode_number, 517 - }} 518 - className={`flex items-center gap-4 p-4 transition-colors ${ 519 - isCurrent 520 - ? "bg-[var(--accent-subtle)]" 521 - : "hover:bg-[var(--background-subtle)]" 522 - } ${ 523 - index !== 524 - seasonDetails.episodes.length - 1 525 - ? "border-b border-[var(--border)]" 526 - : "" 527 - }`} 528 - > 529 - <div className="flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-lg bg-[var(--background-subtle)] font-semibold text-sm"> 530 - {isWatched ? ( 531 - <Check className="h-5 w-5 text-green-500" /> 532 - ) : ( 533 - episode.episode_number 534 - )} 535 - </div> 536 - <div className="flex-1 min-w-0"> 537 - <div className="flex items-center gap-2"> 538 - <h4 className="font-medium truncate"> 539 - {episode.episode_number}.{" "} 540 - {episode.name} 541 - </h4> 542 - {isCurrent && ( 543 - <span className="badge badge-accent"> 544 - Current 545 - </span> 546 - )} 547 - </div> 548 - <p className="text-sm text-[var(--foreground-muted)]"> 549 - {formatRuntime(episode.runtime || 0)} 550 - {episode.air_date && 551 - ` • ${formatDate(episode.air_date)}`} 552 - </p> 553 - </div> 554 - {!isWatched && isAuthenticated && ( 555 - <button 556 - type="button" 557 - onClick={(e) => { 558 - e.preventDefault(); 559 - handleMarkWatched( 560 - season.season_number, 561 - episode.episode_number, 562 - ); 563 - }} 564 - disabled={markWatchedMutation.isPending} 565 - className="btn btn-secondary h-8 px-3 text-xs" 566 - > 567 - {markWatchedMutation.isPending ? ( 568 - <Loader2 className="h-3 w-3 animate-spin" /> 569 - ) : ( 570 - "Watch" 571 - )} 572 - </button> 573 - )} 574 - </Link> 575 - ); 576 - }) 577 - ) : ( 578 - <div className="p-4 text-center text-[var(--foreground-muted)]"> 579 - No episodes available 580 - </div> 581 - )} 582 - </div> 583 - )} 584 - </div> 585 - ))} 586 - </div> 587 - </section> 588 - )} 589 - 590 - {/* Cast */} 591 - {cast.length > 0 && ( 592 - <section> 593 - <h2 className="text-display-3 mb-4">Cast</h2> 594 - <div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3"> 595 - {cast.map((actor) => ( 596 - <div 597 - key={actor.name} 598 - className="card card-interactive flex items-center gap-3 p-3" 599 - > 600 - <img 601 - src={actor.photo} 602 - alt={actor.name} 603 - className="h-12 w-12 rounded-full object-cover" 604 - /> 605 - <div className="min-w-0"> 606 - <p className="font-medium text-sm truncate"> 607 - {actor.name} 608 - </p> 609 - <p className="text-xs text-[var(--foreground-muted)] truncate"> 610 - {actor.character} 611 - </p> 612 - </div> 613 - </div> 614 - ))} 615 - </div> 616 - </section> 617 - )} 618 - 619 - {/* Similar Shows */} 620 - {similarShows.length > 0 && ( 621 - <section> 622 - <h2 className="text-display-3 mb-4">Similar Shows</h2> 623 - <div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4"> 624 - {similarShows.map((similarShow) => ( 625 - <MediaCard 626 - key={similarShow.id} 627 - id={similarShow.id} 628 - title={similarShow.title} 629 - posterUrl={similarShow.posterUrl} 630 - type={similarShow.type} 631 - year={similarShow.year} 632 - rating={similarShow.rating} 633 - size="sm" 634 - layout="poster" 635 - /> 636 - ))} 637 - </div> 638 - </section> 639 - )} 640 - </div> 641 - 642 - {/* Right Column - Sidebar */} 643 - <div className="space-y-6"> 644 - {/* Details Card */} 645 - <section className="card p-5"> 646 - <h3 className="font-display font-semibold mb-4">Details</h3> 647 - <div className="space-y-3 text-sm"> 648 - <div className="flex justify-between"> 649 - <span className="text-[var(--foreground-muted)]"> 650 - Creator 651 - </span> 652 - <span className="font-medium">{creator}</span> 653 - </div> 654 - <div className="flex justify-between"> 655 - <span className="text-[var(--foreground-muted)]"> 656 - Seasons 657 - </span> 658 - <span className="font-medium"> 659 - {show.number_of_seasons || 0} 660 - </span> 661 - </div> 662 - <div className="flex justify-between"> 663 - <span className="text-[var(--foreground-muted)]"> 664 - Episodes 665 - </span> 666 - <span className="font-medium"> 667 - {show.number_of_episodes || 0} 668 - </span> 669 - </div> 670 - <div className="flex justify-between"> 671 - <span className="text-[var(--foreground-muted)]">Status</span> 672 - <span className="font-medium"> 673 - {show.status || "Unknown"} 674 - </span> 675 - </div> 676 - <div className="flex justify-between"> 677 - <span className="text-[var(--foreground-muted)]"> 678 - First Aired 679 - </span> 680 - <span className="font-medium"> 681 - {formatDate(show.first_air_date || "")} 682 - </span> 683 - </div> 684 - <div className="flex justify-between"> 685 - <span className="text-[var(--foreground-muted)]">Genres</span> 686 - <span className="font-medium text-right"> 687 - {show.genres?.map((g) => g.name).join(", ") || "N/A"} 688 - </span> 689 - </div> 690 - </div> 691 - </section> 692 - 693 - {/* Your Progress */} 694 - {isTracking && ( 695 - <section className="card p-5"> 696 - <h3 className="font-display font-semibold mb-4"> 697 - Your Progress 698 - </h3> 699 - <div className="space-y-4"> 700 - <div className="flex items-center justify-between"> 701 - <span className="text-sm text-[var(--foreground-muted)]"> 702 - Episodes Watched 703 - </span> 704 - <span className="font-semibold"> 705 - {episodesWatched}/{totalEpisodes} 706 - </span> 707 - </div> 708 - <div className="h-2 w-full rounded-full bg-[var(--background-subtle)]"> 709 - <div 710 - className="h-full rounded-full bg-[var(--accent)]" 711 - style={{ width: `${progressPercentage}%` }} 712 - /> 713 - </div> 714 - <div className="flex items-center justify-between text-sm"> 715 - <span className="text-[var(--foreground-muted)]"> 716 - {Math.round(progressPercentage)}% complete 717 - </span> 718 - <span className="text-[var(--foreground-muted)]"> 719 - {episodesRemaining} remaining 720 - </span> 721 - </div> 722 - </div> 723 - </section> 724 - )} 725 - 726 - {/* Lists Containing This */} 727 - <section className="card p-5"> 728 - <h3 className="font-display font-semibold mb-4">In Your Lists</h3> 729 - <div className="space-y-2"> 730 - {listsContainingShow.length > 0 ? ( 731 - listsContainingShow.map((list) => ( 732 - <Link 733 - key={list.listId} 734 - to={`/lists/${list.listSlug}`} 735 - className="flex items-center justify-between rounded-lg p-2 transition-colors hover:bg-[var(--background-subtle)]" 736 - > 737 - <span className="text-sm font-medium"> 738 - {list.listName} 739 - </span> 740 - <ChevronRight className="h-4 w-4 text-[var(--foreground-muted)]" /> 741 - </Link> 742 - )) 743 - ) : ( 744 - <p className="text-sm text-[var(--foreground-muted)]"> 745 - Not in any lists yet 746 - </p> 747 - )} 748 - </div> 749 - <button 750 - type="button" 751 - className="mt-3 w-full btn btn-secondary text-sm" 752 - > 753 - <Plus className="h-4 w-4" /> 754 - {listsContainingShow.length > 0 755 - ? "Add to another list" 756 - : "Add to list"} 757 - </button> 758 - </section> 759 - </div> 760 - </div> 761 - </div> 762 - </div> 763 - ); 9 + function ShowLayout() { 10 + return <Outlet />; 764 11 }
+745
apps/web/src/routes/shows/$showId/$showName/index.tsx
··· 1 + import { 2 + listsControllerGetListsForItemOptions, 3 + showsControllerGetSeasonDetailsOptions, 4 + showsControllerGetShowWatchHistoryOptions, 5 + } from "@opnshelf/api"; 6 + import { useQuery } from "@tanstack/react-query"; 7 + import { createFileRoute, Link } from "@tanstack/react-router"; 8 + import { 9 + Check, 10 + ChevronDown, 11 + ChevronLeft, 12 + ChevronRight, 13 + Heart, 14 + Loader2, 15 + Play, 16 + Plus, 17 + Share2, 18 + Star, 19 + } from "lucide-react"; 20 + import { useState } from "react"; 21 + import { setupApiClient } from "#/lib/api"; 22 + import { useAuth } from "#/lib/auth-context"; 23 + import { 24 + useDiscoverShows, 25 + useMarkEpisodeWatched, 26 + useShowDetails, 27 + useUserUpNext, 28 + } from "#/lib/hooks"; 29 + import { slugifyName } from "#/lib/url-utils"; 30 + import MediaCard from "../../../../components/MediaCard"; 31 + 32 + // Initialize API client 33 + setupApiClient(); 34 + 35 + export const Route = createFileRoute("/shows/$showId/$showName/")({ 36 + component: ShowDetailPage, 37 + }); 38 + 39 + // Format runtime from minutes to hours and minutes 40 + function formatRuntime(minutes: number): string { 41 + if (!minutes || minutes <= 0) return "N/A"; 42 + const hours = Math.floor(minutes / 60); 43 + const mins = minutes % 60; 44 + if (hours === 0) return `${mins}m`; 45 + return `${hours}h ${mins}m`; 46 + } 47 + 48 + // Format date to readable string 49 + function formatDate(dateString: string): string { 50 + if (!dateString) return "Unknown"; 51 + try { 52 + return new Date(dateString).toLocaleDateString("en-US", { 53 + month: "long", 54 + day: "numeric", 55 + year: "numeric", 56 + }); 57 + } catch { 58 + return dateString; 59 + } 60 + } 61 + 62 + // Hook to fetch season details 63 + function useSeasonDetails(showId: string, seasonNumber: number | null) { 64 + return useQuery({ 65 + ...showsControllerGetSeasonDetailsOptions({ 66 + path: { showId, seasonNumber: seasonNumber?.toString() || "1" }, 67 + }), 68 + enabled: !!showId && !!seasonNumber, 69 + }); 70 + } 71 + 72 + function ShowDetailPage() { 73 + const { showId } = Route.useParams(); 74 + const { user, isAuthenticated } = useAuth(); 75 + const userDid = user?.did; 76 + 77 + const [expandedSeason, setExpandedSeason] = useState<number | null>(1); 78 + 79 + // Fetch show details from API 80 + const { 81 + data: show, 82 + isLoading: showLoading, 83 + error: showError, 84 + } = useShowDetails(showId); 85 + 86 + // Fetch user's watch history for this specific show 87 + const { data: watchHistory } = useQuery({ 88 + ...showsControllerGetShowWatchHistoryOptions({ 89 + path: { userDid: userDid || "", showId }, 90 + }), 91 + enabled: !!userDid && !!showId, 92 + }); 93 + 94 + // Fetch lists containing this show 95 + const { data: listsForItem } = useQuery({ 96 + ...listsControllerGetListsForItemOptions({ 97 + path: { mediaType: "show", mediaId: showId }, 98 + }), 99 + enabled: !!showId, 100 + }); 101 + 102 + // Count how many lists this show is actually in 103 + const listsContainingShow = 104 + listsForItem?.filter((list) => list.isInList) || []; 105 + 106 + // Fetch user's up next episodes 107 + const { data: upNextData } = useUserUpNext(userDid || ""); 108 + 109 + // Fetch discover shows for similar recommendations 110 + const { data: discoverShowsData } = useDiscoverShows(1); 111 + 112 + // Fetch season details when expanded 113 + const { data: seasonDetails, isLoading: seasonLoading } = useSeasonDetails( 114 + showId, 115 + expandedSeason, 116 + ); 117 + 118 + // Mark episode watched mutation 119 + const markWatchedMutation = useMarkEpisodeWatched(); 120 + 121 + // Check if user tracks this show (based on watch history) 122 + const isTracking = !!watchHistory && watchHistory.length > 0; 123 + 124 + // Find up next episode for this show 125 + const upNextForShow = upNextData?.items?.find( 126 + (item) => item.showId === showId, 127 + ); 128 + const nextEpisode = upNextForShow?.nextEpisode; 129 + 130 + // Calculate watched episodes from watch history 131 + const episodesWatched = watchHistory?.length || 0; 132 + const totalEpisodes = show?.number_of_episodes || 0; 133 + const progressPercentage = 134 + totalEpisodes > 0 ? (episodesWatched / totalEpisodes) * 100 : 0; 135 + const episodesRemaining = totalEpisodes - episodesWatched; 136 + 137 + // Loading state 138 + if (showLoading) { 139 + return ( 140 + <div className="flex h-screen items-center justify-center"> 141 + <Loader2 className="h-12 w-12 animate-spin text-[var(--accent)]" /> 142 + </div> 143 + ); 144 + } 145 + 146 + // Error state 147 + if (showError || !show) { 148 + return ( 149 + <div className="container-app py-8"> 150 + <div className="rounded-lg border border-red-200 bg-red-50 p-8 text-center text-red-800"> 151 + <p className="text-lg font-medium">Failed to load show</p> 152 + <p className="mt-2">Please check your connection and try again.</p> 153 + <Link to="/" className="btn btn-primary mt-4 inline-flex"> 154 + Back to Dashboard 155 + </Link> 156 + </div> 157 + </div> 158 + ); 159 + } 160 + 161 + // Transform API data 162 + const backdropUrl = show.backdrop_path 163 + ? `https://image.tmdb.org/t/p/original${show.backdrop_path}` 164 + : show.poster_path 165 + ? `https://image.tmdb.org/t/p/original${show.poster_path}` 166 + : ""; 167 + const posterUrl = show.poster_path 168 + ? `https://image.tmdb.org/t/p/w500${show.poster_path}` 169 + : ""; 170 + 171 + // Get creator from crew (look for executive producer or creator) 172 + const creator = 173 + show.credits?.crew?.find( 174 + (person) => 175 + person.job === "Executive Producer" || person.job === "Creator", 176 + )?.name || "Unknown"; 177 + 178 + // Get cast (limit to 6) 179 + const cast = 180 + show.credits?.cast?.slice(0, 6).map((actor) => ({ 181 + name: actor.name, 182 + character: actor.character || "", 183 + photo: actor.profile_path 184 + ? `https://image.tmdb.org/t/p/w185${actor.profile_path}` 185 + : `https://i.pravatar.cc/150?u=${actor.id}`, 186 + })) || []; 187 + 188 + // Get similar shows from discover API, excluding current show 189 + const similarShows = 190 + discoverShowsData?.results 191 + ?.filter((s) => s.id !== Number(showId)) 192 + ?.slice(0, 4) 193 + ?.map((s) => ({ 194 + id: s.id, 195 + title: s.name, 196 + type: "show" as const, 197 + year: s.first_air_date 198 + ? new Date(s.first_air_date).getFullYear() 199 + : undefined, 200 + posterUrl: s.poster_path 201 + ? `https://image.tmdb.org/t/p/w300${s.poster_path}` 202 + : "", 203 + rating: s.vote_average, 204 + })) || []; 205 + 206 + // Handle mark episode as watched 207 + const handleMarkWatched = (seasonNumber: number, episodeNumber: number) => { 208 + if (!userDid || !isAuthenticated) return; 209 + 210 + markWatchedMutation.mutate({ 211 + body: { 212 + showId, 213 + seasonNumber, 214 + episodeNumber, 215 + }, 216 + }); 217 + }; 218 + 219 + // Get current episode display text 220 + const getCurrentEpisodeText = () => { 221 + if (nextEpisode) { 222 + return `Continue S${nextEpisode.season_number}E${nextEpisode.episode_number}`; 223 + } 224 + if (isTracking && episodesWatched > 0) { 225 + return "Continue Watching"; 226 + } 227 + return "Start Watching"; 228 + }; 229 + 230 + // Check if an episode is the next/current one 231 + const isCurrentEpisode = (seasonNum: number, episodeNum: number) => { 232 + return ( 233 + nextEpisode?.season_number === seasonNum && 234 + nextEpisode?.episode_number === episodeNum 235 + ); 236 + }; 237 + 238 + // Check if an episode has been watched using watch history 239 + const isEpisodeWatched = (seasonNum: number, episodeNum: number) => { 240 + if (!watchHistory || watchHistory.length === 0) return false; 241 + 242 + return watchHistory.some( 243 + (ep) => ep.seasonNumber === seasonNum && ep.episodeNumber === episodeNum, 244 + ); 245 + }; 246 + 247 + return ( 248 + <div className="min-h-screen pb-8"> 249 + {/* Hero Section with Backdrop */} 250 + <div className="relative z-10 min-h-[50vh] overflow-hidden"> 251 + {/* Backdrop Image */} 252 + <div className="absolute inset-0 h-[60vh] overflow-hidden"> 253 + {backdropUrl ? ( 254 + <img 255 + src={backdropUrl} 256 + alt={show.name} 257 + className="h-full w-full object-cover" 258 + /> 259 + ) : ( 260 + <div className="h-full w-full bg-gradient-to-br from-gray-800 to-gray-900" /> 261 + )} 262 + {/* Gradient Overlays */} 263 + <div className="absolute inset-0 bg-gradient-to-t from-[var(--background)] via-[var(--background)]/60 to-transparent" /> 264 + <div className="absolute inset-0 bg-gradient-to-r from-[var(--background)] via-[var(--background)]/40 to-transparent" /> 265 + </div> 266 + 267 + {/* Content */} 268 + <div className="container-app relative pt-8"> 269 + {/* Back Button */} 270 + <Link to="/" className="btn btn-secondary mb-6 inline-flex gap-2"> 271 + <ChevronLeft className="h-4 w-4" /> 272 + Back to Dashboard 273 + </Link> 274 + 275 + {/* Show Info Header */} 276 + <div className="grid gap-8 lg:grid-cols-[300px_1fr] lg:gap-12"> 277 + {/* Poster */} 278 + <div className="hidden lg:block"> 279 + <div className="aspect-[2/3] overflow-hidden rounded-xl shadow-2xl"> 280 + {posterUrl ? ( 281 + <img 282 + src={posterUrl} 283 + alt={show.name} 284 + className="h-full w-full object-cover" 285 + /> 286 + ) : ( 287 + <div className="h-full w-full bg-gradient-to-br from-gray-700 to-gray-800 flex items-center justify-center"> 288 + <span className="text-gray-400">No Poster</span> 289 + </div> 290 + )} 291 + </div> 292 + </div> 293 + 294 + {/* Info */} 295 + <div className="flex flex-col justify-end pb-8 lg:pb-16"> 296 + {/* Mobile Poster */} 297 + <div className="mb-6 flex gap-4 lg:hidden"> 298 + <div className="h-40 w-28 flex-shrink-0 overflow-hidden rounded-lg"> 299 + {posterUrl ? ( 300 + <img 301 + src={posterUrl} 302 + alt={show.name} 303 + className="h-full w-full object-cover" 304 + /> 305 + ) : ( 306 + <div className="h-full w-full bg-gradient-to-br from-gray-700 to-gray-800" /> 307 + )} 308 + </div> 309 + <div className="flex flex-col justify-center"> 310 + <h1 className="text-display-2">{show.name}</h1> 311 + </div> 312 + </div> 313 + 314 + {/* Desktop Title */} 315 + <div className="hidden lg:block"> 316 + <h1 className="text-display-2">{show.name}</h1> 317 + </div> 318 + 319 + {/* Meta Info */} 320 + <div className="mt-4 flex flex-wrap items-center gap-3 text-sm"> 321 + <div className="flex items-center gap-1"> 322 + <Star className="h-4 w-4 fill-yellow-500 text-yellow-500" /> 323 + <span className="font-semibold"> 324 + {show.vote_average?.toFixed(1) || "N/A"} 325 + </span> 326 + <span className="text-[var(--foreground-muted)]">/10</span> 327 + </div> 328 + <span className="text-[var(--border-strong)]">•</span> 329 + <span> 330 + {show.number_of_seasons || 0} Season 331 + {show.number_of_seasons !== 1 ? "s" : ""} 332 + </span> 333 + <span className="text-[var(--border-strong)]">•</span> 334 + <span>{show.number_of_episodes || 0} Episodes</span> 335 + <span className="text-[var(--border-strong)]">•</span> 336 + <span className="badge badge-accent"> 337 + {show.status || "Unknown"} 338 + </span> 339 + <span className="text-[var(--border-strong)]">•</span> 340 + <div className="flex gap-2"> 341 + {show.genres?.slice(0, 3).map((genre) => ( 342 + <span key={genre.id} className="badge badge-subtle"> 343 + {genre.name} 344 + </span> 345 + ))} 346 + </div> 347 + </div> 348 + 349 + {/* Current Progress */} 350 + {isTracking && nextEpisode && ( 351 + <div className="mt-4 flex items-center gap-2 text-sm"> 352 + <div className="flex h-6 w-6 items-center justify-center rounded-full bg-[var(--accent)] text-[#3f2e00] text-xs font-medium"> 353 + {nextEpisode.episode_number} 354 + </div> 355 + <span className="text-[var(--foreground-muted)]"> 356 + Currently at{" "} 357 + <span className="font-medium text-[var(--foreground)]"> 358 + S{nextEpisode.season_number}E{nextEpisode.episode_number} 359 + </span> 360 + </span> 361 + </div> 362 + )} 363 + 364 + {/* Action Buttons */} 365 + <div className="mt-6 flex flex-wrap gap-3"> 366 + <button type="button" className="btn btn-primary gap-2"> 367 + <Play className="h-4 w-4" /> 368 + {getCurrentEpisodeText()} 369 + </button> 370 + 371 + {isTracking ? ( 372 + <button 373 + type="button" 374 + className="btn btn-secondary gap-2 bg-[var(--accent-subtle)] text-[var(--accent)]" 375 + > 376 + <Check className="h-4 w-4" /> 377 + Tracking 378 + </button> 379 + ) : ( 380 + <button type="button" className="btn btn-secondary gap-2"> 381 + <Plus className="h-4 w-4" /> 382 + Track Show 383 + </button> 384 + )} 385 + 386 + <button 387 + type="button" 388 + className="btn btn-secondary h-10 w-10 p-0" 389 + aria-label="Share" 390 + > 391 + <Share2 className="h-4 w-4" /> 392 + </button> 393 + 394 + <button 395 + type="button" 396 + className="btn btn-secondary h-10 w-10 p-0" 397 + aria-label="Like" 398 + > 399 + <Heart className="h-4 w-4" /> 400 + </button> 401 + </div> 402 + </div> 403 + </div> 404 + </div> 405 + </div> 406 + 407 + {/* Main Content */} 408 + <div className="container-app relative z-20 mt-8"> 409 + <div className="grid gap-8 lg:grid-cols-[2fr_1fr] lg:gap-12"> 410 + {/* Left Column */} 411 + <div className="space-y-8"> 412 + {/* Overview */} 413 + <section> 414 + <h2 className="text-display-3 mb-4">Overview</h2> 415 + <p className="text-[var(--foreground-muted)] leading-relaxed"> 416 + {show.overview || "No overview available."} 417 + </p> 418 + </section> 419 + 420 + {/* Episodes */} 421 + {show.seasons && show.seasons.length > 0 && ( 422 + <section> 423 + <h2 className="text-display-3 mb-4">Episodes</h2> 424 + <div className="space-y-3"> 425 + {show.seasons 426 + .filter((season) => season.season_number > 0) // Filter out specials (season 0) 427 + .map((season) => ( 428 + <div key={season.id} className="card overflow-hidden"> 429 + <div className="flex items-center"> 430 + {/* Season Header */} 431 + <Link 432 + to="/shows/$showId/$showName/seasons/$seasonNumber" 433 + params={{ 434 + showId, 435 + showName: slugifyName(show.name), 436 + seasonNumber: season.season_number, 437 + }} 438 + className="flex flex-1 items-center justify-between p-4 text-left transition-colors hover:bg-[var(--background-subtle)]" 439 + > 440 + <div> 441 + <h3 className="font-semibold hover:text-[var(--accent)]"> 442 + {season.name || 443 + `Season ${season.season_number}`} 444 + </h3> 445 + <p className="text-sm text-[var(--foreground-muted)]"> 446 + {season.episode_count || 0} episodes 447 + </p> 448 + </div> 449 + </Link> 450 + <button 451 + type="button" 452 + onClick={() => 453 + setExpandedSeason( 454 + expandedSeason === season.season_number 455 + ? null 456 + : season.season_number, 457 + ) 458 + } 459 + className="flex items-center justify-center p-4 text-[var(--foreground-muted)] transition-colors hover:bg-[var(--background-subtle)]" 460 + > 461 + <ChevronDown 462 + className={`h-5 w-5 transition-transform ${ 463 + expandedSeason === season.season_number 464 + ? "rotate-180" 465 + : "" 466 + }`} 467 + /> 468 + </button> 469 + </div> 470 + 471 + {/* Episode List */} 472 + {expandedSeason === season.season_number && ( 473 + <div className="border-t border-[var(--border)]"> 474 + {seasonLoading ? ( 475 + <div className="p-4 text-center"> 476 + <Loader2 className="h-6 w-6 animate-spin mx-auto text-[var(--accent)]" /> 477 + </div> 478 + ) : seasonDetails?.episodes ? ( 479 + seasonDetails.episodes.map((episode, index) => { 480 + const isWatched = isEpisodeWatched( 481 + season.season_number, 482 + episode.episode_number, 483 + ); 484 + const isCurrent = isCurrentEpisode( 485 + season.season_number, 486 + episode.episode_number, 487 + ); 488 + 489 + return ( 490 + <Link 491 + key={episode.id} 492 + to="/shows/$showId/$showName/seasons/$seasonNumber/episodes/$episodeNumber" 493 + params={{ 494 + showId, 495 + showName: slugifyName(show.name), 496 + seasonNumber: season.season_number, 497 + episodeNumber: episode.episode_number, 498 + }} 499 + className={`flex items-center gap-4 p-4 transition-colors ${ 500 + isCurrent 501 + ? "bg-[var(--accent-subtle)]" 502 + : "hover:bg-[var(--background-subtle)]" 503 + } ${ 504 + index !== 505 + seasonDetails.episodes.length - 1 506 + ? "border-b border-[var(--border)]" 507 + : "" 508 + }`} 509 + > 510 + <div className="flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-lg bg-[var(--background-subtle)] font-semibold text-sm"> 511 + {isWatched ? ( 512 + <Check className="h-5 w-5 text-green-500" /> 513 + ) : ( 514 + episode.episode_number 515 + )} 516 + </div> 517 + <div className="flex-1 min-w-0"> 518 + <div className="flex items-center gap-2"> 519 + <h4 className="font-medium truncate"> 520 + {episode.episode_number}.{" "} 521 + {episode.name} 522 + </h4> 523 + {isCurrent && ( 524 + <span className="badge badge-accent"> 525 + Current 526 + </span> 527 + )} 528 + </div> 529 + <p className="text-sm text-[var(--foreground-muted)]"> 530 + {formatRuntime(episode.runtime || 0)} 531 + {episode.air_date && 532 + ` • ${formatDate(episode.air_date)}`} 533 + </p> 534 + </div> 535 + {!isWatched && isAuthenticated && ( 536 + <button 537 + type="button" 538 + onClick={(e) => { 539 + e.preventDefault(); 540 + handleMarkWatched( 541 + season.season_number, 542 + episode.episode_number, 543 + ); 544 + }} 545 + disabled={markWatchedMutation.isPending} 546 + className="btn btn-secondary h-8 px-3 text-xs" 547 + > 548 + {markWatchedMutation.isPending ? ( 549 + <Loader2 className="h-3 w-3 animate-spin" /> 550 + ) : ( 551 + "Watch" 552 + )} 553 + </button> 554 + )} 555 + </Link> 556 + ); 557 + }) 558 + ) : ( 559 + <div className="p-4 text-center text-[var(--foreground-muted)]"> 560 + No episodes available 561 + </div> 562 + )} 563 + </div> 564 + )} 565 + </div> 566 + ))} 567 + </div> 568 + </section> 569 + )} 570 + 571 + {/* Cast */} 572 + {cast.length > 0 && ( 573 + <section> 574 + <h2 className="text-display-3 mb-4">Cast</h2> 575 + <div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3"> 576 + {cast.map((actor) => ( 577 + <div 578 + key={actor.name} 579 + className="card card-interactive flex items-center gap-3 p-3" 580 + > 581 + <img 582 + src={actor.photo} 583 + alt={actor.name} 584 + className="h-12 w-12 rounded-full object-cover" 585 + /> 586 + <div className="min-w-0"> 587 + <p className="font-medium text-sm truncate"> 588 + {actor.name} 589 + </p> 590 + <p className="text-xs text-[var(--foreground-muted)] truncate"> 591 + {actor.character} 592 + </p> 593 + </div> 594 + </div> 595 + ))} 596 + </div> 597 + </section> 598 + )} 599 + 600 + {/* Similar Shows */} 601 + {similarShows.length > 0 && ( 602 + <section> 603 + <h2 className="text-display-3 mb-4">Similar Shows</h2> 604 + <div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4"> 605 + {similarShows.map((similarShow) => ( 606 + <MediaCard 607 + key={similarShow.id} 608 + id={similarShow.id} 609 + title={similarShow.title} 610 + posterUrl={similarShow.posterUrl} 611 + type={similarShow.type} 612 + year={similarShow.year} 613 + rating={similarShow.rating} 614 + size="sm" 615 + layout="poster" 616 + /> 617 + ))} 618 + </div> 619 + </section> 620 + )} 621 + </div> 622 + 623 + {/* Right Column - Sidebar */} 624 + <div className="space-y-6"> 625 + {/* Details Card */} 626 + <section className="card p-5"> 627 + <h3 className="font-display font-semibold mb-4">Details</h3> 628 + <div className="space-y-3 text-sm"> 629 + <div className="flex justify-between"> 630 + <span className="text-[var(--foreground-muted)]"> 631 + Creator 632 + </span> 633 + <span className="font-medium">{creator}</span> 634 + </div> 635 + <div className="flex justify-between"> 636 + <span className="text-[var(--foreground-muted)]"> 637 + Seasons 638 + </span> 639 + <span className="font-medium"> 640 + {show.number_of_seasons || 0} 641 + </span> 642 + </div> 643 + <div className="flex justify-between"> 644 + <span className="text-[var(--foreground-muted)]"> 645 + Episodes 646 + </span> 647 + <span className="font-medium"> 648 + {show.number_of_episodes || 0} 649 + </span> 650 + </div> 651 + <div className="flex justify-between"> 652 + <span className="text-[var(--foreground-muted)]">Status</span> 653 + <span className="font-medium"> 654 + {show.status || "Unknown"} 655 + </span> 656 + </div> 657 + <div className="flex justify-between"> 658 + <span className="text-[var(--foreground-muted)]"> 659 + First Aired 660 + </span> 661 + <span className="font-medium"> 662 + {formatDate(show.first_air_date || "")} 663 + </span> 664 + </div> 665 + <div className="flex justify-between"> 666 + <span className="text-[var(--foreground-muted)]">Genres</span> 667 + <span className="font-medium text-right"> 668 + {show.genres?.map((g) => g.name).join(", ") || "N/A"} 669 + </span> 670 + </div> 671 + </div> 672 + </section> 673 + 674 + {/* Your Progress */} 675 + {isTracking && ( 676 + <section className="card p-5"> 677 + <h3 className="font-display font-semibold mb-4"> 678 + Your Progress 679 + </h3> 680 + <div className="space-y-4"> 681 + <div className="flex items-center justify-between"> 682 + <span className="text-sm text-[var(--foreground-muted)]"> 683 + Episodes Watched 684 + </span> 685 + <span className="font-semibold"> 686 + {episodesWatched}/{totalEpisodes} 687 + </span> 688 + </div> 689 + <div className="h-2 w-full rounded-full bg-[var(--background-subtle)]"> 690 + <div 691 + className="h-full rounded-full bg-[var(--accent)]" 692 + style={{ width: `${progressPercentage}%` }} 693 + /> 694 + </div> 695 + <div className="flex items-center justify-between text-sm"> 696 + <span className="text-[var(--foreground-muted)]"> 697 + {Math.round(progressPercentage)}% complete 698 + </span> 699 + <span className="text-[var(--foreground-muted)]"> 700 + {episodesRemaining} remaining 701 + </span> 702 + </div> 703 + </div> 704 + </section> 705 + )} 706 + 707 + {/* Lists Containing This */} 708 + <section className="card p-5"> 709 + <h3 className="font-display font-semibold mb-4">In Your Lists</h3> 710 + <div className="space-y-2"> 711 + {listsContainingShow.length > 0 ? ( 712 + listsContainingShow.map((list) => ( 713 + <Link 714 + key={list.listId} 715 + to={`/lists/${list.listSlug}`} 716 + className="flex items-center justify-between rounded-lg p-2 transition-colors hover:bg-[var(--background-subtle)]" 717 + > 718 + <span className="text-sm font-medium"> 719 + {list.listName} 720 + </span> 721 + <ChevronRight className="h-4 w-4 text-[var(--foreground-muted)]" /> 722 + </Link> 723 + )) 724 + ) : ( 725 + <p className="text-sm text-[var(--foreground-muted)]"> 726 + Not in any lists yet 727 + </p> 728 + )} 729 + </div> 730 + <button 731 + type="button" 732 + className="mt-3 w-full btn btn-secondary text-sm" 733 + > 734 + <Plus className="h-4 w-4" /> 735 + {listsContainingShow.length > 0 736 + ? "Add to another list" 737 + : "Add to list"} 738 + </button> 739 + </section> 740 + </div> 741 + </div> 742 + </div> 743 + </div> 744 + ); 745 + }
+4 -67
apps/web/src/routes/shows/$showId/$showName/seasons.$seasonNumber.tsx
··· 1 - import { createFileRoute, Link } from "@tanstack/react-router"; 2 - import { ChevronLeft, Loader2 } from "lucide-react"; 3 - import { useShowDetails } from "#/lib/hooks"; 4 - import { buildShowUrl } from "#/lib/url-utils"; 1 + import { createFileRoute, Outlet } from "@tanstack/react-router"; 5 2 6 3 export const Route = createFileRoute( 7 4 "/shows/$showId/$showName/seasons/$seasonNumber", 8 5 )({ 9 - component: SeasonDetailPage, 6 + component: SeasonLayout, 10 7 }); 11 8 12 - function SeasonDetailPage() { 13 - const { showId, showName, seasonNumber } = Route.useParams(); 14 - 15 - // Fetch show details for context 16 - const { data: show, isLoading } = useShowDetails(showId); 17 - 18 - const seasonNum = Number.parseInt(seasonNumber, 10); 19 - 20 - // Find the season in show data 21 - const season = show?.seasons?.find((s) => s.season_number === seasonNum); 22 - 23 - if (isLoading) { 24 - return ( 25 - <div className="flex h-screen items-center justify-center"> 26 - <Loader2 className="h-12 w-12 animate-spin text-[var(--accent)]" /> 27 - </div> 28 - ); 29 - } 30 - 31 - return ( 32 - <div className="min-h-screen pb-8"> 33 - <div className="container-app py-8"> 34 - {/* Back Button */} 35 - <Link 36 - to={buildShowUrl(showId, show?.name || showName)} 37 - className="btn btn-secondary mb-6 inline-flex gap-2" 38 - > 39 - <ChevronLeft className="h-4 w-4" /> 40 - Back to {show?.name || showName} 41 - </Link> 42 - 43 - {/* Placeholder Content */} 44 - <div className="card p-8 text-center"> 45 - <h1 className="text-display-2 mb-4">Season Detail - Placeholder</h1> 46 - <p className="text-[var(--foreground-muted)] mb-4"> 47 - This is a placeholder for the season detail page. 48 - </p> 49 - <div className="space-y-2 text-sm text-[var(--foreground-muted)]"> 50 - <p> 51 - <strong>Show ID:</strong> {showId} 52 - </p> 53 - <p> 54 - <strong>Show Name:</strong> {show?.name || showName} 55 - </p> 56 - <p> 57 - <strong>Season:</strong> {seasonNum} 58 - </p> 59 - {season && ( 60 - <> 61 - <p> 62 - <strong>Season Name:</strong> {season.name} 63 - </p> 64 - <p> 65 - <strong>Episodes:</strong> {season.episode_count} 66 - </p> 67 - </> 68 - )} 69 - </div> 70 - </div> 71 - </div> 72 - </div> 73 - ); 9 + function SeasonLayout() { 10 + return <Outlet />; 74 11 }
+67
apps/web/src/routes/shows/$showId/$showName/seasons.$seasonNumber/index.tsx
··· 1 + import { createFileRoute, Link } from "@tanstack/react-router"; 2 + import { ChevronLeft, Loader2 } from "lucide-react"; 3 + import { useShowDetails } from "#/lib/hooks"; 4 + import { buildShowUrl } from "#/lib/url-utils"; 5 + 6 + export const Route = createFileRoute( 7 + "/shows/$showId/$showName/seasons/$seasonNumber/", 8 + )({ 9 + component: SeasonDetailPage, 10 + }); 11 + 12 + function SeasonDetailPage() { 13 + const { showId, showName, seasonNumber } = Route.useParams(); 14 + const { data: show, isLoading } = useShowDetails(showId); 15 + const seasonNum = Number.parseInt(seasonNumber, 10); 16 + const season = show?.seasons?.find((s) => s.season_number === seasonNum); 17 + 18 + if (isLoading) { 19 + return ( 20 + <div className="flex h-screen items-center justify-center"> 21 + <Loader2 className="h-12 w-12 animate-spin text-[var(--accent)]" /> 22 + </div> 23 + ); 24 + } 25 + 26 + return ( 27 + <div className="min-h-screen pb-8"> 28 + <div className="container-app py-8"> 29 + <Link 30 + to={buildShowUrl(showId, show?.name || showName)} 31 + className="btn btn-secondary mb-6 inline-flex gap-2" 32 + > 33 + <ChevronLeft className="h-4 w-4" /> 34 + Back to {show?.name || showName} 35 + </Link> 36 + 37 + <div className="card p-8 text-center"> 38 + <h1 className="text-display-2 mb-4">Season Detail - Placeholder</h1> 39 + <p className="text-[var(--foreground-muted)] mb-4"> 40 + This is a placeholder for the season detail page. 41 + </p> 42 + <div className="space-y-2 text-sm text-[var(--foreground-muted)]"> 43 + <p> 44 + <strong>Show ID:</strong> {showId} 45 + </p> 46 + <p> 47 + <strong>Show Name:</strong> {show?.name || showName} 48 + </p> 49 + <p> 50 + <strong>Season:</strong> {seasonNum} 51 + </p> 52 + {season && ( 53 + <> 54 + <p> 55 + <strong>Season Name:</strong> {season.name} 56 + </p> 57 + <p> 58 + <strong>Episodes:</strong> {season.episode_count} 59 + </p> 60 + </> 61 + )} 62 + </div> 63 + </div> 64 + </div> 65 + </div> 66 + ); 67 + }