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.

Refactor media cards for list and shelf actions

- Add actionable card wrapper for watch and list controls
- Update profile, dashboard, and search views to use the new card
- Align list and shelf DTOs and generated types with episode-aware data

+706 -607
+228
apps/web/src/components/ActionableMediaCard.tsx
··· 1 + import { useMemo, useState } from "react"; 2 + import ConfirmRemoveDialog from "#/components/ConfirmRemoveDialog"; 3 + import ManageListsDialog from "#/components/ManageListsDialog"; 4 + import MediaCard from "#/components/MediaCard"; 5 + import { useAuth } from "#/lib/auth-context"; 6 + import { formatDateTime } from "#/lib/date-utils"; 7 + import { useMediaWatchStatus } from "#/lib/hooks"; 8 + import { useWatchActions } from "#/lib/hooks/useWatchActions"; 9 + 10 + interface ActionableMediaCardProps { 11 + id: string | number; 12 + title: string; 13 + displayTitle?: string; 14 + posterUrl: string; 15 + backdropUrl?: string; 16 + type: "movie" | "show"; 17 + rating?: number; 18 + duration?: string; 19 + episodeInfo?: string; 20 + watchedDate?: string; 21 + seasonNumber?: number; 22 + episodeNumber?: number; 23 + size?: "sm" | "md" | "lg"; 24 + layout?: "poster" | "backdrop"; 25 + interactive?: boolean; 26 + isWatched?: boolean; 27 + onRemove?: () => void; 28 + isRemoving?: boolean; 29 + } 30 + 31 + export default function ActionableMediaCard({ 32 + id, 33 + title, 34 + displayTitle, 35 + posterUrl, 36 + backdropUrl, 37 + type, 38 + rating, 39 + duration, 40 + episodeInfo, 41 + watchedDate, 42 + seasonNumber, 43 + episodeNumber, 44 + size = "md", 45 + layout = "poster", 46 + interactive = true, 47 + isWatched: isWatchedProp, 48 + onRemove, 49 + isRemoving = false, 50 + }: ActionableMediaCardProps) { 51 + const [listDialogOpen, setListDialogOpen] = useState(false); 52 + const [confirmRemoveOpen, setConfirmRemoveOpen] = useState(false); 53 + 54 + const { userSettings } = useAuth(); 55 + const userTimezone = userSettings?.timezone; 56 + const userTimeFormat = userSettings?.timeFormat; 57 + 58 + const formattedWatchedDate = watchedDate 59 + ? formatDateTime(watchedDate, userTimezone, userTimeFormat) 60 + : undefined; 61 + 62 + const isMovie = type === "movie"; 63 + const mediaId = String(id); 64 + const isEpisode = 65 + !isMovie && 66 + typeof seasonNumber === "number" && 67 + typeof episodeNumber === "number"; 68 + 69 + const watchActions = useWatchActions( 70 + isMovie 71 + ? { mediaType: "movie", movieId: mediaId } 72 + : { mediaType: "show", showId: mediaId }, 73 + ); 74 + 75 + const watchStatusOptions = isMovie 76 + ? ({ mediaType: "movie", movieId: mediaId } as const) 77 + : ({ mediaType: "show", showId: mediaId } as const); 78 + 79 + const { 80 + isWatched: queryIsWatched, 81 + isTracking, 82 + movieWatchHistory, 83 + watchHistory, 84 + isEpisodeWatched, 85 + } = useMediaWatchStatus(watchStatusOptions); 86 + 87 + const episodeWatchHistory = useMemo(() => { 88 + if (isMovie || !watchHistory || !Array.isArray(watchHistory)) return []; 89 + if (seasonNumber === undefined || episodeNumber === undefined) return []; 90 + return watchHistory.filter( 91 + (ep: { seasonNumber: number; episodeNumber: number }) => 92 + ep.seasonNumber === seasonNumber && ep.episodeNumber === episodeNumber, 93 + ); 94 + }, [isMovie, watchHistory, seasonNumber, episodeNumber]); 95 + 96 + const watched = 97 + isWatchedProp !== undefined 98 + ? isWatchedProp 99 + : isMovie 100 + ? (queryIsWatched ?? false) 101 + : isEpisode 102 + ? (isEpisodeWatched?.(seasonNumber, episodeNumber) ?? false) 103 + : (isTracking ?? false); 104 + 105 + const confirmEntryCount = isMovie 106 + ? movieWatchHistory?.length || 0 107 + : isEpisode 108 + ? episodeWatchHistory.length 109 + : watchHistory?.length || 0; 110 + 111 + const handleMarkWatched = () => { 112 + if (isMovie) { 113 + watchActions.markMovieWatched(); 114 + } else if ( 115 + isEpisode && 116 + seasonNumber !== undefined && 117 + episodeNumber !== undefined 118 + ) { 119 + watchActions.markEpisodeWatched(seasonNumber, episodeNumber); 120 + } else { 121 + watchActions.markShowWatched(); 122 + } 123 + }; 124 + 125 + const handleUnmarkWatched = () => { 126 + if (confirmEntryCount > 1) { 127 + setConfirmRemoveOpen(true); 128 + return; 129 + } 130 + 131 + if (isMovie) { 132 + watchActions.unmarkMovieWatched(); 133 + } else if ( 134 + isEpisode && 135 + seasonNumber !== undefined && 136 + episodeNumber !== undefined 137 + ) { 138 + watchActions.unmarkEpisodeWatched(seasonNumber, episodeNumber); 139 + } else { 140 + watchActions.unmarkShowWatched(); 141 + } 142 + }; 143 + 144 + const handleConfirmRemove = () => { 145 + if (isMovie) { 146 + watchActions.unmarkMovieWatched(); 147 + } else if ( 148 + isEpisode && 149 + seasonNumber !== undefined && 150 + episodeNumber !== undefined 151 + ) { 152 + watchActions.unmarkEpisodeWatched(seasonNumber, episodeNumber, "all"); 153 + } else { 154 + watchActions.unmarkShowWatched(); 155 + } 156 + setConfirmRemoveOpen(false); 157 + }; 158 + 159 + return ( 160 + <> 161 + <MediaCard 162 + id={id} 163 + title={title} 164 + displayTitle={displayTitle} 165 + posterUrl={posterUrl} 166 + backdropUrl={backdropUrl} 167 + type={type} 168 + rating={rating} 169 + duration={duration} 170 + episodeInfo={episodeInfo} 171 + watchedDate={formattedWatchedDate} 172 + seasonNumber={seasonNumber} 173 + episodeNumber={episodeNumber} 174 + size={size} 175 + layout={layout} 176 + isWatched={watched} 177 + onMarkWatched={interactive ? handleMarkWatched : undefined} 178 + onUnmarkWatched={interactive ? handleUnmarkWatched : undefined} 179 + onManageLists={ 180 + interactive && !onRemove ? () => setListDialogOpen(true) : undefined 181 + } 182 + onRemove={onRemove} 183 + isRemoving={isRemoving} 184 + isMarkWatchedPending={ 185 + isMovie 186 + ? watchActions.isMarkMoviePending 187 + : isEpisode 188 + ? watchActions.isMarkEpisodePending 189 + : watchActions.isMarkShowPending 190 + } 191 + isUnmarkWatchedPending={ 192 + isMovie 193 + ? watchActions.isUnmarkMoviePending 194 + : isEpisode 195 + ? watchActions.isUnmarkEpisodePending 196 + : watchActions.isUnmarkShowPending 197 + } 198 + /> 199 + {interactive && ( 200 + <ManageListsDialog 201 + mediaType={type} 202 + mediaId={mediaId} 203 + seasonNumber={seasonNumber} 204 + episodeNumber={episodeNumber} 205 + open={listDialogOpen} 206 + onOpenChange={setListDialogOpen} 207 + title={`Add "${title}" to lists`} 208 + /> 209 + )} 210 + {interactive && ( 211 + <ConfirmRemoveDialog 212 + open={confirmRemoveOpen} 213 + onOpenChange={setConfirmRemoveOpen} 214 + title={displayTitle || title} 215 + entryCount={confirmEntryCount} 216 + onConfirm={handleConfirmRemove} 217 + isPending={ 218 + isMovie 219 + ? watchActions.isUnmarkMoviePending 220 + : isEpisode 221 + ? watchActions.isUnmarkEpisodePending 222 + : watchActions.isUnmarkShowPending 223 + } 224 + /> 225 + )} 226 + </> 227 + ); 228 + }
+113 -96
apps/web/src/components/MediaCard.tsx
··· 1 1 import { Link } from "@tanstack/react-router"; 2 - import { Check, Clock, Library, Loader2, Play, Star, X } from "lucide-react"; 2 + import { 3 + BookmarkX, 4 + Check, 5 + Clock, 6 + Library, 7 + Loader2, 8 + Play, 9 + Star, 10 + } from "lucide-react"; 3 11 import { useState } from "react"; 4 12 import { 5 13 buildEpisodeUrl, ··· 11 19 export interface MediaCardProps { 12 20 id: string | number; 13 21 title: string; 14 - displayTitle?: string; // Optional different title for display (e.g., episode name) 15 - // Episode-specific props for linking to episode detail page 22 + displayTitle?: string; 16 23 seasonNumber?: number; 17 24 episodeNumber?: number; 18 25 posterUrl: string; 19 26 backdropUrl?: string; 20 27 type: "movie" | "show"; 21 - year?: number; 22 28 rating?: number; 23 29 duration?: string; 24 30 episodeInfo?: string; ··· 48 54 posterUrl, 49 55 backdropUrl, 50 56 type, 51 - year, 52 57 rating, 53 58 duration, 54 59 episodeInfo, ··· 93 98 const imageUrl = 94 99 layout === "backdrop" && backdropUrl ? backdropUrl : posterUrl; 95 100 96 - // Build URL - scoped show items go to season/episode detail pages 97 101 const linkHref = (() => { 98 102 if (href) return href; 99 103 if (type === "movie") { ··· 170 174 </span> 171 175 </div> 172 176 173 - {/* Shelf toggle + lists button — always visible in top-right corner */} 174 - {(onMarkWatched || onUnmarkWatched || onManageLists) && ( 175 - <div className="absolute top-2 right-2 flex items-center gap-1.5"> 176 - {(onMarkWatched || onUnmarkWatched) && ( 177 - <button 178 - type="button" 179 - onClick={(e) => { 180 - e.preventDefault(); 181 - if (isWatched && onUnmarkWatched) { 182 - onUnmarkWatched(); 183 - } else if (onMarkWatched) { 184 - onMarkWatched(); 177 + {/* Actions — top-right corner */} 178 + {(onMarkWatched || onUnmarkWatched || onManageLists || onRemove) && ( 179 + <div className="absolute top-2 right-2 flex flex-col gap-1.5"> 180 + <div className="flex items-center gap-1.5"> 181 + {(onMarkWatched || onUnmarkWatched) && ( 182 + <button 183 + type="button" 184 + onClick={(e) => { 185 + e.preventDefault(); 186 + if (isWatched && onUnmarkWatched) { 187 + onUnmarkWatched(); 188 + } else if (onMarkWatched) { 189 + onMarkWatched(); 190 + } 191 + }} 192 + disabled={isMarkWatchedPending || isUnmarkWatchedPending} 193 + className={`flex h-7 w-7 items-center justify-center rounded-full transition-colors disabled:opacity-50 ${ 194 + isWatched 195 + ? "bg-green-500 text-white hover:bg-green-600" 196 + : "bg-white/20 text-white backdrop-blur-sm hover:bg-white/40" 197 + }`} 198 + aria-label={ 199 + isWatched ? "Remove from shelf" : "Mark as watched" 185 200 } 186 - }} 187 - disabled={isMarkWatchedPending || isUnmarkWatchedPending} 188 - className={`flex h-7 w-7 items-center justify-center rounded-full transition-colors disabled:opacity-50 ${ 189 - isWatched 190 - ? "bg-green-500 text-white hover:bg-green-600" 191 - : "bg-white/20 text-white backdrop-blur-sm hover:bg-white/40" 192 - }`} 193 - aria-label={ 194 - isWatched ? "Remove from shelf" : "Mark as watched" 195 - } 196 - title={isWatched ? "Remove from shelf" : "Add to shelf"} 197 - > 198 - {isMarkWatchedPending || isUnmarkWatchedPending ? ( 199 - <Loader2 className="h-3.5 w-3.5 animate-spin" /> 200 - ) : ( 201 - <Check className="h-3.5 w-3.5" /> 202 - )} 203 - </button> 201 + title={isWatched ? "Remove from shelf" : "Add to shelf"} 202 + > 203 + {isMarkWatchedPending || isUnmarkWatchedPending ? ( 204 + <Loader2 className="h-3.5 w-3.5 animate-spin" /> 205 + ) : ( 206 + <Check className="h-3.5 w-3.5" /> 207 + )} 208 + </button> 209 + )} 210 + {onManageLists && ( 211 + <button 212 + type="button" 213 + onClick={(e) => { 214 + e.preventDefault(); 215 + onManageLists(); 216 + }} 217 + className="flex h-7 w-7 items-center justify-center rounded-full bg-white/20 text-white backdrop-blur-sm transition-colors hover:bg-white/40" 218 + aria-label="Manage lists" 219 + title="Add to list" 220 + > 221 + <Library className="h-3.5 w-3.5" /> 222 + </button> 223 + )} 224 + {onRemove && ( 225 + <button 226 + type="button" 227 + onClick={(e) => { 228 + e.preventDefault(); 229 + onRemove(); 230 + }} 231 + disabled={isRemoving} 232 + className="flex h-7 w-7 items-center justify-center rounded-full bg-red-500/80 text-white backdrop-blur-sm transition-colors hover:bg-red-600 disabled:opacity-50" 233 + aria-label="Remove from list" 234 + > 235 + {isRemoving ? ( 236 + <Loader2 className="h-3.5 w-3.5 animate-spin" /> 237 + ) : ( 238 + <BookmarkX className="h-3.5 w-3.5" /> 239 + )} 240 + </button> 241 + )} 242 + </div> 243 + {/* Static watched indicator (no interactive callback) */} 244 + {isWatched && !onMarkWatched && !onUnmarkWatched && ( 245 + <div className="flex h-6 w-6 items-center justify-center self-end rounded-full bg-green-500 text-white"> 246 + <Check className="h-3.5 w-3.5" /> 247 + </div> 204 248 )} 205 - {onManageLists && ( 206 - <button 207 - type="button" 208 - onClick={(e) => { 209 - e.preventDefault(); 210 - onManageLists(); 211 - }} 212 - className="flex h-7 w-7 items-center justify-center rounded-full bg-white/20 text-white backdrop-blur-sm transition-colors hover:bg-white/40" 213 - aria-label="Manage lists" 214 - title="Add to list" 215 - > 216 - <Library className="h-3.5 w-3.5" /> 217 - </button> 218 - )} 219 - </div> 220 - )} 221 - 222 - {/* Static watched indicator (no interactive callback) */} 223 - {isWatched && !onMarkWatched && !onUnmarkWatched && ( 224 - <div className="absolute top-2 right-2"> 225 - <div className="flex h-6 w-6 items-center justify-center rounded-full bg-green-500 text-white"> 226 - <Check className="h-3.5 w-3.5" /> 227 - </div> 228 249 </div> 229 250 )} 230 251 ··· 277 298 {/* Poster layout content below image */} 278 299 {layout === "poster" && ( 279 300 <div className="mt-2 space-y-1"> 280 - <h3 className="line-clamp-1 font-medium text-(--foreground) text-sm"> 281 - {displayName} 282 - </h3> 283 - <div className="flex items-center gap-2 text-(--foreground-muted) text-xs"> 284 - {year && <span>{year}</span>} 285 - {typeof seasonNumber === "number" && type === "show" && ( 286 - <> 287 - <span>•</span> 288 - <span> 289 - {typeof episodeNumber === "number" 290 - ? `S${seasonNumber}E${episodeNumber}` 291 - : `Season ${seasonNumber}`} 292 - </span> 293 - </> 294 - )} 301 + {episodeInfo ? ( 302 + <> 303 + <h3 className="line-clamp-1 font-medium text-(--foreground) text-sm"> 304 + {episodeInfo} 305 + </h3> 306 + <p className="line-clamp-1 text-(--foreground-muted) text-xs"> 307 + {displayName} 308 + </p> 309 + </> 310 + ) : ( 311 + <h3 className="line-clamp-1 font-medium text-(--foreground) text-sm"> 312 + {displayName} 313 + </h3> 314 + )} 315 + <div className="flex flex-wrap items-center gap-2 text-(--foreground-muted) text-xs"> 316 + {typeof seasonNumber === "number" && 317 + type === "show" && 318 + !episodeInfo && ( 319 + <> 320 + <span>•</span> 321 + <span> 322 + {typeof episodeNumber === "number" 323 + ? `S${seasonNumber}E${episodeNumber}` 324 + : `Season ${seasonNumber}`} 325 + </span> 326 + </> 327 + )} 295 328 {rating && ( 296 329 <> 297 330 <span>•</span> ··· 308 341 </> 309 342 )} 310 343 </div> 344 + {watchedDate && ( 345 + <p className="flex items-center gap-1 text-(--foreground-muted) text-xs"> 346 + <Clock className="h-3 w-3" /> 347 + {watchedDate} 348 + </p> 349 + )} 311 350 </div> 312 351 )} 313 352 </Link> 314 - 315 - {/* Actions menu — visible on mobile, hover-only on desktop */} 316 - {onRemove && ( 317 - <div className="absolute top-2 right-2 flex flex-col gap-1.5 opacity-100 transition-opacity sm:opacity-0 sm:group-hover:opacity-100"> 318 - <button 319 - type="button" 320 - onClick={(e) => { 321 - e.preventDefault(); 322 - onRemove(); 323 - }} 324 - disabled={isRemoving} 325 - className="flex h-8 w-8 items-center justify-center rounded-full bg-red-500/80 text-white backdrop-blur-sm transition-colors hover:bg-red-600 disabled:opacity-50" 326 - aria-label="Remove from list" 327 - > 328 - {isRemoving ? ( 329 - <Loader2 className="h-4 w-4 animate-spin" /> 330 - ) : ( 331 - <X className="h-4 w-4" /> 332 - )} 333 - </button> 334 - </div> 335 - )} 336 353 </article> 337 354 ); 338 355 }
-1
apps/web/src/components/SimilarMediaGrid.tsx
··· 31 31 title={item.title} 32 32 posterUrl={item.posterUrl} 33 33 type={item.type} 34 - year={item.year} 35 34 rating={item.rating} 36 35 size="sm" 37 36 layout="poster"
+69 -54
apps/web/src/components/profile/ProfileListsPage.tsx
··· 10 10 import { useNavigate } from "@tanstack/react-router"; 11 11 import { 12 12 AlertCircle, 13 + BookmarkX, 13 14 Clock, 14 15 Film, 15 16 Grid3X3, ··· 21 22 Search, 22 23 Star, 23 24 Tv, 24 - X, 25 25 } from "lucide-react"; 26 26 import { useEffect, useMemo, useState } from "react"; 27 27 import { Button } from "#/components/ui/button"; ··· 34 34 } from "#/components/ui/dialog"; 35 35 import { useAuth } from "#/lib/auth-context"; 36 36 import { useCreateList } from "#/lib/hooks"; 37 - import MediaCard from "../../components/MediaCard"; 37 + import ActionableMediaCard from "../../components/ActionableMediaCard"; 38 38 39 39 const colorClasses: Record<string, string> = { 40 40 blue: "bg-blue-500", ··· 357 357 {userLists?.map((list) => { 358 358 const color = getListColor(list.name); 359 359 const Icon = iconComponents[color] || List; 360 + const isSelected = selectedListSlug === list.slug; 360 361 return ( 361 362 <button 362 363 key={list.id} 363 364 type="button" 364 365 onClick={() => handleSelectList(list.slug)} 365 366 className={`card card-interactive w-full p-4 text-left transition-all ${ 366 - selectedListSlug === list.slug 367 - ? "border-(--accent) bg-(--accent-subtle)" 368 - : "" 367 + isSelected 368 + ? "border-(--accent) border-2 bg-(--accent-subtle) shadow-sm" 369 + : "hover:border-(--accent)/40" 369 370 }`} 370 371 > 371 372 <div className="flex items-start gap-3"> ··· 404 405 <div className="space-y-6"> 405 406 {/* List Header */} 406 407 <div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between"> 407 - <div> 408 - <h2 className="text-display-3">{activeList.name}</h2> 409 - <p className="text-(--foreground-muted)"> 410 - {activeList.description || "No description"} 411 - </p> 408 + <div className="flex items-center gap-3"> 409 + <div 410 + className={`flex h-9 w-9 shrink-0 items-center justify-center rounded-lg ${colorClasses[getListColor(activeList.name)]} text-white`} 411 + > 412 + {(() => { 413 + const ListIcon = 414 + iconComponents[getListColor(activeList.name)] || List; 415 + return <ListIcon className="h-4.5 w-4.5" />; 416 + })()} 417 + </div> 418 + <div> 419 + <h2 className="text-display-3">{activeList.name}</h2> 420 + <p className="text-(--foreground-muted)"> 421 + {activeList.description || "No description"} 422 + </p> 423 + </div> 412 424 </div> 413 425 414 426 <div className="flex items-center gap-2"> ··· 516 528 index === self.findIndex((i) => i.id === item.id), 517 529 ) 518 530 .map((item: MediaInListDto) => ( 519 - <MediaCard 531 + <ActionableMediaCard 520 532 key={item.id} 521 533 id={String( 522 534 (item.media as Record<string, unknown>).mediaId ?? ··· 525 537 title={getTitle(item.media)} 526 538 seasonNumber={item.seasonNumber} 527 539 episodeNumber={item.episodeNumber} 540 + episodeInfo={ 541 + item.seasonNumber !== undefined && 542 + item.episodeNumber !== undefined 543 + ? item.episodeName 544 + ? `S${item.seasonNumber}E${item.episodeNumber} — ${item.episodeName}` 545 + : `S${item.seasonNumber}E${item.episodeNumber}` 546 + : item.seasonNumber !== undefined 547 + ? `Season ${item.seasonNumber}` 548 + : undefined 549 + } 528 550 posterUrl={getPosterUrl(item.media)} 529 551 backdropUrl={getBackdropUrl(item.media)} 530 552 type={item.mediaType as "movie" | "show"} 531 - year={getYear(item.media)} 532 553 rating={getRating(item.media)} 533 554 duration={formatDuration( 534 555 item.media.runtime as number | undefined, 535 556 )} 536 - size="md" 537 - layout="poster" 538 - {...(isOwner 539 - ? { 540 - onRemove: () => 557 + onRemove={ 558 + isOwner 559 + ? () => 541 560 removeItemMutation.mutate({ 542 561 path: { 543 562 slug: selectedListSlug || "", 544 563 mediaType: item.mediaType, 545 564 mediaId: item.mediaId, 546 565 }, 547 - }), 548 - isRemoving: 549 - removeItemMutation.isPending && 550 - removeItemMutation.variables?.path 551 - ?.mediaId === item.mediaId, 552 - } 553 - : {})} 566 + }) 567 + : undefined 568 + } 569 + isRemoving={ 570 + isOwner && 571 + removeItemMutation.isPending && 572 + removeItemMutation.variables?.path?.mediaId === 573 + item.mediaId 574 + } 554 575 /> 555 576 ))} 556 577 </div> ··· 573 594 loading="lazy" 574 595 /> 575 596 <div className="min-w-0 flex-1"> 576 - <div className="flex items-center gap-2"> 577 - <h3 className="font-semibold"> 578 - {getTitle(item.media)} 579 - </h3> 597 + <h3 className="font-semibold"> 598 + {getTitle(item.media)} 599 + </h3> 600 + {item.seasonNumber !== undefined && ( 601 + <p className="mt-0.5 text-(--foreground-muted) text-sm"> 602 + {item.episodeNumber !== undefined 603 + ? `Season ${item.seasonNumber}, Episode ${item.episodeNumber}${item.episodeName ? ` — ${item.episodeName}` : ""}` 604 + : `Season ${item.seasonNumber}`} 605 + </p> 606 + )} 607 + <div className="mt-1 flex items-center gap-2"> 580 608 <span 581 609 className={`badge ${ 582 610 item.mediaType === "movie" ··· 586 614 > 587 615 {item.mediaType === "movie" ? "Movie" : "TV"} 588 616 </span> 589 - {item.seasonNumber !== undefined && ( 590 - <span className="badge badge-subtle text-[10px]"> 591 - {item.episodeNumber !== undefined 592 - ? `S${item.seasonNumber}E${item.episodeNumber}` 593 - : `Season ${item.seasonNumber}`} 617 + {getYear(item.media) && ( 618 + <span className="text-(--foreground-subtle) text-sm"> 619 + {getYear(item.media)} 594 620 </span> 595 621 )} 596 - </div> 597 - <div className="mt-1 flex items-center gap-3 text-(--foreground-muted) text-sm"> 598 - {getYear(item.media) && ( 599 - <span>{getYear(item.media)}</span> 600 - )} 601 622 {getRating(item.media) && ( 602 - <> 603 - <span>&bull;</span> 604 - <span className="flex items-center gap-1"> 605 - <Star className="h-3 w-3 fill-current text-yellow-500" /> 606 - {getRating(item.media)?.toFixed(1)} 607 - </span> 608 - </> 623 + <span className="flex items-center gap-1 text-(--foreground-subtle) text-sm"> 624 + <Star className="h-3 w-3 fill-current text-yellow-500" /> 625 + {getRating(item.media)?.toFixed(1)} 626 + </span> 609 627 )} 610 628 {formatDuration( 611 629 item.media.runtime as number | undefined, 612 630 ) && ( 613 - <> 614 - <span>&bull;</span> 615 - <span> 616 - {formatDuration( 617 - item.media.runtime as number | undefined, 618 - )} 619 - </span> 620 - </> 631 + <span className="text-(--foreground-subtle) text-sm"> 632 + {formatDuration( 633 + item.media.runtime as number | undefined, 634 + )} 635 + </span> 621 636 )} 622 637 </div> 623 638 </div> ··· 646 661 item.mediaId ? ( 647 662 <Loader2 className="h-4 w-4 animate-spin" /> 648 663 ) : ( 649 - <X className="h-4 w-4" /> 664 + <BookmarkX className="h-4 w-4" /> 650 665 )} 651 666 </button> 652 667 )}
-2
apps/web/src/routes/dashboard.tsx
··· 392 392 posterUrl={item.posterUrl} 393 393 backdropUrl={item.backdropUrl} 394 394 type={item.type} 395 - year={item.year} 396 395 episodeInfo={item.episodeInfo} 397 396 layout="backdrop" 398 397 size="md" ··· 459 458 posterUrl={item.posterUrl} 460 459 backdropUrl={item.backdropUrl} 461 460 type={item.type} 462 - year={item.year} 463 461 episodeInfo={item.episodeInfo} 464 462 isWatched={item.isWatched} 465 463 watchedDate={
+192 -300
apps/web/src/routes/profile.$handle/index.tsx
··· 1 1 import { 2 + listsControllerGetPublicUserListOptions, 2 3 listsControllerGetPublicUserListsOptions, 3 4 moviesControllerGetUserMoviesPaginatedOptions, 4 - moviesControllerUnmarkWatchedMutation, 5 - shelfControllerGetUserShelfOptions, 6 - shelfControllerGetUserShelfQueryKey, 7 5 showsControllerGetUserEpisodesPaginatedOptions, 8 - showsControllerUnmarkWatchedMutation, 9 6 usersControllerGetPublicProfileOptions, 10 7 } from "@opnshelf/api"; 11 - import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; 8 + import { useQuery } from "@tanstack/react-query"; 12 9 import { createFileRoute, Link } from "@tanstack/react-router"; 13 - import { 14 - ChevronRight, 15 - Clock, 16 - Film, 17 - Heart, 18 - List, 19 - Loader2, 20 - Tv, 21 - X, 22 - } from "lucide-react"; 10 + import { ChevronRight, Clock, Film, Heart, List, Tv } from "lucide-react"; 11 + import ActionableMediaCard from "#/components/ActionableMediaCard"; 12 + import MediaCard from "#/components/MediaCard"; 23 13 import { setupApiClient } from "#/lib/api"; 24 14 import { useAuth } from "#/lib/auth-context"; 25 - import { toSlug } from "#/lib/slug"; 26 15 27 16 setupApiClient(); 28 17 ··· 30 19 component: ProfileOverviewPage, 31 20 }); 32 21 33 - function formatWatchedDate(dateStr?: string): string { 34 - if (!dateStr) return ""; 35 - const date = new Date(dateStr); 36 - return date.toLocaleDateString("en-US", { 37 - month: "short", 38 - day: "numeric", 39 - year: 40 - date.getFullYear() === new Date().getFullYear() ? undefined : "numeric", 41 - }); 42 - } 43 - 44 22 function ProfileOverviewPage() { 45 23 const { handle } = Route.useParams(); 46 24 const { user } = useAuth(); 47 - const queryClient = useQueryClient(); 48 25 49 26 const { data: profile } = useQuery({ 50 27 ...usersControllerGetPublicProfileOptions({ path: { handle } }), ··· 53 30 const displayName = profile?.displayName || profile?.handle || handle; 54 31 const isOwner = user?.did === userDid; 55 32 56 - // Fetch recent shelf items (mixed, we'll split client-side for overview) 57 - const { data: shelfData, isLoading: shelfLoading } = useQuery({ 58 - ...shelfControllerGetUserShelfOptions({ 33 + // Fetch recent movies 34 + const { data: moviesData, isLoading: moviesLoading } = useQuery({ 35 + ...moviesControllerGetUserMoviesPaginatedOptions({ 36 + path: { userDid }, 37 + query: { limit: 8 }, 38 + }), 39 + enabled: !!userDid, 40 + }); 41 + 42 + // Fetch recent episodes 43 + const { data: episodesData, isLoading: episodesLoading } = useQuery({ 44 + ...showsControllerGetUserEpisodesPaginatedOptions({ 59 45 path: { userDid }, 60 - query: { page: 1, pageSize: 24 }, 46 + query: { limit: 8 }, 61 47 }), 62 48 enabled: !!userDid, 63 49 }); 64 50 65 - const movies = 66 - shelfData?.items?.filter((item) => item.type === "movie").slice(0, 6) ?? []; 67 - const episodes = 68 - shelfData?.items?.filter((item) => item.type === "episode").slice(0, 6) ?? 69 - []; 51 + const movies = moviesData?.items ?? []; 52 + const episodes = episodesData?.items ?? []; 70 53 71 54 // Fetch public lists 72 55 const { data: listsData, isLoading: listsLoading } = useQuery({ ··· 76 59 enabled: !!userDid, 77 60 }); 78 61 79 - // Fetch total counts 80 - const { data: moviesCountData } = useQuery({ 81 - ...moviesControllerGetUserMoviesPaginatedOptions({ 82 - path: { userDid }, 83 - query: { limit: 1 }, 84 - }), 85 - enabled: !!userDid, 86 - }); 87 - const { data: episodesCountData } = useQuery({ 88 - ...showsControllerGetUserEpisodesPaginatedOptions({ 89 - path: { userDid }, 90 - query: { limit: 1 }, 91 - }), 92 - enabled: !!userDid, 93 - }); 62 + const totalMovies = moviesData?.total ?? 0; 63 + const totalEpisodes = episodesData?.total ?? 0; 64 + const totalLists = listsData?.length ?? 0; 65 + const totalWatched = (moviesData?.total ?? 0) + (episodesData?.total ?? 0); 94 66 95 67 const watchlist = listsData?.find((l) => l.slug === "watchlist"); 96 68 const favorites = listsData?.find((l) => l.slug === "favorites"); 97 69 98 - const totalMovies = moviesCountData?.total ?? 0; 99 - const totalEpisodes = episodesCountData?.total ?? 0; 100 - const totalLists = listsData?.length ?? 0; 101 - const totalWatched = shelfData?.total ?? 0; 102 - 103 - // Mutations for removing from shelf 104 - const removeMovieMutation = useMutation({ 105 - ...moviesControllerUnmarkWatchedMutation(), 106 - onSuccess: () => { 107 - queryClient.invalidateQueries({ 108 - queryKey: shelfControllerGetUserShelfQueryKey({ 109 - path: { userDid }, 110 - }), 111 - }); 112 - }, 113 - }); 114 - const removeEpisodeMutation = useMutation({ 115 - ...showsControllerUnmarkWatchedMutation(), 116 - onSuccess: () => { 117 - queryClient.invalidateQueries({ 118 - queryKey: shelfControllerGetUserShelfQueryKey({ 119 - path: { userDid }, 120 - }), 121 - }); 122 - }, 123 - }); 124 - 125 70 return ( 126 71 <div className="space-y-10"> 127 72 {/* Stats Row */} ··· 130 75 label="Movies" 131 76 value={totalMovies} 132 77 icon={Film} 133 - isLoading={!moviesCountData && !!userDid} 78 + isLoading={!moviesData && !!userDid} 134 79 /> 135 80 <StatCard 136 81 label="Episodes" 137 82 value={totalEpisodes} 138 83 icon={Tv} 139 - isLoading={!episodesCountData && !!userDid} 84 + isLoading={!episodesData && !!userDid} 140 85 /> 141 86 <StatCard 142 87 label="Lists" ··· 148 93 label="Watched" 149 94 value={totalWatched} 150 95 icon={Clock} 151 - isLoading={shelfLoading} 96 + isLoading={!moviesData && !episodesData && !!userDid} 152 97 /> 153 98 </div> 154 99 155 - {/* Last 6 Movies */} 156 - <section> 157 - <div className="mb-4 flex items-center justify-between"> 158 - <h2 className="flex items-center gap-2 text-display-3"> 159 - <Film className="h-5 w-5 text-(--accent)" /> 160 - Last Movies 161 - </h2> 162 - <Link 163 - to="/profile/$handle/shelf" 164 - params={{ handle }} 165 - search={{ type: "movie" }} 166 - className="flex items-center gap-1 font-medium text-(--accent) text-sm hover:text-(--accent-hover)" 167 - > 168 - View all 169 - <ChevronRight className="h-4 w-4" /> 170 - </Link> 171 - </div> 172 - 173 - {shelfLoading ? ( 174 - <div className="grid grid-cols-2 gap-4 sm:grid-cols-3 lg:grid-cols-6"> 175 - {[1, 2, 3, 4, 5, 6].map((i) => ( 176 - <div 177 - key={i} 178 - className="aspect-[2/3] animate-pulse rounded-lg bg-(--background-subtle)" 179 - /> 180 - ))} 181 - </div> 182 - ) : movies.length > 0 ? ( 183 - <div className="grid grid-cols-2 gap-4 sm:grid-cols-3 lg:grid-cols-6"> 184 - {movies.map((item) => ( 185 - <ShelfItemCard 186 - key={item.id} 187 - item={item} 188 - isOwner={isOwner} 189 - onRemove={() => 190 - removeMovieMutation.mutate({ 191 - path: { movieId: item.movieId }, 192 - query: { mode: "all" }, 193 - }) 194 - } 195 - isRemoving={removeMovieMutation.isPending} 196 - /> 197 - ))} 198 - </div> 199 - ) : ( 200 - <div className="card p-8 text-center"> 201 - <p className="text-(--foreground-muted)"> 202 - {displayName} hasn&apos;t watched any movies yet. 203 - </p> 100 + {/* Last Movies & Episodes */} 101 + <div className="grid gap-8 lg:grid-cols-2"> 102 + {/* Last Movies */} 103 + <section> 104 + <div className="mb-4 flex items-center justify-between"> 105 + <h2 className="flex items-center gap-2 text-display-3"> 106 + <Film className="h-5 w-5 text-(--accent)" /> 107 + Recent Movies 108 + </h2> 109 + <Link 110 + to="/profile/$handle/shelf" 111 + params={{ handle }} 112 + search={{ type: "movie" }} 113 + className="flex items-center gap-1 font-medium text-(--accent) text-sm hover:text-(--accent-hover)" 114 + > 115 + View all 116 + <ChevronRight className="h-4 w-4" /> 117 + </Link> 204 118 </div> 205 - )} 206 - </section> 207 119 208 - {/* Last 6 Episodes */} 209 - <section> 210 - <div className="mb-4 flex items-center justify-between"> 211 - <h2 className="flex items-center gap-2 text-display-3"> 212 - <Tv className="h-5 w-5 text-(--accent)" /> 213 - Last Episodes 214 - </h2> 215 - <Link 216 - to="/profile/$handle/shelf" 217 - params={{ handle }} 218 - search={{ type: "episode" }} 219 - className="flex items-center gap-1 font-medium text-(--accent) text-sm hover:text-(--accent-hover)" 220 - > 221 - View all 222 - <ChevronRight className="h-4 w-4" /> 223 - </Link> 224 - </div> 120 + {moviesLoading ? ( 121 + <div className="grid grid-cols-4 gap-4"> 122 + {[1, 2, 3, 4].map((i) => ( 123 + <div 124 + key={i} 125 + className="aspect-[2/3] animate-pulse rounded-lg bg-(--background-subtle)" 126 + /> 127 + ))} 128 + </div> 129 + ) : movies.length > 0 ? ( 130 + <div className="grid grid-cols-4 gap-4"> 131 + {movies.map((item) => ( 132 + <div key={item.id} className="[&_article]:!w-full"> 133 + <ActionableMediaCard 134 + id={item.movie.movieId} 135 + title={item.movie.title} 136 + posterUrl={`https://image.tmdb.org/t/p/w500${item.movie.posterPath}`} 137 + type="movie" 138 + watchedDate={item.watchedDate} 139 + interactive={isOwner} 140 + isWatched={true} 141 + /> 142 + </div> 143 + ))} 144 + </div> 145 + ) : ( 146 + <div className="card p-8 text-center"> 147 + <p className="text-(--foreground-muted)"> 148 + {displayName} hasn&apos;t watched any movies yet. 149 + </p> 150 + </div> 151 + )} 152 + </section> 225 153 226 - {shelfLoading ? ( 227 - <div className="grid grid-cols-2 gap-4 sm:grid-cols-3 lg:grid-cols-6"> 228 - {[1, 2, 3, 4, 5, 6].map((i) => ( 229 - <div 230 - key={i} 231 - className="aspect-[2/3] animate-pulse rounded-lg bg-(--background-subtle)" 232 - /> 233 - ))} 234 - </div> 235 - ) : episodes.length > 0 ? ( 236 - <div className="grid grid-cols-2 gap-4 sm:grid-cols-3 lg:grid-cols-6"> 237 - {episodes.map((item) => ( 238 - <ShelfItemCard 239 - key={item.id} 240 - item={item} 241 - isOwner={isOwner} 242 - onRemove={() => 243 - removeEpisodeMutation.mutate({ 244 - path: { showId: item.showId }, 245 - query: { 246 - seasonNumber: item.seasonNumber, 247 - episodeNumber: item.episodeNumber, 248 - mode: "all", 249 - }, 250 - }) 251 - } 252 - isRemoving={removeEpisodeMutation.isPending} 253 - /> 254 - ))} 154 + {/* Last Episodes */} 155 + <section> 156 + <div className="mb-4 flex items-center justify-between"> 157 + <h2 className="flex items-center gap-2 text-display-3"> 158 + <Tv className="h-5 w-5 text-(--accent)" /> 159 + Recent Episodes 160 + </h2> 161 + <Link 162 + to="/profile/$handle/shelf" 163 + params={{ handle }} 164 + search={{ type: "episode" }} 165 + className="flex items-center gap-1 font-medium text-(--accent) text-sm hover:text-(--accent-hover)" 166 + > 167 + View all 168 + <ChevronRight className="h-4 w-4" /> 169 + </Link> 255 170 </div> 256 - ) : ( 257 - <div className="card p-8 text-center"> 258 - <p className="text-(--foreground-muted)"> 259 - {displayName} hasn&apos;t watched any episodes yet. 260 - </p> 261 - </div> 262 - )} 263 - </section> 171 + 172 + {episodesLoading ? ( 173 + <div className="grid grid-cols-4 gap-4"> 174 + {[1, 2, 3, 4].map((i) => ( 175 + <div 176 + key={i} 177 + className="aspect-[2/3] animate-pulse rounded-lg bg-(--background-subtle)" 178 + /> 179 + ))} 180 + </div> 181 + ) : episodes.length > 0 ? ( 182 + <div className="grid grid-cols-4 gap-4"> 183 + {episodes.map((item) => ( 184 + <div key={item.id} className="[&_article]:!w-full"> 185 + <ActionableMediaCard 186 + id={item.show.showId} 187 + title={item.show.title} 188 + posterUrl={`https://image.tmdb.org/t/p/w500${item.show.posterPath}`} 189 + type="show" 190 + seasonNumber={item.seasonNumber} 191 + episodeNumber={item.episodeNumber} 192 + episodeInfo={`S${item.seasonNumber}E${item.episodeNumber}`} 193 + watchedDate={item.watchedDate} 194 + interactive={isOwner} 195 + isWatched={true} 196 + /> 197 + </div> 198 + ))} 199 + </div> 200 + ) : ( 201 + <div className="card p-8 text-center"> 202 + <p className="text-(--foreground-muted)"> 203 + {displayName} hasn&apos;t watched any episodes yet. 204 + </p> 205 + </div> 206 + )} 207 + </section> 208 + </div> 264 209 265 210 {/* Lists Preview */} 266 211 <div className="grid gap-8 lg:grid-cols-2"> ··· 268 213 title="Watchlist" 269 214 list={watchlist} 270 215 handle={handle} 271 - isLoading={listsLoading} 216 + userDid={userDid} 272 217 icon={Clock} 273 218 emptyText="Nothing on watchlist" 274 219 /> ··· 276 221 title="Favorites" 277 222 list={favorites} 278 223 handle={handle} 279 - isLoading={listsLoading} 224 + userDid={userDid} 280 225 icon={Heart} 281 226 emptyText="Nothing on favorites" 282 227 /> ··· 285 230 ); 286 231 } 287 232 288 - function ShelfItemCard({ 289 - item, 290 - isOwner, 291 - onRemove, 292 - isRemoving, 293 - }: { 294 - item: { 295 - id: string; 296 - type: "movie" | "episode"; 297 - posterPath?: string; 298 - watchedDate?: string; 299 - } & Record<string, unknown>; 300 - isOwner: boolean; 301 - onRemove: () => void; 302 - isRemoving: boolean; 303 - }) { 304 - const isMovie = item.type === "movie"; 305 - const title = isMovie ? (item.title as string) : (item.showTitle as string); 306 - const id = isMovie ? (item.movieId as string) : (item.showId as string); 307 - const year = isMovie 308 - ? (item.releaseYear as number | undefined) 309 - : (item.firstAirYear as number | undefined); 310 - 311 - const episodeInfo = !isMovie 312 - ? `S${item.seasonNumber}E${item.episodeNumber}${item.episodeTitle ? ` — ${item.episodeTitle}` : ""}` 313 - : undefined; 314 - 315 - return ( 316 - <div className="group relative"> 317 - <Link 318 - to={ 319 - isMovie ? "/movies/$movieId/$movieName" : "/shows/$showId/$showName" 320 - } 321 - params={ 322 - isMovie 323 - ? { movieId: id, movieName: toSlug(title) } 324 - : { showId: id, showName: toSlug(title) } 325 - } 326 - className="block" 327 - > 328 - <div className="aspect-[2/3] overflow-hidden rounded-lg bg-(--background-subtle)"> 329 - {item.posterPath ? ( 330 - <img 331 - src={`https://image.tmdb.org/t/p/w500${item.posterPath}`} 332 - alt={title} 333 - className="h-full w-full object-cover transition-transform group-hover:scale-105" 334 - loading="lazy" 335 - /> 336 - ) : ( 337 - <div className="flex h-full w-full items-center justify-center"> 338 - {isMovie ? ( 339 - <Film className="h-8 w-8 text-(--foreground-muted)" /> 340 - ) : ( 341 - <Tv className="h-8 w-8 text-(--foreground-muted)" /> 342 - )} 343 - </div> 344 - )} 345 - </div> 346 - <div className="mt-2"> 347 - <p className="truncate font-medium text-sm">{title}</p> 348 - <div className="flex flex-col gap-0.5 text-(--foreground-muted) text-xs"> 349 - {year && <span>{year}</span>} 350 - {episodeInfo && <span>{episodeInfo}</span>} 351 - {item.watchedDate && ( 352 - <span>{formatWatchedDate(item.watchedDate)}</span> 353 - )} 354 - </div> 355 - </div> 356 - </Link> 357 - 358 - {/* Remove button */} 359 - {isOwner && ( 360 - <button 361 - type="button" 362 - onClick={(e) => { 363 - e.preventDefault(); 364 - e.stopPropagation(); 365 - onRemove(); 366 - }} 367 - disabled={isRemoving} 368 - className="absolute top-2 right-2 flex h-7 w-7 items-center justify-center rounded-full bg-black/60 text-white opacity-0 transition-opacity hover:bg-red-500 disabled:opacity-100 group-hover:opacity-100" 369 - aria-label="Remove from shelf" 370 - > 371 - {isRemoving ? ( 372 - <Loader2 className="h-3.5 w-3.5 animate-spin" /> 373 - ) : ( 374 - <X className="h-3.5 w-3.5" /> 375 - )} 376 - </button> 377 - )} 378 - </div> 379 - ); 380 - } 381 - 382 233 function StatCard({ 383 234 label, 384 235 value, ··· 413 264 title, 414 265 list, 415 266 handle, 416 - isLoading, 267 + userDid, 417 268 icon: Icon, 418 269 emptyText, 419 270 }: { 420 271 title: string; 421 272 list?: { slug: string; itemCount: number }; 422 273 handle: string; 423 - isLoading: boolean; 274 + userDid: string; 424 275 icon: React.ComponentType<{ className?: string }>; 425 276 emptyText: string; 426 277 }) { 278 + const { data: listDetails, isLoading: itemsLoading } = useQuery({ 279 + ...listsControllerGetPublicUserListOptions({ 280 + path: { userDid, slug: list?.slug || "" }, 281 + }), 282 + enabled: !!list && list.itemCount > 0, 283 + }); 284 + 285 + const items = listDetails?.items?.slice(0, 4) ?? []; 286 + 427 287 return ( 428 288 <section> 429 289 <div className="mb-4 flex items-center justify-between"> ··· 443 303 )} 444 304 </div> 445 305 446 - {isLoading ? ( 306 + {!list || list.itemCount === 0 ? ( 307 + <div className="card p-6 text-center"> 308 + <p className="text-(--foreground-muted)">{emptyText}</p> 309 + </div> 310 + ) : itemsLoading ? ( 447 311 <div className="grid grid-cols-3 gap-4"> 448 312 {[1, 2, 3].map((i) => ( 449 313 <div ··· 452 316 /> 453 317 ))} 454 318 </div> 455 - ) : list && list.itemCount > 0 ? ( 456 - <Link 457 - to="/profile/$handle/lists/$listSlug" 458 - params={{ handle, listSlug: list.slug }} 459 - className="card card-interactive flex items-center justify-between p-4" 460 - > 461 - <div> 462 - <h3 className="font-semibold">{title}</h3> 463 - <p className="text-(--foreground-muted) text-sm"> 464 - {list.itemCount} item{list.itemCount === 1 ? "" : "s"} 465 - </p> 466 - </div> 467 - <ChevronRight className="h-5 w-5 text-(--foreground-muted)" /> 468 - </Link> 319 + ) : items.length > 0 ? ( 320 + <div className="grid grid-cols-2 gap-4 sm:grid-cols-4"> 321 + {items.map((item) => { 322 + const media = item.media as Record<string, unknown>; 323 + const posterPath = media.posterPath as string | undefined; 324 + const title = (media.title as string) || "Unknown"; 325 + const mediaId = (media.mediaId as string) || item.mediaId; 326 + const isEpisode = 327 + item.seasonNumber != null && item.episodeNumber != null; 328 + 329 + return ( 330 + <div key={item.id} className="[&_article]:!w-full"> 331 + <MediaCard 332 + id={mediaId} 333 + title={title} 334 + seasonNumber={item.seasonNumber} 335 + episodeNumber={item.episodeNumber} 336 + episodeInfo={ 337 + isEpisode 338 + ? item.episodeName 339 + ? `S${item.seasonNumber}E${item.episodeNumber} — ${item.episodeName}` 340 + : `S${item.seasonNumber}E${item.episodeNumber}` 341 + : undefined 342 + } 343 + posterUrl={ 344 + posterPath 345 + ? `https://image.tmdb.org/t/p/w500${posterPath}` 346 + : "" 347 + } 348 + type={item.mediaType as "movie" | "show"} 349 + href={ 350 + isEpisode 351 + ? `/show/${mediaId}/season/${item.seasonNumber}/episode/${item.episodeNumber}` 352 + : item.mediaType === "movie" 353 + ? `/movie/${mediaId}` 354 + : `/show/${mediaId}` 355 + } 356 + /> 357 + </div> 358 + ); 359 + })} 360 + </div> 469 361 ) : ( 470 362 <div className="card p-6 text-center"> 471 363 <p className="text-(--foreground-muted)">{emptyText}</p>
+33 -137
apps/web/src/routes/profile.$handle/shelf.tsx
··· 8 8 import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; 9 9 import { createFileRoute, Link } from "@tanstack/react-router"; 10 10 import { 11 + BookmarkX, 11 12 Film, 12 13 Grid3X3, 13 14 List as ListIcon, 14 15 Loader2, 15 16 Search, 16 17 Tv, 17 - X, 18 18 } from "lucide-react"; 19 19 import { useState } from "react"; 20 + import ActionableMediaCard from "#/components/ActionableMediaCard"; 20 21 import { Pagination } from "#/components/Pagination"; 21 22 import { setupApiClient } from "#/lib/api"; 22 23 import { useAuth } from "#/lib/auth-context"; ··· 200 201 </div> 201 202 ) : viewMode === "grid" ? ( 202 203 <div className="grid grid-cols-2 gap-4 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-6"> 203 - {items.map((item) => ( 204 - <ShelfGridCard 205 - key={item.id} 206 - item={item} 207 - isOwner={isOwner} 208 - onRemoveMovie={(movieId) => 209 - removeMovieMutation.mutate({ 210 - path: { movieId }, 211 - query: { mode: "all" }, 212 - }) 213 - } 214 - onRemoveEpisode={(showId, seasonNumber, episodeNumber) => 215 - removeEpisodeMutation.mutate({ 216 - path: { showId }, 217 - query: { 218 - seasonNumber, 219 - episodeNumber, 220 - mode: "all", 221 - }, 222 - }) 223 - } 224 - isRemoving={ 225 - removeMovieMutation.isPending || removeEpisodeMutation.isPending 226 - } 227 - /> 228 - ))} 204 + {items.map((item) => { 205 + const isMovie = item.type === "movie"; 206 + return ( 207 + <ActionableMediaCard 208 + key={item.id} 209 + id={ 210 + isMovie ? (item.movieId as string) : (item.showId as string) 211 + } 212 + title={ 213 + isMovie ? (item.title as string) : (item.showTitle as string) 214 + } 215 + posterUrl={`https://image.tmdb.org/t/p/w500${item.posterPath}`} 216 + type={isMovie ? "movie" : "show"} 217 + seasonNumber={ 218 + isMovie ? undefined : (item.seasonNumber as number) 219 + } 220 + episodeNumber={ 221 + isMovie ? undefined : (item.episodeNumber as number) 222 + } 223 + episodeInfo={ 224 + isMovie 225 + ? undefined 226 + : `S${item.seasonNumber}E${item.episodeNumber}${item.episodeTitle ? ` — ${item.episodeTitle}` : ""}` 227 + } 228 + watchedDate={item.watchedDate} 229 + interactive={isOwner} 230 + isWatched={true} 231 + /> 232 + ); 233 + })} 229 234 </div> 230 235 ) : ( 231 236 <div className="space-y-2"> ··· 272 277 ); 273 278 } 274 279 275 - function ShelfGridCard({ 276 - item, 277 - isOwner, 278 - onRemoveMovie, 279 - onRemoveEpisode, 280 - isRemoving, 281 - }: { 282 - item: { 283 - id: string; 284 - type: "movie" | "episode"; 285 - posterPath?: string; 286 - watchedDate?: string; 287 - } & Record<string, unknown>; 288 - isOwner: boolean; 289 - onRemoveMovie: (movieId: string) => void; 290 - onRemoveEpisode: ( 291 - showId: string, 292 - seasonNumber: number, 293 - episodeNumber: number, 294 - ) => void; 295 - isRemoving: boolean; 296 - }) { 297 - const isMovie = item.type === "movie"; 298 - const title = isMovie ? (item.title as string) : (item.showTitle as string); 299 - const id = isMovie ? (item.movieId as string) : (item.showId as string); 300 - const year = isMovie 301 - ? (item.releaseYear as number | undefined) 302 - : (item.firstAirYear as number | undefined); 303 - 304 - const episodeInfo = !isMovie 305 - ? `S${item.seasonNumber}E${item.episodeNumber}${item.episodeTitle ? ` — ${item.episodeTitle}` : ""}` 306 - : undefined; 307 - 308 - const handleRemove = (e: React.MouseEvent) => { 309 - e.preventDefault(); 310 - e.stopPropagation(); 311 - if (isMovie) { 312 - onRemoveMovie(id); 313 - } else { 314 - onRemoveEpisode( 315 - id, 316 - item.seasonNumber as number, 317 - item.episodeNumber as number, 318 - ); 319 - } 320 - }; 321 - 322 - return ( 323 - <div className="group relative"> 324 - <Link 325 - to={ 326 - isMovie ? "/movies/$movieId/$movieName" : "/shows/$showId/$showName" 327 - } 328 - params={ 329 - isMovie 330 - ? { movieId: id, movieName: toSlug(title) } 331 - : { showId: id, showName: toSlug(title) } 332 - } 333 - className="block" 334 - > 335 - <div className="aspect-[2/3] overflow-hidden rounded-lg bg-(--background-subtle)"> 336 - {item.posterPath ? ( 337 - <img 338 - src={`https://image.tmdb.org/t/p/w500${item.posterPath}`} 339 - alt={title} 340 - className="h-full w-full object-cover transition-transform group-hover:scale-105" 341 - loading="lazy" 342 - /> 343 - ) : ( 344 - <div className="flex h-full w-full items-center justify-center"> 345 - {isMovie ? ( 346 - <Film className="h-8 w-8 text-(--foreground-muted)" /> 347 - ) : ( 348 - <Tv className="h-8 w-8 text-(--foreground-muted)" /> 349 - )} 350 - </div> 351 - )} 352 - </div> 353 - <div className="mt-2"> 354 - <p className="truncate font-medium text-sm">{title}</p> 355 - <div className="flex flex-col gap-0.5 text-(--foreground-muted) text-xs"> 356 - {year && <span>{year}</span>} 357 - {episodeInfo && <span>{episodeInfo}</span>} 358 - {item.watchedDate && ( 359 - <span>{formatWatchedDate(item.watchedDate)}</span> 360 - )} 361 - </div> 362 - </div> 363 - </Link> 364 - 365 - {isOwner && ( 366 - <button 367 - type="button" 368 - onClick={handleRemove} 369 - disabled={isRemoving} 370 - className="absolute top-2 right-2 flex h-7 w-7 items-center justify-center rounded-full bg-black/60 text-white opacity-0 transition-opacity hover:bg-red-500 disabled:opacity-100 group-hover:opacity-100" 371 - aria-label="Remove from shelf" 372 - > 373 - {isRemoving ? ( 374 - <Loader2 className="h-3.5 w-3.5 animate-spin" /> 375 - ) : ( 376 - <X className="h-3.5 w-3.5" /> 377 - )} 378 - </button> 379 - )} 380 - </div> 381 - ); 382 - } 383 - 384 280 function ShelfListRow({ 385 281 item, 386 282 isOwner, ··· 480 376 {isRemoving ? ( 481 377 <Loader2 className="h-4 w-4 animate-spin" /> 482 378 ) : ( 483 - <X className="h-4 w-4" /> 379 + <BookmarkX className="h-4 w-4" /> 484 380 )} 485 381 </button> 486 382 )}
+4 -15
apps/web/src/routes/search.tsx
··· 24 24 } from "lucide-react"; 25 25 import { useEffect, useMemo, useState } from "react"; 26 26 import { z } from "zod"; 27 + import ActionableMediaCard from "#/components/ActionableMediaCard"; 27 28 import { UserAvatar } from "#/components/following/UserAvatar"; 28 - import MediaCard from "#/components/MediaCard"; 29 29 import { Pagination } from "#/components/Pagination"; 30 30 import { useDebounce } from "#/hooks/useDebounce"; 31 31 import { setupApiClient } from "#/lib/api"; ··· 62 62 63 63 function getTitle(item: UnifiedSearchResultDto): string { 64 64 return item.title || item.name || "Unknown"; 65 - } 66 - 67 - function getYear(item: UnifiedSearchResultDto): number | undefined { 68 - const date = item.release_date || item.first_air_date; 69 - if (date) { 70 - return new Date(date).getFullYear(); 71 - } 72 - return undefined; 73 65 } 74 66 75 67 function getPosterUrl(item: UnifiedSearchResultDto): string { ··· 281 273 <section> 282 274 <div className="grid gap-4 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5"> 283 275 {searchData.results.map((item) => ( 284 - <MediaCard 276 + <ActionableMediaCard 285 277 key={`media-${item.id}-${item.media_type}`} 286 278 id={item.id} 287 279 title={getTitle(item)} 288 280 posterUrl={getPosterUrl(item)} 289 281 backdropUrl={getBackdropUrl(item)} 290 282 type={item.media_type === "movie" ? "movie" : "show"} 291 - year={getYear(item)} 292 283 rating={item.vote_average || undefined} 293 284 size="md" 294 285 layout="poster" ··· 303 294 <section> 304 295 <div className="grid gap-4 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5"> 305 296 {movies.map((item) => ( 306 - <MediaCard 297 + <ActionableMediaCard 307 298 key={`movie-${item.id}`} 308 299 id={item.id} 309 300 title={getTitle(item)} 310 301 posterUrl={getPosterUrl(item)} 311 302 backdropUrl={getBackdropUrl(item)} 312 303 type="movie" 313 - year={getYear(item)} 314 304 rating={item.vote_average || undefined} 315 305 size="md" 316 306 layout="poster" ··· 325 315 <section> 326 316 <div className="grid gap-4 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5"> 327 317 {shows.map((item) => ( 328 - <MediaCard 318 + <ActionableMediaCard 329 319 key={`show-${item.id}`} 330 320 id={item.id} 331 321 title={getTitle(item)} 332 322 posterUrl={getPosterUrl(item)} 333 323 backdropUrl={getBackdropUrl(item)} 334 324 type="show" 335 - year={getYear(item)} 336 325 rating={item.vote_average || undefined} 337 326 size="md" 338 327 layout="poster"
+4
backend/src/lists/dto/list.dto.ts
··· 123 123 @ApiPropertyOptional({ description: "Episode number for episode show items" }) 124 124 episodeNumber?: number; 125 125 126 + @ApiPropertyOptional({ description: "Episode name for episode show items" }) 127 + episodeName?: string; 128 + 126 129 @ApiPropertyOptional() 127 130 notes?: string; 128 131 ··· 140 143 showId?: string; 141 144 seasonNumber?: number; 142 145 episodeNumber?: number; 146 + episodeName?: string; 143 147 title: string; 144 148 posterPath?: string; 145 149 backdropPath?: string;
+6 -1
backend/src/lists/list-mappers.ts
··· 45 45 items: ListItemRecord[]; 46 46 }; 47 47 48 - export function mapItemToDto(item: ListItemRecord): MediaInListDto { 48 + export function mapItemToDto( 49 + item: ListItemRecord, 50 + episodeName?: string, 51 + ): MediaInListDto { 49 52 const parsedShowScope = 50 53 item.mediaType === "show" 51 54 ? parseScopedShowMediaId(item.mediaId) ··· 82 85 mediaId: item.mediaId, 83 86 seasonNumber: parsedShowScope?.seasonNumber, 84 87 episodeNumber: parsedShowScope?.episodeNumber, 88 + episodeName, 85 89 notes: item.notes ?? undefined, 86 90 position: item.position, 87 91 createdAt: item.createdAt.toISOString(), ··· 92 96 showId: item.show?.showId ?? parsedShowScope?.showId, 93 97 seasonNumber: parsedShowScope?.seasonNumber, 94 98 episodeNumber: parsedShowScope?.episodeNumber, 99 + episodeName, 95 100 title: mediaTitle ?? "", 96 101 posterPath: mediaPosterPath ?? undefined, 97 102 backdropPath: mediaBackdropPath ?? undefined,
+53 -1
backend/src/lists/lists.service.ts
··· 154 154 : {}), 155 155 }); 156 156 157 + const episodeItems = items.filter( 158 + (item) => item.mediaType === "show" && item.showId, 159 + ); 160 + const episodeKeys = episodeItems 161 + .map((item) => { 162 + const parsed = parseScopedShowMediaId(item.mediaId); 163 + if (parsed?.seasonNumber != null && parsed?.episodeNumber != null) { 164 + return { 165 + showId: parsed.showId, 166 + seasonNumber: parsed.seasonNumber, 167 + episodeNumber: parsed.episodeNumber, 168 + }; 169 + } 170 + return null; 171 + }) 172 + .filter((k): k is NonNullable<typeof k> => k != null); 173 + 174 + const episodes = 175 + episodeKeys.length > 0 176 + ? await this.prisma.episode.findMany({ 177 + where: { 178 + OR: episodeKeys.map((k) => ({ 179 + showId: k.showId, 180 + seasonNumber: k.seasonNumber, 181 + episodeNumber: k.episodeNumber, 182 + })), 183 + }, 184 + select: { 185 + showId: true, 186 + seasonNumber: true, 187 + episodeNumber: true, 188 + name: true, 189 + }, 190 + }) 191 + : []; 192 + 193 + const episodeMap = new Map( 194 + episodes.map((ep) => [ 195 + `${ep.showId}:${ep.seasonNumber}:${ep.episodeNumber}`, 196 + ep.name, 197 + ]), 198 + ); 199 + 157 200 return { 158 201 id: list.id, 159 202 rkey: list.rkey, ··· 165 208 isDefault: list.isDefault, 166 209 createdAt: list.createdAt.toISOString(), 167 210 updatedAt: list.updatedAt.toISOString(), 168 - items: items.map((item) => mapItemToDto(item)), 211 + items: items.map((item) => { 212 + const parsed = parseScopedShowMediaId(item.mediaId); 213 + const episodeName = 214 + parsed?.seasonNumber != null && parsed?.episodeNumber != null 215 + ? episodeMap.get( 216 + `${parsed.showId}:${parsed.seasonNumber}:${parsed.episodeNumber}`, 217 + ) 218 + : undefined; 219 + return mapItemToDto(item, episodeName); 220 + }), 169 221 total, 170 222 page: currentPage, 171 223 pageSize: shouldPaginate && safePageSize ? safePageSize : total,
+4
packages/api/src/generated/types.gen.ts
··· 767 767 * Episode number for episode show items 768 768 */ 769 769 episodeNumber?: number; 770 + /** 771 + * Episode name for episode show items 772 + */ 773 + episodeName?: string; 770 774 notes?: string; 771 775 position: number; 772 776 createdAt: string;