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.

Add dashboard media card actions

- Add shelf and list controls to dashboard media cards
- Refresh shelf queries after watch state changes
- Reuse dashboard card wrapper for show and episode items

+217 -74
+92
apps/web/src/components/DashboardMediaCard.tsx
··· 1 + import { useState } from "react"; 2 + import { useWatchActions } from "#/lib/hooks/useWatchActions"; 3 + import ManageListsDialog from "./ManageListsDialog"; 4 + import type { MediaCardProps } from "./MediaCard"; 5 + import MediaCard from "./MediaCard"; 6 + 7 + interface DashboardMediaCardProps 8 + extends Omit< 9 + MediaCardProps, 10 + | "onMarkWatched" 11 + | "onUnmarkWatched" 12 + | "isMarkWatchedPending" 13 + | "isUnmarkWatchedPending" 14 + > { 15 + showId?: string; 16 + } 17 + 18 + export default function DashboardMediaCard(props: DashboardMediaCardProps) { 19 + const { type, id, seasonNumber, episodeNumber, showId, isWatched, ...rest } = 20 + props; 21 + 22 + const [listDialogOpen, setListDialogOpen] = useState(false); 23 + 24 + const isMovie = type === "movie"; 25 + const actualShowId = showId || (type === "show" ? String(id) : undefined); 26 + 27 + const watchActions = useWatchActions( 28 + isMovie 29 + ? { mediaType: "movie", movieId: String(id) } 30 + : { mediaType: "show", showId: actualShowId || "" }, 31 + ); 32 + 33 + const rawMediaId = isMovie ? String(id) : actualShowId || String(id); 34 + 35 + const handleMarkWatched = () => { 36 + if (isMovie) { 37 + watchActions.markMovieWatched(); 38 + } else if ( 39 + actualShowId && 40 + seasonNumber !== undefined && 41 + episodeNumber !== undefined 42 + ) { 43 + watchActions.markEpisodeWatched(seasonNumber, episodeNumber); 44 + } 45 + }; 46 + 47 + const handleUnmarkWatched = () => { 48 + if (isMovie) { 49 + watchActions.unmarkMovieWatched(); 50 + } else if ( 51 + actualShowId && 52 + seasonNumber !== undefined && 53 + episodeNumber !== undefined 54 + ) { 55 + watchActions.unmarkEpisodeWatched(seasonNumber, episodeNumber); 56 + } 57 + }; 58 + 59 + return ( 60 + <> 61 + <MediaCard 62 + {...rest} 63 + id={id} 64 + type={type} 65 + seasonNumber={seasonNumber} 66 + episodeNumber={episodeNumber} 67 + isWatched={isWatched} 68 + onMarkWatched={handleMarkWatched} 69 + onUnmarkWatched={handleUnmarkWatched} 70 + onManageLists={() => setListDialogOpen(true)} 71 + isMarkWatchedPending={ 72 + isMovie 73 + ? watchActions.isMarkMoviePending 74 + : watchActions.isMarkEpisodePending 75 + } 76 + isUnmarkWatchedPending={ 77 + isMovie 78 + ? watchActions.isUnmarkMoviePending 79 + : watchActions.isUnmarkEpisodePending 80 + } 81 + /> 82 + <ManageListsDialog 83 + mediaType={type} 84 + mediaId={rawMediaId} 85 + seasonNumber={seasonNumber} 86 + episodeNumber={episodeNumber} 87 + open={listDialogOpen} 88 + onOpenChange={setListDialogOpen} 89 + /> 90 + </> 91 + ); 92 + }
+92 -71
apps/web/src/components/MediaCard.tsx
··· 1 1 import { Link } from "@tanstack/react-router"; 2 - import { 3 - Check, 4 - Clock, 5 - Loader2, 6 - MoreHorizontal, 7 - Play, 8 - Star, 9 - X, 10 - } from "lucide-react"; 2 + import { Check, Clock, Library, Loader2, Play, Star, X } from "lucide-react"; 11 3 import { useState } from "react"; 12 4 import { 13 5 buildEpisodeUrl, ··· 16 8 buildShowUrl, 17 9 } from "#/lib/url-utils"; 18 10 19 - interface MediaCardProps { 11 + export interface MediaCardProps { 20 12 id: string | number; 21 13 title: string; 22 14 displayTitle?: string; // Optional different title for display (e.g., episode name) ··· 38 30 layout?: "poster" | "backdrop"; 39 31 href?: string; 40 32 onWatch?: () => void; 41 - onAddToList?: () => void; 33 + onManageLists?: () => void; 42 34 onMarkWatched?: () => void; 35 + onUnmarkWatched?: () => void; 43 36 onRemove?: () => void; 44 37 isRemoving?: boolean; 38 + isMarkWatchedPending?: boolean; 39 + isUnmarkWatchedPending?: boolean; 45 40 } 46 41 47 42 export default function MediaCard({ ··· 64 59 layout = "poster", 65 60 href, 66 61 onWatch, 67 - onAddToList, 62 + onManageLists, 68 63 onMarkWatched, 64 + onUnmarkWatched, 69 65 onRemove, 70 66 isRemoving = false, 67 + isMarkWatchedPending = false, 68 + isUnmarkWatchedPending = false, 71 69 }: MediaCardProps) { 72 70 const [imageLoaded, setImageLoaded] = useState(false); 73 71 const [imageError, setImageError] = useState(false); ··· 172 170 </span> 173 171 </div> 174 172 175 - {/* Watched indicator */} 176 - {isWatched && ( 173 + {/* Shelf toggle + lists button — always visible in top-right corner */} 174 + {(onMarkWatched || onUnmarkWatched || onManageLists) && ( 175 + <div className="absolute top-2 right-2 flex items-center gap-1.5"> 176 + {(onMarkWatched || onUnmarkWatched) && ( 177 + <button 178 + type="button" 179 + onClick={(e) => { 180 + e.preventDefault(); 181 + if (isWatched && onUnmarkWatched) { 182 + onUnmarkWatched(); 183 + } else if (onMarkWatched) { 184 + onMarkWatched(); 185 + } 186 + }} 187 + disabled={isMarkWatchedPending || isUnmarkWatchedPending} 188 + className={`flex h-7 w-7 items-center justify-center rounded-full transition-colors disabled:opacity-50 ${ 189 + isWatched 190 + ? "bg-green-500 text-white hover:bg-green-600" 191 + : "bg-white/20 text-white backdrop-blur-sm hover:bg-white/40" 192 + }`} 193 + aria-label={ 194 + isWatched ? "Remove from shelf" : "Mark as watched" 195 + } 196 + title={isWatched ? "Remove from shelf" : "Add to shelf"} 197 + > 198 + {isMarkWatchedPending || isUnmarkWatchedPending ? ( 199 + <Loader2 className="h-3.5 w-3.5 animate-spin" /> 200 + ) : ( 201 + <Check className="h-3.5 w-3.5" /> 202 + )} 203 + </button> 204 + )} 205 + {onManageLists && ( 206 + <button 207 + type="button" 208 + onClick={(e) => { 209 + e.preventDefault(); 210 + onManageLists(); 211 + }} 212 + className="flex h-7 w-7 items-center justify-center rounded-full bg-white/20 text-white backdrop-blur-sm transition-colors hover:bg-white/40" 213 + aria-label="Manage lists" 214 + title="Add to list" 215 + > 216 + <Library className="h-3.5 w-3.5" /> 217 + </button> 218 + )} 219 + </div> 220 + )} 221 + 222 + {/* Static watched indicator (no interactive callback) */} 223 + {isWatched && !onMarkWatched && !onUnmarkWatched && ( 177 224 <div className="absolute top-2 right-2"> 178 225 <div className="flex h-6 w-6 items-center justify-center rounded-full bg-green-500 text-white"> 179 226 <Check className="h-3.5 w-3.5" /> ··· 192 239 )} 193 240 194 241 {/* Hover actions */} 195 - {(onWatch || onMarkWatched) && ( 196 - <div className="absolute inset-0 flex items-center justify-center gap-2 bg-black/40 opacity-0 transition-opacity group-hover:opacity-100"> 197 - {onWatch && ( 198 - <button 199 - type="button" 200 - onClick={(e) => { 201 - e.preventDefault(); 202 - onWatch(); 203 - }} 204 - className="flex h-10 w-10 items-center justify-center rounded-full bg-white text-black transition-transform hover:scale-110" 205 - aria-label="Watch" 206 - > 207 - <Play className="h-5 w-5 fill-current" /> 208 - </button> 209 - )} 210 - {onMarkWatched && !isWatched && ( 211 - <button 212 - type="button" 213 - onClick={(e) => { 214 - e.preventDefault(); 215 - onMarkWatched(); 216 - }} 217 - className="flex h-10 w-10 items-center justify-center rounded-full bg-white text-black transition-transform hover:scale-110" 218 - aria-label="Mark as watched" 219 - > 220 - <Check className="h-5 w-5" /> 221 - </button> 222 - )} 242 + {onWatch && ( 243 + <div className="absolute inset-0 flex items-center justify-center gap-2 bg-black/30 opacity-0 transition-opacity group-hover:opacity-100"> 244 + <button 245 + type="button" 246 + onClick={(e) => { 247 + e.preventDefault(); 248 + onWatch(); 249 + }} 250 + className="flex h-10 w-10 items-center justify-center rounded-full bg-white text-black transition-transform hover:scale-110" 251 + aria-label="Watch" 252 + > 253 + <Play className="h-5 w-5 fill-current" /> 254 + </button> 223 255 </div> 224 256 )} 225 257 ··· 280 312 )} 281 313 </Link> 282 314 283 - {/* Actions menu */} 284 - {(onAddToList || onMarkWatched || onRemove) && ( 315 + {/* Actions menu — hover-only remove button for list pages */} 316 + {onRemove && ( 285 317 <div className="absolute top-2 right-2 flex flex-col gap-1.5 opacity-0 transition-opacity group-hover:opacity-100"> 286 - {onRemove && ( 287 - <button 288 - type="button" 289 - onClick={(e) => { 290 - e.preventDefault(); 291 - onRemove(); 292 - }} 293 - disabled={isRemoving} 294 - className="flex h-8 w-8 items-center justify-center rounded-full bg-red-500/80 text-white backdrop-blur-sm transition-colors hover:bg-red-600 disabled:opacity-50" 295 - aria-label="Remove from list" 296 - > 297 - {isRemoving ? ( 298 - <Loader2 className="h-4 w-4 animate-spin" /> 299 - ) : ( 300 - <X className="h-4 w-4" /> 301 - )} 302 - </button> 303 - )} 304 - {(onAddToList || onMarkWatched) && ( 305 - <button 306 - type="button" 307 - className="flex h-8 w-8 items-center justify-center rounded-full bg-black/50 text-white backdrop-blur-sm transition-colors hover:bg-black/70" 308 - aria-label="More options" 309 - > 310 - <MoreHorizontal className="h-4 w-4" /> 311 - </button> 312 - )} 318 + <button 319 + type="button" 320 + onClick={(e) => { 321 + e.preventDefault(); 322 + onRemove(); 323 + }} 324 + disabled={isRemoving} 325 + className="flex h-8 w-8 items-center justify-center rounded-full bg-red-500/80 text-white backdrop-blur-sm transition-colors hover:bg-red-600 disabled:opacity-50" 326 + aria-label="Remove from list" 327 + > 328 + {isRemoving ? ( 329 + <Loader2 className="h-4 w-4 animate-spin" /> 330 + ) : ( 331 + <X className="h-4 w-4" /> 332 + )} 333 + </button> 313 334 </div> 314 335 )} 315 336 </article>
+25
apps/web/src/lib/hooks/useWatchActions.ts
··· 4 4 moviesControllerGetUserMoviesQueryKey, 5 5 moviesControllerMarkWatchedMutation, 6 6 moviesControllerUnmarkWatchedMutation, 7 + shelfControllerGetUserActivitySummaryQueryKey, 8 + shelfControllerGetUserShelfQueryKey, 7 9 showsControllerDeleteEpisodeWatchHistoryEntryMutation, 8 10 showsControllerGetSeasonDetailsQueryKey, 9 11 showsControllerGetShowWatchHistoryQueryKey, ··· 77 79 }), 78 80 }); 79 81 } 82 + invalidateShelfQueries(); 80 83 }, 81 84 }); 82 85 ··· 119 122 }), 120 123 }); 121 124 } 125 + invalidateShelfQueries(); 122 126 }, 123 127 }); 124 128 ··· 137 141 path: { userDid }, 138 142 }), 139 143 }); 144 + invalidateShelfQueries(); 140 145 }, 141 146 }); 142 147 ··· 158 163 queryClient.invalidateQueries({ queryKey: ["shows"] }); 159 164 }; 160 165 166 + const invalidateShelfQueries = () => { 167 + queryClient.invalidateQueries({ 168 + queryKey: shelfControllerGetUserShelfQueryKey({ 169 + path: { userDid }, 170 + query: { page: 1, pageSize: 6 }, 171 + }), 172 + }); 173 + queryClient.invalidateQueries({ 174 + queryKey: shelfControllerGetUserActivitySummaryQueryKey({ 175 + path: { userDid }, 176 + }), 177 + }); 178 + }; 179 + 161 180 const markEpisodeWatched = useMutation({ 162 181 ...showsControllerMarkWatchedMutation(), 163 182 onSuccess: (_data, variables) => { 164 183 invalidateShowQueries(); 184 + invalidateShelfQueries(); 165 185 // Also invalidate season details so season page stays in sync 166 186 if (options.mediaType === "show") { 167 187 queryClient.invalidateQueries({ ··· 180 200 ...showsControllerUnmarkWatchedMutation(), 181 201 onSuccess: (_data, variables) => { 182 202 invalidateShowQueries(); 203 + invalidateShelfQueries(); 183 204 if (options.mediaType === "show") { 184 205 queryClient.invalidateQueries({ 185 206 queryKey: showsControllerGetSeasonDetailsQueryKey({ ··· 197 218 ...showsControllerMarkShowWatchedMutation(), 198 219 onSuccess: () => { 199 220 invalidateShowQueries(); 221 + invalidateShelfQueries(); 200 222 }, 201 223 }); 202 224 ··· 204 226 ...showsControllerUnmarkWatchedMutation(), 205 227 onSuccess: () => { 206 228 invalidateShowQueries(); 229 + invalidateShelfQueries(); 207 230 }, 208 231 }); 209 232 ··· 211 234 ...showsControllerMarkSeasonWatchedMutation(), 212 235 onSuccess: (_data, variables) => { 213 236 invalidateShowQueries(); 237 + invalidateShelfQueries(); 214 238 if (options.mediaType === "show") { 215 239 queryClient.invalidateQueries({ 216 240 queryKey: showsControllerGetSeasonDetailsQueryKey({ ··· 228 252 ...showsControllerDeleteEpisodeWatchHistoryEntryMutation(), 229 253 onSettled: () => { 230 254 invalidateShowQueries(); 255 + invalidateShelfQueries(); 231 256 }, 232 257 }); 233 258
+8 -3
apps/web/src/routes/dashboard.tsx
··· 21 21 import { useDashboardStats, useUserShelf } from "#/lib/hooks"; 22 22 import { useUserUpNext } from "#/lib/hooks/useMedia"; 23 23 import { buildEpisodeUrl, buildMovieUrl, buildShowUrl } from "#/lib/url-utils"; 24 - import MediaCard from "../components/MediaCard"; 24 + import DashboardMediaCard from "../components/DashboardMediaCard"; 25 25 26 26 // Initialize API client 27 27 setupApiClient(); ··· 235 235 return { 236 236 ...base, 237 237 id: item.movieId, 238 + showId: undefined, 238 239 title: item.title, 239 240 type: "movie" as const, 240 241 posterUrl: item.posterPath ··· 254 255 return { 255 256 ...base, 256 257 id: `${item.showId}-${item.seasonNumber}-${item.episodeNumber}`, // Unique ID for each episode 258 + showId: item.showId, 257 259 title: item.showTitle, // Use show title for URL building 258 260 displayTitle: 259 261 item.episodeTitle || ··· 374 376 ) : upNextContent.length > 0 ? ( 375 377 <div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-3"> 376 378 {upNextContent.map((item) => ( 377 - <MediaCard 379 + <DashboardMediaCard 378 380 key={`${item.id}-${item.seasonNumber}-${item.episodeNumber}`} 379 381 id={item.id} 382 + showId={item.id} 380 383 title={item.title} 381 384 displayTitle={item.displayTitle} 382 385 seasonNumber={item.seasonNumber} ··· 439 442 ) : userContent.length > 0 ? ( 440 443 <div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-3"> 441 444 {userContent.map((item) => ( 442 - <MediaCard 445 + <DashboardMediaCard 443 446 key={item.key} 444 447 id={item.id} 448 + showId={item.showId} 445 449 title={item.title} 446 450 displayTitle={item.displayTitle} 447 451 seasonNumber={item.seasonNumber} ··· 451 455 type={item.type} 452 456 year={item.year} 453 457 episodeInfo={item.episodeInfo} 458 + isWatched={item.isWatched} 454 459 watchedDate={ 455 460 item.watchedDate 456 461 ? formatWatchedDate(