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: new design for detail screens

+2110 -1160
-7
apps/web/.cursorrules
··· 1 - # shadcn instructions 2 - 3 - Use the latest version of Shadcn to install new components, like this command to add a button component: 4 - 5 - ```bash 6 - pnpm dlx shadcn@latest add button 7 - ```
+37
apps/web/src/components/detail/BreadcrumbNav.tsx
··· 1 + import { Link } from "@tanstack/react-router"; 2 + import { ChevronRight } from "lucide-react"; 3 + import type { BreadcrumbItem, ColorTheme } from "./types"; 4 + 5 + type BreadcrumbNavProps = { 6 + items: BreadcrumbItem[]; 7 + colors: ColorTheme; 8 + }; 9 + 10 + export function BreadcrumbNav({ items, colors }: BreadcrumbNavProps) { 11 + return ( 12 + <nav className="flex items-center gap-1 text-sm mb-4"> 13 + {items.map((item, index) => { 14 + const isLast = index === items.length - 1; 15 + 16 + return ( 17 + <div key={item.label} className="flex items-center gap-1"> 18 + {index > 0 && <ChevronRight className="w-4 h-4 text-gray-500" />} 19 + {item.linkTo && !isLast ? ( 20 + <Link 21 + to={item.linkTo.to} 22 + params={item.linkTo.params} 23 + className="text-gray-400 hover:text-gray-200 transition-colors" 24 + > 25 + {item.label} 26 + </Link> 27 + ) : ( 28 + <span className="font-medium" style={{ color: colors.primary }}> 29 + {item.label} 30 + </span> 31 + )} 32 + </div> 33 + ); 34 + })} 35 + </nav> 36 + ); 37 + }
+204
apps/web/src/components/detail/DetailActions.tsx
··· 1 + import { Calendar, Check, ListPlus, RotateCcw, Share2 } from "lucide-react"; 2 + import { useState } from "react"; 3 + import { toast } from "sonner"; 4 + import { AddToShelfButton } from "@/components/AddToShelfButton"; 5 + import { ActionButton } from "@/components/ui/action-button"; 6 + import { TrackedStatusCard } from "./TrackedStatusCard"; 7 + import type { ColorTheme } from "./types"; 8 + 9 + type DetailActionsProps = { 10 + mediaType: "movie" | "show" | "season" | "episode"; 11 + mediaId: string; 12 + seasonNumber?: string; 13 + episodeNumber?: string; 14 + colors: ColorTheme; 15 + isWatched: boolean; 16 + watchedDate?: string | null; 17 + totalWatches?: number; 18 + onMarkWatched: () => void; 19 + onUnmarkWatched?: () => void; 20 + onShowDatePicker: () => void; 21 + isMarkingPending?: boolean; 22 + isUnmarkingPending?: boolean; 23 + listsCount?: number; 24 + onShowListModal?: () => void; 25 + onViewHistory?: () => void; 26 + isLoggedIn?: boolean; 27 + onLogin?: () => void; 28 + }; 29 + 30 + export function DetailActions({ 31 + mediaType, 32 + colors, 33 + isWatched, 34 + watchedDate, 35 + totalWatches = 0, 36 + onMarkWatched, 37 + onUnmarkWatched, 38 + onShowDatePicker, 39 + isMarkingPending = false, 40 + isUnmarkingPending = false, 41 + listsCount = 0, 42 + onShowListModal, 43 + onViewHistory, 44 + isLoggedIn = true, 45 + onLogin, 46 + }: DetailActionsProps) { 47 + const [copied, setCopied] = useState(false); 48 + 49 + const handleShare = async () => { 50 + const url = window.location.href; 51 + if (navigator.share) { 52 + try { 53 + await navigator.share({ url }); 54 + } catch { 55 + // User cancelled share 56 + } 57 + } else { 58 + try { 59 + await navigator.clipboard.writeText(url); 60 + setCopied(true); 61 + toast.success("Link copied to clipboard"); 62 + setTimeout(() => setCopied(false), 2000); 63 + } catch { 64 + toast.error("Failed to copy link"); 65 + } 66 + } 67 + }; 68 + 69 + const isInAnyList = listsCount > 0; 70 + 71 + if (!isLoggedIn && onLogin) { 72 + return ( 73 + <div className="space-y-3"> 74 + <button 75 + type="button" 76 + className="w-full py-4 px-6 rounded-xl m3-label-large text-center transition-all duration-200 hover:scale-[1.02]" 77 + style={{ 78 + background: `linear-gradient(135deg, ${colors.primary} 0%, ${colors.secondary} 100%)`, 79 + boxShadow: `0 15px 35px -10px ${colors.primary}60`, 80 + color: "var(--md-sys-color-on-primary)", 81 + }} 82 + onClick={onLogin} 83 + > 84 + Sign in to Track 85 + </button> 86 + <ActionButton 87 + icon={<Share2 className="w-4 h-4" />} 88 + label="Share" 89 + onClick={handleShare} 90 + /> 91 + </div> 92 + ); 93 + } 94 + 95 + return ( 96 + <div className="space-y-3"> 97 + {isWatched ? ( 98 + <> 99 + <TrackedStatusCard 100 + isWatched={isWatched} 101 + watchedDate={watchedDate} 102 + totalWatches={totalWatches} 103 + onViewHistory={onViewHistory} 104 + onRemove={onUnmarkWatched} 105 + isRemoving={isUnmarkingPending} 106 + colors={colors} 107 + /> 108 + <div className="flex gap-2"> 109 + <AddToShelfButton 110 + onClick={onMarkWatched} 111 + isPending={isMarkingPending} 112 + label="Watch Again" 113 + icon={<RotateCcw className="w-4 h-4" />} 114 + colors={colors} 115 + size="compact" 116 + className="flex-1" 117 + /> 118 + <button 119 + type="button" 120 + onClick={onShowDatePicker} 121 + title={`Watch ${mediaType}`} 122 + className="p-3 rounded-xl border transition-all duration-200 flex items-center justify-center group" 123 + style={{ 124 + backgroundColor: "transparent", 125 + borderColor: "var(--md-sys-color-outline)", 126 + }} 127 + onMouseEnter={(e) => { 128 + e.currentTarget.style.backgroundColor = 129 + "var(--md-sys-color-surface-container)"; 130 + e.currentTarget.style.borderColor = 131 + "var(--md-sys-color-primary)"; 132 + }} 133 + onMouseLeave={(e) => { 134 + e.currentTarget.style.backgroundColor = "transparent"; 135 + e.currentTarget.style.borderColor = 136 + "var(--md-sys-color-outline)"; 137 + }} 138 + > 139 + <Calendar className="w-5 h-5 text-(--md-sys-color-on-surface-variant) group-hover:text-(--md-sys-color-primary) transition-colors" /> 140 + </button> 141 + </div> 142 + </> 143 + ) : ( 144 + <div className="flex gap-2"> 145 + <AddToShelfButton 146 + onClick={onMarkWatched} 147 + isPending={isMarkingPending} 148 + label="Add to Shelf" 149 + icon={<Calendar className="w-5 h-5" />} 150 + colors={colors} 151 + className="flex-1" 152 + /> 153 + <button 154 + type="button" 155 + onClick={onShowDatePicker} 156 + title={`Watch ${mediaType}`} 157 + className="p-3 rounded-xl border transition-all duration-200 flex items-center justify-center group" 158 + style={{ 159 + backgroundColor: "transparent", 160 + borderColor: "var(--md-sys-color-outline)", 161 + }} 162 + onMouseEnter={(e) => { 163 + e.currentTarget.style.backgroundColor = 164 + "var(--md-sys-color-surface-container)"; 165 + e.currentTarget.style.borderColor = "var(--md-sys-color-primary)"; 166 + }} 167 + onMouseLeave={(e) => { 168 + e.currentTarget.style.backgroundColor = "transparent"; 169 + e.currentTarget.style.borderColor = "var(--md-sys-color-outline)"; 170 + }} 171 + > 172 + <Calendar className="w-5 h-5 text-(--md-sys-color-on-surface-variant) group-hover:text-(--md-sys-color-primary) transition-colors" /> 173 + </button> 174 + </div> 175 + )} 176 + 177 + {onShowListModal && ( 178 + <ActionButton 179 + icon={ 180 + isInAnyList ? ( 181 + <Check className="w-4 h-4" /> 182 + ) : ( 183 + <ListPlus className="w-4 h-4" /> 184 + ) 185 + } 186 + label={ 187 + isInAnyList 188 + ? `In ${listsCount} list${listsCount > 1 ? "s" : ""}` 189 + : "Add to List" 190 + } 191 + onClick={onShowListModal} 192 + isActive={isInAnyList} 193 + activeColor={colors.primary} 194 + /> 195 + )} 196 + 197 + <ActionButton 198 + icon={<Share2 className="w-4 h-4" />} 199 + label={copied ? "Copied!" : "Share"} 200 + onClick={handleShare} 201 + /> 202 + </div> 203 + ); 204 + }
+149
apps/web/src/components/detail/DetailHero.tsx
··· 1 + import { Link } from "@tanstack/react-router"; 2 + import { ArrowLeft } from "lucide-react"; 3 + import type { ColorTheme } from "./types"; 4 + 5 + type DetailHeroProps = { 6 + title: string; 7 + subtitle?: string; 8 + backdropUrl?: string | null; 9 + posterUrl?: string | null; 10 + posterLinkTo?: { 11 + to: string; 12 + params: Record<string, string>; 13 + }; 14 + colors: ColorTheme; 15 + isLoading?: boolean; 16 + onBack?: () => void; 17 + }; 18 + 19 + export function DetailHero({ 20 + title, 21 + subtitle, 22 + backdropUrl, 23 + posterUrl, 24 + posterLinkTo, 25 + colors, 26 + isLoading, 27 + onBack, 28 + }: DetailHeroProps) { 29 + if (isLoading) { 30 + return ( 31 + <div className="relative h-[50vh] md:h-[60vh] overflow-hidden"> 32 + <div 33 + className="w-full h-full animate-pulse" 34 + style={{ 35 + background: `linear-gradient(135deg, #1a1a2e 0%, #0f0f1a 100%)`, 36 + }} 37 + /> 38 + <div className="absolute bottom-0 left-0 right-0 p-4 md:p-8"> 39 + <div className="container mx-auto max-w-6xl"> 40 + <div className="flex items-end gap-4 md:gap-8"> 41 + <div className="shrink-0"> 42 + <div className="w-28 md:w-48 lg:w-64 rounded-lg overflow-hidden bg-(--md-sys-color-surface-container)" /> 43 + </div> 44 + <div className="flex-1 pb-2"> 45 + <div className="h-8 md:h-16 lg:w-96 bg-(--md-sys-color-surface-container) rounded-lg animate-pulse" /> 46 + </div> 47 + </div> 48 + </div> 49 + </div> 50 + </div> 51 + ); 52 + } 53 + 54 + const posterContent = posterUrl ? ( 55 + <img 56 + src={posterUrl} 57 + alt={title} 58 + className="w-full aspect-2/3 object-cover" 59 + /> 60 + ) : ( 61 + <div className="w-full aspect-2/3 bg-gray-900 flex items-center justify-center"> 62 + <span className="text-gray-600">No poster</span> 63 + </div> 64 + ); 65 + 66 + return ( 67 + <div className="relative h-[50vh] md:h-[60vh] overflow-hidden"> 68 + {backdropUrl ? ( 69 + <> 70 + <img 71 + src={backdropUrl} 72 + alt="" 73 + className="w-full h-full object-cover" 74 + /> 75 + <div 76 + className="absolute inset-0" 77 + style={{ 78 + background: `linear-gradient(to bottom, transparent 0%, rgba(3, 7, 18, 0.6) 60%, rgb(3, 7, 18) 100%)`, 79 + }} 80 + /> 81 + <div 82 + className="absolute inset-0" 83 + style={{ 84 + background: `linear-gradient(to right, rgba(3, 7, 18, 0.8) 0%, transparent 50%)`, 85 + }} 86 + /> 87 + </> 88 + ) : ( 89 + <div 90 + className="w-full h-full" 91 + style={{ 92 + background: `linear-gradient(135deg, ${colors.muted} 0%, rgb(3, 7, 18) 100%)`, 93 + }} 94 + /> 95 + )} 96 + 97 + <button 98 + type="button" 99 + onClick={onBack} 100 + className="absolute top-4 left-4 z-10 p-2 rounded-full bg-black/50 hover:bg-black/70 transition-colors cursor-pointer" 101 + > 102 + <ArrowLeft className="w-5 h-5" /> 103 + </button> 104 + 105 + <div className="absolute bottom-0 left-0 right-0 p-4 md:p-8"> 106 + <div className="container mx-auto max-w-6xl"> 107 + <div className="flex items-end gap-4 md:gap-8"> 108 + <div className="shrink-0"> 109 + <div 110 + className="w-28 md:w-48 lg:w-64 rounded-lg overflow-hidden shadow-2xl" 111 + style={{ 112 + boxShadow: `0 25px 50px -12px ${colors.primary}40`, 113 + }} 114 + > 115 + {posterLinkTo ? ( 116 + <Link 117 + to={posterLinkTo.to} 118 + params={posterLinkTo.params} 119 + className="block transition-transform hover:scale-105" 120 + > 121 + {posterContent} 122 + </Link> 123 + ) : ( 124 + posterContent 125 + )} 126 + </div> 127 + </div> 128 + 129 + <div className="flex-1 pb-2"> 130 + <h1 131 + className="text-2xl md:text-5xl lg:text-6xl font-bold mb-2" 132 + style={{ 133 + textShadow: `0 4px 30px ${colors.primary}60`, 134 + }} 135 + > 136 + {title} 137 + </h1> 138 + {subtitle && ( 139 + <h2 className="text-lg md:text-2xl text-gray-200"> 140 + {subtitle} 141 + </h2> 142 + )} 143 + </div> 144 + </div> 145 + </div> 146 + </div> 147 + </div> 148 + ); 149 + }
+87
apps/web/src/components/detail/EpisodeCard.tsx
··· 1 + import { Link } from "@tanstack/react-router"; 2 + import { Calendar, Star } from "lucide-react"; 3 + import { formatDateOnly } from "@/lib/utils"; 4 + import type { ColorTheme, EpisodeSummary } from "./types"; 5 + 6 + type EpisodeCardProps = { 7 + showId: string; 8 + title: string; 9 + seasonNumber: string; 10 + episode: EpisodeSummary; 11 + watchedCount?: number; 12 + colors: ColorTheme; 13 + }; 14 + 15 + export function EpisodeCard({ 16 + showId, 17 + title, 18 + seasonNumber, 19 + episode, 20 + watchedCount = 0, 21 + colors, 22 + }: EpisodeCardProps) { 23 + return ( 24 + <Link 25 + to="/shows/$showId/$title/seasons/$seasonNumber/episodes/$episodeNumber" 26 + params={{ 27 + showId, 28 + title, 29 + seasonNumber, 30 + episodeNumber: String(episode.episode_number), 31 + }} 32 + className="group block rounded-xl border bg-gray-900/30 hover:bg-gray-900/50 transition-all overflow-hidden" 33 + style={{ 34 + borderColor: 35 + watchedCount > 0 36 + ? `${colors.primary}40` 37 + : "var(--md-sys-color-outline)", 38 + }} 39 + > 40 + <div className="grid grid-cols-[120px_1fr] gap-4"> 41 + <div className="h-full bg-gray-900 min-h-[67px]"> 42 + {episode.still_path ? ( 43 + <img 44 + src={`https://image.tmdb.org/t/p/w300${episode.still_path}`} 45 + alt={episode.name} 46 + className="w-full h-full object-cover" 47 + /> 48 + ) : ( 49 + <div className="w-full h-full flex items-center justify-center text-gray-600 text-xs"> 50 + No image 51 + </div> 52 + )} 53 + </div> 54 + <div className="p-3 min-w-0"> 55 + <div className="flex items-center justify-between gap-2 mb-1"> 56 + <p className="font-medium line-clamp-1 group-hover:text-white transition-colors"> 57 + E{episode.episode_number} · {episode.name} 58 + </p> 59 + {episode.vote_average ? ( 60 + <span className="text-xs flex items-center gap-1 text-gray-300"> 61 + <Star className="w-3 h-3" /> 62 + {episode.vote_average.toFixed(1)} 63 + </span> 64 + ) : null} 65 + </div> 66 + <p className="text-xs text-gray-400 line-clamp-2 mb-2"> 67 + {episode.overview || "No overview available."} 68 + </p> 69 + <div className="flex items-center gap-3 text-xs text-gray-400"> 70 + <span className="flex items-center gap-1"> 71 + <Calendar className="w-3 h-3" /> 72 + {episode.air_date ? formatDateOnly(episode.air_date) : "TBA"} 73 + </span> 74 + {watchedCount > 0 && ( 75 + <span 76 + className="flex items-center gap-1" 77 + style={{ color: colors.primary }} 78 + > 79 + {watchedCount} watched 80 + </span> 81 + )} 82 + </div> 83 + </div> 84 + </div> 85 + </Link> 86 + ); 87 + }
+177
apps/web/src/components/detail/EpisodeNav.tsx
··· 1 + import { Link } from "@tanstack/react-router"; 2 + import { ArrowLeft, ArrowRight, CircleDot } from "lucide-react"; 3 + import { formatDateOnly } from "@/lib/utils"; 4 + import type { ColorTheme, EpisodeSummary } from "./types"; 5 + 6 + type EpisodeNavProps = { 7 + showId: string; 8 + title: string; 9 + seasonNumber: string; 10 + previousEpisode: EpisodeSummary | null; 11 + currentEpisode: EpisodeSummary; 12 + nextEpisode: EpisodeSummary | null; 13 + colors: ColorTheme; 14 + variant?: "sidebar" | "full"; 15 + }; 16 + 17 + export function EpisodeNav({ 18 + showId, 19 + title, 20 + seasonNumber, 21 + previousEpisode, 22 + currentEpisode, 23 + nextEpisode, 24 + colors, 25 + variant = "full", 26 + }: EpisodeNavProps) { 27 + if (variant === "sidebar") { 28 + const hasPrev = previousEpisode !== null; 29 + const hasNext = nextEpisode !== null; 30 + 31 + if (!hasPrev && !hasNext) { 32 + return null; 33 + } 34 + 35 + return ( 36 + <div className="flex gap-2"> 37 + {hasPrev ? ( 38 + <Link 39 + to="/shows/$showId/$title/seasons/$seasonNumber/episodes/$episodeNumber" 40 + params={{ 41 + showId, 42 + title, 43 + seasonNumber, 44 + episodeNumber: String(previousEpisode.episode_number), 45 + }} 46 + className="flex-1 flex items-center justify-center gap-2 py-2 px-4 rounded-lg border border-(--md-sys-color-outline) hover:bg-gray-900/40 transition-colors text-sm" 47 + > 48 + <ArrowLeft className="w-4 h-4" /> 49 + <span>Episode {previousEpisode.episode_number}</span> 50 + </Link> 51 + ) : ( 52 + <div className="flex-1" /> 53 + )} 54 + 55 + {hasNext ? ( 56 + <Link 57 + to="/shows/$showId/$title/seasons/$seasonNumber/episodes/$episodeNumber" 58 + params={{ 59 + showId, 60 + title, 61 + seasonNumber, 62 + episodeNumber: String(nextEpisode.episode_number), 63 + }} 64 + className="flex-1 flex items-center justify-center gap-2 py-2 px-4 rounded-lg border border-(--md-sys-color-outline) hover:bg-gray-900/40 transition-colors text-sm" 65 + > 66 + <span>Episode {nextEpisode.episode_number}</span> 67 + <ArrowRight className="w-4 h-4" /> 68 + </Link> 69 + ) : ( 70 + <div className="flex-1" /> 71 + )} 72 + </div> 73 + ); 74 + } 75 + const slots = [ 76 + { 77 + key: "previous", 78 + label: "Previous Episode", 79 + icon: <ArrowLeft className="w-4 h-4" />, 80 + episode: previousEpisode, 81 + highlighted: false, 82 + }, 83 + { 84 + key: "current", 85 + label: "Current Episode", 86 + icon: <CircleDot className="w-4 h-4" />, 87 + episode: currentEpisode, 88 + highlighted: true, 89 + }, 90 + { 91 + key: "next", 92 + label: "Next Episode", 93 + icon: <ArrowRight className="w-4 h-4" />, 94 + episode: nextEpisode, 95 + highlighted: false, 96 + }, 97 + ]; 98 + 99 + return ( 100 + <section> 101 + <h2 102 + className="text-xl font-semibold mb-3" 103 + style={{ color: colors.primary }} 104 + > 105 + More In This Season 106 + </h2> 107 + <div className="grid grid-cols-1 md:grid-cols-3 gap-3"> 108 + {slots.map((slot) => { 109 + if (!slot.episode) { 110 + return ( 111 + <div 112 + key={slot.key} 113 + className="rounded-lg border p-3 opacity-50" 114 + style={{ 115 + borderColor: "var(--md-sys-color-outline)", 116 + }} 117 + > 118 + <div className="text-xs uppercase tracking-wide text-gray-400 mb-2 flex items-center gap-2"> 119 + {slot.icon} 120 + {slot.label} 121 + </div> 122 + <div className="text-gray-500 text-sm">No episode</div> 123 + </div> 124 + ); 125 + } 126 + 127 + return ( 128 + <Link 129 + key={slot.key} 130 + to="/shows/$showId/$title/seasons/$seasonNumber/episodes/$episodeNumber" 131 + params={{ 132 + showId, 133 + title, 134 + seasonNumber, 135 + episodeNumber: String(slot.episode.episode_number), 136 + }} 137 + className={`rounded-lg border p-3 transition-colors ${ 138 + slot.highlighted 139 + ? "bg-gray-900/60 hover:bg-gray-900/70" 140 + : "bg-gray-900/30 hover:bg-gray-900/50" 141 + }`} 142 + style={{ 143 + borderColor: slot.highlighted 144 + ? colors.primary 145 + : "var(--md-sys-color-outline)", 146 + }} 147 + > 148 + <div className="text-xs uppercase tracking-wide text-gray-400 mb-2 flex items-center gap-2"> 149 + {slot.icon} 150 + {slot.label} 151 + </div> 152 + <div 153 + className={`rounded-md px-2 py-2 ${slot.highlighted ? "" : ""}`} 154 + style={ 155 + slot.highlighted 156 + ? { 157 + backgroundColor: `${colors.primary}15`, 158 + } 159 + : {} 160 + } 161 + > 162 + <div className="font-medium text-sm"> 163 + E{slot.episode.episode_number}: {slot.episode.name} 164 + </div> 165 + <div className="text-xs text-gray-400 mt-1"> 166 + {slot.episode.air_date 167 + ? formatDateOnly(slot.episode.air_date) 168 + : "TBA"} 169 + </div> 170 + </div> 171 + </Link> 172 + ); 173 + })} 174 + </div> 175 + </section> 176 + ); 177 + }
+43
apps/web/src/components/detail/MetadataPills.tsx
··· 1 + import { Link } from "@tanstack/react-router"; 2 + import type { MetadataPill } from "./types"; 3 + 4 + type MetadataPillsProps = { 5 + items: MetadataPill[]; 6 + }; 7 + 8 + export function MetadataPills({ items }: MetadataPillsProps) { 9 + return ( 10 + <div className="flex flex-wrap gap-3"> 11 + {items.map((item) => { 12 + const content = ( 13 + <> 14 + {item.icon} 15 + {item.label} 16 + </> 17 + ); 18 + 19 + if (item.linkTo) { 20 + return ( 21 + <Link 22 + key={item.label} 23 + to={item.linkTo.to} 24 + params={item.linkTo.params} 25 + 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" 26 + > 27 + {content} 28 + </Link> 29 + ); 30 + } 31 + 32 + return ( 33 + <div 34 + key={item.label} 35 + className="rounded-full border border-(--md-sys-color-outline) px-3 py-1.5 text-sm text-gray-300 flex items-center gap-2" 36 + > 37 + {content} 38 + </div> 39 + ); 40 + })} 41 + </div> 42 + ); 43 + }
+105
apps/web/src/components/detail/SeasonCard.tsx
··· 1 + import type { TmdbShowDetailDto } from "@opnshelf/api"; 2 + import { Link } from "@tanstack/react-router"; 3 + import { Calendar, Film } from "lucide-react"; 4 + import type { ColorTheme } from "./types"; 5 + 6 + type SeasonCardProps = { 7 + showId: string; 8 + title: string; 9 + seasonNumber: number; 10 + posterUrl?: string | null; 11 + airDate?: string; 12 + episodeCount: number; 13 + watchedCount: number; 14 + overview?: string; 15 + colors: ColorTheme; 16 + showData?: TmdbShowDetailDto; 17 + }; 18 + 19 + export function SeasonCard({ 20 + showId, 21 + title, 22 + seasonNumber, 23 + posterUrl, 24 + airDate, 25 + episodeCount, 26 + watchedCount, 27 + overview, 28 + colors, 29 + }: SeasonCardProps) { 30 + const progress = 31 + episodeCount > 0 ? Math.round((watchedCount / episodeCount) * 100) : 0; 32 + 33 + return ( 34 + <Link 35 + to="/shows/$showId/$title/seasons/$seasonNumber" 36 + params={{ 37 + showId, 38 + title, 39 + seasonNumber: String(seasonNumber), 40 + }} 41 + className="group block rounded-xl border bg-gray-900/30 hover:bg-gray-900/50 transition-all overflow-hidden" 42 + style={{ borderColor: "var(--md-sys-color-outline)" }} 43 + > 44 + <div className="grid grid-cols-[100px_1fr] gap-4"> 45 + <div className="aspect-2/3 bg-gray-900"> 46 + {posterUrl ? ( 47 + <img 48 + src={posterUrl} 49 + alt={`Season ${seasonNumber}`} 50 + className="w-full h-full object-cover" 51 + /> 52 + ) : ( 53 + <div className="w-full h-full flex items-center justify-center text-gray-600 text-xs"> 54 + No poster 55 + </div> 56 + )} 57 + </div> 58 + <div className="py-3 pr-4 min-w-0"> 59 + <div className="flex items-center justify-between gap-2 mb-1"> 60 + <h3 61 + className="font-semibold text-lg group-hover:text-white transition-colors" 62 + style={{ color: colors.primary }} 63 + > 64 + Season {seasonNumber} 65 + </h3> 66 + {airDate && ( 67 + <span className="text-xs text-gray-400 flex items-center gap-1"> 68 + <Calendar className="w-3 h-3" /> 69 + {new Date(airDate).getFullYear()} 70 + </span> 71 + )} 72 + </div> 73 + 74 + <div className="flex items-center gap-3 text-xs text-gray-400 mb-2"> 75 + <span className="flex items-center gap-1"> 76 + <Film className="w-3 h-3" /> 77 + {episodeCount} episodes 78 + </span> 79 + {watchedCount > 0 && ( 80 + <span className="text-gray-300">{watchedCount} watched</span> 81 + )} 82 + </div> 83 + 84 + {overview && ( 85 + <p className="text-xs text-gray-400 line-clamp-2 mb-3"> 86 + {overview} 87 + </p> 88 + )} 89 + 90 + {episodeCount > 0 && ( 91 + <div className="w-full h-1.5 bg-gray-800 rounded-full overflow-hidden"> 92 + <div 93 + className="h-full rounded-full transition-all" 94 + style={{ 95 + width: `${progress}%`, 96 + background: `linear-gradient(90deg, ${colors.primary} 0%, ${colors.secondary} 100%)`, 97 + }} 98 + /> 99 + </div> 100 + )} 101 + </div> 102 + </div> 103 + </Link> 104 + ); 105 + }
+61
apps/web/src/components/detail/SeasonNav.tsx
··· 1 + import { Link } from "@tanstack/react-router"; 2 + import { ArrowLeft, ArrowRight } from "lucide-react"; 3 + 4 + type SeasonNavProps = { 5 + showId: string; 6 + title: string; 7 + currentSeason: number; 8 + totalSeasons: number; 9 + }; 10 + 11 + export function SeasonNav({ 12 + showId, 13 + title, 14 + currentSeason, 15 + totalSeasons, 16 + }: SeasonNavProps) { 17 + const hasPrev = currentSeason > 1; 18 + const hasNext = currentSeason < totalSeasons; 19 + 20 + if (!hasPrev && !hasNext) { 21 + return null; 22 + } 23 + 24 + return ( 25 + <div className="flex gap-2"> 26 + {hasPrev ? ( 27 + <Link 28 + to="/shows/$showId/$title/seasons/$seasonNumber" 29 + params={{ 30 + showId, 31 + title, 32 + seasonNumber: String(currentSeason - 1), 33 + }} 34 + className="flex-1 flex items-center justify-center gap-2 py-2 px-4 rounded-lg border border-(--md-sys-color-outline) hover:bg-gray-900/40 transition-colors text-sm" 35 + > 36 + <ArrowLeft className="w-4 h-4" /> 37 + <span>Season {currentSeason - 1}</span> 38 + </Link> 39 + ) : ( 40 + <div className="flex-1" /> 41 + )} 42 + 43 + {hasNext ? ( 44 + <Link 45 + to="/shows/$showId/$title/seasons/$seasonNumber" 46 + params={{ 47 + showId, 48 + title, 49 + seasonNumber: String(currentSeason + 1), 50 + }} 51 + className="flex-1 flex items-center justify-center gap-2 py-2 px-4 rounded-lg border border-(--md-sys-color-outline) hover:bg-gray-900/40 transition-colors text-sm" 52 + > 53 + <span>Season {currentSeason + 1}</span> 54 + <ArrowRight className="w-4 h-4" /> 55 + </Link> 56 + ) : ( 57 + <div className="flex-1" /> 58 + )} 59 + </div> 60 + ); 61 + }
+114
apps/web/src/components/detail/TrackedStatusCard.tsx
··· 1 + import { Check, Eye, History, Loader2, Trash2 } from "lucide-react"; 2 + import type { ColorTheme } from "./types"; 3 + 4 + type TrackedStatusCardProps = { 5 + isWatched: boolean; 6 + watchedDate?: string | null; 7 + totalWatches?: number; 8 + onViewHistory?: () => void; 9 + onRemove?: () => void; 10 + isRemoving?: boolean; 11 + colors: ColorTheme; 12 + }; 13 + 14 + export function TrackedStatusCard({ 15 + isWatched, 16 + watchedDate, 17 + totalWatches = 0, 18 + onViewHistory, 19 + onRemove, 20 + isRemoving = false, 21 + colors, 22 + }: TrackedStatusCardProps) { 23 + if (!isWatched) { 24 + return null; 25 + } 26 + 27 + return ( 28 + <div 29 + className="p-4 rounded-xl" 30 + style={{ 31 + backgroundColor: "var(--md-sys-color-surface-container-highest)", 32 + }} 33 + > 34 + <div 35 + className="flex items-center gap-2 mb-2" 36 + style={{ color: colors.primary }} 37 + > 38 + <Check className="w-5 h-5" /> 39 + <span className="m3-title-medium">On Your Shelf</span> 40 + </div> 41 + 42 + {watchedDate && ( 43 + <p 44 + className="m3-body-medium" 45 + style={{ color: "var(--md-sys-color-on-surface-variant)" }} 46 + > 47 + Watched on {watchedDate} 48 + </p> 49 + )} 50 + 51 + {totalWatches > 1 && ( 52 + <> 53 + <div 54 + className="mt-2 flex items-center gap-2 m3-body-small" 55 + style={{ color: "var(--md-sys-color-on-surface-variant)" }} 56 + > 57 + <History className="w-3 h-3" /> 58 + <span>{totalWatches} total watches</span> 59 + </div> 60 + {onViewHistory && ( 61 + <button 62 + type="button" 63 + onClick={onViewHistory} 64 + className="mt-2 flex items-center gap-2 m3-body-medium transition-colors py-2 px-3 -ml-3 rounded-lg" 65 + style={{ color: "var(--md-sys-color-on-surface-variant)" }} 66 + onMouseEnter={(e) => { 67 + e.currentTarget.style.color = "var(--md-sys-color-on-surface)"; 68 + e.currentTarget.style.backgroundColor = 69 + "var(--md-sys-color-surface-container)"; 70 + }} 71 + onMouseLeave={(e) => { 72 + e.currentTarget.style.color = 73 + "var(--md-sys-color-on-surface-variant)"; 74 + e.currentTarget.style.backgroundColor = "transparent"; 75 + }} 76 + > 77 + <Eye className="w-4 h-4" /> 78 + View all watches 79 + </button> 80 + )} 81 + </> 82 + )} 83 + 84 + {totalWatches <= 1 && onRemove && ( 85 + <button 86 + type="button" 87 + onClick={onRemove} 88 + disabled={isRemoving} 89 + className="mt-2 flex items-center gap-2 m3-body-medium transition-colors py-2 px-3 -ml-3 rounded-lg disabled:opacity-50" 90 + style={{ color: "var(--md-sys-color-error)" }} 91 + onMouseEnter={(e) => { 92 + e.currentTarget.style.backgroundColor = 93 + "var(--md-sys-color-error-container)"; 94 + }} 95 + onMouseLeave={(e) => { 96 + e.currentTarget.style.backgroundColor = "transparent"; 97 + }} 98 + > 99 + {isRemoving ? ( 100 + <> 101 + <Loader2 className="w-4 h-4 animate-spin" /> 102 + Loading 103 + </> 104 + ) : ( 105 + <> 106 + <Trash2 className="w-4 h-4" /> 107 + Remove from shelf 108 + </> 109 + )} 110 + </button> 111 + )} 112 + </div> 113 + ); 114 + }
+16
apps/web/src/components/detail/index.ts
··· 1 + export { BreadcrumbNav } from "./BreadcrumbNav"; 2 + export { DetailActions } from "./DetailActions"; 3 + export { DetailHero } from "./DetailHero"; 4 + export { EpisodeCard } from "./EpisodeCard"; 5 + export { EpisodeNav } from "./EpisodeNav"; 6 + export { MetadataPills } from "./MetadataPills"; 7 + export { SeasonCard } from "./SeasonCard"; 8 + export { SeasonNav } from "./SeasonNav"; 9 + export { TrackedStatusCard } from "./TrackedStatusCard"; 10 + export type { 11 + BreadcrumbItem, 12 + ColorTheme, 13 + EpisodeSummary, 14 + MetadataPill, 15 + SeasonSummary, 16 + } from "./types";
+43
apps/web/src/components/detail/types.ts
··· 1 + import type { ReactNode } from "react"; 2 + 3 + export type ColorTheme = { 4 + primary: string; 5 + secondary: string; 6 + accent: string; 7 + muted: string; 8 + }; 9 + 10 + export type BreadcrumbItem = { 11 + label: string; 12 + linkTo?: { 13 + to: string; 14 + params: Record<string, string>; 15 + }; 16 + }; 17 + 18 + export type MetadataPill = { 19 + icon?: ReactNode; 20 + label: string; 21 + linkTo?: { 22 + to: string; 23 + params: Record<string, string>; 24 + }; 25 + }; 26 + 27 + export type EpisodeSummary = { 28 + episode_number: number; 29 + name: string; 30 + air_date?: string; 31 + overview?: string; 32 + still_path?: string; 33 + vote_average?: number; 34 + }; 35 + 36 + export type SeasonSummary = { 37 + season_number: number; 38 + name: string; 39 + air_date?: string; 40 + overview?: string; 41 + poster_path?: string; 42 + episode_count: number; 43 + };
+84 -400
apps/web/src/routes/movies.$movieId.$title.tsx
··· 15 15 } from "@opnshelf/api"; 16 16 import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; 17 17 import { createFileRoute, useRouter } from "@tanstack/react-router"; 18 - import { 19 - Calendar, 20 - Check, 21 - Eye, 22 - History, 23 - ListPlus, 24 - Loader2, 25 - RotateCcw, 26 - Share2, 27 - Trash2, 28 - } from "lucide-react"; 18 + import { Calendar, Clock, History, Loader2, Star, Trash2 } from "lucide-react"; 29 19 import { useMemo, useState } from "react"; 30 20 import { toast } from "sonner"; 31 21 import { AddToListModal } from "@/components/AddToListModal"; 32 - import { AddToShelfButton } from "@/components/AddToShelfButton"; 33 22 import { CastSection } from "@/components/CastSection"; 34 23 import { CrewSection } from "@/components/CrewSection"; 35 24 import { DatePickerModal } from "@/components/DatePickerModal"; 25 + import { 26 + type ColorTheme, 27 + DetailActions, 28 + DetailHero, 29 + MetadataPills, 30 + } from "@/components/detail"; 36 31 import { GenresSection } from "@/components/GenresSection"; 37 - import { MovieDetails } from "@/components/MovieDetails"; 38 - import { MovieHero } from "@/components/MovieHero"; 39 32 import { useTheme } from "@/components/theme-provider"; 40 - import { ActionButton } from "@/components/ui/action-button"; 41 33 import { 42 34 Dialog, 43 35 DialogContent, ··· 46 38 DialogTitle, 47 39 } from "@/components/ui/dialog"; 48 40 import { M3Button } from "@/components/ui/m3-button"; 49 - import { formatDateWithTimezone } from "@/lib/utils"; 41 + import { 42 + formatDateOnly, 43 + formatDateWithTimezone, 44 + formatRuntime, 45 + getTmdbBackdropUrl, 46 + getTmdbPosterUrl, 47 + } from "@/lib/utils"; 50 48 51 49 export const Route = createFileRoute("/movies/$movieId/$title")({ 52 50 loader: async ({ params, context }) => { ··· 145 143 }); 146 144 147 145 const listsCount = listsForMovie?.filter((l) => l.isInList).length ?? 0; 148 - const isInAnyList = listsCount > 0; 149 146 150 147 const userTimezone = userSettings?.timezone || "UTC"; 151 148 const is24Hour = userSettings?.timeFormat === "24h"; ··· 178 175 }); 179 176 }, [trackedMovie, userTimezone, is24Hour]); 180 177 181 - const colors = movie?.colors || { 182 - primary: seedColor, 183 - secondary: seedColor, 184 - accent: seedColor, 185 - muted: seedColor, 178 + const colors: ColorTheme = { 179 + primary: movie?.colors?.primary || seedColor, 180 + secondary: movie?.colors?.secondary || seedColor, 181 + accent: movie?.colors?.accent || seedColor, 182 + muted: movie?.colors?.muted || seedColor, 186 183 }; 187 184 188 185 const markMutation = useMutation({ ··· 257 254 }); 258 255 }; 259 256 260 - const handleShare = async () => { 261 - const url = window.location.href; 262 - if (navigator.share) { 263 - try { 264 - await navigator.share({ url }); 265 - } catch { 266 - // User cancelled share 267 - } 268 - } else { 269 - try { 270 - await navigator.clipboard.writeText(url); 271 - toast.success("Link copied to clipboard"); 272 - } catch { 273 - toast.error("Failed to copy link"); 274 - } 275 - } 276 - }; 277 - 278 257 const isPending = 279 258 markMutation.isPending && markMutation.variables?.body?.movieId === movieId; 280 259 260 + const backdropUrl = getTmdbBackdropUrl(movie?.backdrop_path); 261 + const posterUrl = getTmdbPosterUrl(movie?.poster_path, "w500"); 262 + 263 + const releaseYear = movie?.release_date 264 + ? new Date(movie.release_date).getFullYear() 265 + : null; 266 + 267 + const subtitle = useMemo(() => { 268 + if (releaseYear) return String(releaseYear); 269 + return null; 270 + }, [releaseYear]); 271 + 272 + const metadataItems = useMemo(() => { 273 + const items = []; 274 + if (movie?.release_date) { 275 + items.push({ 276 + icon: <Calendar className="w-4 h-4" />, 277 + label: formatDateOnly(movie.release_date), 278 + }); 279 + } 280 + if (movie?.runtime) { 281 + items.push({ 282 + icon: <Clock className="w-4 h-4" />, 283 + label: formatRuntime(movie.runtime, false), 284 + }); 285 + } 286 + if (movie?.vote_average) { 287 + items.push({ 288 + icon: <Star className="w-4 h-4" />, 289 + label: `${movie.vote_average.toFixed(1)}/10`, 290 + }); 291 + } 292 + return items; 293 + }, [movie]); 294 + 281 295 return ( 282 296 <div 283 297 className="min-h-screen m3-background m3-on-background" ··· 286 300 color: "var(--md-sys-color-on-background)", 287 301 }} 288 302 > 289 - <MovieHero movie={movie} isLoading={isMovieLoading} /> 303 + <DetailHero 304 + title={movie?.title || ""} 305 + subtitle={subtitle ?? undefined} 306 + backdropUrl={backdropUrl} 307 + posterUrl={posterUrl} 308 + colors={colors} 309 + isLoading={isMovieLoading} 310 + onBack={() => router.history.back()} 311 + /> 290 312 291 - <div className="container mx-auto px-4 py-4 max-w-6xl"> 313 + <div className="container mx-auto px-4 py-6 max-w-6xl"> 292 314 <div className="grid grid-cols-1 md:grid-cols-[300px_1fr] gap-8 min-w-0"> 293 - <div className="md:hidden min-w-0"> 294 - <div className="flex gap-4"> 295 - <div className="flex-1 flex flex-col gap-2 justify-center"> 296 - {user ? ( 297 - !isWatched ? ( 298 - <> 299 - <div className="flex gap-2"> 300 - <AddToShelfButton 301 - onClick={handleMarkWatched} 302 - isPending={isPending} 303 - label="Add to Shelf" 304 - icon={<Calendar className="w-5 h-5" />} 305 - colors={colors} 306 - size="compact" 307 - className="flex-1" 308 - /> 309 - <button 310 - type="button" 311 - onClick={() => setShowDateModal(true)} 312 - title="Watch movie" 313 - className="p-3 rounded-xl border transition-all duration-200 flex items-center justify-center group" 314 - style={{ 315 - backgroundColor: "transparent", 316 - borderColor: "var(--md-sys-color-outline)", 317 - }} 318 - onMouseEnter={(e) => { 319 - e.currentTarget.style.backgroundColor = 320 - "var(--md-sys-color-surface-container)"; 321 - e.currentTarget.style.borderColor = 322 - "var(--md-sys-color-primary)"; 323 - }} 324 - onMouseLeave={(e) => { 325 - e.currentTarget.style.backgroundColor = 326 - "transparent"; 327 - e.currentTarget.style.borderColor = 328 - "var(--md-sys-color-outline)"; 329 - }} 330 - > 331 - <Calendar className="w-5 h-5 text-(--md-sys-color-on-surface-variant) group-hover:text-(--md-sys-color-primary) transition-colors" /> 332 - </button> 333 - </div> 334 - <ActionButton 335 - icon={ 336 - isInAnyList ? ( 337 - <Check className="w-4 h-4" /> 338 - ) : ( 339 - <ListPlus className="w-4 h-4" /> 340 - ) 341 - } 342 - label={ 343 - isInAnyList 344 - ? `In ${listsCount} list${listsCount > 1 ? "s" : ""}` 345 - : "Add to List" 346 - } 347 - onClick={() => setShowListModal(true)} 348 - isActive={isInAnyList} 349 - activeColor={seedColor} 350 - /> 351 - </> 352 - ) : ( 353 - <> 354 - <div className="flex gap-2"> 355 - <AddToShelfButton 356 - onClick={handleMarkWatched} 357 - isPending={isPending} 358 - label="Watch Now" 359 - icon={<RotateCcw className="w-4 h-4" />} 360 - colors={colors} 361 - size="compact" 362 - className="flex-1" 363 - /> 364 - <button 365 - type="button" 366 - onClick={() => setShowDateModal(true)} 367 - title="Watch movie" 368 - className="p-3 rounded-xl border transition-all duration-200 flex items-center justify-center group" 369 - style={{ 370 - backgroundColor: "transparent", 371 - borderColor: "var(--md-sys-color-outline)", 372 - }} 373 - onMouseEnter={(e) => { 374 - e.currentTarget.style.backgroundColor = 375 - "var(--md-sys-color-surface-container)"; 376 - e.currentTarget.style.borderColor = 377 - "var(--md-sys-color-primary)"; 378 - }} 379 - onMouseLeave={(e) => { 380 - e.currentTarget.style.backgroundColor = 381 - "transparent"; 382 - e.currentTarget.style.borderColor = 383 - "var(--md-sys-color-outline)"; 384 - }} 385 - > 386 - <Calendar className="w-5 h-5 text-(--md-sys-color-on-surface-variant) group-hover:text-(--md-sys-color-primary) transition-colors" /> 387 - </button> 388 - </div> 389 - <ActionButton 390 - icon={ 391 - isInAnyList ? ( 392 - <Check className="w-4 h-4" /> 393 - ) : ( 394 - <ListPlus className="w-4 h-4" /> 395 - ) 396 - } 397 - label={ 398 - isInAnyList 399 - ? `In ${listsCount} list${listsCount > 1 ? "s" : ""}` 400 - : "Add to List" 401 - } 402 - onClick={() => setShowListModal(true)} 403 - isActive={isInAnyList} 404 - activeColor={seedColor} 405 - /> 406 - </> 407 - ) 408 - ) : ( 409 - <button 410 - type="button" 411 - className="w-full py-3 px-6 rounded-xl m3-label-large transition-all duration-200 flex items-center justify-center gap-2" 412 - style={{ 413 - background: `linear-gradient(135deg, ${colors.primary} 0%, ${colors.secondary} 100%)`, 414 - boxShadow: `0 10px 30px -10px ${colors.primary}60`, 415 - color: "var(--md-sys-color-on-primary)", 416 - }} 417 - onClick={() => router.navigate({ to: "/login" })} 418 - > 419 - Sign in to Track 420 - </button> 421 - )} 422 - <ActionButton 423 - icon={<Share2 className="w-4 h-4" />} 424 - label="Share" 425 - onClick={handleShare} 426 - /> 427 - </div> 428 - </div> 429 - </div> 430 - 431 - <div className="hidden md:block space-y-4 min-w-0"> 432 - {user ? ( 433 - !isWatched ? ( 434 - <div className="space-y-3"> 435 - <div className="flex gap-2"> 436 - <AddToShelfButton 437 - onClick={handleMarkWatched} 438 - isPending={isPending} 439 - label="Add to Shelf" 440 - icon={<Calendar className="w-5 h-5" />} 441 - colors={colors} 442 - className="flex-1" 443 - /> 444 - <button 445 - type="button" 446 - onClick={() => setShowDateModal(true)} 447 - title="Watch movie" 448 - className="p-3 rounded-xl border transition-all duration-200 flex items-center justify-center group" 449 - style={{ 450 - backgroundColor: "transparent", 451 - borderColor: "var(--md-sys-color-outline)", 452 - }} 453 - onMouseEnter={(e) => { 454 - e.currentTarget.style.backgroundColor = 455 - "var(--md-sys-color-surface-container)"; 456 - e.currentTarget.style.borderColor = 457 - "var(--md-sys-color-primary)"; 458 - }} 459 - onMouseLeave={(e) => { 460 - e.currentTarget.style.backgroundColor = "transparent"; 461 - e.currentTarget.style.borderColor = 462 - "var(--md-sys-color-outline)"; 463 - }} 464 - > 465 - <Calendar className="w-5 h-5 text-(--md-sys-color-on-surface-variant) group-hover:text-(--md-sys-color-primary) transition-colors" /> 466 - </button> 467 - </div> 468 - <ActionButton 469 - icon={ 470 - isInAnyList ? ( 471 - <Check className="w-4 h-4" /> 472 - ) : ( 473 - <ListPlus className="w-4 h-4" /> 474 - ) 475 - } 476 - label={ 477 - isInAnyList 478 - ? `In ${listsCount} list${listsCount > 1 ? "s" : ""}` 479 - : "Add to List" 480 - } 481 - onClick={() => setShowListModal(true)} 482 - isActive={isInAnyList} 483 - activeColor={seedColor} 484 - /> 485 - </div> 486 - ) : ( 487 - <div className="space-y-3"> 488 - <div 489 - className="p-4 rounded-xl" 490 - style={{ 491 - backgroundColor: 492 - "var(--md-sys-color-surface-container-highest)", 493 - }} 494 - > 495 - <div 496 - className="flex items-center gap-2 mb-2" 497 - style={{ color: "var(--md-sys-color-primary)" }} 498 - > 499 - <Check className="w-5 h-5" /> 500 - <span className="m3-title-medium">On Your Shelf</span> 501 - </div> 502 - {formattedWatchedDate && ( 503 - <p 504 - className="m3-body-medium" 505 - style={{ 506 - color: "var(--md-sys-color-on-surface-variant)", 507 - }} 508 - > 509 - Watched on {formattedWatchedDate} 510 - </p> 511 - )} 512 - {(watchHistory?.length ?? 0) > 1 && ( 513 - <> 514 - <div 515 - className="mt-2 flex items-center gap-2 m3-body-small" 516 - style={{ 517 - color: "var(--md-sys-color-on-surface-variant)", 518 - }} 519 - > 520 - <History className="w-3 h-3" /> 521 - <span>{watchHistory?.length} total watches</span> 522 - </div> 523 - <button 524 - type="button" 525 - onClick={() => setShowHistoryDialog(true)} 526 - className="mt-2 flex items-center gap-2 m3-body-medium transition-colors py-2 px-3 -ml-3 rounded-lg" 527 - style={{ 528 - color: "var(--md-sys-color-on-surface-variant)", 529 - }} 530 - onMouseEnter={(e) => { 531 - e.currentTarget.style.color = 532 - "var(--md-sys-color-on-surface)"; 533 - e.currentTarget.style.backgroundColor = 534 - "var(--md-sys-color-surface-container)"; 535 - }} 536 - onMouseLeave={(e) => { 537 - e.currentTarget.style.color = 538 - "var(--md-sys-color-on-surface-variant)"; 539 - e.currentTarget.style.backgroundColor = 540 - "transparent"; 541 - }} 542 - > 543 - <Eye className="w-4 h-4" /> 544 - View all watches 545 - </button> 546 - </> 547 - )} 548 - {(watchHistory?.length ?? 0) === 1 && ( 549 - <button 550 - type="button" 551 - onClick={handleUnmarkWatched} 552 - disabled={unmarkMutation.isPending} 553 - className="mt-2 flex items-center gap-2 m3-body-medium transition-colors py-2 px-3 -ml-3 rounded-lg disabled:opacity-50" 554 - style={{ 555 - color: "var(--md-sys-color-error)", 556 - }} 557 - onMouseEnter={(e) => { 558 - e.currentTarget.style.backgroundColor = 559 - "var(--md-sys-color-error-container)"; 560 - }} 561 - onMouseLeave={(e) => { 562 - e.currentTarget.style.backgroundColor = "transparent"; 563 - }} 564 - > 565 - {unmarkMutation.isPending ? ( 566 - <> 567 - <span className="w-4 h-4 border-2 border-current border-t-transparent rounded-full animate-spin" /> 568 - Loading 569 - </> 570 - ) : ( 571 - <> 572 - <Trash2 className="w-4 h-4" /> 573 - Remove from shelf 574 - </> 575 - )} 576 - </button> 577 - )} 578 - </div> 579 - <div className="flex gap-2"> 580 - <AddToShelfButton 581 - onClick={handleMarkWatched} 582 - isPending={isPending} 583 - label="Watch Again" 584 - icon={<RotateCcw className="w-4 h-4" />} 585 - colors={colors} 586 - size="compact" 587 - className="flex-1" 588 - /> 589 - <button 590 - type="button" 591 - onClick={() => setShowDateModal(true)} 592 - title="Watch movie" 593 - className="p-3 rounded-xl border transition-all duration-200 flex items-center justify-center group" 594 - style={{ 595 - backgroundColor: "transparent", 596 - borderColor: "var(--md-sys-color-outline)", 597 - }} 598 - onMouseEnter={(e) => { 599 - e.currentTarget.style.backgroundColor = 600 - "var(--md-sys-color-surface-container)"; 601 - e.currentTarget.style.borderColor = 602 - "var(--md-sys-color-primary)"; 603 - }} 604 - onMouseLeave={(e) => { 605 - e.currentTarget.style.backgroundColor = "transparent"; 606 - e.currentTarget.style.borderColor = 607 - "var(--md-sys-color-outline)"; 608 - }} 609 - > 610 - <Calendar className="w-5 h-5 text-(--md-sys-color-on-surface-variant) group-hover:text-(--md-sys-color-primary) transition-colors" /> 611 - </button> 612 - </div> 613 - <ActionButton 614 - icon={ 615 - isInAnyList ? ( 616 - <Check className="w-4 h-4" /> 617 - ) : ( 618 - <ListPlus className="w-4 h-4" /> 619 - ) 620 - } 621 - label={ 622 - isInAnyList 623 - ? `In ${listsCount} list${listsCount > 1 ? "s" : ""}` 624 - : "Add to List" 625 - } 626 - onClick={() => setShowListModal(true)} 627 - isActive={isInAnyList} 628 - activeColor={seedColor} 629 - /> 630 - </div> 631 - ) 632 - ) : ( 633 - <button 634 - type="button" 635 - className="w-full py-4 px-6 rounded-xl m3-label-large text-center transition-all duration-200 hover:scale-[1.02]" 636 - style={{ 637 - background: `linear-gradient(135deg, ${colors.primary} 0%, ${colors.secondary} 100%)`, 638 - boxShadow: `0 15px 35px -10px ${colors.primary}60`, 639 - color: "var(--md-sys-color-on-primary)", 640 - }} 641 - onClick={() => router.navigate({ to: "/login" })} 642 - > 643 - Sign in to Track 644 - </button> 645 - )} 646 - <ActionButton 647 - icon={<Share2 className="w-4 h-4" />} 648 - label="Share" 649 - onClick={handleShare} 315 + <div className="space-y-4 min-w-0"> 316 + <DetailActions 317 + mediaType="movie" 318 + mediaId={movieId} 319 + colors={colors} 320 + isWatched={isWatched} 321 + watchedDate={formattedWatchedDate} 322 + totalWatches={watchHistory?.length ?? 0} 323 + onMarkWatched={handleMarkWatched} 324 + onUnmarkWatched={handleUnmarkWatched} 325 + onShowDatePicker={() => setShowDateModal(true)} 326 + isMarkingPending={isPending} 327 + isUnmarkingPending={unmarkMutation.isPending} 328 + listsCount={listsCount} 329 + onShowListModal={() => setShowListModal(true)} 330 + onViewHistory={() => setShowHistoryDialog(true)} 331 + isLoggedIn={!!user} 332 + onLogin={() => router.navigate({ to: "/login" })} 650 333 /> 651 334 </div> 652 335 653 336 <div className="space-y-6 min-w-0 w-full"> 337 + <MetadataPills items={metadataItems} /> 338 + 654 339 <section> 655 340 <h2 656 341 className="m3-title-large mb-3" ··· 666 351 </p> 667 352 </section> 668 353 669 - <MovieDetails movie={movie} colors={colors} /> 670 354 <GenresSection genres={movie?.genres} colors={colors} /> 671 355 <CastSection cast={movie?.credits?.cast} colors={colors} /> 672 356 <CrewSection crew={movie?.credits?.crew} colors={colors} />
+110 -443
apps/web/src/routes/shows.$showId.$title.seasons.$seasonNumber.episodes.$episodeNumber.tsx
··· 10 10 showsControllerGetUserShowsQueryKey, 11 11 showsControllerMarkWatchedMutation, 12 12 showsControllerUnmarkWatchedMutation, 13 + type TmdbShowDetailDto, 13 14 usersControllerGetMySettingsOptions, 14 15 } from "@opnshelf/api"; 15 16 import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; 16 - import { createFileRoute, Link, useRouter } from "@tanstack/react-router"; 17 - import { 18 - ArrowLeft, 19 - ArrowRight, 20 - Calendar, 21 - Check, 22 - CircleDot, 23 - Eye, 24 - Film, 25 - History, 26 - Layers, 27 - ListPlus, 28 - RotateCcw, 29 - Share2, 30 - Star, 31 - Trash2, 32 - } from "lucide-react"; 17 + import { createFileRoute, useRouter } from "@tanstack/react-router"; 18 + import { Calendar, Film, History, Layers, Star } from "lucide-react"; 19 + import type { ReactNode } from "react"; 33 20 import { useMemo, useState } from "react"; 34 21 import { toast } from "sonner"; 35 22 import { AddToListModal } from "@/components/AddToListModal"; 36 - import { AddToShelfButton } from "@/components/AddToShelfButton"; 37 23 import { CastSection } from "@/components/CastSection"; 38 24 import { CrewSection } from "@/components/CrewSection"; 39 25 import { DatePickerModal } from "@/components/DatePickerModal"; 40 - import { ActionButton } from "@/components/ui/action-button"; 26 + import { 27 + type ColorTheme, 28 + DetailActions, 29 + DetailHero, 30 + EpisodeNav, 31 + MetadataPills, 32 + } from "@/components/detail"; 41 33 import { 42 34 Dialog, 43 35 DialogContent, ··· 93 85 const { showId, title, seasonNumber, episodeNumber } = Route.useParams(); 94 86 const queryClient = useQueryClient(); 95 87 const router = useRouter(); 88 + 96 89 const [showDateModal, setShowDateModal] = useState(false); 97 90 const [showListModal, setShowListModal] = useState(false); 98 91 const [showHistoryDialog, setShowHistoryDialog] = useState(false); ··· 140 133 enabled: !!user?.did, 141 134 }); 142 135 136 + const show = showData as TmdbShowDetailDto | undefined; 137 + 143 138 const watchedCountForEpisode = 144 139 history?.filter( 145 140 (h) => ··· 177 172 ); 178 173 }, [history, seasonNumber, episodeNumber]); 179 174 const listsCount = listsForShow?.filter((l) => l.isInList).length ?? 0; 180 - const isInAnyList = listsCount > 0; 181 175 const userTimezone = userSettings?.timezone || "UTC"; 182 176 const is24Hour = userSettings?.timeFormat === "24h"; 183 177 ··· 239 233 }, 240 234 }); 241 235 242 - const colors = showData?.colors || { 243 - primary: "#F59E0B", 244 - secondary: "#D97706", 245 - accent: "#FBBF24", 246 - muted: "#6b7280", 236 + const colors: ColorTheme = { 237 + primary: show?.colors?.primary || "#F59E0B", 238 + secondary: show?.colors?.secondary || "#D97706", 239 + accent: show?.colors?.accent || "#FBBF24", 240 + muted: show?.colors?.muted || "#6b7280", 247 241 }; 248 242 249 - const backdropUrl = getTmdbBackdropUrl(showData?.backdrop_path); 250 - const showPoster = getTmdbPosterUrl(showData?.poster_path, "w500"); 243 + const backdropUrl = getTmdbBackdropUrl(show?.backdrop_path); 244 + const showPoster = getTmdbPosterUrl(show?.poster_path, "w500"); 251 245 const stillUrl = episode?.still_path 252 246 ? `https://image.tmdb.org/t/p/w780${episode.still_path}` 253 247 : null; 254 - const isPending = 255 - markMutation.isPending && 256 - markMutation.variables?.body?.showId === showId && 257 - markMutation.variables?.body?.seasonNumber === Number(seasonNumber) && 258 - markMutation.variables?.body?.episodeNumber === Number(episodeNumber); 259 248 260 249 const handleMarkWatched = () => { 261 250 markMutation.mutate({ ··· 277 266 }); 278 267 }; 279 268 280 - const handleShare = async () => { 281 - const url = window.location.href; 282 - if (navigator.share) { 283 - try { 284 - await navigator.share({ url }); 285 - } catch { 286 - // User cancelled share 287 - } 288 - } else { 289 - try { 290 - await navigator.clipboard.writeText(url); 291 - toast.success("Link copied to clipboard"); 292 - } catch { 293 - toast.error("Failed to copy link"); 294 - } 295 - } 296 - }; 297 - 298 269 const seasonEpisodeContext = useMemo(() => { 299 270 if (!season?.episodes?.length) 300 271 return { previous: null, current: null, next: null }; ··· 312 283 }; 313 284 }, [season?.episodes, episodeNumber]); 314 285 286 + const formattedWatchedDate = useMemo(() => { 287 + if (!latestEpisodeWatch) return null; 288 + return formatDateWithTimezone(latestEpisodeWatch.watchedDate, { 289 + timezone: userTimezone, 290 + is24Hour, 291 + }); 292 + }, [latestEpisodeWatch, userTimezone, is24Hour]); 293 + 294 + const metadataItems = useMemo(() => { 295 + const items: Array<{ 296 + icon?: ReactNode; 297 + label: string; 298 + linkTo?: { to: string; params: Record<string, string> }; 299 + }> = []; 300 + items.push({ 301 + icon: <Layers className="w-4 h-4" />, 302 + label: `Season ${seasonNumber}`, 303 + linkTo: { 304 + to: "/shows/$showId/$title/seasons/$seasonNumber", 305 + params: { showId, title, seasonNumber }, 306 + }, 307 + }); 308 + items.push({ 309 + icon: <Film className="w-4 h-4" />, 310 + label: `Episode ${episodeNumber}`, 311 + }); 312 + if (episode?.air_date) { 313 + items.push({ 314 + icon: <Calendar className="w-4 h-4" />, 315 + label: formatDateOnly(episode.air_date), 316 + }); 317 + } 318 + if (episode?.vote_average) { 319 + items.push({ 320 + icon: <Star className="w-4 h-4" />, 321 + label: `${episode.vote_average.toFixed(1)}/10`, 322 + }); 323 + } 324 + return items; 325 + }, [episode, seasonNumber, episodeNumber, showId, title]); 326 + 315 327 return ( 316 328 <div> 317 - <div className="relative h-[42vh] md:h-[52vh] overflow-hidden"> 318 - {stillUrl || backdropUrl ? ( 319 - <> 320 - <img 321 - src={stillUrl || backdropUrl || undefined} 322 - alt="" 323 - className="w-full h-full object-cover" 324 - /> 325 - <div 326 - className="absolute inset-0" 327 - style={{ 328 - background: 329 - "linear-gradient(to bottom, transparent 0%, rgba(3, 7, 18, 0.7) 62%, rgb(3, 7, 18) 100%)", 330 - }} 331 - /> 332 - </> 333 - ) : ( 334 - <div 335 - className="w-full h-full" 336 - style={{ 337 - background: `linear-gradient(135deg, ${colors.muted} 0%, rgb(3, 7, 18) 100%)`, 338 - }} 339 - /> 340 - )} 341 - 342 - <button 343 - type="button" 344 - onClick={() => router.history.back()} 345 - className="absolute top-4 left-4 z-10 p-2 rounded-full bg-black/50 hover:bg-black/70 transition-colors cursor-pointer" 346 - > 347 - <ArrowLeft className="w-5 h-5" /> 348 - </button> 349 - 350 - <div className="absolute bottom-0 left-0 right-0 p-4 md:p-8"> 351 - <div className="container mx-auto max-w-6xl"> 352 - <div className="flex items-end gap-4 md:gap-8"> 353 - <Link 354 - to="/shows/$showId/$title" 355 - params={{ showId, title }} 356 - className="w-24 md:w-40 rounded-lg overflow-hidden shadow-2xl cursor-pointer transition-transform hover:scale-105" 357 - style={{ boxShadow: `0 25px 50px -12px ${colors.primary}40` }} 358 - > 359 - {showPoster ? ( 360 - <img 361 - src={showPoster} 362 - alt={showData?.name} 363 - className="w-full aspect-2/3 object-cover" 364 - /> 365 - ) : ( 366 - <div className="w-full aspect-2/3 bg-gray-900 flex items-center justify-center text-gray-600 text-xs"> 367 - No poster 368 - </div> 369 - )} 370 - </Link> 371 - <div className="pb-2"> 372 - <h1 373 - className="text-2xl md:text-5xl font-bold mb-2" 374 - style={{ textShadow: `0 4px 30px ${colors.primary}60` }} 375 - > 376 - {showData?.name} 377 - </h1> 378 - <h2 className="text-lg md:text-2xl text-gray-200"> 379 - S{seasonNumber} · E{episodeNumber}: {episode?.name} 380 - </h2> 381 - </div> 382 - </div> 383 - </div> 384 - </div> 385 - </div> 329 + <DetailHero 330 + title={show?.name || ""} 331 + subtitle={`S${seasonNumber} · E${episodeNumber}: ${episode?.name || ""}`} 332 + backdropUrl={stillUrl || backdropUrl} 333 + posterUrl={showPoster} 334 + posterLinkTo={{ 335 + to: "/shows/$showId/$title", 336 + params: { showId, title }, 337 + }} 338 + colors={colors} 339 + onBack={() => router.history.back()} 340 + /> 386 341 387 342 <div className="container mx-auto px-4 py-6 max-w-6xl"> 388 343 <div className="grid grid-cols-1 md:grid-cols-[300px_1fr] gap-8 min-w-0"> 389 344 <div className="space-y-4 min-w-0"> 390 - {user ? ( 391 - !isWatchedEpisode ? ( 392 - <div className="space-y-3"> 393 - <div className="flex gap-2"> 394 - <AddToShelfButton 395 - onClick={handleMarkWatched} 396 - isPending={isPending} 397 - label="Add to Shelf" 398 - icon={<Calendar className="w-5 h-5" />} 399 - colors={colors} 400 - className="flex-1" 401 - /> 402 - <button 403 - type="button" 404 - onClick={() => setShowDateModal(true)} 405 - title="Watch episode" 406 - className="p-3 rounded-xl border transition-all duration-200 flex items-center justify-center group" 407 - style={{ 408 - backgroundColor: "transparent", 409 - borderColor: "var(--md-sys-color-outline)", 410 - }} 411 - onMouseEnter={(e) => { 412 - e.currentTarget.style.backgroundColor = 413 - "var(--md-sys-color-surface-container)"; 414 - e.currentTarget.style.borderColor = 415 - "var(--md-sys-color-primary)"; 416 - }} 417 - onMouseLeave={(e) => { 418 - e.currentTarget.style.backgroundColor = "transparent"; 419 - e.currentTarget.style.borderColor = 420 - "var(--md-sys-color-outline)"; 421 - }} 422 - > 423 - <Calendar className="w-5 h-5 text-(--md-sys-color-on-surface-variant) group-hover:text-(--md-sys-color-primary) transition-colors" /> 424 - </button> 425 - </div> 426 - <ActionButton 427 - icon={ 428 - isInAnyList ? ( 429 - <Check className="w-4 h-4" /> 430 - ) : ( 431 - <ListPlus className="w-4 h-4" /> 432 - ) 433 - } 434 - label={ 435 - isInAnyList 436 - ? `In ${listsCount} list${listsCount > 1 ? "s" : ""}` 437 - : "Add to List" 438 - } 439 - onClick={() => setShowListModal(true)} 440 - isActive={isInAnyList} 441 - activeColor={colors.primary} 442 - /> 443 - </div> 444 - ) : ( 445 - <div className="space-y-3"> 446 - <div 447 - className="p-4 rounded-xl" 448 - style={{ 449 - backgroundColor: 450 - "var(--md-sys-color-surface-container-highest)", 451 - }} 452 - > 453 - <div 454 - className="flex items-center gap-2" 455 - style={{ color: "var(--md-sys-color-primary)" }} 456 - > 457 - <Check className="w-5 h-5" /> 458 - <span className="m3-title-medium">On Your Shelf</span> 459 - </div> 460 - {latestEpisodeWatch && ( 461 - <p 462 - className="m3-body-medium mt-2" 463 - style={{ 464 - color: "var(--md-sys-color-on-surface-variant)", 465 - }} 466 - > 467 - Watched on{" "} 468 - {formatDateWithTimezone( 469 - latestEpisodeWatch.watchedDate, 470 - { 471 - timezone: userTimezone, 472 - is24Hour, 473 - }, 474 - )} 475 - </p> 476 - )} 477 - {episodeWatchHistory.length > 1 ? ( 478 - <> 479 - <div 480 - className="mt-2 flex items-center gap-2 m3-body-small" 481 - style={{ 482 - color: "var(--md-sys-color-on-surface-variant)", 483 - }} 484 - > 485 - <History className="w-3 h-3" /> 486 - <span> 487 - {episodeWatchHistory.length} total watches 488 - </span> 489 - </div> 490 - <button 491 - type="button" 492 - onClick={() => setShowHistoryDialog(true)} 493 - className="mt-2 flex items-center gap-2 m3-body-medium transition-colors py-2 px-3 -ml-3 rounded-lg" 494 - style={{ 495 - color: "var(--md-sys-color-on-surface-variant)", 496 - }} 497 - onMouseEnter={(e) => { 498 - e.currentTarget.style.color = 499 - "var(--md-sys-color-on-surface)"; 500 - e.currentTarget.style.backgroundColor = 501 - "var(--md-sys-color-surface-container)"; 502 - }} 503 - onMouseLeave={(e) => { 504 - e.currentTarget.style.color = 505 - "var(--md-sys-color-on-surface-variant)"; 506 - e.currentTarget.style.backgroundColor = 507 - "transparent"; 508 - }} 509 - > 510 - <Eye className="w-4 h-4" /> 511 - View all watches 512 - </button> 513 - </> 514 - ) : ( 515 - <button 516 - type="button" 517 - onClick={handleUnmarkWatched} 518 - disabled={unmarkMutation.isPending} 519 - className="mt-2 flex items-center gap-2 m3-body-medium transition-colors py-2 px-3 -ml-3 rounded-lg disabled:opacity-50" 520 - style={{ 521 - color: "var(--md-sys-color-error)", 522 - }} 523 - onMouseEnter={(e) => { 524 - e.currentTarget.style.backgroundColor = 525 - "var(--md-sys-color-error-container)"; 526 - }} 527 - onMouseLeave={(e) => { 528 - e.currentTarget.style.backgroundColor = "transparent"; 529 - }} 530 - > 531 - {unmarkMutation.isPending ? ( 532 - <> 533 - <span className="w-4 h-4 border-2 border-current border-t-transparent rounded-full animate-spin" /> 534 - Loading 535 - </> 536 - ) : ( 537 - <> 538 - <Trash2 className="w-4 h-4" /> 539 - Remove from shelf 540 - </> 541 - )} 542 - </button> 543 - )} 544 - </div> 545 - <div className="flex gap-2"> 546 - <AddToShelfButton 547 - onClick={handleMarkWatched} 548 - isPending={isPending} 549 - label="Watch Again" 550 - icon={<RotateCcw className="w-4 h-4" />} 551 - colors={colors} 552 - size="compact" 553 - className="flex-1" 554 - /> 555 - <button 556 - type="button" 557 - onClick={() => setShowDateModal(true)} 558 - title="Watch episode" 559 - className="p-3 rounded-xl border transition-all duration-200 flex items-center justify-center group" 560 - style={{ 561 - backgroundColor: "transparent", 562 - borderColor: "var(--md-sys-color-outline)", 563 - }} 564 - onMouseEnter={(e) => { 565 - e.currentTarget.style.backgroundColor = 566 - "var(--md-sys-color-surface-container)"; 567 - e.currentTarget.style.borderColor = 568 - "var(--md-sys-color-primary)"; 569 - }} 570 - onMouseLeave={(e) => { 571 - e.currentTarget.style.backgroundColor = "transparent"; 572 - e.currentTarget.style.borderColor = 573 - "var(--md-sys-color-outline)"; 574 - }} 575 - > 576 - <Calendar className="w-5 h-5 text-(--md-sys-color-on-surface-variant) group-hover:text-(--md-sys-color-primary) transition-colors" /> 577 - </button> 578 - </div> 579 - <ActionButton 580 - icon={ 581 - isInAnyList ? ( 582 - <Check className="w-4 h-4" /> 583 - ) : ( 584 - <ListPlus className="w-4 h-4" /> 585 - ) 586 - } 587 - label={ 588 - isInAnyList 589 - ? `In ${listsCount} list${listsCount > 1 ? "s" : ""}` 590 - : "Add to List" 591 - } 592 - onClick={() => setShowListModal(true)} 593 - isActive={isInAnyList} 594 - activeColor={colors.primary} 595 - /> 596 - </div> 597 - ) 598 - ) : ( 599 - <AddToShelfButton 600 - onClick={() => router.navigate({ to: "/login" })} 601 - label="Sign in to Track" 602 - icon={<Calendar className="w-5 h-5" />} 345 + <DetailActions 346 + mediaType="episode" 347 + mediaId={showId} 348 + seasonNumber={seasonNumber} 349 + episodeNumber={episodeNumber} 350 + colors={colors} 351 + isWatched={isWatchedEpisode} 352 + watchedDate={formattedWatchedDate} 353 + totalWatches={episodeWatchHistory.length} 354 + onMarkWatched={handleMarkWatched} 355 + onUnmarkWatched={handleUnmarkWatched} 356 + onShowDatePicker={() => setShowDateModal(true)} 357 + isMarkingPending={markMutation.isPending} 358 + isUnmarkingPending={unmarkMutation.isPending} 359 + listsCount={listsCount} 360 + onShowListModal={() => setShowListModal(true)} 361 + onViewHistory={() => setShowHistoryDialog(true)} 362 + isLoggedIn={!!user} 363 + onLogin={() => router.navigate({ to: "/login" })} 364 + /> 365 + 366 + {seasonEpisodeContext.current && ( 367 + <EpisodeNav 368 + showId={showId} 369 + title={title} 370 + seasonNumber={seasonNumber} 371 + previousEpisode={seasonEpisodeContext.previous} 372 + currentEpisode={seasonEpisodeContext.current} 373 + nextEpisode={seasonEpisodeContext.next} 603 374 colors={colors} 375 + variant="sidebar" 604 376 /> 605 377 )} 606 - <ActionButton 607 - icon={<Share2 className="w-4 h-4" />} 608 - label="Share" 609 - onClick={handleShare} 610 - /> 611 378 </div> 612 379 613 380 <div className="space-y-6 min-w-0"> 614 - <div className="flex flex-wrap gap-3"> 615 - <Link 616 - to="/shows/$showId/$title/seasons/$seasonNumber" 617 - params={{ showId, title, seasonNumber }} 618 - 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" 619 - > 620 - <Layers className="w-4 h-4" /> 621 - Season {seasonNumber} 622 - </Link> 623 - <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"> 624 - <Film className="w-4 h-4" /> 625 - Episode {episodeNumber} 626 - </div> 627 - <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"> 628 - <Calendar className="w-4 h-4" /> 629 - {episode?.air_date 630 - ? formatDateOnly(episode.air_date) 631 - : "Air date unknown"} 632 - </div> 633 - <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"> 634 - <Star className="w-4 h-4" /> 635 - {episode?.vote_average 636 - ? `${episode.vote_average.toFixed(1)}/10` 637 - : "Not rated"} 638 - </div> 639 - </div> 381 + <MetadataPills items={metadataItems} /> 382 + 640 383 <section> 641 384 <h2 642 385 className="text-xl font-semibold mb-3" ··· 649 392 </p> 650 393 </section> 651 394 652 - {seasonEpisodeContext.current ? ( 653 - <section> 654 - <h2 655 - className="text-xl font-semibold mb-3" 656 - style={{ color: colors.primary }} 657 - > 658 - More In This Season 659 - </h2> 660 - <div className="grid grid-cols-1 md:grid-cols-3 gap-3"> 661 - {[ 662 - { 663 - key: "previous", 664 - label: "Previous Episode", 665 - icon: <ArrowLeft className="w-4 h-4" />, 666 - episode: seasonEpisodeContext.previous, 667 - highlighted: false, 668 - }, 669 - { 670 - key: "current", 671 - label: "Current Episode", 672 - icon: <CircleDot className="w-4 h-4" />, 673 - episode: seasonEpisodeContext.current, 674 - highlighted: true, 675 - }, 676 - { 677 - key: "next", 678 - label: "Next Episode", 679 - icon: <ArrowRight className="w-4 h-4" />, 680 - episode: seasonEpisodeContext.next, 681 - highlighted: false, 682 - }, 683 - ].map((slot) => { 684 - if (!slot.episode) return null; 685 - return ( 686 - <Link 687 - key={slot.key} 688 - to="/shows/$showId/$title/seasons/$seasonNumber/episodes/$episodeNumber" 689 - params={{ 690 - showId, 691 - title, 692 - seasonNumber, 693 - episodeNumber: String(slot.episode.episode_number), 694 - }} 695 - className={`rounded-lg border p-3 transition-colors ${ 696 - slot.highlighted 697 - ? "bg-gray-900/60 border-(--md-sys-color-primary) hover:bg-gray-900/70" 698 - : "bg-gray-900/30 border-(--md-sys-color-outline) hover:bg-gray-900/50" 699 - }`} 700 - > 701 - <div className="text-xs uppercase tracking-wide text-gray-400 mb-2 flex items-center gap-2"> 702 - {slot.icon} 703 - {slot.label} 704 - </div> 705 - <div 706 - className={`rounded-md px-2 py-2 ${ 707 - slot.highlighted 708 - ? "bg-(--md-sys-color-primary)/15" 709 - : "" 710 - }`} 711 - > 712 - <div className="font-medium text-sm"> 713 - E{slot.episode.episode_number}: {slot.episode.name} 714 - </div> 715 - <div className="text-xs text-gray-400 mt-1"> 716 - {slot.episode.air_date 717 - ? formatDateOnly(slot.episode.air_date) 718 - : "TBA"} 719 - </div> 720 - </div> 721 - </Link> 722 - ); 723 - })} 724 - </div> 725 - </section> 726 - ) : null} 727 - 728 - <CastSection cast={showData?.credits?.cast} colors={colors} /> 729 - <CrewSection crew={showData?.credits?.crew} colors={colors} /> 395 + <CastSection cast={show?.credits?.cast} colors={colors} /> 396 + <CrewSection crew={show?.credits?.crew} colors={colors} /> 730 397 </div> 731 398 </div> 732 399 </div> ··· 748 415 onOpenChange={setShowListModal} 749 416 mediaType="show" 750 417 mediaId={showId} 751 - mediaTitle={showData?.name || "Show"} 418 + mediaTitle={show?.name || "Show"} 752 419 user={user} 753 420 /> 754 421 )} ··· 820 487 {deleteWatchEntryMutation.isPending ? ( 821 488 <span className="w-4 h-4 border-2 border-current border-t-transparent rounded-full animate-spin inline-block" /> 822 489 ) : ( 823 - <Trash2 className="w-4 h-4" /> 490 + <History className="w-4 h-4" /> 824 491 )} 825 492 </button> 826 493 </div>
+184 -165
apps/web/src/routes/shows.$showId.$title.seasons.$seasonNumber.tsx
··· 1 1 import { 2 2 authControllerMeOptions, 3 + listsControllerGetListsForItemOptions, 3 4 showsControllerGetSeasonDetailsOptions, 4 5 showsControllerGetShowDetailsOptions, 5 6 showsControllerGetShowWatchHistoryOptions, 7 + showsControllerGetUserShowsQueryKey, 8 + showsControllerMarkSeasonWatchedMutation, 6 9 type TmdbSeasonDetailDto, 10 + type TmdbShowDetailDto, 7 11 } from "@opnshelf/api"; 8 - import { useQuery } from "@tanstack/react-query"; 12 + import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; 9 13 import { 10 14 createFileRoute, 11 - Link, 12 15 Outlet, 13 16 useMatches, 14 17 useRouter, 15 18 } from "@tanstack/react-router"; 16 - import { ArrowLeft, Calendar, Star } from "lucide-react"; 19 + import { Calendar, Film } from "lucide-react"; 20 + import { useMemo, useState } from "react"; 21 + import { toast } from "sonner"; 22 + import { AddToListModal } from "@/components/AddToListModal"; 17 23 import { CastSection } from "@/components/CastSection"; 18 24 import { CrewSection } from "@/components/CrewSection"; 25 + import { 26 + type ColorTheme, 27 + DetailActions, 28 + DetailHero, 29 + EpisodeCard, 30 + MetadataPills, 31 + SeasonNav, 32 + } from "@/components/detail"; 33 + import { GenresSection } from "@/components/GenresSection"; 34 + import { useTheme } from "@/components/theme-provider"; 19 35 import { 20 36 formatDateOnly, 21 37 getTmdbBackdropUrl, ··· 62 78 const matches = useMatches(); 63 79 const isLeafRoute = matches[matches.length - 1]?.routeId === Route.id; 64 80 const router = useRouter(); 81 + const queryClient = useQueryClient(); 82 + const { seedColor } = useTheme(); 83 + 84 + const [showListModal, setShowListModal] = useState(false); 65 85 66 86 const { data: user } = useQuery({ 67 87 ...authControllerMeOptions(), ··· 74 94 path: { showId, seasonNumber }, 75 95 }), 76 96 }); 97 + 77 98 const { data: showData } = useQuery({ 78 99 ...showsControllerGetShowDetailsOptions({ 79 100 path: { showId }, 80 101 }), 81 102 }); 82 103 104 + const show = showData as TmdbShowDetailDto | undefined; 105 + const season = seasonData as TmdbSeasonDetailDto | undefined; 106 + 83 107 const { data: history } = useQuery({ 84 108 ...showsControllerGetShowWatchHistoryOptions({ 85 109 path: { userDid: user?.did || "", showId }, ··· 87 111 enabled: !!user?.did, 88 112 }); 89 113 90 - const season = seasonData as TmdbSeasonDetailDto | undefined; 91 - const colors = showData?.colors || { 92 - primary: "#F59E0B", 93 - secondary: "#D97706", 94 - accent: "#FBBF24", 95 - muted: "#6b7280", 114 + const { data: listsForShow } = useQuery({ 115 + ...listsControllerGetListsForItemOptions({ 116 + path: { mediaType: "show", mediaId: showId }, 117 + }), 118 + enabled: !!user?.did, 119 + }); 120 + 121 + const listsCount = listsForShow?.filter((l) => l.isInList).length ?? 0; 122 + 123 + const colors: ColorTheme = { 124 + primary: show?.colors?.primary || seedColor, 125 + secondary: show?.colors?.secondary || seedColor, 126 + accent: show?.colors?.accent || seedColor, 127 + muted: show?.colors?.muted || "#6b7280", 96 128 }; 97 - const backdropUrl = getTmdbBackdropUrl(showData?.backdrop_path); 129 + 130 + const backdropUrl = getTmdbBackdropUrl(show?.backdrop_path); 98 131 const seasonPoster = getTmdbPosterUrl(season?.poster_path, "w500"); 99 132 const seasonEpisodes = season?.episodes || []; 100 133 134 + const markSeasonWatchedMutation = useMutation({ 135 + ...showsControllerMarkSeasonWatchedMutation(), 136 + onSuccess: (data) => { 137 + queryClient.invalidateQueries({ 138 + queryKey: showsControllerGetUserShowsQueryKey({ 139 + path: { userDid: user?.did || "" }, 140 + }), 141 + }); 142 + queryClient.invalidateQueries({ 143 + queryKey: ["showsControllerGetShowWatchHistory"], 144 + }); 145 + toast.success(`Marked ${data.count} episodes as watched`); 146 + }, 147 + onError: () => { 148 + toast.error("Failed to mark season as watched. Please try again."); 149 + }, 150 + }); 151 + 152 + const handleMarkWatched = () => { 153 + markSeasonWatchedMutation.mutate({ 154 + body: { 155 + showId, 156 + seasonNumber: Number(seasonNumber), 157 + }, 158 + }); 159 + }; 160 + 161 + const watchedEpisodeCount = useMemo(() => { 162 + if (!history) return 0; 163 + return history.filter((h) => h.seasonNumber === Number(seasonNumber)) 164 + .length; 165 + }, [history, seasonNumber]); 166 + 167 + const episodeWatchedCounts = useMemo(() => { 168 + if (!history) return new Map<number, number>(); 169 + const counts = new Map<number, number>(); 170 + for (const h of history) { 171 + if (h.seasonNumber === Number(seasonNumber)) { 172 + const current = counts.get(h.episodeNumber) ?? 0; 173 + counts.set(h.episodeNumber, current + 1); 174 + } 175 + } 176 + return counts; 177 + }, [history, seasonNumber]); 178 + 179 + const metadataItems = useMemo(() => { 180 + const items = []; 181 + if (season?.air_date) { 182 + items.push({ 183 + icon: <Calendar className="w-4 h-4" />, 184 + label: formatDateOnly(season.air_date), 185 + }); 186 + } 187 + if (seasonEpisodes.length > 0) { 188 + items.push({ 189 + icon: <Film className="w-4 h-4" />, 190 + label: `${seasonEpisodes.length} episodes`, 191 + }); 192 + } 193 + return items; 194 + }, [show, season?.air_date, seasonEpisodes.length, showId, title]); 195 + 101 196 return ( 102 197 <div> 103 198 {isLeafRoute && ( 104 199 <> 105 - <div className="relative h-[45vh] md:h-[55vh] overflow-hidden"> 106 - {backdropUrl ? ( 107 - <> 108 - <img 109 - src={backdropUrl} 110 - alt="" 111 - className="w-full h-full object-cover" 200 + <DetailHero 201 + title={show?.name || title.replace(/-/g, " ")} 202 + subtitle={`Season ${seasonNumber}`} 203 + backdropUrl={backdropUrl} 204 + posterUrl={seasonPoster} 205 + posterLinkTo={{ 206 + to: "/shows/$showId/$title", 207 + params: { showId, title }, 208 + }} 209 + colors={colors} 210 + onBack={() => router.history.back()} 211 + /> 212 + 213 + <div className="container mx-auto px-4 py-6 max-w-6xl"> 214 + <div className="grid grid-cols-1 md:grid-cols-[300px_1fr] gap-8 min-w-0"> 215 + <div className="space-y-4 min-w-0"> 216 + <DetailActions 217 + mediaType="season" 218 + mediaId={showId} 219 + seasonNumber={seasonNumber} 220 + colors={colors} 221 + isWatched={watchedEpisodeCount > 0} 222 + watchedDate={null} 223 + totalWatches={watchedEpisodeCount} 224 + onMarkWatched={handleMarkWatched} 225 + onShowDatePicker={() => {}} 226 + isMarkingPending={markSeasonWatchedMutation.isPending} 227 + listsCount={listsCount} 228 + onShowListModal={() => setShowListModal(true)} 229 + isLoggedIn={!!user} 230 + onLogin={() => router.navigate({ to: "/login" })} 112 231 /> 113 - <div 114 - className="absolute inset-0" 115 - style={{ 116 - background: 117 - "linear-gradient(to bottom, transparent 0%, rgba(3, 7, 18, 0.65) 60%, rgb(3, 7, 18) 100%)", 118 - }} 119 - /> 120 - </> 121 - ) : ( 122 - <div 123 - className="w-full h-full" 124 - style={{ 125 - background: `linear-gradient(135deg, ${colors.muted} 0%, rgb(3, 7, 18) 100%)`, 126 - }} 127 - /> 128 - )} 129 232 130 - <button 131 - type="button" 132 - onClick={() => router.history.back()} 133 - className="absolute top-4 left-4 z-10 p-2 rounded-full bg-black/50 hover:bg-black/70 transition-colors cursor-pointer" 134 - > 135 - <ArrowLeft className="w-5 h-5" /> 136 - </button> 137 - 138 - <div className="absolute bottom-0 left-0 right-0 p-4 md:p-8"> 139 - <div className="container mx-auto max-w-6xl"> 140 - <div className="flex items-end gap-4 md:gap-8"> 141 - <Link 142 - to="/shows/$showId/$title" 143 - params={{ showId, title }} 144 - className="w-24 md:w-40 rounded-lg overflow-hidden shadow-2xl cursor-pointer transition-transform hover:scale-105" 145 - style={{ 146 - boxShadow: `0 25px 50px -12px ${colors.primary}40`, 147 - }} 148 - > 149 - {seasonPoster ? ( 150 - <img 151 - src={seasonPoster} 152 - alt={season?.name || `Season ${seasonNumber}`} 153 - className="w-full aspect-2/3 object-cover" 154 - /> 155 - ) : ( 156 - <div className="w-full aspect-2/3 bg-gray-900 flex items-center justify-center text-gray-600 text-xs"> 157 - No poster 158 - </div> 159 - )} 160 - </Link> 161 - <div className="pb-2"> 162 - <h1 163 - className="text-2xl md:text-5xl font-bold mb-2" 164 - style={{ textShadow: `0 4px 30px ${colors.primary}60` }} 165 - > 166 - {showData?.name || title.replace(/-/g, " ")} 167 - </h1> 168 - <h2 className="text-lg md:text-2xl text-gray-200"> 169 - Season {seasonNumber} 170 - </h2> 171 - </div> 172 - </div> 233 + {(show?.number_of_seasons ?? 0) > 1 && ( 234 + <SeasonNav 235 + showId={showId} 236 + title={title} 237 + currentSeason={Number(seasonNumber)} 238 + totalSeasons={show?.number_of_seasons ?? 1} 239 + /> 240 + )} 173 241 </div> 174 - </div> 175 - </div> 176 - 177 - <div className="container mx-auto px-4 py-6 max-w-6xl"> 178 - <div className="grid grid-cols-1 md:grid-cols-[300px_1fr] gap-8 min-w-0"> 179 - <div className="space-y-4" /> 180 242 181 243 <div className="space-y-6 min-w-0"> 182 - <div className="flex flex-wrap gap-3"> 183 - {season?.air_date && ( 184 - <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"> 185 - <Calendar className="w-4 h-4" /> 186 - {formatDateOnly(season.air_date)} 187 - </div> 188 - )} 189 - <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"> 190 - <span>{seasonEpisodes.length} episodes</span> 191 - </div> 192 - </div> 244 + <MetadataPills items={metadataItems} /> 193 245 194 246 <section> 195 247 <h2 ··· 203 255 </p> 204 256 </section> 205 257 206 - <section> 207 - <h2 208 - className="text-xl font-semibold mb-4" 209 - style={{ color: colors.primary }} 210 - > 211 - Episodes 212 - </h2> 213 - <div className="grid grid-cols-1 gap-3"> 214 - {seasonEpisodes.map((episode) => { 215 - const episodeWatches = 216 - history?.filter( 217 - (h) => 218 - h.seasonNumber === episode.season_number && 219 - h.episodeNumber === episode.episode_number, 220 - ).length || 0; 258 + <GenresSection genres={show?.genres} colors={colors} /> 221 259 222 - return ( 223 - <Link 260 + {seasonEpisodes.length > 0 && ( 261 + <section> 262 + <h2 263 + className="text-xl font-semibold mb-4" 264 + style={{ color: colors.primary }} 265 + > 266 + Episodes 267 + </h2> 268 + <div className="space-y-3"> 269 + {seasonEpisodes.map((episode) => ( 270 + <EpisodeCard 224 271 key={episode.id} 225 - to="/shows/$showId/$title/seasons/$seasonNumber/episodes/$episodeNumber" 226 - params={{ 227 - showId, 228 - title, 229 - seasonNumber, 230 - episodeNumber: String(episode.episode_number), 231 - }} 232 - className="group rounded-xl border bg-gray-900/30 hover:bg-gray-900/50 transition-colors overflow-hidden" 233 - style={{ borderColor: "var(--md-sys-color-outline)" }} 234 - > 235 - <div className="grid grid-cols-[120px_1fr] gap-4"> 236 - <div className="h-full bg-gray-900"> 237 - {episode.still_path ? ( 238 - <img 239 - src={`https://image.tmdb.org/t/p/w300${episode.still_path}`} 240 - alt={episode.name} 241 - className="w-full h-full object-cover" 242 - /> 243 - ) : null} 244 - </div> 245 - <div className="p-3 min-w-0"> 246 - <div className="flex items-center justify-between gap-2 mb-1"> 247 - <p className="font-medium line-clamp-1"> 248 - E{episode.episode_number} · {episode.name} 249 - </p> 250 - {episode.vote_average ? ( 251 - <span className="text-xs flex items-center gap-1 text-gray-300"> 252 - <Star className="w-3 h-3" /> 253 - {episode.vote_average.toFixed(1)} 254 - </span> 255 - ) : null} 256 - </div> 257 - <p className="text-xs text-gray-400 line-clamp-2"> 258 - {episode.overview || "No overview available."} 259 - </p> 260 - <div className="mt-2 flex items-center gap-3 text-xs text-gray-400"> 261 - <span className="flex items-center gap-1"> 262 - <Calendar className="w-3 h-3" /> 263 - {episode.air_date 264 - ? formatDateOnly(episode.air_date) 265 - : "TBA"} 266 - </span> 267 - {user ? ( 268 - <span>{episodeWatches} watched</span> 269 - ) : null} 270 - </div> 271 - </div> 272 - </div> 273 - </Link> 274 - ); 275 - })} 276 - </div> 277 - </section> 272 + showId={showId} 273 + title={title} 274 + seasonNumber={seasonNumber} 275 + episode={episode} 276 + watchedCount={ 277 + episodeWatchedCounts.get(episode.episode_number) ?? 278 + 0 279 + } 280 + colors={colors} 281 + /> 282 + ))} 283 + </div> 284 + </section> 285 + )} 278 286 279 - <CastSection cast={showData?.credits?.cast} colors={colors} /> 280 - <CrewSection crew={showData?.credits?.crew} colors={colors} /> 287 + <CastSection cast={show?.credits?.cast} colors={colors} /> 288 + <CrewSection crew={show?.credits?.crew} colors={colors} /> 281 289 </div> 282 290 </div> 283 291 </div> 284 292 </> 285 293 )} 286 294 <Outlet /> 295 + 296 + {user && ( 297 + <AddToListModal 298 + open={showListModal} 299 + onOpenChange={setShowListModal} 300 + mediaType="show" 301 + mediaId={showId} 302 + mediaTitle={show?.name || ""} 303 + user={user} 304 + /> 305 + )} 287 306 </div> 288 307 ); 289 308 }
+178 -140
apps/web/src/routes/shows.$showId.$title.tsx
··· 1 1 import { 2 2 authControllerMeOptions, 3 + listsControllerGetListsForItemOptions, 3 4 showsControllerGetShowDetailsOptions, 5 + showsControllerGetShowWatchHistoryOptions, 6 + showsControllerGetUserShowsQueryKey, 7 + showsControllerMarkShowWatchedMutation, 4 8 type TmdbShowDetailDto, 5 9 } from "@opnshelf/api"; 6 - import { useQuery } from "@tanstack/react-query"; 10 + import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; 7 11 import { 8 12 createFileRoute, 9 - Link, 10 13 Outlet, 11 14 useMatches, 12 15 useRouter, 13 16 } from "@tanstack/react-router"; 14 - import { ArrowLeft, Calendar, Tv } from "lucide-react"; 17 + import { Calendar, Tv } from "lucide-react"; 18 + import { useMemo, useState } from "react"; 19 + import { toast } from "sonner"; 20 + import { AddToListModal } from "@/components/AddToListModal"; 15 21 import { CastSection } from "@/components/CastSection"; 16 22 import { CrewSection } from "@/components/CrewSection"; 23 + import { 24 + type ColorTheme, 25 + DetailActions, 26 + DetailHero, 27 + MetadataPills, 28 + SeasonCard, 29 + } from "@/components/detail"; 17 30 import { GenresSection } from "@/components/GenresSection"; 18 - import { getTmdbBackdropUrl, getTmdbPosterUrl } from "@/lib/utils"; 31 + import { useTheme } from "@/components/theme-provider"; 32 + import { 33 + formatDateOnly, 34 + getTmdbBackdropUrl, 35 + getTmdbPosterUrl, 36 + } from "@/lib/utils"; 19 37 20 38 export const Route = createFileRoute("/shows/$showId/$title")({ 21 39 loader: async ({ params, context }) => { ··· 46 64 const matches = useMatches(); 47 65 const isLeafRoute = matches[matches.length - 1]?.routeId === Route.id; 48 66 const router = useRouter(); 67 + const queryClient = useQueryClient(); 68 + const { seedColor } = useTheme(); 69 + 70 + const [showListModal, setShowListModal] = useState(false); 49 71 50 72 const { data: user } = useQuery({ 51 73 ...authControllerMeOptions(), 52 74 staleTime: 5 * 60 * 1000, 53 75 retry: false, 54 76 }); 77 + 55 78 const { data: showData, isLoading } = useQuery({ 56 79 ...showsControllerGetShowDetailsOptions({ 57 80 path: { showId }, ··· 59 82 }); 60 83 61 84 const show = showData as TmdbShowDetailDto | undefined; 85 + 86 + const { data: history } = useQuery({ 87 + ...showsControllerGetShowWatchHistoryOptions({ 88 + path: { userDid: user?.did || "", showId }, 89 + }), 90 + enabled: !!user?.did, 91 + }); 92 + 93 + const { data: listsForShow } = useQuery({ 94 + ...listsControllerGetListsForItemOptions({ 95 + path: { mediaType: "show", mediaId: showId }, 96 + }), 97 + enabled: !!user?.did, 98 + }); 99 + 100 + const listsCount = listsForShow?.filter((l) => l.isInList).length ?? 0; 101 + const watchedEpisodeCount = history?.length ?? 0; 102 + 103 + const colors: ColorTheme = { 104 + primary: show?.colors?.primary || seedColor, 105 + secondary: show?.colors?.secondary || seedColor, 106 + accent: show?.colors?.accent || seedColor, 107 + muted: show?.colors?.muted || "#6b7280", 108 + }; 109 + 62 110 const backdropUrl = getTmdbBackdropUrl(show?.backdrop_path); 63 111 const posterUrl = getTmdbPosterUrl(show?.poster_path, "w500"); 64 112 const seasonCount = show?.number_of_seasons || 0; 65 113 const episodeCount = show?.number_of_episodes || 0; 66 - const colors = show?.colors || { 67 - primary: "#F59E0B", 68 - secondary: "#D97706", 69 - accent: "#FBBF24", 70 - muted: "#6b7280", 114 + 115 + const markShowWatchedMutation = useMutation({ 116 + ...showsControllerMarkShowWatchedMutation(), 117 + onSuccess: (data) => { 118 + queryClient.invalidateQueries({ 119 + queryKey: showsControllerGetUserShowsQueryKey({ 120 + path: { userDid: user?.did || "" }, 121 + }), 122 + }); 123 + queryClient.invalidateQueries({ 124 + queryKey: ["showsControllerGetShowWatchHistory"], 125 + }); 126 + toast.success(`Marked ${data.count} episodes as watched`); 127 + }, 128 + onError: () => { 129 + toast.error("Failed to mark show as watched. Please try again."); 130 + }, 131 + }); 132 + 133 + const handleMarkWatched = () => { 134 + markShowWatchedMutation.mutate({ 135 + body: { showId }, 136 + }); 71 137 }; 72 138 139 + const metadataItems = useMemo(() => { 140 + const items = []; 141 + if (show?.first_air_date) { 142 + items.push({ 143 + icon: <Calendar className="w-4 h-4" />, 144 + label: formatDateOnly(show.first_air_date), 145 + }); 146 + } 147 + if (seasonCount > 0) { 148 + items.push({ 149 + icon: <Tv className="w-4 h-4" />, 150 + label: `${seasonCount} season${seasonCount !== 1 ? "s" : ""}`, 151 + }); 152 + } 153 + if (episodeCount > 0) { 154 + items.push({ 155 + icon: <Tv className="w-4 h-4" />, 156 + label: `${episodeCount} episodes`, 157 + }); 158 + } 159 + return items; 160 + }, [show?.first_air_date, episodeCount, seasonCount]); 161 + 162 + const seasonWatchedCounts = useMemo(() => { 163 + if (!history) return new Map<number, number>(); 164 + const counts = new Map<number, number>(); 165 + for (const h of history) { 166 + const current = counts.get(h.seasonNumber) ?? 0; 167 + counts.set(h.seasonNumber, current + 1); 168 + } 169 + return counts; 170 + }, [history]); 171 + 73 172 return ( 74 173 <div> 75 174 {isLeafRoute && ( 76 175 <> 77 - <div className="relative h-[50vh] md:h-[60vh] overflow-hidden"> 78 - {backdropUrl ? ( 79 - <> 80 - <img 81 - src={backdropUrl} 82 - alt="" 83 - className="w-full h-full object-cover" 84 - /> 85 - <div 86 - className="absolute inset-0" 87 - style={{ 88 - background: 89 - "linear-gradient(to bottom, transparent 0%, rgba(3, 7, 18, 0.6) 60%, rgb(3, 7, 18) 100%)", 90 - }} 91 - /> 92 - <div 93 - className="absolute inset-0" 94 - style={{ 95 - background: 96 - "linear-gradient(to right, rgba(3, 7, 18, 0.8) 0%, transparent 50%)", 97 - }} 98 - /> 99 - </> 100 - ) : ( 101 - <div 102 - className="w-full h-full" 103 - style={{ 104 - background: `linear-gradient(135deg, ${colors.muted} 0%, rgb(3, 7, 18) 100%)`, 105 - }} 106 - /> 107 - )} 108 - 109 - <button 110 - type="button" 111 - onClick={() => router.history.back()} 112 - className="absolute top-4 left-4 z-10 p-2 rounded-full bg-black/50 hover:bg-black/70 transition-colors cursor-pointer" 113 - > 114 - <ArrowLeft className="w-5 h-5" /> 115 - </button> 116 - 117 - <div className="absolute bottom-0 left-0 right-0 p-4 md:p-8"> 118 - <div className="container mx-auto max-w-6xl"> 119 - <div className="flex items-end gap-4 md:gap-8"> 120 - <div className="shrink-0"> 121 - <div 122 - className="w-28 md:w-48 lg:w-64 rounded-lg overflow-hidden shadow-2xl" 123 - style={{ 124 - boxShadow: `0 25px 50px -12px ${colors.primary}40`, 125 - }} 126 - > 127 - {posterUrl ? ( 128 - <img 129 - src={posterUrl} 130 - alt={show?.name || title} 131 - className="w-full aspect-2/3 object-cover" 132 - /> 133 - ) : ( 134 - <div className="w-full aspect-2/3 bg-gray-900 flex items-center justify-center"> 135 - <span className="text-gray-600">No poster</span> 136 - </div> 137 - )} 138 - </div> 139 - </div> 140 - 141 - <div className="flex-1 pb-2"> 142 - <h1 143 - className="text-2xl md:text-5xl lg:text-6xl font-bold mb-2" 144 - style={{ textShadow: `0 4px 30px ${colors.primary}60` }} 145 - > 146 - {isLoading 147 - ? "Loading..." 148 - : (show?.name ?? title.replace(/-/g, " "))} 149 - </h1> 150 - </div> 151 - </div> 152 - </div> 153 - </div> 154 - </div> 176 + <DetailHero 177 + title={show?.name || title.replace(/-/g, " ")} 178 + backdropUrl={backdropUrl} 179 + posterUrl={posterUrl} 180 + colors={colors} 181 + isLoading={isLoading} 182 + onBack={() => router.history.back()} 183 + /> 155 184 156 185 <div className="container mx-auto px-4 py-6 max-w-6xl"> 157 186 <div className="grid grid-cols-1 md:grid-cols-[300px_1fr] gap-8 min-w-0"> 158 - <div className="space-y-4" /> 187 + <div className="space-y-4 min-w-0"> 188 + <DetailActions 189 + mediaType="show" 190 + mediaId={showId} 191 + colors={colors} 192 + isWatched={watchedEpisodeCount > 0} 193 + watchedDate={null} 194 + totalWatches={watchedEpisodeCount} 195 + onMarkWatched={handleMarkWatched} 196 + onShowDatePicker={() => {}} 197 + isMarkingPending={markShowWatchedMutation.isPending} 198 + listsCount={listsCount} 199 + onShowListModal={() => setShowListModal(true)} 200 + isLoggedIn={!!user} 201 + onLogin={() => router.navigate({ to: "/login" })} 202 + /> 203 + </div> 159 204 160 205 <div className="space-y-6 min-w-0"> 161 - <div className="flex flex-wrap gap-3"> 162 - {show?.first_air_date && ( 163 - <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"> 164 - <Calendar className="w-4 h-4" /> 165 - {new Date(show.first_air_date).getFullYear()} 166 - </div> 167 - )} 168 - <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"> 169 - <Tv className="w-4 h-4" /> 170 - {episodeCount} episodes 171 - </div> 172 - <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"> 173 - <span> 174 - {seasonCount} season{seasonCount !== 1 ? "s" : ""} 175 - </span> 176 - </div> 177 - </div> 206 + <MetadataPills items={metadataItems} /> 178 207 179 208 <section> 180 209 <h2 ··· 190 219 191 220 <GenresSection genres={show?.genres} colors={colors} /> 192 221 193 - <section className="pt-2"> 194 - <h2 195 - className="text-xl font-semibold mb-4" 196 - style={{ color: colors.primary }} 197 - > 198 - Seasons 199 - </h2> 200 - <div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-3"> 201 - {Array.from({ length: seasonCount }).map((_, idx) => { 202 - const seasonNumber = idx + 1; 203 - return ( 204 - <Link 205 - key={seasonNumber} 206 - to="/shows/$showId/$title/seasons/$seasonNumber" 207 - params={{ 208 - showId, 209 - title, 210 - seasonNumber: String(seasonNumber), 211 - }} 212 - className="rounded-xl p-4 border hover:bg-gray-900/40 transition-colors" 213 - style={{ borderColor: "var(--md-sys-color-outline)" }} 214 - > 215 - <div className="font-medium"> 216 - Season {seasonNumber} 217 - </div> 218 - {user && ( 219 - <div className="text-xs mt-1 text-gray-400"> 220 - Open details 221 - </div> 222 - )} 223 - </Link> 224 - ); 225 - })} 226 - </div> 227 - </section> 222 + {seasonCount > 0 && ( 223 + <section> 224 + <h2 225 + className="text-xl font-semibold mb-4" 226 + style={{ color: colors.primary }} 227 + > 228 + Seasons 229 + </h2> 230 + <div className="space-y-3"> 231 + {show?.seasons?.map((season) => { 232 + const watchedCount = 233 + seasonWatchedCounts.get(season.season_number) ?? 0; 234 + 235 + return ( 236 + <SeasonCard 237 + key={season.id} 238 + showId={showId} 239 + title={title} 240 + seasonNumber={season.season_number} 241 + airDate={season.air_date} 242 + episodeCount={season.episode_count ?? 0} 243 + watchedCount={watchedCount} 244 + colors={colors} 245 + posterUrl={getTmdbPosterUrl( 246 + season.poster_path, 247 + "w500", 248 + )} 249 + /> 250 + ); 251 + })} 252 + </div> 253 + </section> 254 + )} 228 255 229 256 <CastSection cast={show?.credits?.cast} colors={colors} /> 230 257 <CrewSection crew={show?.credits?.crew} colors={colors} /> ··· 234 261 </> 235 262 )} 236 263 <Outlet /> 264 + 265 + {user && ( 266 + <AddToListModal 267 + open={showListModal} 268 + onOpenChange={setShowListModal} 269 + mediaType="show" 270 + mediaId={showId} 271 + mediaTitle={show?.name || ""} 272 + user={user} 273 + /> 274 + )} 237 275 </div> 238 276 ); 239 277 }
+70
backend/src/shows/dto/show.dto.ts
··· 213 213 episodes: TMDBEpisodeDto[]; 214 214 } 215 215 216 + export class TMDBSeasonSummaryDto { 217 + @ApiProperty() 218 + id: number; 219 + 220 + @ApiProperty() 221 + name: string; 222 + 223 + @ApiProperty() 224 + season_number: number; 225 + 226 + @ApiPropertyOptional() 227 + overview?: string; 228 + 229 + @ApiPropertyOptional() 230 + poster_path?: string; 231 + 232 + @ApiPropertyOptional() 233 + air_date?: string; 234 + 235 + @ApiPropertyOptional() 236 + episode_count?: number; 237 + 238 + @ApiPropertyOptional() 239 + vote_average?: number; 240 + } 241 + 216 242 export class TMDBShowDetailDto extends TMDBShowResultDto { 217 243 @ApiPropertyOptional({ type: [TMDBGenreDto] }) 218 244 genres?: TMDBGenreDto[]; ··· 222 248 223 249 @ApiPropertyOptional() 224 250 number_of_episodes?: number; 251 + 252 + @ApiPropertyOptional({ type: [TMDBSeasonSummaryDto] }) 253 + seasons?: TMDBSeasonSummaryDto[]; 225 254 226 255 @ApiPropertyOptional({ type: MovieColorsDto }) 227 256 @IsOptional() ··· 311 340 @ApiProperty({ description: "Total count of items" }) 312 341 total: number; 313 342 } 343 + 344 + export class MarkSeasonWatchedDto { 345 + @ApiProperty({ description: "TMDB show ID" }) 346 + @IsString() 347 + showId: string; 348 + 349 + @ApiProperty({ description: "TMDB season number" }) 350 + @Type(() => Number) 351 + @IsInt() 352 + seasonNumber: number; 353 + 354 + @ApiPropertyOptional({ 355 + description: 356 + "Custom watch datetime (ISO 8601). If not provided, current time is used.", 357 + }) 358 + @IsOptional() 359 + @IsDateString() 360 + watchedAt?: string; 361 + } 362 + 363 + export class MarkShowWatchedDto { 364 + @ApiProperty({ description: "TMDB show ID" }) 365 + @IsString() 366 + showId: string; 367 + 368 + @ApiPropertyOptional({ 369 + description: 370 + "Custom watch datetime (ISO 8601). If not provided, current time is used.", 371 + }) 372 + @IsOptional() 373 + @IsDateString() 374 + watchedAt?: string; 375 + } 376 + 377 + export class MarkedEpisodesResponseDto { 378 + @ApiProperty({ type: [TrackedEpisodeDto] }) 379 + episodes: TrackedEpisodeDto[]; 380 + 381 + @ApiProperty({ description: "Number of episodes marked as watched" }) 382 + count: number; 383 + }
+62
backend/src/shows/shows.controller.ts
··· 25 25 import { 26 26 type DiscoverShowsDto, 27 27 EpisodeHistoryItemDto, 28 + MarkedEpisodesResponseDto, 28 29 MarkEpisodeWatchedDto, 30 + MarkSeasonWatchedDto, 31 + MarkShowWatchedDto, 29 32 PaginatedEpisodesQueryDto, 30 33 PaginatedEpisodesResponseDto, 31 34 SearchShowsResultsDto, ··· 336 339 req.user.session as ATSession, 337 340 trackedEpisodeId, 338 341 ); 342 + } 343 + 344 + @Post("season/watched") 345 + @UseGuards(AuthGuard) 346 + @ApiOperation({ summary: "Mark all episodes in a season as watched" }) 347 + @ApiBody({ type: MarkSeasonWatchedDto }) 348 + @ApiResponse({ status: 201, type: MarkedEpisodesResponseDto }) 349 + @ApiResponse({ status: 401, description: "Not authenticated" }) 350 + async markSeasonWatched( 351 + @Body() dto: MarkSeasonWatchedDto, 352 + @Req() req: AuthenticatedRequest, 353 + ) { 354 + const user = req.user; 355 + const result = await this.showsService.markSeasonWatched( 356 + user.did, 357 + user.session as ATSession, 358 + dto.showId, 359 + dto.seasonNumber, 360 + dto.watchedAt, 361 + ); 362 + 363 + return { 364 + episodes: result.episodes.map((ep) => ({ 365 + ...ep, 366 + watchedDate: ep.watchedDate?.toISOString(), 367 + createdAt: ep.createdAt.toISOString(), 368 + updatedAt: ep.updatedAt.toISOString(), 369 + })), 370 + count: result.count, 371 + }; 372 + } 373 + 374 + @Post("show/watched") 375 + @UseGuards(AuthGuard) 376 + @ApiOperation({ summary: "Mark all episodes in a show as watched" }) 377 + @ApiBody({ type: MarkShowWatchedDto }) 378 + @ApiResponse({ status: 201, type: MarkedEpisodesResponseDto }) 379 + @ApiResponse({ status: 401, description: "Not authenticated" }) 380 + async markShowWatched( 381 + @Body() dto: MarkShowWatchedDto, 382 + @Req() req: AuthenticatedRequest, 383 + ) { 384 + const user = req.user; 385 + const result = await this.showsService.markShowWatched( 386 + user.did, 387 + user.session as ATSession, 388 + dto.showId, 389 + dto.watchedAt, 390 + ); 391 + 392 + return { 393 + episodes: result.episodes.map((ep) => ({ 394 + ...ep, 395 + watchedDate: ep.watchedDate?.toISOString(), 396 + createdAt: ep.createdAt.toISOString(), 397 + updatedAt: ep.updatedAt.toISOString(), 398 + })), 399 + count: result.count, 400 + }; 339 401 } 340 402 }
+237
backend/src/shows/shows.service.ts
··· 548 548 }, 549 549 }); 550 550 } 551 + 552 + async markSeasonWatched( 553 + userDid: string, 554 + session: ATSession, 555 + showId: string, 556 + seasonNumber: number, 557 + customWatchedAt?: string, 558 + ) { 559 + const season = await this.getSeasonDetails(showId, seasonNumber); 560 + const episodes = season.episodes || []; 561 + 562 + if (episodes.length === 0) { 563 + return { episodes: [], count: 0 }; 564 + } 565 + 566 + const watchedAt = customWatchedAt 567 + ? new Date(customWatchedAt).toISOString() 568 + : new Date().toISOString(); 569 + const now = new Date().toISOString(); 570 + 571 + const agent = new Agent( 572 + session as unknown as ConstructorParameters<typeof Agent>[0], 573 + ); 574 + 575 + const results: Array<{ 576 + uri: string; 577 + cid: string; 578 + rkey: string; 579 + record: EpisodeRecord; 580 + seasonNumber: number; 581 + episodeNumber: number; 582 + }> = []; 583 + 584 + for (const episode of episodes) { 585 + const rkey = TID.nextStr(); 586 + const record: EpisodeRecord = episodeSchema.build({ 587 + showId, 588 + seasonNumber, 589 + episodeNumber: episode.episode_number, 590 + source: "tmdb", 591 + watchedAt, 592 + createdAt: now, 593 + }); 594 + 595 + const response = await agent.com.atproto.repo.putRecord({ 596 + repo: session.did, 597 + collection: COLLECTION, 598 + rkey, 599 + record, 600 + validate: false, 601 + }); 602 + 603 + results.push({ 604 + uri: response.data.uri, 605 + cid: response.data.cid, 606 + rkey, 607 + record, 608 + seasonNumber, 609 + episodeNumber: episode.episode_number, 610 + }); 611 + } 612 + 613 + const showData = await this.getShowDetails(showId); 614 + await this.upsertShow(showData); 615 + 616 + const trackedEpisodes: Array<{ 617 + id: string; 618 + rkey: string; 619 + uri: string; 620 + cid: string; 621 + userDid: string; 622 + showId: string; 623 + seasonNumber: number; 624 + episodeNumber: number; 625 + status: string; 626 + watchedDate: Date | null; 627 + createdAt: Date; 628 + updatedAt: Date; 629 + show: { 630 + showId: string; 631 + title: string; 632 + posterPath: string | null; 633 + backdropPath: string | null; 634 + firstAirYear: number | null; 635 + firstAirDate: Date | null; 636 + overview: string | null; 637 + colors: unknown; 638 + createdAt: Date; 639 + updatedAt: Date; 640 + }; 641 + }> = []; 642 + for (const result of results) { 643 + try { 644 + const tracked = await this.prisma.trackedEpisode.create({ 645 + data: { 646 + uri: result.uri, 647 + rkey: result.rkey, 648 + cid: result.cid, 649 + userDid, 650 + showId, 651 + seasonNumber: result.seasonNumber, 652 + episodeNumber: result.episodeNumber, 653 + watchedDate: new Date(watchedAt), 654 + status: "watched", 655 + }, 656 + include: { show: true }, 657 + }); 658 + trackedEpisodes.push(tracked); 659 + } catch (err: unknown) { 660 + this.logger.warn( 661 + { err: err instanceof Error ? err.message : String(err) }, 662 + "Failed to index episode, firehose will catch it", 663 + ); 664 + } 665 + } 666 + 667 + return { episodes: trackedEpisodes, count: results.length }; 668 + } 669 + 670 + async markShowWatched( 671 + userDid: string, 672 + session: ATSession, 673 + showId: string, 674 + customWatchedAt?: string, 675 + ) { 676 + const show = await this.getShowDetails(showId); 677 + const numberOfSeasons = show.number_of_seasons || 1; 678 + 679 + const watchedAt = customWatchedAt 680 + ? new Date(customWatchedAt).toISOString() 681 + : new Date().toISOString(); 682 + 683 + const allResults: Array<{ 684 + uri: string; 685 + cid: string; 686 + rkey: string; 687 + record: EpisodeRecord; 688 + seasonNumber: number; 689 + episodeNumber: number; 690 + }> = []; 691 + 692 + for (let seasonNum = 1; seasonNum <= numberOfSeasons; seasonNum++) { 693 + const season = await this.getSeasonDetails(showId, seasonNum); 694 + const episodes = season.episodes || []; 695 + 696 + const now = new Date().toISOString(); 697 + 698 + const agent = new Agent( 699 + session as unknown as ConstructorParameters<typeof Agent>[0], 700 + ); 701 + 702 + for (const episode of episodes) { 703 + const rkey = TID.nextStr(); 704 + const record: EpisodeRecord = episodeSchema.build({ 705 + showId, 706 + seasonNumber: seasonNum, 707 + episodeNumber: episode.episode_number, 708 + source: "tmdb", 709 + watchedAt, 710 + createdAt: now, 711 + }); 712 + 713 + const response = await agent.com.atproto.repo.putRecord({ 714 + repo: session.did, 715 + collection: COLLECTION, 716 + rkey, 717 + record, 718 + validate: false, 719 + }); 720 + 721 + allResults.push({ 722 + uri: response.data.uri, 723 + cid: response.data.cid, 724 + rkey, 725 + record, 726 + seasonNumber: seasonNum, 727 + episodeNumber: episode.episode_number, 728 + }); 729 + } 730 + } 731 + 732 + const showData = await this.getShowDetails(showId); 733 + await this.upsertShow(showData); 734 + 735 + const trackedEpisodes: Array<{ 736 + id: string; 737 + rkey: string; 738 + uri: string; 739 + cid: string; 740 + userDid: string; 741 + showId: string; 742 + seasonNumber: number; 743 + episodeNumber: number; 744 + status: string; 745 + watchedDate: Date | null; 746 + createdAt: Date; 747 + updatedAt: Date; 748 + show: { 749 + showId: string; 750 + title: string; 751 + posterPath: string | null; 752 + backdropPath: string | null; 753 + firstAirYear: number | null; 754 + firstAirDate: Date | null; 755 + overview: string | null; 756 + colors: unknown; 757 + createdAt: Date; 758 + updatedAt: Date; 759 + }; 760 + }> = []; 761 + for (const result of allResults) { 762 + try { 763 + const tracked = await this.prisma.trackedEpisode.create({ 764 + data: { 765 + uri: result.uri, 766 + rkey: result.rkey, 767 + cid: result.cid, 768 + userDid, 769 + showId, 770 + seasonNumber: result.seasonNumber, 771 + episodeNumber: result.episodeNumber, 772 + watchedDate: new Date(watchedAt), 773 + status: "watched", 774 + }, 775 + include: { show: true }, 776 + }); 777 + trackedEpisodes.push(tracked); 778 + } catch (err: unknown) { 779 + this.logger.warn( 780 + { err: err instanceof Error ? err.message : String(err) }, 781 + "Failed to index episode, firehose will catch it", 782 + ); 783 + } 784 + } 785 + 786 + return { episodes: trackedEpisodes, count: allResults.length }; 787 + } 551 788 }
+36 -2
packages/api/src/generated/@tanstack/react-query.gen.ts
··· 3 3 import { type DefaultError, type InfiniteData, infiniteQueryOptions, queryOptions, type UseMutationOptions } from '@tanstack/react-query'; 4 4 5 5 import { client } from '../client.gen'; 6 - import { authControllerCallback, authControllerGetClientMetadata, authControllerLogin, authControllerLogout, authControllerMe, authControllerSuggestions, listsControllerAddItemToList, listsControllerAddToList, listsControllerCreateList, listsControllerDeleteList, listsControllerGetList, listsControllerGetListsForItem, listsControllerGetListsForMovie, listsControllerGetUserLists, listsControllerInitDefaultLists, listsControllerRemoveFromList, listsControllerRemoveItemFromList, listsControllerUpdateList, moviesControllerDeleteWatchHistoryEntry, moviesControllerDiscoverMovies, moviesControllerGetMovie, moviesControllerGetMovieDetails, moviesControllerGetMovieWatchHistory, moviesControllerGetUserMovies, moviesControllerGetUserMoviesPaginated, moviesControllerMarkWatched, moviesControllerSearchMovies, moviesControllerUnmarkWatched, type Options, shelfControllerGetUserShelf, showsControllerDeleteEpisodeWatchHistoryEntry, showsControllerDiscoverShows, showsControllerGetEpisodeDetails, showsControllerGetSeasonDetails, showsControllerGetShow, showsControllerGetShowDetails, showsControllerGetShowWatchHistory, showsControllerGetUserEpisodesPaginated, showsControllerGetUserShows, showsControllerMarkWatched, showsControllerSearchShows, showsControllerUnmarkWatched, usersControllerDeleteMyAccount, usersControllerGetMySettings, usersControllerUpdateMySettings } from '../sdk.gen'; 7 - import type { AuthControllerCallbackData, AuthControllerGetClientMetadataData, AuthControllerLoginData, AuthControllerLogoutData, AuthControllerMeData, AuthControllerMeResponse, AuthControllerSuggestionsData, ListsControllerAddItemToListData, ListsControllerAddToListData, ListsControllerCreateListData, ListsControllerCreateListResponse, ListsControllerDeleteListData, ListsControllerGetListData, ListsControllerGetListResponse, ListsControllerGetListsForItemData, ListsControllerGetListsForItemResponse, ListsControllerGetListsForMovieData, ListsControllerGetUserListsData, ListsControllerGetUserListsResponse, ListsControllerInitDefaultListsData, ListsControllerInitDefaultListsResponse, ListsControllerRemoveFromListData, ListsControllerRemoveItemFromListData, ListsControllerUpdateListData, ListsControllerUpdateListResponse, MoviesControllerDeleteWatchHistoryEntryData, MoviesControllerDeleteWatchHistoryEntryResponse, MoviesControllerDiscoverMoviesData, MoviesControllerDiscoverMoviesResponse, MoviesControllerGetMovieData, MoviesControllerGetMovieDetailsData, MoviesControllerGetMovieDetailsResponse, MoviesControllerGetMovieResponse, MoviesControllerGetMovieWatchHistoryData, MoviesControllerGetMovieWatchHistoryResponse, MoviesControllerGetUserMoviesData, MoviesControllerGetUserMoviesPaginatedData, MoviesControllerGetUserMoviesPaginatedResponse, MoviesControllerGetUserMoviesResponse, MoviesControllerMarkWatchedData, MoviesControllerMarkWatchedResponse, MoviesControllerSearchMoviesData, MoviesControllerSearchMoviesResponse, MoviesControllerUnmarkWatchedData, MoviesControllerUnmarkWatchedResponse, ShelfControllerGetUserShelfData, ShelfControllerGetUserShelfResponse, ShowsControllerDeleteEpisodeWatchHistoryEntryData, ShowsControllerDeleteEpisodeWatchHistoryEntryResponse, ShowsControllerDiscoverShowsData, ShowsControllerDiscoverShowsResponse, ShowsControllerGetEpisodeDetailsData, ShowsControllerGetEpisodeDetailsResponse, ShowsControllerGetSeasonDetailsData, ShowsControllerGetSeasonDetailsResponse, ShowsControllerGetShowData, ShowsControllerGetShowDetailsData, ShowsControllerGetShowDetailsResponse, ShowsControllerGetShowResponse, ShowsControllerGetShowWatchHistoryData, ShowsControllerGetShowWatchHistoryResponse, ShowsControllerGetUserEpisodesPaginatedData, ShowsControllerGetUserEpisodesPaginatedResponse, ShowsControllerGetUserShowsData, ShowsControllerGetUserShowsResponse, ShowsControllerMarkWatchedData, ShowsControllerMarkWatchedResponse, ShowsControllerSearchShowsData, ShowsControllerSearchShowsResponse, ShowsControllerUnmarkWatchedData, ShowsControllerUnmarkWatchedResponse, UsersControllerDeleteMyAccountData, UsersControllerDeleteMyAccountResponse, UsersControllerGetMySettingsData, UsersControllerGetMySettingsResponse, UsersControllerUpdateMySettingsData, UsersControllerUpdateMySettingsResponse } from '../types.gen'; 6 + import { authControllerCallback, authControllerGetClientMetadata, authControllerLogin, authControllerLogout, authControllerMe, authControllerSuggestions, listsControllerAddItemToList, listsControllerAddToList, listsControllerCreateList, listsControllerDeleteList, listsControllerGetList, listsControllerGetListsForItem, listsControllerGetListsForMovie, listsControllerGetUserLists, listsControllerInitDefaultLists, listsControllerRemoveFromList, listsControllerRemoveItemFromList, listsControllerUpdateList, moviesControllerDeleteWatchHistoryEntry, moviesControllerDiscoverMovies, moviesControllerGetMovie, moviesControllerGetMovieDetails, moviesControllerGetMovieWatchHistory, moviesControllerGetUserMovies, moviesControllerGetUserMoviesPaginated, moviesControllerMarkWatched, moviesControllerSearchMovies, moviesControllerUnmarkWatched, type Options, shelfControllerGetUserShelf, showsControllerDeleteEpisodeWatchHistoryEntry, showsControllerDiscoverShows, showsControllerGetEpisodeDetails, showsControllerGetSeasonDetails, showsControllerGetShow, showsControllerGetShowDetails, showsControllerGetShowWatchHistory, showsControllerGetUserEpisodesPaginated, showsControllerGetUserShows, showsControllerMarkSeasonWatched, showsControllerMarkShowWatched, showsControllerMarkWatched, showsControllerSearchShows, showsControllerUnmarkWatched, usersControllerDeleteMyAccount, usersControllerGetMySettings, usersControllerUpdateMySettings } from '../sdk.gen'; 7 + import type { AuthControllerCallbackData, AuthControllerGetClientMetadataData, AuthControllerLoginData, AuthControllerLogoutData, AuthControllerMeData, AuthControllerMeResponse, AuthControllerSuggestionsData, ListsControllerAddItemToListData, ListsControllerAddToListData, ListsControllerCreateListData, ListsControllerCreateListResponse, ListsControllerDeleteListData, ListsControllerGetListData, ListsControllerGetListResponse, ListsControllerGetListsForItemData, ListsControllerGetListsForItemResponse, ListsControllerGetListsForMovieData, ListsControllerGetUserListsData, ListsControllerGetUserListsResponse, ListsControllerInitDefaultListsData, ListsControllerInitDefaultListsResponse, ListsControllerRemoveFromListData, ListsControllerRemoveItemFromListData, ListsControllerUpdateListData, ListsControllerUpdateListResponse, MoviesControllerDeleteWatchHistoryEntryData, MoviesControllerDeleteWatchHistoryEntryResponse, MoviesControllerDiscoverMoviesData, MoviesControllerDiscoverMoviesResponse, MoviesControllerGetMovieData, MoviesControllerGetMovieDetailsData, MoviesControllerGetMovieDetailsResponse, MoviesControllerGetMovieResponse, MoviesControllerGetMovieWatchHistoryData, MoviesControllerGetMovieWatchHistoryResponse, MoviesControllerGetUserMoviesData, MoviesControllerGetUserMoviesPaginatedData, MoviesControllerGetUserMoviesPaginatedResponse, MoviesControllerGetUserMoviesResponse, MoviesControllerMarkWatchedData, MoviesControllerMarkWatchedResponse, MoviesControllerSearchMoviesData, MoviesControllerSearchMoviesResponse, MoviesControllerUnmarkWatchedData, MoviesControllerUnmarkWatchedResponse, ShelfControllerGetUserShelfData, ShelfControllerGetUserShelfResponse, ShowsControllerDeleteEpisodeWatchHistoryEntryData, ShowsControllerDeleteEpisodeWatchHistoryEntryResponse, ShowsControllerDiscoverShowsData, ShowsControllerDiscoverShowsResponse, ShowsControllerGetEpisodeDetailsData, ShowsControllerGetEpisodeDetailsResponse, ShowsControllerGetSeasonDetailsData, ShowsControllerGetSeasonDetailsResponse, ShowsControllerGetShowData, ShowsControllerGetShowDetailsData, ShowsControllerGetShowDetailsResponse, ShowsControllerGetShowResponse, ShowsControllerGetShowWatchHistoryData, ShowsControllerGetShowWatchHistoryResponse, ShowsControllerGetUserEpisodesPaginatedData, ShowsControllerGetUserEpisodesPaginatedResponse, ShowsControllerGetUserShowsData, ShowsControllerGetUserShowsResponse, ShowsControllerMarkSeasonWatchedData, ShowsControllerMarkSeasonWatchedResponse, ShowsControllerMarkShowWatchedData, ShowsControllerMarkShowWatchedResponse, ShowsControllerMarkWatchedData, ShowsControllerMarkWatchedResponse, ShowsControllerSearchShowsData, ShowsControllerSearchShowsResponse, ShowsControllerUnmarkWatchedData, ShowsControllerUnmarkWatchedResponse, UsersControllerDeleteMyAccountData, UsersControllerDeleteMyAccountResponse, UsersControllerGetMySettingsData, UsersControllerGetMySettingsResponse, UsersControllerUpdateMySettingsData, UsersControllerUpdateMySettingsResponse } from '../types.gen'; 8 8 9 9 export type QueryKey<TOptions extends Options> = [ 10 10 Pick<TOptions, 'baseUrl' | 'body' | 'headers' | 'path' | 'query'> & { ··· 609 609 const mutationOptions: UseMutationOptions<ShowsControllerDeleteEpisodeWatchHistoryEntryResponse, DefaultError, Options<ShowsControllerDeleteEpisodeWatchHistoryEntryData>> = { 610 610 mutationFn: async (fnOptions) => { 611 611 const { data } = await showsControllerDeleteEpisodeWatchHistoryEntry({ 612 + ...options, 613 + ...fnOptions, 614 + throwOnError: true 615 + }); 616 + return data; 617 + } 618 + }; 619 + return mutationOptions; 620 + }; 621 + 622 + /** 623 + * Mark all episodes in a season as watched 624 + */ 625 + export const showsControllerMarkSeasonWatchedMutation = (options?: Partial<Options<ShowsControllerMarkSeasonWatchedData>>): UseMutationOptions<ShowsControllerMarkSeasonWatchedResponse, DefaultError, Options<ShowsControllerMarkSeasonWatchedData>> => { 626 + const mutationOptions: UseMutationOptions<ShowsControllerMarkSeasonWatchedResponse, DefaultError, Options<ShowsControllerMarkSeasonWatchedData>> = { 627 + mutationFn: async (fnOptions) => { 628 + const { data } = await showsControllerMarkSeasonWatched({ 629 + ...options, 630 + ...fnOptions, 631 + throwOnError: true 632 + }); 633 + return data; 634 + } 635 + }; 636 + return mutationOptions; 637 + }; 638 + 639 + /** 640 + * Mark all episodes in a show as watched 641 + */ 642 + export const showsControllerMarkShowWatchedMutation = (options?: Partial<Options<ShowsControllerMarkShowWatchedData>>): UseMutationOptions<ShowsControllerMarkShowWatchedResponse, DefaultError, Options<ShowsControllerMarkShowWatchedData>> => { 643 + const mutationOptions: UseMutationOptions<ShowsControllerMarkShowWatchedResponse, DefaultError, Options<ShowsControllerMarkShowWatchedData>> = { 644 + mutationFn: async (fnOptions) => { 645 + const { data } = await showsControllerMarkShowWatched({ 612 646 ...options, 613 647 ...fnOptions, 614 648 throwOnError: true
+2 -2
packages/api/src/generated/index.ts
··· 1 1 // This file is auto-generated by @hey-api/openapi-ts 2 2 3 - export { authControllerCallback, authControllerGetClientMetadata, authControllerLogin, authControllerLogout, authControllerMe, authControllerSuggestions, listsControllerAddItemToList, listsControllerAddToList, listsControllerCreateList, listsControllerDeleteList, listsControllerGetList, listsControllerGetListsForItem, listsControllerGetListsForMovie, listsControllerGetUserLists, listsControllerInitDefaultLists, listsControllerRemoveFromList, listsControllerRemoveItemFromList, listsControllerUpdateList, moviesControllerDeleteWatchHistoryEntry, moviesControllerDiscoverMovies, moviesControllerGetMovie, moviesControllerGetMovieDetails, moviesControllerGetMovieWatchHistory, moviesControllerGetUserMovies, moviesControllerGetUserMoviesPaginated, moviesControllerMarkWatched, moviesControllerSearchMovies, moviesControllerUnmarkWatched, type Options, shelfControllerGetUserShelf, showsControllerDeleteEpisodeWatchHistoryEntry, showsControllerDiscoverShows, showsControllerGetEpisodeDetails, showsControllerGetSeasonDetails, showsControllerGetShow, showsControllerGetShowDetails, showsControllerGetShowWatchHistory, showsControllerGetUserEpisodesPaginated, showsControllerGetUserShows, showsControllerMarkWatched, showsControllerSearchShows, showsControllerUnmarkWatched, usersControllerDeleteMyAccount, usersControllerGetMySettings, usersControllerUpdateMySettings } from './sdk.gen'; 4 - export type { AddToListDto, AuthControllerCallbackData, AuthControllerGetClientMetadataData, AuthControllerGetClientMetadataResponses, AuthControllerLoginData, AuthControllerLogoutData, AuthControllerLogoutResponses, AuthControllerMeData, AuthControllerMeErrors, AuthControllerMeResponse, AuthControllerMeResponses, AuthControllerSuggestionsData, AuthControllerSuggestionsResponses, ClientOptions, CreateListDto, DeleteUserAccountDto, EpisodeHistoryItemDto, ListsControllerAddItemToListData, ListsControllerAddItemToListErrors, ListsControllerAddItemToListResponses, ListsControllerAddToListData, ListsControllerAddToListResponses, ListsControllerCreateListData, ListsControllerCreateListErrors, ListsControllerCreateListResponse, ListsControllerCreateListResponses, ListsControllerDeleteListData, ListsControllerDeleteListErrors, ListsControllerDeleteListResponses, ListsControllerGetListData, ListsControllerGetListErrors, ListsControllerGetListResponse, ListsControllerGetListResponses, ListsControllerGetListsForItemData, ListsControllerGetListsForItemErrors, ListsControllerGetListsForItemResponse, ListsControllerGetListsForItemResponses, ListsControllerGetListsForMovieData, ListsControllerGetListsForMovieResponses, ListsControllerGetUserListsData, ListsControllerGetUserListsErrors, ListsControllerGetUserListsResponse, ListsControllerGetUserListsResponses, ListsControllerInitDefaultListsData, ListsControllerInitDefaultListsErrors, ListsControllerInitDefaultListsResponse, ListsControllerInitDefaultListsResponses, ListsControllerRemoveFromListData, ListsControllerRemoveFromListResponses, ListsControllerRemoveItemFromListData, ListsControllerRemoveItemFromListErrors, ListsControllerRemoveItemFromListResponses, ListsControllerUpdateListData, ListsControllerUpdateListErrors, ListsControllerUpdateListResponse, ListsControllerUpdateListResponses, MarkEpisodeWatchedDto, MediaInListDto, MovieColorsDto, MovieDto, MovieListDto, MovieListsForItemDto, MovieListSummaryDto, MovieListWithMoviesDto, MoviesControllerDeleteWatchHistoryEntryData, MoviesControllerDeleteWatchHistoryEntryErrors, MoviesControllerDeleteWatchHistoryEntryResponse, MoviesControllerDeleteWatchHistoryEntryResponses, MoviesControllerDiscoverMoviesData, MoviesControllerDiscoverMoviesResponse, MoviesControllerDiscoverMoviesResponses, MoviesControllerGetMovieData, MoviesControllerGetMovieDetailsData, MoviesControllerGetMovieDetailsResponse, MoviesControllerGetMovieDetailsResponses, MoviesControllerGetMovieResponse, MoviesControllerGetMovieResponses, MoviesControllerGetMovieWatchHistoryData, MoviesControllerGetMovieWatchHistoryErrors, MoviesControllerGetMovieWatchHistoryResponse, MoviesControllerGetMovieWatchHistoryResponses, MoviesControllerGetUserMoviesData, MoviesControllerGetUserMoviesPaginatedData, MoviesControllerGetUserMoviesPaginatedResponse, MoviesControllerGetUserMoviesPaginatedResponses, MoviesControllerGetUserMoviesResponse, MoviesControllerGetUserMoviesResponses, MoviesControllerMarkWatchedData, MoviesControllerMarkWatchedErrors, MoviesControllerMarkWatchedResponse, MoviesControllerMarkWatchedResponses, MoviesControllerSearchMoviesData, MoviesControllerSearchMoviesResponse, MoviesControllerSearchMoviesResponses, MoviesControllerUnmarkWatchedData, MoviesControllerUnmarkWatchedErrors, MoviesControllerUnmarkWatchedResponse, MoviesControllerUnmarkWatchedResponses, PaginatedEpisodesResponseDto, PaginatedMoviesResponseDto, SearchResultsDto, SearchShowsResultsDto, ShelfControllerGetUserShelfData, ShelfControllerGetUserShelfResponse, ShelfControllerGetUserShelfResponses, ShelfResponseDto, ShowDto, ShowsControllerDeleteEpisodeWatchHistoryEntryData, ShowsControllerDeleteEpisodeWatchHistoryEntryErrors, ShowsControllerDeleteEpisodeWatchHistoryEntryResponse, ShowsControllerDeleteEpisodeWatchHistoryEntryResponses, ShowsControllerDiscoverShowsData, ShowsControllerDiscoverShowsResponse, ShowsControllerDiscoverShowsResponses, ShowsControllerGetEpisodeDetailsData, ShowsControllerGetEpisodeDetailsResponse, ShowsControllerGetEpisodeDetailsResponses, ShowsControllerGetSeasonDetailsData, ShowsControllerGetSeasonDetailsResponse, ShowsControllerGetSeasonDetailsResponses, ShowsControllerGetShowData, ShowsControllerGetShowDetailsData, ShowsControllerGetShowDetailsResponse, ShowsControllerGetShowDetailsResponses, ShowsControllerGetShowResponse, ShowsControllerGetShowResponses, ShowsControllerGetShowWatchHistoryData, ShowsControllerGetShowWatchHistoryErrors, ShowsControllerGetShowWatchHistoryResponse, ShowsControllerGetShowWatchHistoryResponses, ShowsControllerGetUserEpisodesPaginatedData, ShowsControllerGetUserEpisodesPaginatedResponse, ShowsControllerGetUserEpisodesPaginatedResponses, ShowsControllerGetUserShowsData, ShowsControllerGetUserShowsResponse, ShowsControllerGetUserShowsResponses, ShowsControllerMarkWatchedData, ShowsControllerMarkWatchedErrors, ShowsControllerMarkWatchedResponse, ShowsControllerMarkWatchedResponses, ShowsControllerSearchShowsData, ShowsControllerSearchShowsResponse, ShowsControllerSearchShowsResponses, ShowsControllerUnmarkWatchedData, ShowsControllerUnmarkWatchedResponse, ShowsControllerUnmarkWatchedResponses, TmdbCastDto, TmdbCreditsDto, TmdbCrewDto, TmdbEpisodeDto, TmdbGenreDto, TmdbMovieDetailDto, TmdbMovieResultDto, TmdbSeasonDetailDto, TmdbShowDetailDto, TmdbShowResultDto, TrackedEpisodeDto, TrackedMovieDto, TrackedShowSummaryDto, UpdateListDto, UpdateUserSettingsDto, UserDto, UsersControllerDeleteMyAccountData, UsersControllerDeleteMyAccountErrors, UsersControllerDeleteMyAccountResponse, UsersControllerDeleteMyAccountResponses, UsersControllerGetMySettingsData, UsersControllerGetMySettingsErrors, UsersControllerGetMySettingsResponse, UsersControllerGetMySettingsResponses, UsersControllerUpdateMySettingsData, UsersControllerUpdateMySettingsErrors, UsersControllerUpdateMySettingsResponse, UsersControllerUpdateMySettingsResponses, UserSettingsDto, WatchHistoryItemDto } from './types.gen'; 3 + export { authControllerCallback, authControllerGetClientMetadata, authControllerLogin, authControllerLogout, authControllerMe, authControllerSuggestions, listsControllerAddItemToList, listsControllerAddToList, listsControllerCreateList, listsControllerDeleteList, listsControllerGetList, listsControllerGetListsForItem, listsControllerGetListsForMovie, listsControllerGetUserLists, listsControllerInitDefaultLists, listsControllerRemoveFromList, listsControllerRemoveItemFromList, listsControllerUpdateList, moviesControllerDeleteWatchHistoryEntry, moviesControllerDiscoverMovies, moviesControllerGetMovie, moviesControllerGetMovieDetails, moviesControllerGetMovieWatchHistory, moviesControllerGetUserMovies, moviesControllerGetUserMoviesPaginated, moviesControllerMarkWatched, moviesControllerSearchMovies, moviesControllerUnmarkWatched, type Options, shelfControllerGetUserShelf, showsControllerDeleteEpisodeWatchHistoryEntry, showsControllerDiscoverShows, showsControllerGetEpisodeDetails, showsControllerGetSeasonDetails, showsControllerGetShow, showsControllerGetShowDetails, showsControllerGetShowWatchHistory, showsControllerGetUserEpisodesPaginated, showsControllerGetUserShows, showsControllerMarkSeasonWatched, showsControllerMarkShowWatched, showsControllerMarkWatched, showsControllerSearchShows, showsControllerUnmarkWatched, usersControllerDeleteMyAccount, usersControllerGetMySettings, usersControllerUpdateMySettings } from './sdk.gen'; 4 + export type { AddToListDto, AuthControllerCallbackData, AuthControllerGetClientMetadataData, AuthControllerGetClientMetadataResponses, AuthControllerLoginData, AuthControllerLogoutData, AuthControllerLogoutResponses, AuthControllerMeData, AuthControllerMeErrors, AuthControllerMeResponse, AuthControllerMeResponses, AuthControllerSuggestionsData, AuthControllerSuggestionsResponses, ClientOptions, CreateListDto, DeleteUserAccountDto, EpisodeHistoryItemDto, ListsControllerAddItemToListData, ListsControllerAddItemToListErrors, ListsControllerAddItemToListResponses, ListsControllerAddToListData, ListsControllerAddToListResponses, ListsControllerCreateListData, ListsControllerCreateListErrors, ListsControllerCreateListResponse, ListsControllerCreateListResponses, ListsControllerDeleteListData, ListsControllerDeleteListErrors, ListsControllerDeleteListResponses, ListsControllerGetListData, ListsControllerGetListErrors, ListsControllerGetListResponse, ListsControllerGetListResponses, ListsControllerGetListsForItemData, ListsControllerGetListsForItemErrors, ListsControllerGetListsForItemResponse, ListsControllerGetListsForItemResponses, ListsControllerGetListsForMovieData, ListsControllerGetListsForMovieResponses, ListsControllerGetUserListsData, ListsControllerGetUserListsErrors, ListsControllerGetUserListsResponse, ListsControllerGetUserListsResponses, ListsControllerInitDefaultListsData, ListsControllerInitDefaultListsErrors, ListsControllerInitDefaultListsResponse, ListsControllerInitDefaultListsResponses, ListsControllerRemoveFromListData, ListsControllerRemoveFromListResponses, ListsControllerRemoveItemFromListData, ListsControllerRemoveItemFromListErrors, ListsControllerRemoveItemFromListResponses, ListsControllerUpdateListData, ListsControllerUpdateListErrors, ListsControllerUpdateListResponse, ListsControllerUpdateListResponses, MarkedEpisodesResponseDto, MarkEpisodeWatchedDto, MarkSeasonWatchedDto, MarkShowWatchedDto, MediaInListDto, MovieColorsDto, MovieDto, MovieListDto, MovieListsForItemDto, MovieListSummaryDto, MovieListWithMoviesDto, MoviesControllerDeleteWatchHistoryEntryData, MoviesControllerDeleteWatchHistoryEntryErrors, MoviesControllerDeleteWatchHistoryEntryResponse, MoviesControllerDeleteWatchHistoryEntryResponses, MoviesControllerDiscoverMoviesData, MoviesControllerDiscoverMoviesResponse, MoviesControllerDiscoverMoviesResponses, MoviesControllerGetMovieData, MoviesControllerGetMovieDetailsData, MoviesControllerGetMovieDetailsResponse, MoviesControllerGetMovieDetailsResponses, MoviesControllerGetMovieResponse, MoviesControllerGetMovieResponses, MoviesControllerGetMovieWatchHistoryData, MoviesControllerGetMovieWatchHistoryErrors, MoviesControllerGetMovieWatchHistoryResponse, MoviesControllerGetMovieWatchHistoryResponses, MoviesControllerGetUserMoviesData, MoviesControllerGetUserMoviesPaginatedData, MoviesControllerGetUserMoviesPaginatedResponse, MoviesControllerGetUserMoviesPaginatedResponses, MoviesControllerGetUserMoviesResponse, MoviesControllerGetUserMoviesResponses, MoviesControllerMarkWatchedData, MoviesControllerMarkWatchedErrors, MoviesControllerMarkWatchedResponse, MoviesControllerMarkWatchedResponses, MoviesControllerSearchMoviesData, MoviesControllerSearchMoviesResponse, MoviesControllerSearchMoviesResponses, MoviesControllerUnmarkWatchedData, MoviesControllerUnmarkWatchedErrors, MoviesControllerUnmarkWatchedResponse, MoviesControllerUnmarkWatchedResponses, PaginatedEpisodesResponseDto, PaginatedMoviesResponseDto, SearchResultsDto, SearchShowsResultsDto, ShelfControllerGetUserShelfData, ShelfControllerGetUserShelfResponse, ShelfControllerGetUserShelfResponses, ShelfResponseDto, ShowDto, ShowsControllerDeleteEpisodeWatchHistoryEntryData, ShowsControllerDeleteEpisodeWatchHistoryEntryErrors, ShowsControllerDeleteEpisodeWatchHistoryEntryResponse, ShowsControllerDeleteEpisodeWatchHistoryEntryResponses, ShowsControllerDiscoverShowsData, ShowsControllerDiscoverShowsResponse, ShowsControllerDiscoverShowsResponses, ShowsControllerGetEpisodeDetailsData, ShowsControllerGetEpisodeDetailsResponse, ShowsControllerGetEpisodeDetailsResponses, ShowsControllerGetSeasonDetailsData, ShowsControllerGetSeasonDetailsResponse, ShowsControllerGetSeasonDetailsResponses, ShowsControllerGetShowData, ShowsControllerGetShowDetailsData, ShowsControllerGetShowDetailsResponse, ShowsControllerGetShowDetailsResponses, ShowsControllerGetShowResponse, ShowsControllerGetShowResponses, ShowsControllerGetShowWatchHistoryData, ShowsControllerGetShowWatchHistoryErrors, ShowsControllerGetShowWatchHistoryResponse, ShowsControllerGetShowWatchHistoryResponses, ShowsControllerGetUserEpisodesPaginatedData, ShowsControllerGetUserEpisodesPaginatedResponse, ShowsControllerGetUserEpisodesPaginatedResponses, ShowsControllerGetUserShowsData, ShowsControllerGetUserShowsResponse, ShowsControllerGetUserShowsResponses, ShowsControllerMarkSeasonWatchedData, ShowsControllerMarkSeasonWatchedErrors, ShowsControllerMarkSeasonWatchedResponse, ShowsControllerMarkSeasonWatchedResponses, ShowsControllerMarkShowWatchedData, ShowsControllerMarkShowWatchedErrors, ShowsControllerMarkShowWatchedResponse, ShowsControllerMarkShowWatchedResponses, ShowsControllerMarkWatchedData, ShowsControllerMarkWatchedErrors, ShowsControllerMarkWatchedResponse, ShowsControllerMarkWatchedResponses, ShowsControllerSearchShowsData, ShowsControllerSearchShowsResponse, ShowsControllerSearchShowsResponses, ShowsControllerUnmarkWatchedData, ShowsControllerUnmarkWatchedResponse, ShowsControllerUnmarkWatchedResponses, TmdbCastDto, TmdbCreditsDto, TmdbCrewDto, TmdbEpisodeDto, TmdbGenreDto, TmdbMovieDetailDto, TmdbMovieResultDto, TmdbSeasonDetailDto, TmdbSeasonSummaryDto, TmdbShowDetailDto, TmdbShowResultDto, TrackedEpisodeDto, TrackedMovieDto, TrackedShowSummaryDto, UpdateListDto, UpdateUserSettingsDto, UserDto, UsersControllerDeleteMyAccountData, UsersControllerDeleteMyAccountErrors, UsersControllerDeleteMyAccountResponse, UsersControllerDeleteMyAccountResponses, UsersControllerGetMySettingsData, UsersControllerGetMySettingsErrors, UsersControllerGetMySettingsResponse, UsersControllerGetMySettingsResponses, UsersControllerUpdateMySettingsData, UsersControllerUpdateMySettingsErrors, UsersControllerUpdateMySettingsResponse, UsersControllerUpdateMySettingsResponses, UserSettingsDto, WatchHistoryItemDto } from './types.gen';
+25 -1
packages/api/src/generated/sdk.gen.ts
··· 2 2 3 3 import type { Client, Options as Options2, TDataShape } from './client'; 4 4 import { client } from './client.gen'; 5 - import type { AuthControllerCallbackData, AuthControllerGetClientMetadataData, AuthControllerGetClientMetadataResponses, AuthControllerLoginData, AuthControllerLogoutData, AuthControllerLogoutResponses, AuthControllerMeData, AuthControllerMeErrors, AuthControllerMeResponses, AuthControllerSuggestionsData, AuthControllerSuggestionsResponses, ListsControllerAddItemToListData, ListsControllerAddItemToListErrors, ListsControllerAddItemToListResponses, ListsControllerAddToListData, ListsControllerAddToListResponses, ListsControllerCreateListData, ListsControllerCreateListErrors, ListsControllerCreateListResponses, ListsControllerDeleteListData, ListsControllerDeleteListErrors, ListsControllerDeleteListResponses, ListsControllerGetListData, ListsControllerGetListErrors, ListsControllerGetListResponses, ListsControllerGetListsForItemData, ListsControllerGetListsForItemErrors, ListsControllerGetListsForItemResponses, ListsControllerGetListsForMovieData, ListsControllerGetListsForMovieResponses, ListsControllerGetUserListsData, ListsControllerGetUserListsErrors, ListsControllerGetUserListsResponses, ListsControllerInitDefaultListsData, ListsControllerInitDefaultListsErrors, ListsControllerInitDefaultListsResponses, ListsControllerRemoveFromListData, ListsControllerRemoveFromListResponses, ListsControllerRemoveItemFromListData, ListsControllerRemoveItemFromListErrors, ListsControllerRemoveItemFromListResponses, ListsControllerUpdateListData, ListsControllerUpdateListErrors, ListsControllerUpdateListResponses, MoviesControllerDeleteWatchHistoryEntryData, MoviesControllerDeleteWatchHistoryEntryErrors, MoviesControllerDeleteWatchHistoryEntryResponses, MoviesControllerDiscoverMoviesData, MoviesControllerDiscoverMoviesResponses, MoviesControllerGetMovieData, MoviesControllerGetMovieDetailsData, MoviesControllerGetMovieDetailsResponses, MoviesControllerGetMovieResponses, MoviesControllerGetMovieWatchHistoryData, MoviesControllerGetMovieWatchHistoryErrors, MoviesControllerGetMovieWatchHistoryResponses, MoviesControllerGetUserMoviesData, MoviesControllerGetUserMoviesPaginatedData, MoviesControllerGetUserMoviesPaginatedResponses, MoviesControllerGetUserMoviesResponses, MoviesControllerMarkWatchedData, MoviesControllerMarkWatchedErrors, MoviesControllerMarkWatchedResponses, MoviesControllerSearchMoviesData, MoviesControllerSearchMoviesResponses, MoviesControllerUnmarkWatchedData, MoviesControllerUnmarkWatchedErrors, MoviesControllerUnmarkWatchedResponses, ShelfControllerGetUserShelfData, ShelfControllerGetUserShelfResponses, ShowsControllerDeleteEpisodeWatchHistoryEntryData, ShowsControllerDeleteEpisodeWatchHistoryEntryErrors, ShowsControllerDeleteEpisodeWatchHistoryEntryResponses, ShowsControllerDiscoverShowsData, ShowsControllerDiscoverShowsResponses, ShowsControllerGetEpisodeDetailsData, ShowsControllerGetEpisodeDetailsResponses, ShowsControllerGetSeasonDetailsData, ShowsControllerGetSeasonDetailsResponses, ShowsControllerGetShowData, ShowsControllerGetShowDetailsData, ShowsControllerGetShowDetailsResponses, ShowsControllerGetShowResponses, ShowsControllerGetShowWatchHistoryData, ShowsControllerGetShowWatchHistoryErrors, ShowsControllerGetShowWatchHistoryResponses, ShowsControllerGetUserEpisodesPaginatedData, ShowsControllerGetUserEpisodesPaginatedResponses, ShowsControllerGetUserShowsData, ShowsControllerGetUserShowsResponses, ShowsControllerMarkWatchedData, ShowsControllerMarkWatchedErrors, ShowsControllerMarkWatchedResponses, ShowsControllerSearchShowsData, ShowsControllerSearchShowsResponses, ShowsControllerUnmarkWatchedData, ShowsControllerUnmarkWatchedResponses, UsersControllerDeleteMyAccountData, UsersControllerDeleteMyAccountErrors, UsersControllerDeleteMyAccountResponses, UsersControllerGetMySettingsData, UsersControllerGetMySettingsErrors, UsersControllerGetMySettingsResponses, UsersControllerUpdateMySettingsData, UsersControllerUpdateMySettingsErrors, UsersControllerUpdateMySettingsResponses } from './types.gen'; 5 + import type { AuthControllerCallbackData, AuthControllerGetClientMetadataData, AuthControllerGetClientMetadataResponses, AuthControllerLoginData, AuthControllerLogoutData, AuthControllerLogoutResponses, AuthControllerMeData, AuthControllerMeErrors, AuthControllerMeResponses, AuthControllerSuggestionsData, AuthControllerSuggestionsResponses, ListsControllerAddItemToListData, ListsControllerAddItemToListErrors, ListsControllerAddItemToListResponses, ListsControllerAddToListData, ListsControllerAddToListResponses, ListsControllerCreateListData, ListsControllerCreateListErrors, ListsControllerCreateListResponses, ListsControllerDeleteListData, ListsControllerDeleteListErrors, ListsControllerDeleteListResponses, ListsControllerGetListData, ListsControllerGetListErrors, ListsControllerGetListResponses, ListsControllerGetListsForItemData, ListsControllerGetListsForItemErrors, ListsControllerGetListsForItemResponses, ListsControllerGetListsForMovieData, ListsControllerGetListsForMovieResponses, ListsControllerGetUserListsData, ListsControllerGetUserListsErrors, ListsControllerGetUserListsResponses, ListsControllerInitDefaultListsData, ListsControllerInitDefaultListsErrors, ListsControllerInitDefaultListsResponses, ListsControllerRemoveFromListData, ListsControllerRemoveFromListResponses, ListsControllerRemoveItemFromListData, ListsControllerRemoveItemFromListErrors, ListsControllerRemoveItemFromListResponses, ListsControllerUpdateListData, ListsControllerUpdateListErrors, ListsControllerUpdateListResponses, MoviesControllerDeleteWatchHistoryEntryData, MoviesControllerDeleteWatchHistoryEntryErrors, MoviesControllerDeleteWatchHistoryEntryResponses, MoviesControllerDiscoverMoviesData, MoviesControllerDiscoverMoviesResponses, MoviesControllerGetMovieData, MoviesControllerGetMovieDetailsData, MoviesControllerGetMovieDetailsResponses, MoviesControllerGetMovieResponses, MoviesControllerGetMovieWatchHistoryData, MoviesControllerGetMovieWatchHistoryErrors, MoviesControllerGetMovieWatchHistoryResponses, MoviesControllerGetUserMoviesData, MoviesControllerGetUserMoviesPaginatedData, MoviesControllerGetUserMoviesPaginatedResponses, MoviesControllerGetUserMoviesResponses, MoviesControllerMarkWatchedData, MoviesControllerMarkWatchedErrors, MoviesControllerMarkWatchedResponses, MoviesControllerSearchMoviesData, MoviesControllerSearchMoviesResponses, MoviesControllerUnmarkWatchedData, MoviesControllerUnmarkWatchedErrors, MoviesControllerUnmarkWatchedResponses, ShelfControllerGetUserShelfData, ShelfControllerGetUserShelfResponses, ShowsControllerDeleteEpisodeWatchHistoryEntryData, ShowsControllerDeleteEpisodeWatchHistoryEntryErrors, ShowsControllerDeleteEpisodeWatchHistoryEntryResponses, ShowsControllerDiscoverShowsData, ShowsControllerDiscoverShowsResponses, ShowsControllerGetEpisodeDetailsData, ShowsControllerGetEpisodeDetailsResponses, ShowsControllerGetSeasonDetailsData, ShowsControllerGetSeasonDetailsResponses, ShowsControllerGetShowData, ShowsControllerGetShowDetailsData, ShowsControllerGetShowDetailsResponses, ShowsControllerGetShowResponses, ShowsControllerGetShowWatchHistoryData, ShowsControllerGetShowWatchHistoryErrors, ShowsControllerGetShowWatchHistoryResponses, ShowsControllerGetUserEpisodesPaginatedData, ShowsControllerGetUserEpisodesPaginatedResponses, ShowsControllerGetUserShowsData, ShowsControllerGetUserShowsResponses, ShowsControllerMarkSeasonWatchedData, ShowsControllerMarkSeasonWatchedErrors, ShowsControllerMarkSeasonWatchedResponses, ShowsControllerMarkShowWatchedData, ShowsControllerMarkShowWatchedErrors, ShowsControllerMarkShowWatchedResponses, ShowsControllerMarkWatchedData, ShowsControllerMarkWatchedErrors, ShowsControllerMarkWatchedResponses, ShowsControllerSearchShowsData, ShowsControllerSearchShowsResponses, ShowsControllerUnmarkWatchedData, ShowsControllerUnmarkWatchedResponses, UsersControllerDeleteMyAccountData, UsersControllerDeleteMyAccountErrors, UsersControllerDeleteMyAccountResponses, UsersControllerGetMySettingsData, UsersControllerGetMySettingsErrors, UsersControllerGetMySettingsResponses, UsersControllerUpdateMySettingsData, UsersControllerUpdateMySettingsErrors, UsersControllerUpdateMySettingsResponses } from './types.gen'; 6 6 7 7 export type Options<TData extends TDataShape = TDataShape, ThrowOnError extends boolean = boolean> = Options2<TData, ThrowOnError> & { 8 8 /** ··· 171 171 * Delete a specific episode watch history entry 172 172 */ 173 173 export const showsControllerDeleteEpisodeWatchHistoryEntry = <ThrowOnError extends boolean = false>(options: Options<ShowsControllerDeleteEpisodeWatchHistoryEntryData, ThrowOnError>) => (options.client ?? client).delete<ShowsControllerDeleteEpisodeWatchHistoryEntryResponses, ShowsControllerDeleteEpisodeWatchHistoryEntryErrors, ThrowOnError>({ url: '/shows/history/{trackedEpisodeId}', ...options }); 174 + 175 + /** 176 + * Mark all episodes in a season as watched 177 + */ 178 + export const showsControllerMarkSeasonWatched = <ThrowOnError extends boolean = false>(options: Options<ShowsControllerMarkSeasonWatchedData, ThrowOnError>) => (options.client ?? client).post<ShowsControllerMarkSeasonWatchedResponses, ShowsControllerMarkSeasonWatchedErrors, ThrowOnError>({ 179 + url: '/shows/season/watched', 180 + ...options, 181 + headers: { 182 + 'Content-Type': 'application/json', 183 + ...options.headers 184 + } 185 + }); 186 + 187 + /** 188 + * Mark all episodes in a show as watched 189 + */ 190 + export const showsControllerMarkShowWatched = <ThrowOnError extends boolean = false>(options: Options<ShowsControllerMarkShowWatchedData, ThrowOnError>) => (options.client ?? client).post<ShowsControllerMarkShowWatchedResponses, ShowsControllerMarkShowWatchedErrors, ThrowOnError>({ 191 + url: '/shows/show/watched', 192 + ...options, 193 + headers: { 194 + 'Content-Type': 'application/json', 195 + ...options.headers 196 + } 197 + }); 174 198 175 199 /** 176 200 * Get all lists for the authenticated user
+86
packages/api/src/generated/types.gen.ts
··· 149 149 page: number; 150 150 }; 151 151 152 + export type TmdbSeasonSummaryDto = { 153 + id: number; 154 + name: string; 155 + season_number: number; 156 + overview?: string; 157 + poster_path?: string; 158 + air_date?: string; 159 + episode_count?: number; 160 + vote_average?: number; 161 + }; 162 + 152 163 export type TmdbShowDetailDto = { 153 164 id: number; 154 165 name: string; ··· 159 170 genres?: Array<TmdbGenreDto>; 160 171 number_of_seasons?: number; 161 172 number_of_episodes?: number; 173 + seasons?: Array<TmdbSeasonSummaryDto>; 162 174 colors?: MovieColorsDto; 163 175 credits?: TmdbCreditsDto; 164 176 }; ··· 256 268 watchedDate: string; 257 269 seasonNumber: number; 258 270 episodeNumber: number; 271 + }; 272 + 273 + export type MarkSeasonWatchedDto = { 274 + /** 275 + * TMDB show ID 276 + */ 277 + showId: string; 278 + /** 279 + * TMDB season number 280 + */ 281 + seasonNumber: number; 282 + /** 283 + * Custom watch datetime (ISO 8601). If not provided, current time is used. 284 + */ 285 + watchedAt?: string; 286 + }; 287 + 288 + export type MarkedEpisodesResponseDto = { 289 + episodes: Array<TrackedEpisodeDto>; 290 + /** 291 + * Number of episodes marked as watched 292 + */ 293 + count: number; 294 + }; 295 + 296 + export type MarkShowWatchedDto = { 297 + /** 298 + * TMDB show ID 299 + */ 300 + showId: string; 301 + /** 302 + * Custom watch datetime (ISO 8601). If not provided, current time is used. 303 + */ 304 + watchedAt?: string; 259 305 }; 260 306 261 307 export type MovieListSummaryDto = { ··· 1000 1046 }; 1001 1047 1002 1048 export type ShowsControllerDeleteEpisodeWatchHistoryEntryResponse = ShowsControllerDeleteEpisodeWatchHistoryEntryResponses[keyof ShowsControllerDeleteEpisodeWatchHistoryEntryResponses]; 1049 + 1050 + export type ShowsControllerMarkSeasonWatchedData = { 1051 + body: MarkSeasonWatchedDto; 1052 + path?: never; 1053 + query?: never; 1054 + url: '/shows/season/watched'; 1055 + }; 1056 + 1057 + export type ShowsControllerMarkSeasonWatchedErrors = { 1058 + /** 1059 + * Not authenticated 1060 + */ 1061 + 401: unknown; 1062 + }; 1063 + 1064 + export type ShowsControllerMarkSeasonWatchedResponses = { 1065 + 201: MarkedEpisodesResponseDto; 1066 + }; 1067 + 1068 + export type ShowsControllerMarkSeasonWatchedResponse = ShowsControllerMarkSeasonWatchedResponses[keyof ShowsControllerMarkSeasonWatchedResponses]; 1069 + 1070 + export type ShowsControllerMarkShowWatchedData = { 1071 + body: MarkShowWatchedDto; 1072 + path?: never; 1073 + query?: never; 1074 + url: '/shows/show/watched'; 1075 + }; 1076 + 1077 + export type ShowsControllerMarkShowWatchedErrors = { 1078 + /** 1079 + * Not authenticated 1080 + */ 1081 + 401: unknown; 1082 + }; 1083 + 1084 + export type ShowsControllerMarkShowWatchedResponses = { 1085 + 201: MarkedEpisodesResponseDto; 1086 + }; 1087 + 1088 + export type ShowsControllerMarkShowWatchedResponse = ShowsControllerMarkShowWatchedResponses[keyof ShowsControllerMarkShowWatchedResponses]; 1003 1089 1004 1090 export type ListsControllerGetUserListsData = { 1005 1091 body?: never;