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(routes): restructure URLs with SEO-friendly slugs for shows, seasons, and episodes

- Change show URLs from /show/$id to /shows/[showId]/[showName]
- Change movie URLs from /movie/$id to /movies/[movieId]/[movieName]
- Add new episode detail page at /shows/[showId]/[showName]/seasons/[season]/episodes/[episode]
- Add new season detail page at /shows/[showId]/[showName]/seasons/[season]
- Create URL utility functions (slugifyName, buildShowUrl, buildMovieUrl, buildSeasonUrl, buildEpisodeUrl)
- Update MediaCard to support episode and season links
- Update dashboard, calendar, and search to use new URL format
- Install slugify package for consistent URL slugs
- Routes render as standalone pages (not nested within show detail)

+494 -110
+1
apps/web/package.json
··· 35 35 "radix-ui": "^1.4.3", 36 36 "react": "^19.2.0", 37 37 "react-dom": "^19.2.0", 38 + "slugify": "^1.6.9", 38 39 "tailwind-merge": "^3.0.2", 39 40 "tailwindcss": "^4.1.18", 40 41 "tw-animate-css": "^1.3.6",
+31 -4
apps/web/src/components/MediaCard.tsx
··· 1 1 import { Link } from "@tanstack/react-router"; 2 2 import { Check, Clock, MoreHorizontal, Play, Star } from "lucide-react"; 3 3 import { useState } from "react"; 4 + import { buildEpisodeUrl, buildMovieUrl, buildShowUrl } from "#/lib/url-utils"; 4 5 5 6 interface MediaCardProps { 6 7 id: string | number; 7 8 title: string; 9 + displayTitle?: string; // Optional different title for display (e.g., episode name) 10 + // Episode-specific props for linking to episode detail page 11 + seasonNumber?: number; 12 + episodeNumber?: number; 8 13 posterUrl: string; 9 14 backdropUrl?: string; 10 15 type: "movie" | "show"; ··· 27 32 export default function MediaCard({ 28 33 id, 29 34 title, 35 + displayTitle, 36 + seasonNumber, 37 + episodeNumber, 30 38 posterUrl, 31 39 backdropUrl, 32 40 type, ··· 69 77 70 78 const imageUrl = 71 79 layout === "backdrop" && backdropUrl ? backdropUrl : posterUrl; 72 - const linkHref = href || (type === "movie" ? `/movie/${id}` : `/show/${id}`); 80 + 81 + // Build URL - episodes go to episode detail page if season/episode numbers provided 82 + const linkHref = (() => { 83 + if (href) return href; 84 + if (type === "movie") { 85 + return buildMovieUrl(id, title); 86 + } 87 + if ( 88 + type === "show" && 89 + seasonNumber !== undefined && 90 + episodeNumber !== undefined 91 + ) { 92 + return buildEpisodeUrl(id, title, seasonNumber, episodeNumber); 93 + } 94 + return buildShowUrl(id, title); 95 + })(); 96 + 97 + const displayName = displayTitle || title; 73 98 74 99 return ( 75 100 <article ··· 109 134 )} 110 135 </div> 111 136 <p className="px-2 text-xs text-[var(--foreground-muted)]"> 112 - {title} 137 + {displayName} 113 138 </p> 114 139 </div> 115 140 </div> ··· 185 210 {/* Backdrop layout content */} 186 211 {layout === "backdrop" && ( 187 212 <div className="absolute bottom-0 left-0 right-0 p-4"> 188 - <h3 className="font-semibold text-white line-clamp-1">{title}</h3> 213 + <h3 className="font-semibold text-white line-clamp-1"> 214 + {displayName} 215 + </h3> 189 216 {episodeInfo && ( 190 217 <p className="mt-1 text-sm text-white/70">{episodeInfo}</p> 191 218 )} ··· 203 230 {layout === "poster" && ( 204 231 <div className="mt-2 space-y-1"> 205 232 <h3 className="font-medium text-sm text-[var(--foreground)] line-clamp-1"> 206 - {title} 233 + {displayName} 207 234 </h3> 208 235 <div className="flex items-center gap-2 text-xs text-[var(--foreground-muted)]"> 209 236 {year && <span>{year}</span>}
+3 -2
apps/web/src/components/SearchCommand.tsx
··· 33 33 CommandSeparator, 34 34 CommandShortcut, 35 35 } from "#/components/ui/command"; 36 + import { buildMovieUrl, buildShowUrl } from "#/lib/url-utils"; 36 37 37 38 interface SearchCommandProps { 38 39 open?: boolean; ··· 218 219 {movies.slice(0, 5).map((movie: UnifiedSearchResultDto) => ( 219 220 <CommandItem key={`movie-${movie.id}`} asChild> 220 221 <Link 221 - to={`/movie/${movie.id}`} 222 + to={buildMovieUrl(movie.id, getTitle(movie))} 222 223 className="flex items-center gap-2" 223 224 > 224 225 <Film className="h-4 w-4" /> ··· 249 250 {shows.slice(0, 5).map((show: UnifiedSearchResultDto) => ( 250 251 <CommandItem key={`show-${show.id}`} asChild> 251 252 <Link 252 - to={`/show/${show.id}`} 253 + to={buildShowUrl(show.id, getTitle(show))} 253 254 className="flex items-center gap-2" 254 255 > 255 256 <Tv className="h-4 w-4" />
+63
apps/web/src/lib/url-utils.ts
··· 1 + import slugify from "slugify"; 2 + 3 + /** 4 + * Convert a show/movie name to a URL-friendly slug 5 + */ 6 + export function slugifyName(name: string): string { 7 + return slugify(name, { 8 + lower: true, 9 + strict: true, 10 + trim: true, 11 + }); 12 + } 13 + 14 + /** 15 + * Build a show detail URL 16 + * Format: /shows/[showId]/[showName] 17 + */ 18 + export function buildShowUrl( 19 + showId: string | number, 20 + showName: string, 21 + ): string { 22 + const slug = slugifyName(showName); 23 + return `/shows/${showId}/${slug}`; 24 + } 25 + 26 + /** 27 + * Build a season detail URL 28 + * Format: /shows/[showId]/[showName]/seasons/[seasonNumber] 29 + */ 30 + export function buildSeasonUrl( 31 + showId: string | number, 32 + showName: string, 33 + seasonNumber: number, 34 + ): string { 35 + const slug = slugifyName(showName); 36 + return `/shows/${showId}/${slug}/seasons/${seasonNumber}`; 37 + } 38 + 39 + /** 40 + * Build an episode detail URL 41 + * Format: /shows/[showId]/[showName]/seasons/[seasonNumber]/episodes/[episodeNumber] 42 + */ 43 + export function buildEpisodeUrl( 44 + showId: string | number, 45 + showName: string, 46 + seasonNumber: number, 47 + episodeNumber: number, 48 + ): string { 49 + const slug = slugifyName(showName); 50 + return `/shows/${showId}/${slug}/seasons/${seasonNumber}/episodes/${episodeNumber}`; 51 + } 52 + 53 + /** 54 + * Build a movie detail URL 55 + * Format: /movies/[movieId]/[movieName] 56 + */ 57 + export function buildMovieUrl( 58 + movieId: string | number, 59 + movieName: string, 60 + ): string { 61 + const slug = slugifyName(movieName); 62 + return `/movies/${movieId}/${slug}`; 63 + }
+110 -41
apps/web/src/routeTree.gen.ts
··· 16 16 import { Route as CalendarRouteImport } from './routes/calendar' 17 17 import { Route as AboutRouteImport } from './routes/about' 18 18 import { Route as IndexRouteImport } from './routes/index' 19 - import { Route as ShowIdRouteImport } from './routes/show/$id' 20 - import { Route as MovieIdRouteImport } from './routes/movie/$id' 21 19 import { Route as AuthCompleteRouteImport } from './routes/auth/complete' 20 + import { Route as ShowsShowIdShowNameRouteImport } from './routes/shows/$showId/$showName' 21 + import { Route as MoviesMovieIdMovieNameRouteImport } from './routes/movies/$movieId/$movieName' 22 + import { Route as ShowsShowIdShowNameSeasonsSeasonNumberRouteImport } from './routes/shows/$showId/$showName/seasons.$seasonNumber' 23 + import { Route as ShowsShowIdShowNameSeasonsSeasonNumberEpisodesEpisodeNumberRouteImport } from './routes/shows/$showId/$showName/seasons.$seasonNumber.episodes.$episodeNumber' 22 24 23 25 const LoginRoute = LoginRouteImport.update({ 24 26 id: '/login', ··· 55 57 path: '/', 56 58 getParentRoute: () => rootRouteImport, 57 59 } as any) 58 - const ShowIdRoute = ShowIdRouteImport.update({ 59 - id: '/show/$id', 60 - path: '/show/$id', 60 + const AuthCompleteRoute = AuthCompleteRouteImport.update({ 61 + id: '/auth/complete', 62 + path: '/auth/complete', 61 63 getParentRoute: () => rootRouteImport, 62 64 } as any) 63 - const MovieIdRoute = MovieIdRouteImport.update({ 64 - id: '/movie/$id', 65 - path: '/movie/$id', 65 + const ShowsShowIdShowNameRoute = ShowsShowIdShowNameRouteImport.update({ 66 + id: '/shows/$showId/$showName', 67 + path: '/shows/$showId/$showName', 66 68 getParentRoute: () => rootRouteImport, 67 69 } as any) 68 - const AuthCompleteRoute = AuthCompleteRouteImport.update({ 69 - id: '/auth/complete', 70 - path: '/auth/complete', 70 + const MoviesMovieIdMovieNameRoute = MoviesMovieIdMovieNameRouteImport.update({ 71 + id: '/movies/$movieId/$movieName', 72 + path: '/movies/$movieId/$movieName', 71 73 getParentRoute: () => rootRouteImport, 72 74 } as any) 75 + const ShowsShowIdShowNameSeasonsSeasonNumberRoute = 76 + ShowsShowIdShowNameSeasonsSeasonNumberRouteImport.update({ 77 + id: '/seasons/$seasonNumber', 78 + path: '/seasons/$seasonNumber', 79 + getParentRoute: () => ShowsShowIdShowNameRoute, 80 + } as any) 81 + const ShowsShowIdShowNameSeasonsSeasonNumberEpisodesEpisodeNumberRoute = 82 + ShowsShowIdShowNameSeasonsSeasonNumberEpisodesEpisodeNumberRouteImport.update( 83 + { 84 + id: '/episodes/$episodeNumber', 85 + path: '/episodes/$episodeNumber', 86 + getParentRoute: () => ShowsShowIdShowNameSeasonsSeasonNumberRoute, 87 + } as any, 88 + ) 73 89 74 90 export interface FileRoutesByFullPath { 75 91 '/': typeof IndexRoute ··· 80 96 '/lists': typeof ListsRoute 81 97 '/login': typeof LoginRoute 82 98 '/auth/complete': typeof AuthCompleteRoute 83 - '/movie/$id': typeof MovieIdRoute 84 - '/show/$id': typeof ShowIdRoute 99 + '/movies/$movieId/$movieName': typeof MoviesMovieIdMovieNameRoute 100 + '/shows/$showId/$showName': typeof ShowsShowIdShowNameRouteWithChildren 101 + '/shows/$showId/$showName/seasons/$seasonNumber': typeof ShowsShowIdShowNameSeasonsSeasonNumberRouteWithChildren 102 + '/shows/$showId/$showName/seasons/$seasonNumber/episodes/$episodeNumber': typeof ShowsShowIdShowNameSeasonsSeasonNumberEpisodesEpisodeNumberRoute 85 103 } 86 104 export interface FileRoutesByTo { 87 105 '/': typeof IndexRoute ··· 92 110 '/lists': typeof ListsRoute 93 111 '/login': typeof LoginRoute 94 112 '/auth/complete': typeof AuthCompleteRoute 95 - '/movie/$id': typeof MovieIdRoute 96 - '/show/$id': typeof ShowIdRoute 113 + '/movies/$movieId/$movieName': typeof MoviesMovieIdMovieNameRoute 114 + '/shows/$showId/$showName': typeof ShowsShowIdShowNameRouteWithChildren 115 + '/shows/$showId/$showName/seasons/$seasonNumber': typeof ShowsShowIdShowNameSeasonsSeasonNumberRouteWithChildren 116 + '/shows/$showId/$showName/seasons/$seasonNumber/episodes/$episodeNumber': typeof ShowsShowIdShowNameSeasonsSeasonNumberEpisodesEpisodeNumberRoute 97 117 } 98 118 export interface FileRoutesById { 99 119 __root__: typeof rootRouteImport ··· 105 125 '/lists': typeof ListsRoute 106 126 '/login': typeof LoginRoute 107 127 '/auth/complete': typeof AuthCompleteRoute 108 - '/movie/$id': typeof MovieIdRoute 109 - '/show/$id': typeof ShowIdRoute 128 + '/movies/$movieId/$movieName': typeof MoviesMovieIdMovieNameRoute 129 + '/shows/$showId/$showName': typeof ShowsShowIdShowNameRouteWithChildren 130 + '/shows/$showId/$showName/seasons/$seasonNumber': typeof ShowsShowIdShowNameSeasonsSeasonNumberRouteWithChildren 131 + '/shows/$showId/$showName/seasons/$seasonNumber/episodes/$episodeNumber': typeof ShowsShowIdShowNameSeasonsSeasonNumberEpisodesEpisodeNumberRoute 110 132 } 111 133 export interface FileRouteTypes { 112 134 fileRoutesByFullPath: FileRoutesByFullPath ··· 119 141 | '/lists' 120 142 | '/login' 121 143 | '/auth/complete' 122 - | '/movie/$id' 123 - | '/show/$id' 144 + | '/movies/$movieId/$movieName' 145 + | '/shows/$showId/$showName' 146 + | '/shows/$showId/$showName/seasons/$seasonNumber' 147 + | '/shows/$showId/$showName/seasons/$seasonNumber/episodes/$episodeNumber' 124 148 fileRoutesByTo: FileRoutesByTo 125 149 to: 126 150 | '/' ··· 131 155 | '/lists' 132 156 | '/login' 133 157 | '/auth/complete' 134 - | '/movie/$id' 135 - | '/show/$id' 158 + | '/movies/$movieId/$movieName' 159 + | '/shows/$showId/$showName' 160 + | '/shows/$showId/$showName/seasons/$seasonNumber' 161 + | '/shows/$showId/$showName/seasons/$seasonNumber/episodes/$episodeNumber' 136 162 id: 137 163 | '__root__' 138 164 | '/' ··· 143 169 | '/lists' 144 170 | '/login' 145 171 | '/auth/complete' 146 - | '/movie/$id' 147 - | '/show/$id' 172 + | '/movies/$movieId/$movieName' 173 + | '/shows/$showId/$showName' 174 + | '/shows/$showId/$showName/seasons/$seasonNumber' 175 + | '/shows/$showId/$showName/seasons/$seasonNumber/episodes/$episodeNumber' 148 176 fileRoutesById: FileRoutesById 149 177 } 150 178 export interface RootRouteChildren { ··· 156 184 ListsRoute: typeof ListsRoute 157 185 LoginRoute: typeof LoginRoute 158 186 AuthCompleteRoute: typeof AuthCompleteRoute 159 - MovieIdRoute: typeof MovieIdRoute 160 - ShowIdRoute: typeof ShowIdRoute 187 + MoviesMovieIdMovieNameRoute: typeof MoviesMovieIdMovieNameRoute 188 + ShowsShowIdShowNameRoute: typeof ShowsShowIdShowNameRouteWithChildren 161 189 } 162 190 163 191 declare module '@tanstack/react-router' { ··· 211 239 preLoaderRoute: typeof IndexRouteImport 212 240 parentRoute: typeof rootRouteImport 213 241 } 214 - '/show/$id': { 215 - id: '/show/$id' 216 - path: '/show/$id' 217 - fullPath: '/show/$id' 218 - preLoaderRoute: typeof ShowIdRouteImport 219 - parentRoute: typeof rootRouteImport 220 - } 221 - '/movie/$id': { 222 - id: '/movie/$id' 223 - path: '/movie/$id' 224 - fullPath: '/movie/$id' 225 - preLoaderRoute: typeof MovieIdRouteImport 226 - parentRoute: typeof rootRouteImport 227 - } 228 242 '/auth/complete': { 229 243 id: '/auth/complete' 230 244 path: '/auth/complete' ··· 232 246 preLoaderRoute: typeof AuthCompleteRouteImport 233 247 parentRoute: typeof rootRouteImport 234 248 } 249 + '/shows/$showId/$showName': { 250 + id: '/shows/$showId/$showName' 251 + path: '/shows/$showId/$showName' 252 + fullPath: '/shows/$showId/$showName' 253 + preLoaderRoute: typeof ShowsShowIdShowNameRouteImport 254 + parentRoute: typeof rootRouteImport 255 + } 256 + '/movies/$movieId/$movieName': { 257 + id: '/movies/$movieId/$movieName' 258 + path: '/movies/$movieId/$movieName' 259 + fullPath: '/movies/$movieId/$movieName' 260 + preLoaderRoute: typeof MoviesMovieIdMovieNameRouteImport 261 + parentRoute: typeof rootRouteImport 262 + } 263 + '/shows/$showId/$showName/seasons/$seasonNumber': { 264 + id: '/shows/$showId/$showName/seasons/$seasonNumber' 265 + path: '/seasons/$seasonNumber' 266 + fullPath: '/shows/$showId/$showName/seasons/$seasonNumber' 267 + preLoaderRoute: typeof ShowsShowIdShowNameSeasonsSeasonNumberRouteImport 268 + parentRoute: typeof ShowsShowIdShowNameRoute 269 + } 270 + '/shows/$showId/$showName/seasons/$seasonNumber/episodes/$episodeNumber': { 271 + id: '/shows/$showId/$showName/seasons/$seasonNumber/episodes/$episodeNumber' 272 + path: '/episodes/$episodeNumber' 273 + fullPath: '/shows/$showId/$showName/seasons/$seasonNumber/episodes/$episodeNumber' 274 + preLoaderRoute: typeof ShowsShowIdShowNameSeasonsSeasonNumberEpisodesEpisodeNumberRouteImport 275 + parentRoute: typeof ShowsShowIdShowNameSeasonsSeasonNumberRoute 276 + } 235 277 } 236 278 } 237 279 280 + interface ShowsShowIdShowNameSeasonsSeasonNumberRouteChildren { 281 + ShowsShowIdShowNameSeasonsSeasonNumberEpisodesEpisodeNumberRoute: typeof ShowsShowIdShowNameSeasonsSeasonNumberEpisodesEpisodeNumberRoute 282 + } 283 + 284 + const ShowsShowIdShowNameSeasonsSeasonNumberRouteChildren: ShowsShowIdShowNameSeasonsSeasonNumberRouteChildren = 285 + { 286 + ShowsShowIdShowNameSeasonsSeasonNumberEpisodesEpisodeNumberRoute: 287 + ShowsShowIdShowNameSeasonsSeasonNumberEpisodesEpisodeNumberRoute, 288 + } 289 + 290 + const ShowsShowIdShowNameSeasonsSeasonNumberRouteWithChildren = 291 + ShowsShowIdShowNameSeasonsSeasonNumberRoute._addFileChildren( 292 + ShowsShowIdShowNameSeasonsSeasonNumberRouteChildren, 293 + ) 294 + 295 + interface ShowsShowIdShowNameRouteChildren { 296 + ShowsShowIdShowNameSeasonsSeasonNumberRoute: typeof ShowsShowIdShowNameSeasonsSeasonNumberRouteWithChildren 297 + } 298 + 299 + const ShowsShowIdShowNameRouteChildren: ShowsShowIdShowNameRouteChildren = { 300 + ShowsShowIdShowNameSeasonsSeasonNumberRoute: 301 + ShowsShowIdShowNameSeasonsSeasonNumberRouteWithChildren, 302 + } 303 + 304 + const ShowsShowIdShowNameRouteWithChildren = 305 + ShowsShowIdShowNameRoute._addFileChildren(ShowsShowIdShowNameRouteChildren) 306 + 238 307 const rootRouteChildren: RootRouteChildren = { 239 308 IndexRoute: IndexRoute, 240 309 AboutRoute: AboutRoute, ··· 244 313 ListsRoute: ListsRoute, 245 314 LoginRoute: LoginRoute, 246 315 AuthCompleteRoute: AuthCompleteRoute, 247 - MovieIdRoute: MovieIdRoute, 248 - ShowIdRoute: ShowIdRoute, 316 + MoviesMovieIdMovieNameRoute: MoviesMovieIdMovieNameRoute, 317 + ShowsShowIdShowNameRoute: ShowsShowIdShowNameRouteWithChildren, 249 318 } 250 319 export const routeTree = rootRouteImport 251 320 ._addFileChildren(rootRouteChildren)
+3 -2
apps/web/src/routes/calendar.tsx
··· 12 12 Tv, 13 13 } from "lucide-react"; 14 14 import { useEffect, useMemo, useState } from "react"; 15 + import { buildMovieUrl, buildShowUrl } from "#/lib/url-utils"; 15 16 import { useAuth } from "../lib/auth-context"; 16 17 17 18 export const Route = createFileRoute("/calendar")({ ··· 241 242 242 243 const getItemUrl = (item: ReleaseCalendarItemDto) => { 243 244 if (item.mediaType === "movie" && item.movieId) { 244 - return `/movies/${item.movieId}`; 245 + return buildMovieUrl(item.movieId, item.title); 245 246 } 246 247 if (item.mediaType === "show" && item.showId) { 247 - return `/shows/${item.showId}`; 248 + return buildShowUrl(item.showId, item.title); 248 249 } 249 250 return "#"; 250 251 };
+21 -7
apps/web/src/routes/dashboard.tsx
··· 23 23 import { setupApiClient } from "#/lib/api"; 24 24 import { useAuth } from "#/lib/auth-context"; 25 25 import { useDashboardStats, useUserShelf } from "#/lib/hooks"; 26 + import { buildEpisodeUrl, buildMovieUrl, buildShowUrl } from "#/lib/url-utils"; 26 27 import MediaCard from "../components/MediaCard"; 27 28 28 29 // Initialize API client ··· 234 235 } 235 236 // Episode type 236 237 return { 237 - id: item.id, // Use the unique tracked episode ID 238 - showId: item.showId, 239 - title: 238 + id: item.showId, // Use the TMDB show ID for URLs 239 + title: item.showTitle, // Use show title for URL building 240 + displayTitle: 240 241 item.episodeTitle || 241 242 `${item.showTitle} S${item.seasonNumber}E${item.episodeNumber}`, 243 + seasonNumber: item.seasonNumber, 244 + episodeNumber: item.episodeNumber, 242 245 type: "show" as const, 243 246 posterUrl: item.posterPath 244 247 ? `https://image.tmdb.org/t/p/w500${item.posterPath}` ··· 338 341 key={item.id} 339 342 id={item.id} 340 343 title={item.title} 344 + displayTitle={item.displayTitle} 345 + seasonNumber={item.seasonNumber} 346 + episodeNumber={item.episodeNumber} 341 347 posterUrl={item.posterUrl} 342 348 backdropUrl={item.backdropUrl} 343 349 type={item.type} ··· 437 443 <Link 438 444 to={ 439 445 item.type === "movie" 440 - ? `/movies/${item.movieId}` 441 - : `/shows/${item.showId}/seasons/${item.seasonNumber}/episodes/${item.episodeNumber}` 446 + ? buildMovieUrl( 447 + item.movieId || "", 448 + item.title || "", 449 + ) 450 + : buildEpisodeUrl( 451 + item.showId || "", 452 + item.showTitle || "", 453 + item.seasonNumber || 0, 454 + item.episodeNumber || 0, 455 + ) 442 456 } 443 457 className="hover:text-[var(--accent)]" 444 458 > ··· 538 552 key={`${release.showId || release.movieId || release.title}-${release.releaseDate}`} 539 553 to={ 540 554 release.mediaType === "movie" && release.movieId 541 - ? `/movies/${release.movieId}` 555 + ? buildMovieUrl(release.movieId, release.title) 542 556 : release.showId 543 - ? `/shows/${release.showId}` 557 + ? buildShowUrl(release.showId, release.title) 544 558 : "#" 545 559 } 546 560 className="card card-interactive flex items-center gap-3 p-3"
+27 -25
apps/web/src/routes/movie/$id.tsx apps/web/src/routes/movies/$movieId/$movieName.tsx
··· 4 4 listsControllerGetUserListsOptions, 5 5 moviesControllerGetMovieWatchHistoryOptions, 6 6 moviesControllerGetUserMoviesOptions, 7 + moviesControllerMarkWatchedMutation, 8 + moviesControllerUnmarkWatchedMutation, 7 9 } from "@opnshelf/api"; 8 10 import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; 9 11 import { createFileRoute, Link } from "@tanstack/react-router"; ··· 24 26 import { setupApiClient } from "#/lib/api"; 25 27 import { useAuth } from "#/lib/auth-context"; 26 28 import { useDiscoverMovies, useMovieDetails } from "#/lib/hooks"; 27 - import MediaCard from "../../components/MediaCard"; 29 + import MediaCard from "../../../components/MediaCard"; 28 30 29 31 // Initialize API client 30 32 setupApiClient(); 31 33 32 - export const Route = createFileRoute("/movie/$id")({ 34 + export const Route = createFileRoute("/movies/$movieId/$movieName")({ 33 35 component: MovieDetailPage, 34 36 }); 35 37 ··· 49 51 } 50 52 51 53 function MovieDetailPage() { 52 - const { id } = Route.useParams(); 54 + const { movieId } = Route.useParams(); 53 55 const { user, isAuthenticated } = useAuth(); 54 56 const userDid = user?.did; 55 57 const queryClient = useQueryClient(); 56 58 const [showListDropdown, setShowListDropdown] = useState(false); 57 59 58 60 // Fetch movie details from API 59 - const { data: movie, isLoading, error } = useMovieDetails(id); 61 + const { data: movie, isLoading, error } = useMovieDetails(movieId); 60 62 const { data: similarMoviesData } = useDiscoverMovies(1); 61 63 62 64 // Fetch user movies to check if this movie is tracked ··· 70 72 // Check if movie is in user's lists 71 73 const { data: listsForItem } = useQuery({ 72 74 ...listsControllerGetListsForItemOptions({ 73 - path: { mediaType: "movie", mediaId: id }, 75 + path: { mediaType: "movie", mediaId: movieId }, 74 76 }), 75 77 enabled: isAuthenticated, 76 78 }); ··· 78 80 // Fetch watch history for activity section 79 81 const { data: watchHistory } = useQuery({ 80 82 ...moviesControllerGetMovieWatchHistoryOptions({ 81 - path: { userDid: userDid || "", movieId: id }, 83 + path: { userDid: userDid || "", movieId }, 82 84 }), 83 85 enabled: !!userDid, 84 86 }); ··· 93 95 const isWatched = useMemo(() => { 94 96 if (!userMovies || !Array.isArray(userMovies)) return false; 95 97 return userMovies.some( 96 - (um: { movieId: number }) => um.movieId === Number(id), 98 + (um: { movieId: number }) => um.movieId === Number(movieId), 97 99 ); 98 - }, [userMovies, id]); 100 + }, [userMovies, movieId]); 99 101 100 102 const isInWatchlist = useMemo(() => { 101 103 if (!listsForItem || !Array.isArray(listsForItem)) return false; ··· 111 113 112 114 // Mark watched mutation with optimistic update 113 115 const markWatchedMutation = useMutation({ 114 - mutationKey: ["movies", id, "markWatched"], 116 + mutationKey: ["movies", movieId, "markWatched"], 115 117 ...moviesControllerMarkWatchedMutation(), 116 118 onMutate: async () => { 117 119 // Cancel outgoing refetches ··· 135 137 ["moviesControllerGetUserMovies"], 136 138 (old: unknown) => { 137 139 if (!old || !Array.isArray(old)) return old; 138 - return [...old, { movieId: Number(id), title: movie?.title }]; 140 + return [...old, { movieId: Number(movieId), title: movie?.title }]; 139 141 }, 140 142 ); 141 143 ··· 172 174 173 175 // Unmark watched mutation with optimistic update 174 176 const unmarkWatchedMutation = useMutation({ 175 - mutationKey: ["movies", id, "unmarkWatched"], 177 + mutationKey: ["movies", movieId, "unmarkWatched"], 176 178 ...moviesControllerUnmarkWatchedMutation(), 177 179 onMutate: async () => { 178 180 await queryClient.cancelQueries({ ··· 187 189 (old: unknown) => { 188 190 if (!old || !Array.isArray(old)) return old; 189 191 return old.filter( 190 - (m: { movieId: number }) => m.movieId !== Number(id), 192 + (m: { movieId: number }) => m.movieId !== Number(movieId), 191 193 ); 192 194 }, 193 195 ); ··· 229 231 const handleMarkWatched = () => { 230 232 if (!isAuthenticated) return; 231 233 markWatchedMutation.mutate({ 232 - body: { movieId: Number(id) }, 234 + body: { movieId: Number(movieId) }, 233 235 }); 234 236 }; 235 237 236 238 const handleUnmarkWatched = () => { 237 239 if (!isAuthenticated) return; 238 240 unmarkWatchedMutation.mutate({ 239 - path: { movieId: id }, 241 + path: { movieId: movieId }, 240 242 }); 241 243 }; 242 244 ··· 246 248 path: { slug }, 247 249 body: { 248 250 mediaType: "movie", 249 - mediaId: Number(id), 251 + mediaId: Number(movieId), 250 252 title: movie?.title || "", 251 253 }, 252 254 }); ··· 311 313 // Get similar movies from discover API, excluding current movie 312 314 const similarMovies = 313 315 similarMoviesData?.results 314 - ?.filter((m) => m.id !== Number(id)) 316 + ?.filter((m) => m.id !== Number(movieId)) 315 317 ?.slice(0, 4) 316 318 ?.map((m) => ({ 317 319 id: m.id, ··· 561 563 562 564 {/* Main Content */} 563 565 <div className="container-app relative z-20 mt-8"> 564 - <div className="grid gap-8 lg:grid-cols-[2fr_1fr]"> 566 + <div className="grid gap-8 lg:grid-cols-[2fr_1fr] lg:gap-12"> 565 567 {/* Left Column */} 566 568 <div className="space-y-8"> 567 569 {/* Overview */} ··· 603 605 <section> 604 606 <h2 className="text-display-3 mb-4">Similar Movies</h2> 605 607 <div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4"> 606 - {similarMovies.map((movie) => ( 608 + {similarMovies.map((similarMovie) => ( 607 609 <MediaCard 608 - key={movie.id} 609 - id={movie.id} 610 - title={movie.title} 611 - posterUrl={movie.posterUrl} 612 - type={movie.type} 613 - year={movie.year} 614 - rating={movie.rating} 610 + key={similarMovie.id} 611 + id={similarMovie.id} 612 + title={similarMovie.title} 613 + posterUrl={similarMovie.posterUrl} 614 + type={similarMovie.type} 615 + year={similarMovie.year} 616 + rating={similarMovie.rating} 615 617 size="sm" 616 618 layout="poster" 617 619 />
+72 -29
apps/web/src/routes/show/$id.tsx apps/web/src/routes/shows/$showId/$showName.tsx
··· 4 4 showsControllerGetShowWatchHistoryOptions, 5 5 } from "@opnshelf/api"; 6 6 import { useQuery } from "@tanstack/react-query"; 7 - import { createFileRoute, Link } from "@tanstack/react-router"; 7 + import { 8 + createFileRoute, 9 + Link, 10 + Outlet, 11 + useMatches, 12 + } from "@tanstack/react-router"; 8 13 import { 9 14 Check, 10 15 ChevronDown, ··· 26 31 useShowDetails, 27 32 useUserUpNext, 28 33 } from "#/lib/hooks"; 29 - import MediaCard from "../../components/MediaCard"; 34 + import { slugifyName } from "#/lib/url-utils"; 35 + import MediaCard from "../../../components/MediaCard"; 30 36 31 37 // Initialize API client 32 38 setupApiClient(); 33 39 34 - export const Route = createFileRoute("/show/$id")({ 40 + export const Route = createFileRoute("/shows/$showId/$showName")({ 35 41 component: ShowDetailPage, 36 42 }); 37 43 ··· 69 75 } 70 76 71 77 function ShowDetailPage() { 72 - const { id } = Route.useParams(); 78 + const { showId } = Route.useParams(); 79 + const matches = useMatches(); 73 80 const { user, isAuthenticated } = useAuth(); 74 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 75 93 76 94 const [expandedSeason, setExpandedSeason] = useState<number | null>(1); 77 95 ··· 80 98 data: show, 81 99 isLoading: showLoading, 82 100 error: showError, 83 - } = useShowDetails(id); 101 + } = useShowDetails(showId); 84 102 85 103 // Fetch user's watch history for this specific show 86 104 const { data: watchHistory } = useQuery({ 87 105 ...showsControllerGetShowWatchHistoryOptions({ 88 - path: { userDid: userDid || "", showId: id }, 106 + path: { userDid: userDid || "", showId }, 89 107 }), 90 - enabled: !!userDid && !!id, 108 + enabled: !!userDid && !!showId, 91 109 }); 92 110 93 111 // Fetch lists containing this show 94 112 const { data: listsForItem } = useQuery({ 95 113 ...listsControllerGetListsForItemOptions({ 96 - path: { mediaType: "show", mediaId: id }, 114 + path: { mediaType: "show", mediaId: showId }, 97 115 }), 98 - enabled: !!id, 116 + enabled: !!showId, 99 117 }); 100 118 101 119 // Count how many lists this show is actually in ··· 110 128 111 129 // Fetch season details when expanded 112 130 const { data: seasonDetails, isLoading: seasonLoading } = useSeasonDetails( 113 - id, 131 + showId, 114 132 expandedSeason, 115 133 ); 116 134 117 135 // Mark episode watched mutation 118 136 const markWatchedMutation = useMarkEpisodeWatched(); 119 137 138 + // If on season or episode page, render only the outlet (child component) 139 + if (isChildRoute) { 140 + return <Outlet />; 141 + } 142 + 120 143 // Check if user tracks this show (based on watch history) 121 144 const isTracking = !!watchHistory && watchHistory.length > 0; 122 145 123 146 // Find up next episode for this show 124 - const upNextForShow = upNextData?.items?.find((item) => item.showId === id); 147 + const upNextForShow = upNextData?.items?.find( 148 + (item) => item.showId === showId, 149 + ); 125 150 const nextEpisode = upNextForShow?.nextEpisode; 126 151 127 152 // Calculate watched episodes from watch history ··· 185 210 // Get similar shows from discover API, excluding current show 186 211 const similarShows = 187 212 discoverShowsData?.results 188 - ?.filter((s) => s.id !== Number(id)) 213 + ?.filter((s) => s.id !== Number(showId)) 189 214 ?.slice(0, 4) 190 215 ?.map((s) => ({ 191 216 id: s.id, ··· 206 231 207 232 markWatchedMutation.mutate({ 208 233 body: { 209 - showId: id, 234 + showId, 210 235 seasonNumber, 211 236 episodeNumber, 212 237 }, ··· 403 428 404 429 {/* Main Content */} 405 430 <div className="container-app relative z-20 mt-8"> 406 - <div className="grid gap-8 lg:grid-cols-[2fr_1fr]"> 431 + <div className="grid gap-8 lg:grid-cols-[2fr_1fr] lg:gap-12"> 407 432 {/* Left Column */} 408 433 <div className="space-y-8"> 409 434 {/* Overview */} ··· 424 449 .map((season) => ( 425 450 <div key={season.id} className="card overflow-hidden"> 426 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> 427 470 <button 428 471 type="button" 429 472 onClick={() => ··· 433 476 : season.season_number, 434 477 ) 435 478 } 436 - className="flex w-full items-center justify-between p-4 text-left transition-colors hover:bg-[var(--background-subtle)]" 479 + className="flex items-center justify-center p-4 text-[var(--foreground-muted)] transition-colors hover:bg-[var(--background-subtle)]" 437 480 > 438 - <div> 439 - <h3 className="font-semibold"> 440 - {season.name || `Season ${season.season_number}`} 441 - </h3> 442 - <p className="text-sm text-[var(--foreground-muted)]"> 443 - {season.episode_count || 0} episodes 444 - </p> 445 - </div> 446 481 <ChevronDown 447 - className={`h-5 w-5 text-[var(--foreground-muted)] transition-transform ${ 482 + className={`h-5 w-5 transition-transform ${ 448 483 expandedSeason === season.season_number 449 484 ? "rotate-180" 450 485 : "" ··· 471 506 ); 472 507 473 508 return ( 474 - <div 509 + <Link 475 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 + }} 476 518 className={`flex items-center gap-4 p-4 transition-colors ${ 477 519 isCurrent 478 520 ? "bg-[var(--accent-subtle)]" ··· 512 554 {!isWatched && isAuthenticated && ( 513 555 <button 514 556 type="button" 515 - onClick={() => 557 + onClick={(e) => { 558 + e.preventDefault(); 516 559 handleMarkWatched( 517 560 season.season_number, 518 561 episode.episode_number, 519 - ) 520 - } 562 + ); 563 + }} 521 564 disabled={markWatchedMutation.isPending} 522 565 className="btn btn-secondary h-8 px-3 text-xs" 523 566 > ··· 528 571 )} 529 572 </button> 530 573 )} 531 - </div> 574 + </Link> 532 575 ); 533 576 }) 534 577 ) : (
+80
apps/web/src/routes/shows/$showId/$showName/seasons.$seasonNumber.episodes.$episodeNumber.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/episodes/$episodeNumber", 8 + )({ 9 + component: EpisodeDetailPage, 10 + }); 11 + 12 + function EpisodeDetailPage() { 13 + const { showId, showName, seasonNumber, episodeNumber } = 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 + const episodeNum = Number.parseInt(episodeNumber, 10); 20 + 21 + // Find the episode in show data 22 + const episode = show?.seasons 23 + ?.find((s) => s.season_number === seasonNum) 24 + ?.episodes?.find((e) => e.episode_number === episodeNum); 25 + 26 + if (isLoading) { 27 + return ( 28 + <div className="flex h-screen items-center justify-center"> 29 + <Loader2 className="h-12 w-12 animate-spin text-[var(--accent)]" /> 30 + </div> 31 + ); 32 + } 33 + 34 + return ( 35 + <div className="min-h-screen pb-8"> 36 + <div className="container-app py-8"> 37 + {/* Back Button */} 38 + <Link 39 + to={buildShowUrl(showId, show?.name || showName)} 40 + className="btn btn-secondary mb-6 inline-flex gap-2" 41 + > 42 + <ChevronLeft className="h-4 w-4" /> 43 + Back to {show?.name || showName} 44 + </Link> 45 + 46 + {/* Placeholder Content */} 47 + <div className="card p-8 text-center"> 48 + <h1 className="text-display-2 mb-4">Episode Detail - Placeholder</h1> 49 + <p className="text-[var(--foreground-muted)] mb-4"> 50 + This is a placeholder for the episode detail page. 51 + </p> 52 + <div className="space-y-2 text-sm text-[var(--foreground-muted)]"> 53 + <p> 54 + <strong>Show ID:</strong> {showId} 55 + </p> 56 + <p> 57 + <strong>Show Name:</strong> {show?.name || showName} 58 + </p> 59 + <p> 60 + <strong>Season:</strong> {seasonNum} 61 + </p> 62 + <p> 63 + <strong>Episode:</strong> {episodeNum} 64 + </p> 65 + {episode && ( 66 + <> 67 + <p> 68 + <strong>Episode Name:</strong> {episode.name} 69 + </p> 70 + <p> 71 + <strong>Air Date:</strong> {episode.air_date || "Unknown"} 72 + </p> 73 + </> 74 + )} 75 + </div> 76 + </div> 77 + </div> 78 + </div> 79 + ); 80 + }
+74
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"; 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 + 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 + ); 74 + }
+9
pnpm-lock.yaml
··· 77 77 react-dom: 78 78 specifier: ^19.2.0 79 79 version: 19.2.4(react@19.2.4) 80 + slugify: 81 + specifier: ^1.6.9 82 + version: 1.6.9 80 83 tailwind-merge: 81 84 specifier: ^3.0.2 82 85 version: 3.4.0 ··· 6104 6107 slash@3.0.0: 6105 6108 resolution: {integrity: sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==} 6106 6109 engines: {node: '>=8'} 6110 + 6111 + slugify@1.6.9: 6112 + resolution: {integrity: sha512-vZ7rfeehZui7wQs438JXBckYLkIIdfHOXsaVEUMyS5fHo1483l1bMdo0EDSWYclY0yZKFOipDy4KHuKs6ssvdg==} 6113 + engines: {node: '>=8.0.0'} 6107 6114 6108 6115 solid-js@1.9.11: 6109 6116 resolution: {integrity: sha512-WEJtcc5mkh/BnHA6Yrg4whlF8g6QwpmXXRg4P2ztPmcKeHHlH4+djYecBLhSpecZY2RRECXYUwIc/C2r3yzQ4Q==} ··· 13508 13515 simple-xml-to-json@1.2.3: {} 13509 13516 13510 13517 slash@3.0.0: {} 13518 + 13519 + slugify@1.6.9: {} 13511 13520 13512 13521 solid-js@1.9.11: 13513 13522 dependencies: