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.

Improve person filmography cards

Add role and year metadata to media cards and show them on
person detail known-for and filmography sections. Switch
filmography loading to the paginated hook and broaden shelf
query invalidation after watch actions.

+101 -57
+6
apps/web/src/components/ActionableMediaCard.tsx
··· 20 20 watchedDate?: string; 21 21 seasonNumber?: number; 22 22 episodeNumber?: number; 23 + role?: string; 24 + year?: string | number; 23 25 size?: "sm" | "md" | "lg"; 24 26 layout?: "poster" | "backdrop"; 25 27 interactive?: boolean; ··· 41 43 watchedDate, 42 44 seasonNumber, 43 45 episodeNumber, 46 + role, 47 + year, 44 48 size = "md", 45 49 layout = "poster", 46 50 interactive = true, ··· 171 175 watchedDate={formattedWatchedDate} 172 176 seasonNumber={seasonNumber} 173 177 episodeNumber={episodeNumber} 178 + role={role} 179 + year={year} 174 180 size={size} 175 181 layout={layout} 176 182 isWatched={watched}
+51 -26
apps/web/src/components/MediaCard.tsx
··· 32 32 isWatched?: boolean; 33 33 isInWatchlist?: boolean; 34 34 watchedDate?: string; 35 + role?: string; 36 + year?: string | number; 35 37 size?: "sm" | "md" | "lg"; 36 38 layout?: "poster" | "backdrop"; 37 39 href?: string; ··· 60 62 progress, 61 63 isWatched = false, 62 64 watchedDate, 65 + role, 66 + year, 63 67 size = "md", 64 68 layout = "poster", 65 69 href, ··· 312 316 {displayName} 313 317 </h3> 314 318 )} 319 + {role && ( 320 + <p className="line-clamp-1 text-(--foreground-muted) text-xs italic"> 321 + {role} 322 + </p> 323 + )} 315 324 <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 - )} 328 - {rating && ( 329 - <> 330 - <span>•</span> 331 - <span className="flex items-center gap-1"> 332 - <Star className="size-3 fill-current text-yellow-500" /> 333 - {rating.toFixed(1)} 325 + {(() => { 326 + const parts: { key: string; node: React.ReactNode }[] = []; 327 + if (year) 328 + parts.push({ key: "year", node: <span>{year}</span> }); 329 + if ( 330 + typeof seasonNumber === "number" && 331 + type === "show" && 332 + !episodeInfo 333 + ) { 334 + parts.push({ 335 + key: "season", 336 + node: ( 337 + <span> 338 + {typeof episodeNumber === "number" 339 + ? `S${seasonNumber}E${episodeNumber}` 340 + : `Season ${seasonNumber}`} 341 + </span> 342 + ), 343 + }); 344 + } 345 + if (rating) { 346 + parts.push({ 347 + key: "rating", 348 + node: ( 349 + <span className="flex items-center gap-1"> 350 + <Star className="size-3 fill-current text-yellow-500" /> 351 + {rating.toFixed(1)} 352 + </span> 353 + ), 354 + }); 355 + } 356 + if (duration) 357 + parts.push({ 358 + key: "duration", 359 + node: <span>{duration}</span>, 360 + }); 361 + return parts.map((part, i) => ( 362 + <span key={part.key} className="flex items-center gap-2"> 363 + {i > 0 && <span>•</span>} 364 + {part.node} 334 365 </span> 335 - </> 336 - )} 337 - {duration && ( 338 - <> 339 - <span>•</span> 340 - <span>{duration}</span> 341 - </> 342 - )} 366 + )); 367 + })()} 343 368 </div> 344 369 {watchedDate && ( 345 370 <p className="flex items-center gap-1 text-(--foreground-muted) text-xs">
-1
apps/web/src/lib/hooks/useWatchActions.ts
··· 167 167 queryClient.invalidateQueries({ 168 168 queryKey: shelfControllerGetUserShelfQueryKey({ 169 169 path: { userDid }, 170 - query: { page: 1, pageSize: 6 }, 171 170 }), 172 171 }); 173 172 queryClient.invalidateQueries({
+44 -30
apps/web/src/routes/people/$personId/$personName.tsx
··· 1 + import type { PersonFilmographyItemDto } from "@opnshelf/api"; 1 2 import { peopleControllerGetPersonDetailsOptions } from "@opnshelf/api"; 2 3 import { createFileRoute, Link } from "@tanstack/react-router"; 3 4 import { ··· 7 8 MapPin, 8 9 Star, 9 10 } from "lucide-react"; 10 - import { useMemo, useState } from "react"; 11 + import { useMemo } from "react"; 11 12 import { setupApiClient } from "#/lib/api"; 12 13 import { useAuth } from "#/lib/auth-context"; 13 14 import { formatDate } from "#/lib/date-utils"; 14 - import { usePersonDetails } from "#/lib/hooks"; 15 + import { usePersonDetails, usePersonFilmography } from "#/lib/hooks"; 15 16 import { buildPersonPageMeta } from "#/lib/media-meta"; 16 17 import ActionableMediaCard from "../../../components/ActionableMediaCard"; 17 18 import DetailsCard from "../../../components/DetailsCard"; ··· 20 21 21 22 setupApiClient(); 22 23 23 - const FILMOGRAPHY_PAGE_SIZE = 20; 24 + function getRoleText(item: PersonFilmographyItemDto): string | undefined { 25 + if (item.character) return item.character; 26 + if (item.job) return item.job; 27 + if (item.roles && item.roles.length > 0) { 28 + const roles = item.roles.map((r) => r.character || r.job).filter(Boolean); 29 + return [...new Set(roles)].join(" / ") || undefined; 30 + } 31 + return undefined; 32 + } 33 + 34 + function getYear(item: PersonFilmographyItemDto): string | undefined { 35 + const date = item.release_date || item.first_air_date; 36 + if (!date) return undefined; 37 + const year = new Date(date).getFullYear(); 38 + return Number.isNaN(year) ? undefined : String(year); 39 + } 24 40 25 41 export const Route = createFileRoute("/people/$personId/$personName")({ 26 42 loader: async ({ context, params }) => { ··· 51 67 const userTimezone = userSettings?.timezone; 52 68 53 69 const { data: person, isLoading, error } = usePersonDetails(personId); 54 - const [filmographyLimit, setFilmographyLimit] = useState( 55 - FILMOGRAPHY_PAGE_SIZE, 56 - ); 70 + const { 71 + data: filmographyData, 72 + fetchNextPage, 73 + hasNextPage, 74 + isFetchingNextPage, 75 + } = usePersonFilmography(personId); 57 76 58 77 const allFilmography = useMemo(() => { 59 - if (!person?.filmography) return []; 60 - return [...person.filmography].sort((a, b) => { 78 + const items = filmographyData?.pages.flatMap((page) => page.items) ?? []; 79 + return [...items].sort((a, b) => { 61 80 const dateA = a.release_date || a.first_air_date || ""; 62 81 const dateB = b.release_date || b.first_air_date || ""; 63 82 return dateB.localeCompare(dateA); 64 83 }); 65 - }, [person?.filmography]); 66 - 67 - const filmographyItems = useMemo(() => { 68 - return allFilmography.slice(0, filmographyLimit); 69 - }, [allFilmography, filmographyLimit]); 70 - 71 - const hasMoreFilmography = filmographyItems.length < allFilmography.length; 84 + }, [filmographyData]); 72 85 73 86 const knownForItems = useMemo(() => { 74 - if (!person?.filmography) return []; 75 - return [...person.filmography] 87 + if (allFilmography.length === 0) return []; 88 + return [...allFilmography] 76 89 .filter((item) => item.vote_average && item.vote_average > 0) 77 90 .sort((a, b) => (b.vote_average ?? 0) - (a.vote_average ?? 0)) 78 91 .slice(0, 6); 79 - }, [person?.filmography]); 92 + }, [allFilmography]); 80 93 81 94 if (isLoading) return <LoadingState />; 82 95 if (error || !person) { ··· 223 236 {knownForItems.length > 0 && ( 224 237 <section> 225 238 <h2 className="mb-4 text-display-3">Known For</h2> 226 - <div className="grid grid-cols-2 gap-4 sm:grid-cols-2 lg:grid-cols-3"> 239 + <div className="flex flex-wrap gap-4"> 227 240 {knownForItems.map((item) => ( 228 241 <ActionableMediaCard 229 242 key={`known-${item.id}-${item.media_type}`} ··· 240 253 ? Math.round(item.vote_average * 10) / 10 241 254 : undefined 242 255 } 243 - size="sm" 256 + role={getRoleText(item)} 257 + year={getYear(item)} 258 + size="md" 244 259 layout="poster" 245 260 /> 246 261 ))} ··· 257 272 </p> 258 273 ) : ( 259 274 <> 260 - <div className="grid grid-cols-2 gap-4 sm:grid-cols-2 lg:grid-cols-4"> 261 - {filmographyItems.map((item) => ( 275 + <div className="flex flex-wrap gap-4"> 276 + {allFilmography.map((item) => ( 262 277 <ActionableMediaCard 263 278 key={`film-${item.id}-${item.media_type}`} 264 279 id={item.id} ··· 274 289 ? Math.round(item.vote_average * 10) / 10 275 290 : undefined 276 291 } 277 - size="sm" 292 + role={getRoleText(item)} 293 + year={getYear(item)} 294 + size="md" 278 295 layout="poster" 279 296 /> 280 297 ))} 281 298 </div> 282 - {hasMoreFilmography && ( 299 + {hasNextPage && ( 283 300 <div className="mt-6 flex justify-center"> 284 301 <button 285 302 type="button" 286 - onClick={() => 287 - setFilmographyLimit( 288 - (prev) => prev + FILMOGRAPHY_PAGE_SIZE, 289 - ) 290 - } 303 + onClick={() => fetchNextPage()} 304 + disabled={isFetchingNextPage} 291 305 className="btn btn-secondary gap-2" 292 306 > 293 - Load more 307 + {isFetchingNextPage ? "Loading..." : "Load more"} 294 308 </button> 295 309 </div> 296 310 )}