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.

Highlight current episode block

+126 -68
+2 -2
apps/mobile/app/_layout.tsx
··· 71 71 }} 72 72 /> 73 73 <Stack.Screen 74 - name="show/[id]/season/[seasonNumber]" 74 + name="show/[id]/season/[seasonNumber]/index" 75 75 options={{ 76 76 title: "Season", 77 77 headerShown: true, 78 78 }} 79 79 /> 80 80 <Stack.Screen 81 - name="show/[id]/season/[seasonNumber]/episode/[episodeNumber]" 81 + name="show/[id]/season/[seasonNumber]/episode/[episodeNumber]/index" 82 82 options={{ 83 83 title: "Episode", 84 84 headerShown: true,
+124 -66
apps/web/src/routes/shows.$showId.$title.seasons.$seasonNumber.episodes.$episodeNumber.tsx
··· 16 16 import { createFileRoute, Link, useRouter } from "@tanstack/react-router"; 17 17 import { 18 18 ArrowLeft, 19 + ArrowRight, 19 20 Calendar, 20 21 Check, 22 + CircleDot, 21 23 Eye, 22 24 Film, 23 25 History, ··· 126 128 ) 127 129 .sort( 128 130 (a, b) => 129 - new Date(b.watchedDate).getTime() - new Date(a.watchedDate).getTime(), 131 + new Date(b.watchedDate).getTime() - 132 + new Date(a.watchedDate).getTime(), 130 133 )[0] ?? null 131 134 ); 132 135 }, [history, seasonNumber, episodeNumber]); ··· 262 265 } 263 266 }; 264 267 265 - const relatedEpisodes = useMemo(() => { 266 - if (!season?.episodes?.length) return []; 267 - return season.episodes 268 - .filter((e) => e.episode_number !== Number(episodeNumber)) 269 - .slice(0, 4); 268 + const seasonEpisodeContext = useMemo(() => { 269 + if (!season?.episodes?.length) 270 + return { previous: null, current: null, next: null }; 271 + const sortedEpisodes = [...season.episodes].sort( 272 + (a, b) => a.episode_number - b.episode_number, 273 + ); 274 + const currentIndex = sortedEpisodes.findIndex( 275 + (e) => e.episode_number === Number(episodeNumber), 276 + ); 277 + if (currentIndex < 0) return { previous: null, current: null, next: null }; 278 + return { 279 + previous: sortedEpisodes[currentIndex - 1] ?? null, 280 + current: sortedEpisodes[currentIndex] ?? null, 281 + next: sortedEpisodes[currentIndex + 1] ?? null, 282 + }; 270 283 }, [season?.episodes, episodeNumber]); 271 284 272 285 return ( ··· 399 412 }} 400 413 > 401 414 Watched on{" "} 402 - {formatDateWithTimezone(latestEpisodeWatch.watchedDate, { 403 - timezone: userTimezone, 404 - is24Hour, 405 - })} 415 + {formatDateWithTimezone( 416 + latestEpisodeWatch.watchedDate, 417 + { 418 + timezone: userTimezone, 419 + is24Hour, 420 + }, 421 + )} 406 422 </p> 407 423 )} 408 424 {episodeWatchHistory.length > 1 ? ( ··· 414 430 }} 415 431 > 416 432 <History className="w-3 h-3" /> 417 - <span>{episodeWatchHistory.length} total watches</span> 433 + <span> 434 + {episodeWatchHistory.length} total watches 435 + </span> 418 436 </div> 419 437 <button 420 438 type="button" ··· 432 450 onMouseLeave={(e) => { 433 451 e.currentTarget.style.color = 434 452 "var(--md-sys-color-on-surface-variant)"; 435 - e.currentTarget.style.backgroundColor = "transparent"; 453 + e.currentTarget.style.backgroundColor = 454 + "transparent"; 436 455 }} 437 456 > 438 457 <Eye className="w-4 h-4" /> ··· 518 537 </div> 519 538 520 539 <div className="space-y-6 min-w-0"> 540 + <div className="flex flex-wrap gap-3"> 541 + <Link 542 + to="/shows/$showId/$title/seasons/$seasonNumber" 543 + params={{ showId, title, seasonNumber }} 544 + className="rounded-full border border-(--md-sys-color-outline) px-3 py-1.5 text-sm text-gray-300 flex items-center gap-2 hover:bg-gray-900/40 transition-colors" 545 + > 546 + <Layers className="w-4 h-4" />S{seasonNumber} 547 + </Link> 548 + <div className="rounded-full border border-(--md-sys-color-outline) px-3 py-1.5 text-sm text-gray-300 flex items-center gap-2"> 549 + <Film className="w-4 h-4" />E{episodeNumber} 550 + </div> 551 + <div className="rounded-full border border-(--md-sys-color-outline) px-3 py-1.5 text-sm text-gray-300 flex items-center gap-2"> 552 + <Calendar className="w-4 h-4" /> 553 + {episode?.air_date 554 + ? formatDateOnly(episode.air_date) 555 + : "Air date unknown"} 556 + </div> 557 + <div className="rounded-full border border-(--md-sys-color-outline) px-3 py-1.5 text-sm text-gray-300 flex items-center gap-2"> 558 + <Star className="w-4 h-4" /> 559 + {episode?.vote_average 560 + ? `${episode.vote_average.toFixed(1)}/10` 561 + : "Not rated"} 562 + </div> 563 + </div> 521 564 <section> 522 565 <h2 523 566 className="text-xl font-semibold mb-3" ··· 528 571 <p className="text-gray-300 leading-relaxed mb-4"> 529 572 {episode?.overview || "No overview available."} 530 573 </p> 531 - <div className="flex flex-wrap gap-3"> 532 - <div className="rounded-full border border-[var(--md-sys-color-outline)] px-3 py-1.5 text-sm text-gray-300 flex items-center gap-2"> 533 - <Calendar className="w-4 h-4" /> 534 - {episode?.air_date ? formatDateOnly(episode.air_date) : "Air date unknown"} 535 - </div> 536 - <div className="rounded-full border border-[var(--md-sys-color-outline)] px-3 py-1.5 text-sm text-gray-300 flex items-center gap-2"> 537 - <Star className="w-4 h-4" /> 538 - {episode?.vote_average ? `${episode.vote_average.toFixed(1)}/10` : "Not rated"} 539 - </div> 540 - </div> 541 574 </section> 542 575 543 - <div className="grid grid-cols-1 sm:grid-cols-2 gap-3"> 544 - <Link 545 - to="/shows/$showId/$title/seasons/$seasonNumber" 546 - params={{ showId, title, seasonNumber }} 547 - className="p-4 rounded-lg bg-gray-900/40 border border-[var(--md-sys-color-outline)] hover:bg-gray-900/60 transition-colors" 548 - > 549 - <div className="text-sm text-gray-400 mb-1">Season</div> 550 - <div className="font-medium flex items-center gap-2"> 551 - <Layers className="w-4 h-4" /> 552 - {seasonNumber} 553 - </div> 554 - </Link> 555 - <div className="p-4 rounded-lg bg-gray-900/40 border border-[var(--md-sys-color-outline)]"> 556 - <div className="text-sm text-gray-400 mb-1">Episode</div> 557 - <div className="font-medium flex items-center gap-2"> 558 - <Film className="w-4 h-4" /> 559 - {episodeNumber} 560 - </div> 561 - </div> 562 - </div> 563 - 564 - {relatedEpisodes.length ? ( 576 + {seasonEpisodeContext.current ? ( 565 577 <section> 566 578 <h2 567 579 className="text-xl font-semibold mb-3" ··· 569 581 > 570 582 More In This Season 571 583 </h2> 572 - <div className="grid grid-cols-1 sm:grid-cols-2 gap-3"> 573 - {relatedEpisodes.map((e) => ( 574 - <Link 575 - key={e.id} 576 - to="/shows/$showId/$title/seasons/$seasonNumber/episodes/$episodeNumber" 577 - params={{ 578 - showId, 579 - title, 580 - seasonNumber, 581 - episodeNumber: String(e.episode_number), 582 - }} 583 - className="rounded-lg border border-[var(--md-sys-color-outline)] p-3 bg-gray-900/30 hover:bg-gray-900/50 transition-colors" 584 - > 585 - <div className="font-medium text-sm"> 586 - E{e.episode_number}: {e.name} 587 - </div> 588 - <div className="text-xs text-gray-400 mt-1"> 589 - {e.air_date ? formatDateOnly(e.air_date) : "TBA"} 590 - </div> 591 - </Link> 592 - ))} 584 + <div className="grid grid-cols-1 md:grid-cols-3 gap-3"> 585 + {[ 586 + { 587 + key: "previous", 588 + label: "Previous Episode", 589 + icon: <ArrowLeft className="w-4 h-4" />, 590 + episode: seasonEpisodeContext.previous, 591 + highlighted: false, 592 + }, 593 + { 594 + key: "current", 595 + label: "Current Episode", 596 + icon: <CircleDot className="w-4 h-4" />, 597 + episode: seasonEpisodeContext.current, 598 + highlighted: true, 599 + }, 600 + { 601 + key: "next", 602 + label: "Next Episode", 603 + icon: <ArrowRight className="w-4 h-4" />, 604 + episode: seasonEpisodeContext.next, 605 + highlighted: false, 606 + }, 607 + ].map((slot) => { 608 + if (!slot.episode) return null; 609 + return ( 610 + <Link 611 + key={slot.key} 612 + to="/shows/$showId/$title/seasons/$seasonNumber/episodes/$episodeNumber" 613 + params={{ 614 + showId, 615 + title, 616 + seasonNumber, 617 + episodeNumber: String(slot.episode.episode_number), 618 + }} 619 + className={`rounded-lg border p-3 transition-colors ${ 620 + slot.highlighted 621 + ? "bg-gray-900/60 border-(--md-sys-color-primary) hover:bg-gray-900/70" 622 + : "bg-gray-900/30 border-(--md-sys-color-outline) hover:bg-gray-900/50" 623 + }`} 624 + > 625 + <div className="text-xs uppercase tracking-wide text-gray-400 mb-2 flex items-center gap-2"> 626 + {slot.icon} 627 + {slot.label} 628 + </div> 629 + <div 630 + className={`rounded-md px-2 py-2 ${ 631 + slot.highlighted 632 + ? "bg-(--md-sys-color-primary)/15" 633 + : "" 634 + }`} 635 + > 636 + <div className="font-medium text-sm"> 637 + E{slot.episode.episode_number}: {slot.episode.name} 638 + </div> 639 + <div className="text-xs text-gray-400 mt-1"> 640 + {slot.episode.air_date 641 + ? formatDateOnly(slot.episode.air_date) 642 + : "TBA"} 643 + </div> 644 + </div> 645 + </Link> 646 + ); 647 + })} 593 648 </div> 594 649 </section> 595 650 ) : null} ··· 704 759 )} 705 760 </div> 706 761 <div className="mt-4 flex justify-end"> 707 - <M3Button variant="outlined" onClick={() => setShowHistoryDialog(false)}> 762 + <M3Button 763 + variant="outlined" 764 + onClick={() => setShowHistoryDialog(false)} 765 + > 708 766 Close 709 767 </M3Button> 710 768 </div>