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.

rewrite collections with simpler modal usage

Pas 61593e7f 295efd46

+182 -230
+8 -1
src/assets/locales/en.json
··· 403 403 "episode": "Episode", 404 404 "airs": "Airs", 405 405 "endsAt": "Ends at {{time}}", 406 - "trailer": "Trailer" 406 + "trailer": "Trailer", 407 + "collection": { 408 + "movies": "Movies", 409 + "movie": "Movie", 410 + "sortBy": "Sort by", 411 + "releaseDate": "Release Date", 412 + "rating": "Rating" 413 + } 407 414 }, 408 415 "migration": { 409 416 "loginRequired": "You must be logged in to migrate your data! Please go back and login to continue.",
+174 -229
src/components/overlays/detailsModal/components/overlays/CollectionOverlay.tsx
··· 1 1 import classNames from "classnames"; 2 - import { useCallback, useEffect, useRef, useState } from "react"; 3 - import { createPortal } from "react-dom"; 2 + import { useEffect, useRef, useState } from "react"; 4 3 import { useTranslation } from "react-i18next"; 5 - import { useNavigate } from "react-router-dom"; 6 4 7 - import { ThemeProvider } from "@/stores/theme"; 8 - 9 - import { 10 - getCollectionDetails, 11 - getMediaBackdrop, 12 - getMediaPoster, 13 - mediaItemToId, 14 - } from "@/backend/metadata/tmdb"; 5 + import { getCollectionDetails, getMediaPoster } from "@/backend/metadata/tmdb"; 15 6 import { IconPatch } from "@/components/buttons/IconPatch"; 16 7 import { Icon, Icons } from "@/components/Icon"; 17 8 import { MediaCard } from "@/components/media/MediaCard"; 18 - import { DetailsModal } from "@/components/overlays/detailsModal"; 9 + import { Flare } from "@/components/utils/Flare"; 10 + import { useIsMobile } from "@/hooks/useIsMobile"; 11 + import { CarouselNavButtons } from "@/pages/discover/components/CarouselNavButtons"; 19 12 import { MediaItem } from "@/utils/mediaTypes"; 20 13 14 + // Simple carousel component for collection overlay 15 + interface SimpleCarouselProps { 16 + mediaItems: MediaItem[]; 17 + onShowDetails: (movieId: number) => void; 18 + categorySlug?: string; 19 + } 20 + 21 + function SimpleCarousel({ 22 + mediaItems, 23 + onShowDetails, 24 + categorySlug = "collection", 25 + }: SimpleCarouselProps) { 26 + const { isMobile } = useIsMobile(); 27 + const carouselRef = useRef<HTMLDivElement>(null); 28 + const carouselRefs = useRef<{ [key: string]: HTMLDivElement | null }>({ 29 + [categorySlug]: null, 30 + }); 31 + 32 + useEffect(() => { 33 + if (carouselRef.current) { 34 + carouselRefs.current[categorySlug] = carouselRef.current; 35 + } 36 + }, [categorySlug]); 37 + 38 + if (mediaItems.length === 0) return null; 39 + 40 + return ( 41 + <div className="relative"> 42 + {/* Carousel Container */} 43 + <div 44 + ref={carouselRef} 45 + className="grid grid-flow-col auto-cols-max gap-4 pt-0 overflow-x-scroll scrollbar-none rounded-xl overflow-y-hidden md:pl-8 md:pr-8" 46 + style={{ 47 + scrollSnapType: "x mandatory", 48 + scrollBehavior: "smooth", 49 + }} 50 + > 51 + <div className="md:w-12" /> 52 + 53 + {mediaItems.map((media) => ( 54 + <div 55 + key={media.id} 56 + className="relative mt-4 group cursor-pointer user-select-none rounded-xl p-2 bg-transparent transition-colors duration-300 w-[10rem] md:w-[11.5rem] h-auto" 57 + style={{ scrollSnapAlign: "start" }} 58 + > 59 + <MediaCard 60 + media={media} 61 + onShowDetails={() => onShowDetails(Number(media.id))} 62 + linkable 63 + /> 64 + </div> 65 + ))} 66 + 67 + <div className="md:w-12" /> 68 + </div> 69 + 70 + {/* Navigation Buttons */} 71 + {!isMobile && ( 72 + <CarouselNavButtons 73 + categorySlug={categorySlug} 74 + carouselRefs={carouselRefs} 75 + /> 76 + )} 77 + </div> 78 + ); 79 + } 80 + 21 81 interface CollectionMovie { 22 82 id: number; 23 83 title: string; ··· 51 111 onMovieClick, 52 112 }: CollectionOverlayProps) { 53 113 const { t } = useTranslation(); 54 - const navigate = useNavigate(); 55 114 const [collection, setCollection] = useState<CollectionData | null>(null); 56 115 const [loading, setLoading] = useState(true); 57 116 const [error, setError] = useState<string | null>(null); 58 - const [selectedMovie, setSelectedMovie] = useState<MediaItem | null>(null); 59 - const [isClosing, setIsClosing] = useState(false); 60 117 const [sortOrder, setSortOrder] = useState<"release" | "rating">("release"); 61 - const overlayRef = useRef<HTMLDivElement>(null); 62 118 63 119 useEffect(() => { 64 120 const fetchCollection = async () => { ··· 107 163 }; 108 164 }; 109 165 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} 166 + return ( 167 + <div 168 + className="fixed inset-0 bg-black/90 backdrop-blur-sm z-50 flex items-center justify-center p-4 sm:p-6 lg:p-8 transition-opacity duration-300" 169 + onClick={onClose} 170 + > 171 + <Flare.Base 148 172 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", 173 + "group -m-[0.705em] rounded-3xl bg-background-main transition-colors duration-300 focus:relative focus:z-10", 174 + "w-full mx-4 p-6 bg-mediaCard-hoverBackground bg-opacity-60 backdrop-filter backdrop-blur-lg shadow-lg", 175 + "max-w-7xl max-h-[90vh]", 152 176 )} 153 - style={{ zIndex: 9999 }} 154 177 > 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"> 178 + <div className="transition-transform duration-300 overflow-hidden rounded-3xl"> 179 + <Flare.Light 180 + flareSize={300} 181 + cssColorVar="--colors-mediaCard-hoverAccent" 182 + backgroundClass="bg-mediaCard-hoverBackground duration-100" 183 + className="rounded-3xl bg-background-main group-hover:opacity-100" 184 + /> 185 + <Flare.Child className="pointer-events-auto relative transition-transform duration-300"> 186 + <div 187 + className="relative w-full h-full" 188 + onClick={(e) => e.stopPropagation()} 189 + > 190 + {/* Header */} 191 + <div className="flex-shrink-0 px-0 py-4 sm:px-8"> 179 192 <div className="flex items-start justify-between gap-4"> 180 193 <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"> 194 + <h2 className="text-2xl sm:text-3xl font-bold text-white mb-2 drop-shadow-lg"> 182 195 {collectionName} 183 196 </h2> 184 197 <div className="flex items-center gap-4 flex-wrap"> 185 198 {collection && ( 186 - <p className="text-sm text-type-secondary"> 187 - <span className="text-type-emphasis font-semibold"> 199 + <p className="text-sm text-white/80"> 200 + <span className="text-white font-semibold"> 188 201 {collection.parts.length} 189 202 </span>{" "} 190 - {t( 191 - `media.types.movie${ 192 - collection.parts.length !== 1 ? "s" : "" 193 - }`, 194 - )} 203 + {collection.parts.length > 1 204 + ? t("details.collection.movies") 205 + : t("details.collection.movie")} 195 206 </p> 196 207 )} 197 - 198 - {/* Sort controls */} 199 208 {!loading && !error && sortedMovies.length > 1 && ( 200 209 <div className="flex items-center gap-2"> 201 - <span className="text-xs text-type-dimmed"> 202 - {t("media.sortBy")}: 210 + <span className="text-xs text-white/60"> 211 + {t("details.collection.sortBy")}: 203 212 </span> 204 213 <button 205 214 type="button" ··· 207 216 className={classNames( 208 217 "px-3 py-1 rounded-md text-xs font-medium transition-colors", 209 218 sortOrder === "release" 210 - ? "bg-pill-activeBackground text-type-emphasis" 211 - : "bg-pill-background hover:bg-pill-backgroundHover text-type-secondary", 219 + ? "bg-white/20 text-white" 220 + : "bg-white/10 hover:bg-white/20 text-white/70", 212 221 )} 213 222 > 214 - {t("media.releaseDate")} 223 + {t("details.collection.releaseDate")} 215 224 </button> 216 225 <button 217 226 type="button" ··· 219 228 className={classNames( 220 229 "px-3 py-1 rounded-md text-xs font-medium transition-colors", 221 230 sortOrder === "rating" 222 - ? "bg-pill-activeBackground text-type-emphasis" 223 - : "bg-pill-background hover:bg-pill-backgroundHover text-type-secondary", 231 + ? "bg-white/20 text-white" 232 + : "bg-white/10 hover:bg-white/20 text-white/70", 224 233 )} 225 234 > 226 - {t("media.rating")} 235 + {t("details.collection.rating")} 227 236 </button> 228 237 </div> 229 238 )} 230 239 </div> 231 240 </div> 232 241 233 - <IconPatch 234 - icon={Icons.X} 235 - clickable 236 - onClick={handleClose} 237 - className="text-type-secondary hover:text-type-emphasis transition-colors" 238 - /> 242 + <IconPatch icon={Icons.X} onClick={onClose} /> 239 243 </div> 240 244 241 245 {/* Collection Overview */} 242 246 {collection?.overview && ( 243 - <p className="text-sm text-type-secondary mt-4 line-clamp-3 max-w-4xl leading-relaxed"> 247 + <p className="text-sm text-white/80 mt-4 line-clamp-3 max-w-4xl leading-relaxed"> 244 248 {collection.overview} 245 249 </p> 246 250 )} 247 251 </div> 248 252 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" /> 253 + {/* Content */} 254 + <div className="relative overflow-hidden md:pb-4"> 255 + <div className="overflow-y-auto max-h-[65vh] scrollbar-thin scrollbar-track-transparent scrollbar-thumb-white/20 hover:scrollbar-thumb-white/40"> 256 + {loading && ( 257 + <div className="grid grid-flow-col auto-cols-max gap-4 pt-0 overflow-x-scroll scrollbar-none rounded-xl overflow-y-hidden md:pl-8 md:pr-8"> 258 + <div className="md:w-12" /> 259 + {Array(8) 260 + .fill(null) 261 + .map((_, index) => ( 262 + <div 263 + key={`skeleton-loading-${Math.random().toString(36).substring(2)}`} 264 + className="relative mt-4 group cursor-default user-select-none rounded-xl p-2 bg-transparent transition-colors duration-300 w-[10rem] md:w-[11.5rem] h-auto" 265 + > 266 + <MediaCard 267 + media={{ 268 + id: `skeleton-${index}`, 269 + title: "", 270 + poster: "", 271 + type: "movie", 272 + }} 273 + forceSkeleton 274 + /> 275 + </div> 276 + ))} 277 + <div className="md:w-12" /> 261 278 </div> 262 - <p className="mt-6 text-type-secondary animate-pulse"> 263 - {t("media.loading")} 264 - </p> 265 - </div> 266 - )} 279 + )} 267 280 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 - /> 281 + {/* Error State */} 282 + {error && ( 283 + <div className="flex flex-col items-center justify-center py-20"> 284 + <div className="p-4 rounded-full bg-red-500/10 mb-4"> 285 + <Icon 286 + icon={Icons.CIRCLE_EXCLAMATION} 287 + className="text-red-400 text-4xl" 288 + /> 289 + </div> 290 + <p className="text-red-400 text-lg font-semibold mb-2"> 291 + {t("media.errors.errorLoading")} 292 + </p> 293 + <p className="text-white/70 text-sm">{error}</p> 276 294 </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 - )} 295 + )} 283 296 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 - /> 297 + {!loading && !error && sortedMovies.length === 0 && ( 298 + <div className="flex flex-col items-center justify-center py-20"> 299 + <div className="p-4 rounded-full bg-white/10 mb-4"> 300 + <Icon 301 + icon={Icons.FILM} 302 + className="text-white/60 text-4xl" 303 + /> 304 + </div> 305 + <p className="text-white/70"> 306 + {t("media.noMoviesInCollection")} 307 + </p> 291 308 </div> 292 - <p className="text-type-secondary"> 293 - {t("media.noMoviesInCollection")} 294 - </p> 295 - </div> 296 - )} 309 + )} 297 310 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 - )} 311 + {!loading && !error && sortedMovies.length > 0 && ( 312 + <SimpleCarousel 313 + mediaItems={sortedMovies.map(movieToMediaItem)} 314 + onShowDetails={onMovieClick} 315 + categorySlug="collection" 316 + /> 317 + )} 318 + </div> 314 319 </div> 315 320 </div> 316 - </div> 321 + </Flare.Child> 317 322 </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, 323 + </Flare.Base> 324 + </div> 380 325 ); 381 326 }