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: episode titles in shelf, UI improvements, and API updates

- Add episodeTitle to shelf endpoint (backend)
- Show episode titles in Your Shelf section (frontend)
- Rename 'Your Library' to 'Your Shelf'
- Swap show/episode title display order
- Replace Recent Activity with social feed
- Remove Following section from dashboard
- Remove Quick Actions from dashboard
- Fix button styling (replace .btn classes with explicit Tailwind)
- Fix mobile menu overlay positioning
- Fix getErrorMessage initialization error in auth/complete
- Add custom error screens (404 and error boundary)

+285 -273
+3 -2
apps/web/src/lib/hooks/useMedia.ts
··· 70 70 }); 71 71 } 72 72 73 - // User's tracked shows 74 - export function useUserShows(userDid: string) { 73 + // User's tracked shows with pagination 74 + export function useUserShows(userDid: string, pageSize = 20) { 75 75 return useQuery({ 76 76 ...showsControllerGetUserShowsOptions({ 77 77 path: { userDid }, 78 + query: { page: 1, pageSize }, 78 79 }), 79 80 enabled: !!userDid, 80 81 });
+15 -14
apps/web/src/routes/auth/complete.tsx
··· 16 16 ); 17 17 const [errorMessage, setErrorMessage] = useState(""); 18 18 19 + // Helper function to get error message - defined before useEffect 20 + const getErrorMessage = (error: string): string => { 21 + switch (error) { 22 + case "handle_required": 23 + return "Please provide your handle to sign in."; 24 + case "auth_failed": 25 + return "Authentication failed. Please check your handle and try again."; 26 + case "callback_failed": 27 + return "Something went wrong during authentication. Please try again."; 28 + default: 29 + return "An unexpected error occurred. Please try again."; 30 + } 31 + }; 32 + 19 33 useEffect(() => { 20 34 // Check for error in query params 21 35 const error = (search as { error?: string }).error; ··· 40 54 setStatus("error"); 41 55 setErrorMessage("Failed to complete authentication. Please try again."); 42 56 }); 43 - }, [queryClient, search, getErrorMessage]); 44 - 45 - const getErrorMessage = (error: string): string => { 46 - switch (error) { 47 - case "handle_required": 48 - return "Please provide your handle to sign in."; 49 - case "auth_failed": 50 - return "Authentication failed. Please check your handle and try again."; 51 - case "callback_failed": 52 - return "Something went wrong during authentication. Please try again."; 53 - default: 54 - return "An unexpected error occurred. Please try again."; 55 - } 56 - }; 57 + }, [queryClient, search]); 57 58 58 59 return ( 59 60 <div className="container-app flex min-h-[calc(100vh-4rem)] items-center justify-center py-12">
+6 -1
apps/web/src/routes/calendar.tsx
··· 107 107 108 108 const today = new Date(); 109 109 today.setHours(0, 0, 0, 0); 110 + const twoWeeksLater = new Date(today); 111 + twoWeeksLater.setDate(today.getDate() + 14); 110 112 111 113 return calendarData.items 112 - .filter((item) => new Date(item.releaseDate) >= today) 114 + .filter((item) => { 115 + const releaseDate = new Date(item.releaseDate); 116 + return releaseDate >= today && releaseDate <= twoWeeksLater; 117 + }) 113 118 .sort( 114 119 (a, b) => 115 120 new Date(a.releaseDate).getTime() - new Date(b.releaseDate).getTime(),
+137 -164
apps/web/src/routes/index.tsx
··· 1 - import type { ReleaseCalendarItemDto } from "@opnshelf/api"; 1 + import type { FollowedActivityItemDto, ReleaseCalendarItemDto } from "@opnshelf/api"; 2 2 import { 3 3 showsControllerGetUserReleaseCalendarOptions, 4 - socialControllerGetFollowingOptions, 4 + socialControllerGetFeedOptions, 5 5 } from "@opnshelf/api"; 6 6 import { useQuery } from "@tanstack/react-query"; 7 7 import { createFileRoute, Link } from "@tanstack/react-router"; ··· 10 10 ChevronRight, 11 11 Clock, 12 12 Film, 13 + Heart, 13 14 Loader2, 15 + MessageCircle, 14 16 TrendingUp, 15 17 Tv, 16 18 Users, ··· 22 24 useDiscoverMovies, 23 25 useDiscoverShows, 24 26 useUserShelf, 25 - useUserShelfActivity, 26 27 } from "#/lib/hooks"; 27 28 import MediaCard from "../components/MediaCard"; 28 29 ··· 69 70 return date.toLocaleDateString("en-US", { month: "short", day: "numeric" }); 70 71 } 71 72 73 + // Helper function to format relative time for social feed 74 + function formatRelativeTime(dateString: string): string { 75 + const date = new Date(dateString); 76 + const now = new Date(); 77 + const diffInSeconds = Math.floor((now.getTime() - date.getTime()) / 1000); 78 + 79 + if (diffInSeconds < 60) return "Just now"; 80 + if (diffInSeconds < 3600) 81 + return `${Math.floor(diffInSeconds / 60)} minutes ago`; 82 + if (diffInSeconds < 86400) 83 + return `${Math.floor(diffInSeconds / 3600)} hours ago`; 84 + if (diffInSeconds < 604800) 85 + return `${Math.floor(diffInSeconds / 86400)} days ago`; 86 + return date.toLocaleDateString(); 87 + } 88 + 72 89 // Helper function to get episode info 73 90 function getEpisodeInfo(item: ReleaseCalendarItemDto): string | undefined { 74 91 if (item.releaseKind === "episode" && item.seasonNumber !== undefined) { ··· 130 147 const { data: statsData, isLoading: statsLoading } = useDashboardStats( 131 148 userDid || "", 132 149 ); 133 - const { data: recentActivity, isLoading: activityLoading } = 134 - useUserShelfActivity(userDid || "", user?.displayName || "You"); 135 150 136 - // Fetch following data 137 - const { data: followingData, isLoading: followingLoading } = useQuery({ 138 - ...socialControllerGetFollowingOptions({ 139 - path: { handle: user?.handle || "" }, 151 + // Fetch social activity feed 152 + const { data: feedData, isLoading: feedLoading } = useQuery({ 153 + ...socialControllerGetFeedOptions({ 140 154 query: { pageSize: 10 }, 141 155 }), 142 - enabled: !!user?.handle, 156 + enabled: !!userDid, 143 157 }); 144 158 145 - const following = followingData?.items || []; 146 - const followingCount = following.length; 147 - const displayFollowing = following.slice(0, 4); 148 - const remainingCount = Math.max(0, followingCount - 4); 149 - 150 159 // Fetch release calendar data 151 160 const { data: calendarData, isLoading: calendarLoading } = useQuery({ 152 161 ...showsControllerGetUserReleaseCalendarOptions({ ··· 155 164 enabled: !!user?.did, 156 165 }); 157 166 158 - // Get upcoming releases - filter to next 10 upcoming 167 + // Get upcoming releases - filter to next two weeks, limit to 10 159 168 const upcomingReleases = calendarData?.items 160 169 ? calendarData.items 161 - .filter((item) => new Date(item.releaseDate) >= new Date()) 170 + .filter((item) => { 171 + const releaseDate = new Date(item.releaseDate); 172 + const today = new Date(); 173 + today.setHours(0, 0, 0, 0); 174 + const twoWeeksLater = new Date(today); 175 + twoWeeksLater.setDate(today.getDate() + 14); 176 + return releaseDate >= today && releaseDate <= twoWeeksLater; 177 + }) 162 178 .sort( 163 179 (a, b) => 164 180 new Date(a.releaseDate).getTime() - ··· 172 188 showsLoading || 173 189 shelfLoading || 174 190 statsLoading || 175 - activityLoading || 191 + feedLoading || 176 192 authLoading || 177 193 calendarLoading; 178 194 const hasError = moviesError || showsError; ··· 272 288 } 273 289 // Episode type 274 290 return { 275 - id: item.showId, 276 - title: item.showTitle, 291 + id: item.id, // Use the unique tracked episode ID 292 + showId: item.showId, 293 + title: item.episodeTitle || `${item.showTitle} S${item.seasonNumber}E${item.episodeNumber}`, 277 294 type: "show" as const, 278 295 posterUrl: item.posterPath 279 296 ? `https://image.tmdb.org/t/p/w500${item.posterPath}` ··· 282 299 ? `https://image.tmdb.org/t/p/original${item.backdropPath}` 283 300 : undefined, 284 301 year: item.firstAirYear, 285 - episodeInfo: `S${item.seasonNumber}E${item.episodeNumber}`, 302 + episodeInfo: `${item.showTitle} • S${item.seasonNumber}E${item.episodeNumber}`, 286 303 isWatched: !!item.watchedDate, 287 304 watchedDate: item.watchedDate, 288 305 }; ··· 365 382 <section> 366 383 <div className="mb-4 flex items-center justify-between"> 367 384 <h2 className="text-display-3"> 368 - {userContent.length > 0 ? "Your Library" : "Featured For You"} 385 + {userContent.length > 0 ? "Your Shelf" : "Featured For You"} 369 386 </h2> 370 387 <Link 371 388 to="/shelf" ··· 382 399 </div> 383 400 ) : displayContent.length > 0 ? ( 384 401 <div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-3"> 385 - {displayContent.map((item) => ( 386 - <MediaCard 387 - key={item.id} 388 - id={item.id} 389 - title={item.title} 390 - posterUrl={item.posterUrl} 391 - backdropUrl={item.backdropUrl} 392 - type={item.type} 393 - year={item.year} 394 - watchedDate={ 395 - item.watchedDate 396 - ? formatWatchedDate(item.watchedDate) 397 - : undefined 398 - } 399 - layout="backdrop" 400 - size="md" 401 - /> 402 - ))} 402 + {displayContent.map((item) => ( 403 + <MediaCard 404 + key={item.id} 405 + id={item.id} 406 + title={item.title} 407 + posterUrl={item.posterUrl} 408 + backdropUrl={item.backdropUrl} 409 + type={item.type} 410 + year={item.year} 411 + episodeInfo={item.episodeInfo} 412 + watchedDate={ 413 + item.watchedDate 414 + ? formatWatchedDate(item.watchedDate) 415 + : undefined 416 + } 417 + layout="backdrop" 418 + size="md" 419 + /> 420 + ))} 403 421 </div> 404 422 ) : ( 405 423 <div className="card p-8 text-center"> ··· 413 431 )} 414 432 </section> 415 433 416 - {/* Recent Activity - Real data from API */} 434 + {/* Social Feed - Activity from people you follow */} 417 435 <section> 418 - <h2 className="text-display-3 mb-4">Recent Activity</h2> 419 - {activityLoading ? ( 436 + <div className="mb-4 flex items-center justify-between"> 437 + <h2 className="text-display-3">Friend Activity</h2> 438 + <Link 439 + to="/following" 440 + className="flex items-center gap-1 text-sm font-medium text-[var(--accent)] hover:text-[var(--accent-hover)]" 441 + > 442 + <Users className="h-4 w-4" /> 443 + View all 444 + </Link> 445 + </div> 446 + {feedLoading ? ( 420 447 <div className="card p-8"> 421 448 <div className="space-y-3"> 422 449 {[1, 2, 3].map((i) => ( ··· 424 451 key={i} 425 452 className="flex items-center gap-3 animate-pulse" 426 453 > 427 - <div className="h-10 w-10 rounded-lg bg-[var(--background-subtle)]" /> 454 + <div className="h-10 w-10 rounded-full bg-[var(--background-subtle)]" /> 428 455 <div className="flex-1 space-y-1"> 429 456 <div className="h-4 w-1/2 rounded bg-[var(--background-subtle)]" /> 430 457 <div className="h-3 w-1/3 rounded bg-[var(--background-subtle)]" /> ··· 433 460 ))} 434 461 </div> 435 462 </div> 436 - ) : recentActivity && recentActivity.length > 0 ? ( 463 + ) : feedData?.items && feedData.items.length > 0 ? ( 437 464 <div className="card divide-y divide-[var(--border)]"> 438 - {recentActivity.map((activity) => ( 465 + {feedData.items.map((item: FollowedActivityItemDto) => ( 439 466 <div 440 - key={activity.id} 441 - className="flex items-center gap-3 p-3 first:pt-4 last:pb-4" 467 + key={item.id} 468 + className="flex items-start gap-3 p-4 first:pt-5 last:pb-5" 442 469 > 443 - <div 444 - className={`flex h-10 w-10 shrink-0 items-center justify-center rounded-lg ${activity.type === "movie" ? "bg-[var(--background-subtle)]" : "bg-[var(--accent-subtle)]"} ${activity.type === "movie" ? "text-[var(--foreground-muted)]" : "text-[var(--accent)]"}`} 445 - > 446 - {activity.type === "movie" ? ( 447 - <Film className="h-5 w-5" /> 448 - ) : ( 449 - <Tv className="h-5 w-5" /> 450 - )} 451 - </div> 470 + {/* User Avatar */} 471 + <img 472 + src={item.actor.avatar || `https://i.pravatar.cc/150?u=${item.actor.did}`} 473 + alt={item.actor.displayName || item.actor.handle} 474 + className="h-10 w-10 rounded-full object-cover" 475 + /> 452 476 <div className="flex-1 min-w-0"> 453 - <p className="font-medium text-sm truncate"> 454 - {activity.title} 477 + {/* Activity Header */} 478 + <p className="text-sm"> 479 + <Link 480 + to={`/profile/${item.actor.handle}`} 481 + className="font-medium hover:text-[var(--accent)]" 482 + > 483 + {item.actor.displayName || item.actor.handle} 484 + </Link>{" "} 485 + {item.verb === "watch" && ( 486 + <span className="text-[var(--foreground-muted)]">watched</span> 487 + )} 488 + {item.verb === "follow" && ( 489 + <span className="text-[var(--foreground-muted)]">followed</span> 490 + )} 491 + {item.verb === "list_add" && ( 492 + <span className="text-[var(--foreground-muted)]">added to list</span> 493 + )} 455 494 </p> 456 - <p className="text-xs text-[var(--foreground-muted)]"> 457 - <span className="text-[var(--accent)]"> 458 - {activity.user} 459 - </span>{" "} 460 - {activity.action} {activity.date} 495 + {/* Content Title */} 496 + <p className="font-medium text-sm mt-0.5"> 497 + <Link 498 + to={`/${item.content.type}/${item.content.id}`} 499 + className="hover:text-[var(--accent)]" 500 + > 501 + {item.content.title} 502 + {item.content.type === "episode" && item.content.episodeTitle && ( 503 + <span className="text-[var(--foreground-muted)]"> 504 + {" "} 505 + (S{item.content.seasonNumber}E 506 + {item.content.episodeNumber}) 507 + </span> 508 + )} 509 + </Link> 461 510 </p> 511 + {/* Timestamp & Actions */} 512 + <div className="flex items-center gap-3 mt-1.5 text-xs text-[var(--foreground-muted)]"> 513 + <span>{formatRelativeTime(item.createdAt)}</span> 514 + {item.verb === "watch" && ( 515 + <button 516 + type="button" 517 + className="flex items-center gap-1 hover:text-[var(--accent)]" 518 + > 519 + <Heart className="h-3 w-3" /> 520 + Like 521 + </button> 522 + )} 523 + </div> 462 524 </div> 525 + {/* Content Type Badge */} 463 526 <span 464 - className={`badge ${activity.type === "movie" ? "badge-subtle" : "badge-accent"}`} 527 + className={`badge ${item.content.type === "movie" ? "badge-subtle" : "badge-accent"}`} 465 528 > 466 - {activity.type === "movie" ? "Movie" : "TV"} 529 + {item.content.type === "movie" ? "Movie" : "TV"} 467 530 </span> 468 531 </div> 469 532 ))} 470 533 </div> 471 534 ) : ( 472 535 <div className="card p-8 text-center"> 536 + <MessageCircle className="h-12 w-12 mx-auto mb-3 text-[var(--foreground-muted)]" /> 473 537 <p className="text-[var(--foreground-muted)]"> 474 - Your recent activity will appear here once you start tracking 475 - movies and shows. 538 + Activity from people you follow will appear here. 476 539 </p> 477 - <Link to="/search" className="btn btn-primary mt-4 inline-flex"> 478 - Start Tracking 540 + <Link 541 + to="/following" 542 + className="btn btn-primary mt-4 inline-flex" 543 + > 544 + <Users className="h-4 w-4 mr-2" /> 545 + Find people to follow 479 546 </Link> 480 547 </div> 481 548 )} ··· 581 648 ))} 582 649 </div> 583 650 )} 584 - </section> 585 - 586 - {/* Quick Actions */} 587 - <section> 588 - <h2 className="text-display-3 mb-4">Quick Actions</h2> 589 - <div className="space-y-2"> 590 - <Link 591 - to="/search" 592 - className="btn btn-primary w-full justify-start gap-2" 593 - > 594 - <Film className="h-4 w-4" /> 595 - Find a Movie 596 - </Link> 597 - <Link 598 - to="/search" 599 - className="btn btn-secondary w-full justify-start gap-2" 600 - > 601 - <Tv className="h-4 w-4" /> 602 - Find a Show 603 - </Link> 604 - <Link 605 - to="/lists" 606 - className="btn btn-ghost w-full justify-start gap-2" 607 - > 608 - <ChevronRight className="h-4 w-4" /> 609 - View Your Lists 610 - </Link> 611 - </div> 612 - </section> 613 - 614 - {/* Following Preview */} 615 - <section> 616 - <div className="mb-4 flex items-center justify-between"> 617 - <h2 className="text-display-3">Following</h2> 618 - <Link 619 - to="/following" 620 - className="flex items-center gap-1 text-sm font-medium text-[var(--accent)] hover:text-[var(--accent-hover)]" 621 - > 622 - View all 623 - <ChevronRight className="h-4 w-4" /> 624 - </Link> 625 - </div> 626 - <div className="card p-4"> 627 - {followingLoading ? ( 628 - <div className="flex items-center gap-3"> 629 - <Loader2 className="h-5 w-5 animate-spin text-[var(--accent)]" /> 630 - <span className="text-sm text-[var(--foreground-muted)]"> 631 - Loading... 632 - </span> 633 - </div> 634 - ) : followingCount === 0 ? ( 635 - <> 636 - <p className="text-sm text-[var(--foreground-muted)]"> 637 - Start following people to see what they&apos;re watching. 638 - </p> 639 - <Link 640 - to="/following" 641 - className="mt-3 btn btn-secondary w-full text-sm" 642 - > 643 - <Users className="h-4 w-4" /> 644 - Find people to follow 645 - </Link> 646 - </> 647 - ) : ( 648 - <> 649 - <p className="text-sm text-[var(--foreground-muted)]"> 650 - You&apos;re following {followingCount}{" "} 651 - {followingCount === 1 ? "friend" : "friends"} 652 - </p> 653 - <Link 654 - to="/following" 655 - className="mt-3 flex -space-x-2 cursor-pointer" 656 - > 657 - {displayFollowing.map((friend) => ( 658 - <img 659 - key={friend.did} 660 - src={ 661 - friend.avatar 662 - ? String(friend.avatar) 663 - : `https://i.pravatar.cc/150?u=${friend.did}` 664 - } 665 - alt={String(friend.displayName) || friend.handle} 666 - className="h-8 w-8 rounded-full border-2 border-[var(--background)] object-cover" 667 - /> 668 - ))} 669 - {remainingCount > 0 && ( 670 - <div className="flex h-8 w-8 items-center justify-center rounded-full border-2 border-[var(--background)] bg-[var(--accent)] text-xs font-medium text-white"> 671 - +{remainingCount} 672 - </div> 673 - )} 674 - </Link> 675 - </> 676 - )} 677 - </div> 678 651 </section> 679 652 </div> 680 653 </div>
+2 -2
apps/web/src/routes/movie/$id.tsx
··· 329 329 return ( 330 330 <div className="min-h-screen pb-8"> 331 331 {/* Hero Section with Backdrop */} 332 - <div className="relative"> 332 + <div className="relative z-10 min-h-[50vh] overflow-hidden"> 333 333 {/* Backdrop Image */} 334 334 <div className="absolute inset-0 h-[60vh] overflow-hidden"> 335 335 <img ··· 560 560 </div> 561 561 562 562 {/* Main Content */} 563 - <div className="container-app"> 563 + <div className="container-app relative z-20 mt-8"> 564 564 <div className="grid gap-8 lg:grid-cols-[2fr_1fr]"> 565 565 {/* Left Column */} 566 566 <div className="space-y-8">
+56 -40
apps/web/src/routes/show/$id.tsx
··· 1 - import { showsControllerGetSeasonDetailsOptions } from "@opnshelf/api"; 1 + import { 2 + showsControllerGetSeasonDetailsOptions, 3 + showsControllerGetShowWatchHistoryOptions, 4 + listsControllerGetListsForItemOptions, 5 + } from "@opnshelf/api"; 2 6 import { useQuery } from "@tanstack/react-query"; 3 7 import { createFileRoute, Link } from "@tanstack/react-router"; 4 8 import { ··· 20 24 useDiscoverShows, 21 25 useMarkEpisodeWatched, 22 26 useShowDetails, 23 - useUserShows, 24 27 useUserUpNext, 25 28 } from "#/lib/hooks"; 26 29 import MediaCard from "../../components/MediaCard"; ··· 79 82 error: showError, 80 83 } = useShowDetails(id); 81 84 82 - // Fetch user's tracked shows 83 - const { data: userShowsData } = useUserShows(userDid || ""); 85 + // Fetch user's watch history for this specific show 86 + const { data: watchHistory } = useQuery({ 87 + ...showsControllerGetShowWatchHistoryOptions({ 88 + path: { userDid: userDid || "", showId: id }, 89 + }), 90 + enabled: !!userDid && !!id, 91 + }); 92 + 93 + // Fetch lists containing this show 94 + const { data: listsForItem } = useQuery({ 95 + ...listsControllerGetListsForItemOptions({ 96 + path: { mediaType: "show", mediaId: id }, 97 + }), 98 + enabled: !!id, 99 + }); 100 + 101 + // Count how many lists this show is actually in 102 + const listsContainingShow = 103 + listsForItem?.filter((list) => list.isInList) || []; 84 104 85 105 // Fetch user's up next episodes 86 106 const { data: upNextData } = useUserUpNext(userDid || ""); ··· 97 117 // Mark episode watched mutation 98 118 const markWatchedMutation = useMarkEpisodeWatched(); 99 119 100 - // Check if user tracks this show 101 - const trackedShow = userShowsData?.find((s) => s.showId === id); 102 - const isTracking = !!trackedShow; 120 + // Check if user tracks this show (based on watch history) 121 + const isTracking = !!watchHistory && watchHistory.length > 0; 103 122 104 123 // Find up next episode for this show 105 124 const upNextForShow = upNextData?.items?.find((item) => item.showId === id); 106 125 const nextEpisode = upNextForShow?.nextEpisode; 107 126 108 - // Calculate watched episodes 109 - const episodesWatched = upNextForShow?.episodesWatched || 0; 110 - const totalEpisodes = 111 - upNextForShow?.totalEpisodes || show?.number_of_episodes || 0; 127 + // Calculate watched episodes from watch history 128 + const episodesWatched = watchHistory?.length || 0; 129 + const totalEpisodes = show?.number_of_episodes || 0; 112 130 const progressPercentage = 113 131 totalEpisodes > 0 ? (episodesWatched / totalEpisodes) * 100 : 0; 114 132 const episodesRemaining = totalEpisodes - episodesWatched; ··· 214 232 ); 215 233 }; 216 234 217 - // Check if an episode has been watched (estimate based on up next data) 235 + // Check if an episode has been watched using watch history 218 236 const isEpisodeWatched = (seasonNum: number, episodeNum: number) => { 219 - // This is a simplified check - in a real app you'd fetch the actual watch history 220 - // For now, we estimate based on the next episode position 221 - if (!upNextForShow) return false; 222 - 223 - const lastWatchedSeason = upNextForShow.lastWatched?.seasonNumber || 1; 224 - const lastWatchedEpisode = upNextForShow.lastWatched?.episodeNumber || 0; 237 + if (!watchHistory || watchHistory.length === 0) return false; 225 238 226 - if (seasonNum < lastWatchedSeason) return true; 227 - if (seasonNum === lastWatchedSeason && episodeNum <= lastWatchedEpisode) 228 - return true; 229 - return false; 239 + return watchHistory.some( 240 + (ep) => 241 + ep.seasonNumber === seasonNum && ep.episodeNumber === episodeNum, 242 + ); 230 243 }; 231 244 232 245 return ( 233 246 <div className="min-h-screen pb-8"> 234 247 {/* Hero Section with Backdrop */} 235 - <div className="relative"> 248 + <div className="relative z-10 min-h-[50vh] overflow-hidden"> 236 249 {/* Backdrop Image */} 237 250 <div className="absolute inset-0 h-[60vh] overflow-hidden"> 238 251 {backdropUrl ? ( ··· 390 403 </div> 391 404 392 405 {/* Main Content */} 393 - <div className="container-app"> 406 + <div className="container-app relative z-20 mt-8"> 394 407 <div className="grid gap-8 lg:grid-cols-[2fr_1fr]"> 395 408 {/* Left Column */} 396 409 <div className="space-y-8"> ··· 672 685 <section className="card p-5"> 673 686 <h3 className="font-display font-semibold mb-4">In Your Lists</h3> 674 687 <div className="space-y-2"> 675 - {isTracking ? ( 676 - <Link 677 - to="/lists" 678 - className="flex items-center justify-between rounded-lg p-2 transition-colors hover:bg-[var(--background-subtle)]" 679 - > 680 - <span className="text-sm font-medium"> 681 - Currently Watching 682 - </span> 683 - <ChevronRight className="h-4 w-4 text-[var(--foreground-muted)]" /> 684 - </Link> 685 - ) : ( 686 - <p className="text-sm text-[var(--foreground-muted)]"> 687 - Not in any lists yet 688 - </p> 689 - )} 688 + {listsContainingShow.length > 0 ? ( 689 + listsContainingShow.map((list) => ( 690 + <Link 691 + key={list.listId} 692 + to={`/lists/${list.listSlug}`} 693 + className="flex items-center justify-between rounded-lg p-2 transition-colors hover:bg-[var(--background-subtle)]" 694 + > 695 + <span className="text-sm font-medium"> 696 + {list.listName} 697 + </span> 698 + <ChevronRight className="h-4 w-4 text-[var(--foreground-muted)]" /> 699 + </Link> 700 + )) 701 + ) : ( 702 + <p className="text-sm text-[var(--foreground-muted)]"> 703 + Not in any lists yet 704 + </p> 705 + )} 690 706 </div> 691 707 <button 692 708 type="button" 693 709 className="mt-3 w-full btn btn-secondary text-sm" 694 710 > 695 711 <Plus className="h-4 w-4" /> 696 - Add to another list 712 + {listsContainingShow.length > 0 ? "Add to another list" : "Add to list"} 697 713 </button> 698 714 </section> 699 715 </div>
+2
backend/src/shelf/shelf.controller.ts
··· 71 71 showTitle: string; 72 72 seasonNumber: number; 73 73 episodeNumber: number; 74 + episodeTitle?: string; 74 75 posterPath?: string; 75 76 backdropPath?: string; 76 77 firstAirYear?: number; ··· 86 87 showTitle: episodeData.showTitle, 87 88 seasonNumber: episodeData.seasonNumber, 88 89 episodeNumber: episodeData.episodeNumber, 90 + episodeTitle: episodeData.episodeTitle, 89 91 posterPath: episodeData.posterPath, 90 92 backdropPath: episodeData.backdropPath, 91 93 firstAirYear: episodeData.firstAirYear,
+4
backend/src/shelf/shelf.dto.ts
··· 58 58 episodeNumber: number; 59 59 60 60 @ApiPropertyOptional() 61 + episodeTitle?: string; 62 + 63 + @ApiPropertyOptional() 61 64 posterPath?: string; 62 65 63 66 @ApiPropertyOptional() ··· 106 109 showTitle: { type: "string" }, 107 110 seasonNumber: { type: "number" }, 108 111 episodeNumber: { type: "number" }, 112 + episodeTitle: { type: "string" }, 109 113 posterPath: { type: "string" }, 110 114 backdropPath: { type: "string" }, 111 115 firstAirYear: { type: "number" },
+59 -50
backend/src/shelf/shelf.service.ts
··· 31 31 showTitle: string; 32 32 seasonNumber: number; 33 33 episodeNumber: number; 34 + episodeTitle?: string; 34 35 posterPath?: string; 35 36 backdropPath?: string; 36 37 firstAirYear?: number; ··· 55 56 releaseDate: Date | null; 56 57 seasonNumber: number | null; 57 58 episodeNumber: number | null; 59 + episodeName: string | null; 58 60 firstAirYear: number | null; 59 61 firstAirDate: Date | null; 60 62 overview: string | null; ··· 124 126 shelf."backdropPath", 125 127 shelf."releaseYear", 126 128 shelf."releaseDate", 127 - shelf."seasonNumber", 128 - shelf."episodeNumber", 129 - shelf."firstAirYear", 130 - shelf."firstAirDate", 131 - shelf."overview" 132 - FROM ( 133 - SELECT 134 - tm.id AS "trackedId", 135 - 'movie' AS "type", 136 - tm."watchedDate" AS "watchedDate", 137 - tm."createdAt" AS "createdAt", 138 - COALESCE(tm."watchedDate", tm."createdAt") AS "sortDate", 139 - tm."movieId" AS "movieId", 140 - NULL::text AS "showId", 141 - m.title AS "title", 142 - m."posterPath" AS "posterPath", 143 - m."backdropPath" AS "backdropPath", 144 - m."releaseYear" AS "releaseYear", 145 - m."releaseDate" AS "releaseDate", 146 - NULL::integer AS "seasonNumber", 147 - NULL::integer AS "episodeNumber", 148 - NULL::integer AS "firstAirYear", 149 - NULL::timestamp AS "firstAirDate", 150 - m.overview AS "overview" 151 - FROM "TrackedMovie" tm 152 - INNER JOIN "Movie" m ON m."movieId" = tm."movieId" 153 - WHERE tm."userDid" = ${userDid} 129 + shelf."seasonNumber", 130 + shelf."episodeNumber", 131 + shelf."episodeName", 132 + shelf."firstAirYear", 133 + shelf."firstAirDate", 134 + shelf."overview" 135 + FROM ( 136 + SELECT 137 + tm.id AS "trackedId", 138 + 'movie' AS "type", 139 + tm."watchedDate" AS "watchedDate", 140 + tm."createdAt" AS "createdAt", 141 + COALESCE(tm."watchedDate", tm."createdAt") AS "sortDate", 142 + tm."movieId" AS "movieId", 143 + NULL::text AS "showId", 144 + m.title AS "title", 145 + m."posterPath" AS "posterPath", 146 + m."backdropPath" AS "backdropPath", 147 + m."releaseYear" AS "releaseYear", 148 + m."releaseDate" AS "releaseDate", 149 + NULL::integer AS "seasonNumber", 150 + NULL::integer AS "episodeNumber", 151 + NULL::text AS "episodeName", 152 + NULL::integer AS "firstAirYear", 153 + NULL::timestamp AS "firstAirDate", 154 + m.overview AS "overview" 155 + FROM "TrackedMovie" tm 156 + INNER JOIN "Movie" m ON m."movieId" = tm."movieId" 157 + WHERE tm."userDid" = ${userDid} 154 158 155 - UNION ALL 159 + UNION ALL 156 160 157 - SELECT 158 - te.id AS "trackedId", 159 - 'episode' AS "type", 160 - te."watchedDate" AS "watchedDate", 161 - te."createdAt" AS "createdAt", 162 - COALESCE(te."watchedDate", te."createdAt") AS "sortDate", 163 - NULL::text AS "movieId", 164 - te."showId" AS "showId", 165 - s.title AS "title", 166 - s."posterPath" AS "posterPath", 167 - s."backdropPath" AS "backdropPath", 168 - NULL::integer AS "releaseYear", 169 - NULL::timestamp AS "releaseDate", 170 - te."seasonNumber" AS "seasonNumber", 171 - te."episodeNumber" AS "episodeNumber", 172 - s."firstAirYear" AS "firstAirYear", 173 - s."firstAirDate" AS "firstAirDate", 174 - s.overview AS "overview" 175 - FROM "TrackedEpisode" te 176 - INNER JOIN "Show" s ON s."showId" = te."showId" 177 - WHERE te."userDid" = ${userDid} 178 - ) shelf 161 + SELECT 162 + te.id AS "trackedId", 163 + 'episode' AS "type", 164 + te."watchedDate" AS "watchedDate", 165 + te."createdAt" AS "createdAt", 166 + COALESCE(te."watchedDate", te."createdAt") AS "sortDate", 167 + NULL::text AS "movieId", 168 + te."showId" AS "showId", 169 + s.title AS "title", 170 + s."posterPath" AS "posterPath", 171 + s."backdropPath" AS "backdropPath", 172 + NULL::integer AS "releaseYear", 173 + NULL::timestamp AS "releaseDate", 174 + te."seasonNumber" AS "seasonNumber", 175 + te."episodeNumber" AS "episodeNumber", 176 + ep.name AS "episodeName", 177 + s."firstAirYear" AS "firstAirYear", 178 + s."firstAirDate" AS "firstAirDate", 179 + ep.overview AS "overview" 180 + FROM "TrackedEpisode" te 181 + INNER JOIN "Show" s ON s."showId" = te."showId" 182 + LEFT JOIN "Episode" ep ON ep."showId" = te."showId" 183 + AND ep."seasonNumber" = te."seasonNumber" 184 + AND ep."episodeNumber" = te."episodeNumber" 185 + WHERE te."userDid" = ${userDid} 186 + ) shelf 179 187 ORDER BY 180 188 shelf."sortDate" DESC, 181 189 shelf."createdAt" DESC, ··· 228 236 showTitle: row.title, 229 237 seasonNumber: row.seasonNumber, 230 238 episodeNumber: row.episodeNumber, 239 + episodeTitle: row.episodeName ?? undefined, 231 240 posterPath: row.posterPath ?? undefined, 232 241 backdropPath: row.backdropPath ?? undefined, 233 242 firstAirYear: row.firstAirYear ?? undefined,
+1
packages/api/src/generated/types.gen.ts
··· 977 977 showTitle: string; 978 978 seasonNumber: number; 979 979 episodeNumber: number; 980 + episodeTitle?: string; 980 981 posterPath?: string; 981 982 backdropPath?: string; 982 983 firstAirYear?: number;