Merge branch 'pr/57' into dev

This commit is contained in:
Pas 2025-10-26 21:25:07 -06:00
commit 295efd468e
6 changed files with 439 additions and 0 deletions

View file

@ -411,6 +411,10 @@ export function getMediaPoster(posterPath: string | null): string | undefined {
if (posterPath) return imgUrl;
}
export async function getCollectionDetails(collectionId: number): Promise<any> {
return get<any>(`/collection/${collectionId}`);
}
export async function getEpisodes(
id: string,
season: number,

View file

@ -16,6 +16,7 @@ import { scrapeRottenTomatoes } from "@/utils/rottenTomatoesScraper";
import { DetailsContentProps } from "../../types";
import { EpisodeCarousel } from "../carousels/EpisodeCarousel";
import { CastCarousel } from "../carousels/PeopleCarousel";
import { CollectionOverlay } from "../overlays/CollectionOverlay";
import { TrailerOverlay } from "../overlays/TrailerOverlay";
import { DetailsBody } from "../sections/DetailsBody";
import { DetailsInfo } from "../sections/DetailsInfo";
@ -28,6 +29,7 @@ export function DetailsContent({ data, minimal = false }: DetailsContentProps) {
);
const [, setIsLoadingImdb] = useState(false);
const [showTrailer, setShowTrailer] = useState(false);
const [showCollection, setShowCollection] = useState(false);
const [selectedSeason, setSelectedSeason] = useState<number>(1);
const [, copyToClipboard] = useCopyToClipboard();
const [hasCopiedShare, setHasCopiedShare] = useState(false);
@ -207,6 +209,20 @@ export function DetailsContent({ data, minimal = false }: DetailsContentProps) {
/>
)}
{/* Collection Overlay */}
{showCollection && data.collection && (
<CollectionOverlay
collectionId={data.collection.id}
collectionName={data.collection.name}
onClose={() => setShowCollection(false)}
onMovieClick={(movieId) => {
setShowCollection(false);
// Optionally navigate to the movie details
window.location.href = `/media/tmdb-movie-${movieId}`;
}}
/>
)}
{/* Backdrop */}
<div
className="relative -mt-12 z-20"
@ -320,6 +336,7 @@ export function DetailsContent({ data, minimal = false }: DetailsContentProps) {
imdbData={imdbData}
rtData={rtData}
provider={providerData}
onCollectionClick={() => setShowCollection(true)}
/>
</div>
</div>

View file

@ -66,6 +66,7 @@ export function DetailsModal({ id, data, minimal }: DetailsModalProps) {
id: movieDetails.id,
imdbId: movieDetails.external_ids?.imdb_id,
logoUrl,
collection: movieDetails.belongs_to_collection,
});
} else {
const showDetails = details as TMDBShowData & {

View file

@ -0,0 +1,381 @@
import classNames from "classnames";
import { useCallback, useEffect, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { useTranslation } from "react-i18next";
import { useNavigate } from "react-router-dom";
import { ThemeProvider } from "@/stores/theme";
import {
getCollectionDetails,
getMediaBackdrop,
getMediaPoster,
mediaItemToId,
} from "@/backend/metadata/tmdb";
import { IconPatch } from "@/components/buttons/IconPatch";
import { Icon, Icons } from "@/components/Icon";
import { MediaCard } from "@/components/media/MediaCard";
import { DetailsModal } from "@/components/overlays/detailsModal";
import { MediaItem } from "@/utils/mediaTypes";
interface CollectionMovie {
id: number;
title: string;
poster_path: string | null;
release_date: string;
overview: string;
vote_average?: number;
backdrop_path?: string | null;
}
interface CollectionData {
id: number;
name: string;
overview: string;
poster_path: string | null;
backdrop_path: string | null;
parts: CollectionMovie[];
}
interface CollectionOverlayProps {
collectionId: number;
collectionName: string;
onClose: () => void;
onMovieClick: (movieId: number) => void;
}
export function CollectionOverlay({
collectionId,
collectionName,
onClose,
onMovieClick,
}: CollectionOverlayProps) {
const { t } = useTranslation();
const navigate = useNavigate();
const [collection, setCollection] = useState<CollectionData | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [selectedMovie, setSelectedMovie] = useState<MediaItem | null>(null);
const [isClosing, setIsClosing] = useState(false);
const [sortOrder, setSortOrder] = useState<"release" | "rating">("release");
const overlayRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const fetchCollection = async () => {
setLoading(true);
setError(null);
try {
const data = await getCollectionDetails(collectionId);
setCollection(data);
} catch (err) {
console.error("Failed to fetch collection:", err);
setError(t("media.errors.failedToLoad"));
} finally {
setLoading(false);
}
};
fetchCollection();
}, [collectionId, t]);
const sortedMovies = collection?.parts
? [...collection.parts].sort((a, b) => {
if (sortOrder === "release") {
const dateA = new Date(a.release_date || "").getTime();
const dateB = new Date(b.release_date || "").getTime();
return dateA - dateB;
}
return (b.vote_average || 0) - (a.vote_average || 0);
})
: [];
const movieToMediaItem = (movie: CollectionMovie): MediaItem => {
const year = movie.release_date
? new Date(movie.release_date).getFullYear()
: undefined;
return {
id: movie.id.toString(),
title: movie.title,
poster: getMediaPoster(movie.poster_path) || "/placeholder.png",
type: "movie",
year,
release_date: movie.release_date
? new Date(movie.release_date)
: undefined,
};
};
const handleClose = useCallback(() => {
setIsClosing(true);
setTimeout(() => {
onClose();
}, 200);
}, [onClose]);
const handleMovieClick = useCallback(
(media: MediaItem) => {
if (onMovieClick) {
onMovieClick(Number(media.id));
} else {
setSelectedMovie(media);
handleClose();
setTimeout(() => {
const mediaId = mediaItemToId(media);
navigate(`/media/${encodeURIComponent(mediaId)}`);
}, 250);
}
},
[handleClose, navigate, onMovieClick],
);
useEffect(() => {
const handleEscape = (e: KeyboardEvent) => {
if (e.key === "Escape") {
handleClose();
}
};
window.addEventListener("keydown", handleEscape);
return () => window.removeEventListener("keydown", handleEscape);
}, [handleClose]);
return createPortal(
<ThemeProvider>
<div
ref={overlayRef}
className={classNames(
"fixed inset-0 flex items-center justify-center p-4 sm:p-6 lg:p-8",
"transition-all duration-300",
isClosing ? "opacity-0" : "opacity-100",
)}
style={{ zIndex: 9999 }}
>
{/* Blur detail modal while collection overlay is open */}
<div
className={classNames(
"absolute inset-0 bg-black/70 backdrop-blur-xl",
"transition-opacity duration-300",
isClosing ? "opacity-0" : "opacity-100",
)}
onClick={handleClose}
aria-label="Close overlay"
/>
<div
className={classNames(
"relative w-full max-w-7xl max-h-[90vh] z-10 pointer-events-auto",
"transition-all duration-300 ease-out",
isClosing
? "scale-95 opacity-0"
: "scale-100 opacity-100 animate-[modalShow_0.3s_ease-out]",
)}
onClick={(e) => e.stopPropagation()}
>
<div className="relative w-full h-full">
<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]">
<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">
<div className="flex items-start justify-between gap-4">
<div className="flex-1 min-w-0">
<h2 className="text-2xl sm:text-3xl font-bold text-type-emphasis mb-2 drop-shadow-lg">
{collectionName}
</h2>
<div className="flex items-center gap-4 flex-wrap">
{collection && (
<p className="text-sm text-type-secondary">
<span className="text-type-emphasis font-semibold">
{collection.parts.length}
</span>{" "}
{t(
`media.types.movie${
collection.parts.length !== 1 ? "s" : ""
}`,
)}
</p>
)}
{/* Sort controls */}
{!loading && !error && sortedMovies.length > 1 && (
<div className="flex items-center gap-2">
<span className="text-xs text-type-dimmed">
{t("media.sortBy")}:
</span>
<button
type="button"
onClick={() => setSortOrder("release")}
className={classNames(
"px-3 py-1 rounded-md text-xs font-medium transition-colors",
sortOrder === "release"
? "bg-pill-activeBackground text-type-emphasis"
: "bg-pill-background hover:bg-pill-backgroundHover text-type-secondary",
)}
>
{t("media.releaseDate")}
</button>
<button
type="button"
onClick={() => setSortOrder("rating")}
className={classNames(
"px-3 py-1 rounded-md text-xs font-medium transition-colors",
sortOrder === "rating"
? "bg-pill-activeBackground text-type-emphasis"
: "bg-pill-background hover:bg-pill-backgroundHover text-type-secondary",
)}
>
{t("media.rating")}
</button>
</div>
)}
</div>
</div>
<IconPatch
icon={Icons.X}
clickable
onClick={handleClose}
className="text-type-secondary hover:text-type-emphasis transition-colors"
/>
</div>
{/* Collection Overview */}
{collection?.overview && (
<p className="text-sm text-type-secondary mt-4 line-clamp-3 max-w-4xl leading-relaxed">
{collection.overview}
</p>
)}
</div>
<div
className={classNames(
"flex-1 overflow-y-auto px-6 py-6 sm:px-8 sm:py-8",
"scrollbar-thin scrollbar-track-transparent scrollbar-thumb-type-divider/30",
"[&:hover]:scrollbar-thumb-type-divider/50",
)}
>
{loading && (
<div className="flex flex-col items-center justify-center py-20">
<div className="relative">
<div className="w-16 h-16 border-4 border-themePreview-primary/20 rounded-full" />
<div className="absolute inset-0 w-16 h-16 border-4 border-themePreview-primary border-t-transparent rounded-full animate-spin" />
</div>
<p className="mt-6 text-type-secondary animate-pulse">
{t("media.loading")}
</p>
</div>
)}
{/* Error State */}
{error && (
<div className="flex flex-col items-center justify-center py-20">
<div className="p-4 rounded-full bg-semantic-red-c100/10 mb-4">
<Icon
icon={Icons.CIRCLE_EXCLAMATION}
className="text-semantic-red-c100 text-4xl"
/>
</div>
<p className="text-type-danger text-lg font-semibold mb-2">
{t("media.errors.errorLoading")}
</p>
<p className="text-type-secondary text-sm">{error}</p>
</div>
)}
{!loading && !error && sortedMovies.length === 0 && (
<div className="flex flex-col items-center justify-center py-20">
<div className="p-4 rounded-full bg-type-divider/10 mb-4">
<Icon
icon={Icons.FILM}
className="text-type-dimmed text-4xl"
/>
</div>
<p className="text-type-secondary">
{t("media.noMoviesInCollection")}
</p>
</div>
)}
{!loading && !error && sortedMovies.length > 0 && (
<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">
{sortedMovies.map((movie) => {
const mediaItem = movieToMediaItem(movie);
return (
<MediaCard
key={movie.id}
media={mediaItem}
onShowDetails={handleMovieClick}
linkable
/>
);
})}
</div>
)}
</div>
</div>
</div>
</div>
</div>
{selectedMovie && (
<DetailsModal
id="collection-details"
data={{
id: Number(selectedMovie.id),
type: "movie",
}}
/>
)}
<style>{`
.collection-grid .group.rounded-xl.bg-background-main {
position: relative;
overflow: hidden;
background-color: var(--colors-modal-background) !important;
border-radius: 12px;
border: none !important;
}
.collection-grid .group.rounded-xl.bg-background-main > .flare-border {
position: absolute;
inset: 0;
border-radius: inherit;
border: 2px solid var(--colors-flare, #A359EC);
box-shadow: 0 0 18px 3px var(--colors-flare, #A359EC);
opacity: 0;
transition: opacity 0.3s ease;
pointer-events: none;
}
.collection-grid .group.rounded-xl.bg-background-main:hover > .flare-border {
opacity: 1;
}
.collection-grid .group > div.rounded-xl.bg-background-main {
background: transparent !important;
}
.collection-grid .bookmark-button {
opacity: 0 !important;
transition: opacity 0.2s ease;
}
.collection-grid .group:hover .bookmark-button {
opacity: 1 !important;
}
@media (max-width: 1024px) {
.collection-grid .group:hover .bookmark-button {
opacity: 0 !important;
}
}
`}</style>
</ThemeProvider>,
document.body,
);
}

View file

@ -2,6 +2,8 @@ import { t } from "i18next";
import { useEffect, useState } from "react";
import { Trans } from "react-i18next";
import { Icon, Icons } from "@/components/Icon";
import { DetailsRatings } from "./DetailsRatings";
import { DetailsInfoProps } from "../../types";
@ -10,6 +12,7 @@ export function DetailsInfo({
imdbData,
rtData,
provider,
onCollectionClick,
}: DetailsInfoProps) {
const [isShiftPressed, setIsShiftPressed] = useState(false);
const [showCopied, setShowCopied] = useState(false);
@ -105,6 +108,32 @@ export function DetailsInfo({
</div>
)}
{/* Collection Button */}
{data.collection && data.type === "movie" && onCollectionClick && (
<button
type="button"
onClick={onCollectionClick}
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"
>
<Icon
icon={Icons.FILM}
className="text-white/60 group-hover:text-white/80 transition-colors"
/>
<div className="flex flex-col items-start flex-1 min-w-0">
<span className="text-[10px] text-white/50 font-medium uppercase tracking-wide">
Collection
</span>
<span className="text-xs font-medium truncate w-full text-left">
{data.collection.name}
</span>
</div>
<Icon
icon={Icons.CHEVRON_RIGHT}
className="text-white/40 group-hover:text-white/60 transition-colors flex-shrink-0"
/>
</button>
)}
{/* Hidden TMDB ID */}
{data.id && isShiftPressed && (
<div

View file

@ -46,6 +46,12 @@ export interface DetailsContent {
}>;
};
logoUrl?: string;
collection?: {
id: number;
name: string;
poster_path: string | null;
backdrop_path: string | null;
} | null;
}
export interface DetailsModalProps {
@ -123,6 +129,7 @@ export interface DetailsInfoProps {
imdbData?: any;
rtData?: any;
provider?: string;
onCollectionClick?: () => void;
}
export interface DetailsRatingsProps {