pstream is dead; long live pstream taciturnaxolotl.github.io/pstream-ng/
1
fork

Configure Feed

Select the types of activity you want to include in your feed.

Merge branch 'pr/57' into dev

Pas 295efd46 8687d6da

+439
+4
src/backend/metadata/tmdb.ts
··· 411 411 if (posterPath) return imgUrl; 412 412 } 413 413 414 + export async function getCollectionDetails(collectionId: number): Promise<any> { 415 + return get<any>(`/collection/${collectionId}`); 416 + } 417 + 414 418 export async function getEpisodes( 415 419 id: string, 416 420 season: number,
+17
src/components/overlays/detailsModal/components/layout/DetailsContent.tsx
··· 16 16 import { DetailsContentProps } from "../../types"; 17 17 import { EpisodeCarousel } from "../carousels/EpisodeCarousel"; 18 18 import { CastCarousel } from "../carousels/PeopleCarousel"; 19 + import { CollectionOverlay } from "../overlays/CollectionOverlay"; 19 20 import { TrailerOverlay } from "../overlays/TrailerOverlay"; 20 21 import { DetailsBody } from "../sections/DetailsBody"; 21 22 import { DetailsInfo } from "../sections/DetailsInfo"; ··· 28 29 ); 29 30 const [, setIsLoadingImdb] = useState(false); 30 31 const [showTrailer, setShowTrailer] = useState(false); 32 + const [showCollection, setShowCollection] = useState(false); 31 33 const [selectedSeason, setSelectedSeason] = useState<number>(1); 32 34 const [, copyToClipboard] = useCopyToClipboard(); 33 35 const [hasCopiedShare, setHasCopiedShare] = useState(false); ··· 207 209 /> 208 210 )} 209 211 212 + {/* Collection Overlay */} 213 + {showCollection && data.collection && ( 214 + <CollectionOverlay 215 + collectionId={data.collection.id} 216 + collectionName={data.collection.name} 217 + onClose={() => setShowCollection(false)} 218 + onMovieClick={(movieId) => { 219 + setShowCollection(false); 220 + // Optionally navigate to the movie details 221 + window.location.href = `/media/tmdb-movie-${movieId}`; 222 + }} 223 + /> 224 + )} 225 + 210 226 {/* Backdrop */} 211 227 <div 212 228 className="relative -mt-12 z-20" ··· 320 336 imdbData={imdbData} 321 337 rtData={rtData} 322 338 provider={providerData} 339 + onCollectionClick={() => setShowCollection(true)} 323 340 /> 324 341 </div> 325 342 </div>
+1
src/components/overlays/detailsModal/components/layout/DetailsModal.tsx
··· 66 66 id: movieDetails.id, 67 67 imdbId: movieDetails.external_ids?.imdb_id, 68 68 logoUrl, 69 + collection: movieDetails.belongs_to_collection, 69 70 }); 70 71 } else { 71 72 const showDetails = details as TMDBShowData & {
+381
src/components/overlays/detailsModal/components/overlays/CollectionOverlay.tsx
··· 1 + import classNames from "classnames"; 2 + import { useCallback, useEffect, useRef, useState } from "react"; 3 + import { createPortal } from "react-dom"; 4 + import { useTranslation } from "react-i18next"; 5 + import { useNavigate } from "react-router-dom"; 6 + 7 + import { ThemeProvider } from "@/stores/theme"; 8 + 9 + import { 10 + getCollectionDetails, 11 + getMediaBackdrop, 12 + getMediaPoster, 13 + mediaItemToId, 14 + } from "@/backend/metadata/tmdb"; 15 + import { IconPatch } from "@/components/buttons/IconPatch"; 16 + import { Icon, Icons } from "@/components/Icon"; 17 + import { MediaCard } from "@/components/media/MediaCard"; 18 + import { DetailsModal } from "@/components/overlays/detailsModal"; 19 + import { MediaItem } from "@/utils/mediaTypes"; 20 + 21 + interface CollectionMovie { 22 + id: number; 23 + title: string; 24 + poster_path: string | null; 25 + release_date: string; 26 + overview: string; 27 + vote_average?: number; 28 + backdrop_path?: string | null; 29 + } 30 + 31 + interface CollectionData { 32 + id: number; 33 + name: string; 34 + overview: string; 35 + poster_path: string | null; 36 + backdrop_path: string | null; 37 + parts: CollectionMovie[]; 38 + } 39 + 40 + interface CollectionOverlayProps { 41 + collectionId: number; 42 + collectionName: string; 43 + onClose: () => void; 44 + onMovieClick: (movieId: number) => void; 45 + } 46 + 47 + export function CollectionOverlay({ 48 + collectionId, 49 + collectionName, 50 + onClose, 51 + onMovieClick, 52 + }: CollectionOverlayProps) { 53 + const { t } = useTranslation(); 54 + const navigate = useNavigate(); 55 + const [collection, setCollection] = useState<CollectionData | null>(null); 56 + const [loading, setLoading] = useState(true); 57 + const [error, setError] = useState<string | null>(null); 58 + const [selectedMovie, setSelectedMovie] = useState<MediaItem | null>(null); 59 + const [isClosing, setIsClosing] = useState(false); 60 + const [sortOrder, setSortOrder] = useState<"release" | "rating">("release"); 61 + const overlayRef = useRef<HTMLDivElement>(null); 62 + 63 + useEffect(() => { 64 + const fetchCollection = async () => { 65 + setLoading(true); 66 + setError(null); 67 + try { 68 + const data = await getCollectionDetails(collectionId); 69 + setCollection(data); 70 + } catch (err) { 71 + console.error("Failed to fetch collection:", err); 72 + setError(t("media.errors.failedToLoad")); 73 + } finally { 74 + setLoading(false); 75 + } 76 + }; 77 + 78 + fetchCollection(); 79 + }, [collectionId, t]); 80 + 81 + const sortedMovies = collection?.parts 82 + ? [...collection.parts].sort((a, b) => { 83 + if (sortOrder === "release") { 84 + const dateA = new Date(a.release_date || "").getTime(); 85 + const dateB = new Date(b.release_date || "").getTime(); 86 + return dateA - dateB; 87 + } 88 + 89 + return (b.vote_average || 0) - (a.vote_average || 0); 90 + }) 91 + : []; 92 + 93 + const movieToMediaItem = (movie: CollectionMovie): MediaItem => { 94 + const year = movie.release_date 95 + ? new Date(movie.release_date).getFullYear() 96 + : undefined; 97 + 98 + return { 99 + id: movie.id.toString(), 100 + title: movie.title, 101 + poster: getMediaPoster(movie.poster_path) || "/placeholder.png", 102 + type: "movie", 103 + year, 104 + release_date: movie.release_date 105 + ? new Date(movie.release_date) 106 + : undefined, 107 + }; 108 + }; 109 + 110 + const handleClose = useCallback(() => { 111 + setIsClosing(true); 112 + setTimeout(() => { 113 + onClose(); 114 + }, 200); 115 + }, [onClose]); 116 + 117 + const handleMovieClick = useCallback( 118 + (media: MediaItem) => { 119 + if (onMovieClick) { 120 + onMovieClick(Number(media.id)); 121 + } else { 122 + setSelectedMovie(media); 123 + handleClose(); 124 + 125 + setTimeout(() => { 126 + const mediaId = mediaItemToId(media); 127 + navigate(`/media/${encodeURIComponent(mediaId)}`); 128 + }, 250); 129 + } 130 + }, 131 + [handleClose, navigate, onMovieClick], 132 + ); 133 + 134 + useEffect(() => { 135 + const handleEscape = (e: KeyboardEvent) => { 136 + if (e.key === "Escape") { 137 + handleClose(); 138 + } 139 + }; 140 + window.addEventListener("keydown", handleEscape); 141 + return () => window.removeEventListener("keydown", handleEscape); 142 + }, [handleClose]); 143 + 144 + return createPortal( 145 + <ThemeProvider> 146 + <div 147 + ref={overlayRef} 148 + className={classNames( 149 + "fixed inset-0 flex items-center justify-center p-4 sm:p-6 lg:p-8", 150 + "transition-all duration-300", 151 + isClosing ? "opacity-0" : "opacity-100", 152 + )} 153 + style={{ zIndex: 9999 }} 154 + > 155 + {/* Blur detail modal while collection overlay is open */} 156 + <div 157 + className={classNames( 158 + "absolute inset-0 bg-black/70 backdrop-blur-xl", 159 + "transition-opacity duration-300", 160 + isClosing ? "opacity-0" : "opacity-100", 161 + )} 162 + onClick={handleClose} 163 + aria-label="Close overlay" 164 + /> 165 + 166 + <div 167 + className={classNames( 168 + "relative w-full max-w-7xl max-h-[90vh] z-10 pointer-events-auto", 169 + "transition-all duration-300 ease-out", 170 + isClosing 171 + ? "scale-95 opacity-0" 172 + : "scale-100 opacity-100 animate-[modalShow_0.3s_ease-out]", 173 + )} 174 + onClick={(e) => e.stopPropagation()} 175 + > 176 + <div className="relative w-full h-full"> 177 + <div className="rounded-2xl overflow-hidden bg-modal-background backdrop-blur-md border border-type-divider/10 shadow-2xl flex flex-col max-h-[90vh]"> 178 + <div className="relative flex-shrink-0 px-6 py-5 sm:px-8 sm:py-6 border-b border-type-divider/20 bg-gradient-to-b from-black/40 to-transparent"> 179 + <div className="flex items-start justify-between gap-4"> 180 + <div className="flex-1 min-w-0"> 181 + <h2 className="text-2xl sm:text-3xl font-bold text-type-emphasis mb-2 drop-shadow-lg"> 182 + {collectionName} 183 + </h2> 184 + <div className="flex items-center gap-4 flex-wrap"> 185 + {collection && ( 186 + <p className="text-sm text-type-secondary"> 187 + <span className="text-type-emphasis font-semibold"> 188 + {collection.parts.length} 189 + </span>{" "} 190 + {t( 191 + `media.types.movie${ 192 + collection.parts.length !== 1 ? "s" : "" 193 + }`, 194 + )} 195 + </p> 196 + )} 197 + 198 + {/* Sort controls */} 199 + {!loading && !error && sortedMovies.length > 1 && ( 200 + <div className="flex items-center gap-2"> 201 + <span className="text-xs text-type-dimmed"> 202 + {t("media.sortBy")}: 203 + </span> 204 + <button 205 + type="button" 206 + onClick={() => setSortOrder("release")} 207 + className={classNames( 208 + "px-3 py-1 rounded-md text-xs font-medium transition-colors", 209 + sortOrder === "release" 210 + ? "bg-pill-activeBackground text-type-emphasis" 211 + : "bg-pill-background hover:bg-pill-backgroundHover text-type-secondary", 212 + )} 213 + > 214 + {t("media.releaseDate")} 215 + </button> 216 + <button 217 + type="button" 218 + onClick={() => setSortOrder("rating")} 219 + className={classNames( 220 + "px-3 py-1 rounded-md text-xs font-medium transition-colors", 221 + sortOrder === "rating" 222 + ? "bg-pill-activeBackground text-type-emphasis" 223 + : "bg-pill-background hover:bg-pill-backgroundHover text-type-secondary", 224 + )} 225 + > 226 + {t("media.rating")} 227 + </button> 228 + </div> 229 + )} 230 + </div> 231 + </div> 232 + 233 + <IconPatch 234 + icon={Icons.X} 235 + clickable 236 + onClick={handleClose} 237 + className="text-type-secondary hover:text-type-emphasis transition-colors" 238 + /> 239 + </div> 240 + 241 + {/* Collection Overview */} 242 + {collection?.overview && ( 243 + <p className="text-sm text-type-secondary mt-4 line-clamp-3 max-w-4xl leading-relaxed"> 244 + {collection.overview} 245 + </p> 246 + )} 247 + </div> 248 + 249 + <div 250 + className={classNames( 251 + "flex-1 overflow-y-auto px-6 py-6 sm:px-8 sm:py-8", 252 + "scrollbar-thin scrollbar-track-transparent scrollbar-thumb-type-divider/30", 253 + "[&:hover]:scrollbar-thumb-type-divider/50", 254 + )} 255 + > 256 + {loading && ( 257 + <div className="flex flex-col items-center justify-center py-20"> 258 + <div className="relative"> 259 + <div className="w-16 h-16 border-4 border-themePreview-primary/20 rounded-full" /> 260 + <div className="absolute inset-0 w-16 h-16 border-4 border-themePreview-primary border-t-transparent rounded-full animate-spin" /> 261 + </div> 262 + <p className="mt-6 text-type-secondary animate-pulse"> 263 + {t("media.loading")} 264 + </p> 265 + </div> 266 + )} 267 + 268 + {/* Error State */} 269 + {error && ( 270 + <div className="flex flex-col items-center justify-center py-20"> 271 + <div className="p-4 rounded-full bg-semantic-red-c100/10 mb-4"> 272 + <Icon 273 + icon={Icons.CIRCLE_EXCLAMATION} 274 + className="text-semantic-red-c100 text-4xl" 275 + /> 276 + </div> 277 + <p className="text-type-danger text-lg font-semibold mb-2"> 278 + {t("media.errors.errorLoading")} 279 + </p> 280 + <p className="text-type-secondary text-sm">{error}</p> 281 + </div> 282 + )} 283 + 284 + {!loading && !error && sortedMovies.length === 0 && ( 285 + <div className="flex flex-col items-center justify-center py-20"> 286 + <div className="p-4 rounded-full bg-type-divider/10 mb-4"> 287 + <Icon 288 + icon={Icons.FILM} 289 + className="text-type-dimmed text-4xl" 290 + /> 291 + </div> 292 + <p className="text-type-secondary"> 293 + {t("media.noMoviesInCollection")} 294 + </p> 295 + </div> 296 + )} 297 + 298 + {!loading && !error && sortedMovies.length > 0 && ( 299 + <div className="grid grid-cols-2 gap-7 sm:grid-cols-3 md:grid-cols-4 xl:grid-cols-6 3xl:grid-cols-8 4xl:grid-cols-10 collection-grid"> 300 + {sortedMovies.map((movie) => { 301 + const mediaItem = movieToMediaItem(movie); 302 + 303 + return ( 304 + <MediaCard 305 + key={movie.id} 306 + media={mediaItem} 307 + onShowDetails={handleMovieClick} 308 + linkable 309 + /> 310 + ); 311 + })} 312 + </div> 313 + )} 314 + </div> 315 + </div> 316 + </div> 317 + </div> 318 + </div> 319 + 320 + {selectedMovie && ( 321 + <DetailsModal 322 + id="collection-details" 323 + data={{ 324 + id: Number(selectedMovie.id), 325 + type: "movie", 326 + }} 327 + /> 328 + )} 329 + 330 + <style>{` 331 + 332 + .collection-grid .group.rounded-xl.bg-background-main { 333 + position: relative; 334 + overflow: hidden; 335 + background-color: var(--colors-modal-background) !important; 336 + border-radius: 12px; 337 + border: none !important; 338 + } 339 + 340 + 341 + .collection-grid .group.rounded-xl.bg-background-main > .flare-border { 342 + position: absolute; 343 + inset: 0; 344 + border-radius: inherit; 345 + border: 2px solid var(--colors-flare, #A359EC); 346 + box-shadow: 0 0 18px 3px var(--colors-flare, #A359EC); 347 + opacity: 0; 348 + transition: opacity 0.3s ease; 349 + pointer-events: none; 350 + } 351 + 352 + 353 + .collection-grid .group.rounded-xl.bg-background-main:hover > .flare-border { 354 + opacity: 1; 355 + } 356 + 357 + 358 + .collection-grid .group > div.rounded-xl.bg-background-main { 359 + background: transparent !important; 360 + } 361 + 362 + 363 + .collection-grid .bookmark-button { 364 + opacity: 0 !important; 365 + transition: opacity 0.2s ease; 366 + } 367 + 368 + .collection-grid .group:hover .bookmark-button { 369 + opacity: 1 !important; 370 + } 371 + 372 + @media (max-width: 1024px) { 373 + .collection-grid .group:hover .bookmark-button { 374 + opacity: 0 !important; 375 + } 376 + } 377 + `}</style> 378 + </ThemeProvider>, 379 + document.body, 380 + ); 381 + }
+29
src/components/overlays/detailsModal/components/sections/DetailsInfo.tsx
··· 2 2 import { useEffect, useState } from "react"; 3 3 import { Trans } from "react-i18next"; 4 4 5 + import { Icon, Icons } from "@/components/Icon"; 6 + 5 7 import { DetailsRatings } from "./DetailsRatings"; 6 8 import { DetailsInfoProps } from "../../types"; 7 9 ··· 10 12 imdbData, 11 13 rtData, 12 14 provider, 15 + onCollectionClick, 13 16 }: DetailsInfoProps) { 14 17 const [isShiftPressed, setIsShiftPressed] = useState(false); 15 18 const [showCopied, setShowCopied] = useState(false); ··· 103 106 <span className="font-medium">{t("details.rating")}</span>{" "} 104 107 {data.rating} 105 108 </div> 109 + )} 110 + 111 + {/* Collection Button */} 112 + {data.collection && data.type === "movie" && onCollectionClick && ( 113 + <button 114 + type="button" 115 + onClick={onCollectionClick} 116 + className="flex items-center gap-2 text-white/80 hover:text-white bg-white/5 hover:bg-white/10 px-3 py-2 rounded-lg transition-all duration-200 w-full group" 117 + > 118 + <Icon 119 + icon={Icons.FILM} 120 + className="text-white/60 group-hover:text-white/80 transition-colors" 121 + /> 122 + <div className="flex flex-col items-start flex-1 min-w-0"> 123 + <span className="text-[10px] text-white/50 font-medium uppercase tracking-wide"> 124 + Collection 125 + </span> 126 + <span className="text-xs font-medium truncate w-full text-left"> 127 + {data.collection.name} 128 + </span> 129 + </div> 130 + <Icon 131 + icon={Icons.CHEVRON_RIGHT} 132 + className="text-white/40 group-hover:text-white/60 transition-colors flex-shrink-0" 133 + /> 134 + </button> 106 135 )} 107 136 108 137 {/* Hidden TMDB ID */}
+7
src/components/overlays/detailsModal/types.ts
··· 46 46 }>; 47 47 }; 48 48 logoUrl?: string; 49 + collection?: { 50 + id: number; 51 + name: string; 52 + poster_path: string | null; 53 + backdrop_path: string | null; 54 + } | null; 49 55 } 50 56 51 57 export interface DetailsModalProps { ··· 123 129 imdbData?: any; 124 130 rtData?: any; 125 131 provider?: string; 132 + onCollectionClick?: () => void; 126 133 } 127 134 128 135 export interface DetailsRatingsProps {