update details modal

This commit is contained in:
Pas 2025-06-02 11:10:49 -06:00
parent f1d97d4892
commit 0b54fc5182
8 changed files with 238 additions and 167 deletions

View file

@ -210,6 +210,7 @@
"tmdb": "View on TMDB",
"imdb": "View on IMDb",
"episodes": "Episodes",
"seasons": "Season/s",
"season": "Season",
"episode": "Episode",
"airs": "Airs",

View file

@ -76,6 +76,7 @@ export function OverlayPortal(props: {
darken?: boolean;
show?: boolean;
close?: () => void;
durationClass?: string;
}) {
const [portalElement, setPortalElement] = useState<Element | null>(null);
const ref = useRef<HTMLDivElement>(null);
@ -106,6 +107,7 @@ export function OverlayPortal(props: {
animation="slide-up"
className="absolute inset-0 pointer-events-none"
isChild
durationClass={props.durationClass ?? "duration-200"}
>
{/* a tabable index that does nothing - used so focus trap doesn't error when nothing is rendered yet */}
<div

View file

@ -1,10 +1,11 @@
import { t } from "i18next";
import { useEffect, useMemo, useState } from "react";
import { useEffect, useMemo, useRef, useState } from "react";
import { useCopyToClipboard } from "react-use";
import { TMDBContentTypes } from "@/backend/metadata/types/tmdb";
import { Icon, Icons } from "@/components/Icon";
import { useLanguageStore } from "@/stores/language";
import { usePreferencesStore } from "@/stores/preferences";
import { useProgressStore } from "@/stores/progress";
import { shouldShowProgress } from "@/stores/progress/utils";
import { scrapeIMDb } from "@/utils/imdbScraper";
@ -26,7 +27,12 @@ export function DetailsContent({ data, minimal = false }: DetailsContentProps) {
const [selectedSeason, setSelectedSeason] = useState<number>(1);
const [, copyToClipboard] = useCopyToClipboard();
const [hasCopiedShare, setHasCopiedShare] = useState(false);
const [logoHeight, setLogoHeight] = useState<number>(0);
const logoRef = useRef<HTMLDivElement>(null);
const progress = useProgressStore((s) => s.items);
const enableImageLogos = usePreferencesStore(
(state) => state.enableImageLogos,
);
const showProgress = useMemo(() => {
if (!data.id) return null;
@ -42,6 +48,20 @@ export function DetailsContent({ data, minimal = false }: DetailsContentProps) {
}
}, [showProgress]);
// Add effect to measure logo height
useEffect(() => {
if (logoRef.current) {
const resizeObserver = new ResizeObserver((entries) => {
for (const entry of entries) {
setLogoHeight(entry.contentRect.height);
}
});
resizeObserver.observe(logoRef.current);
return () => resizeObserver.disconnect();
}
}, []);
useEffect(() => {
const fetchExternalData = async () => {
if (!data.imdbId) return;
@ -133,40 +153,87 @@ export function DetailsContent({ data, minimal = false }: DetailsContentProps) {
/>
)}
{/* Backdrop - Even taller */}
<div className="h-64 lg:h-80 xl:h-96 relative -mt-12">
{/* Backdrop */}
<div
className="relative -mt-12 z-20"
style={{
height: `${Math.max(500, logoHeight + 400)}px`,
}}
>
{/* Title/Logo positioned on backdrop */}
<div ref={logoRef} className="absolute inset-x-0 bottom-20 z-30 px-6">
{data.logoUrl && enableImageLogos ? (
<img
src={data.logoUrl}
alt={data.title}
className="max-w-[16rem] md:max-w-[20rem] lg:max-w-[30rem] max-h-[12rem] object-contain drop-shadow-lg bg-transparent"
style={{ background: "none" }}
/>
) : (
<h3 className="text-3xl md:text-4xl font-bold text-white drop-shadow-lg">
{data.title}
</h3>
)}
</div>
<div
className="absolute inset-0 bg-cover bg-center"
className="absolute inset-0 bg-cover bg-center opacity-60 before:absolute before:inset-0 before:bg-[radial-gradient(circle_at_center,_transparent_0%,_rgba(0,0,0,0.5)_80%)]"
style={{
backgroundImage: data.backdrop
? `url(${data.backdrop})`
: undefined,
maskImage:
"linear-gradient(to top, rgba(0, 0, 0, 0), rgba(0, 0, 0, 1) 60px)",
"linear-gradient(to top, rgba(0, 0, 0, 0), rgba(0, 0, 0, 1) 120px)",
WebkitMaskImage:
"linear-gradient(to top, rgba(0, 0, 0, 0), rgba(0, 0, 0, 1) 60px)",
"linear-gradient(to top, rgba(0, 0, 0, 0), rgba(0, 0, 0, 1) 120px)",
zIndex: -1,
}}
/>
</div>
{/* Content */}
<div className="px-6 pb-6 mt-[-70px] flex-grow">
<div className="px-6 pb-6 mt-[-70px] flex-grow relative z-30">
<DetailsHeader
data={data}
onPlayClick={handlePlayClick}
onTrailerClick={() => setShowTrailer(true)}
onShareClick={handleShareClick}
showProgress={showProgress}
voteAverage={data.voteAverage}
voteCount={data.voteCount}
releaseDate={data.releaseDate}
seasons={
data.type === "show" ? data.seasonData?.seasons.length : undefined
}
/>
{/* Two Column Layout - Stacked on Mobile */}
<div className="grid grid-cols-1 md:grid-cols-3 md:gap-6">
{/* Left Column - Description */}
<div className="grid grid-cols-1 md:grid-cols-3 md:gap-6 pt-4">
{/* Left Column - Main Content (2/3) */}
<div className="md:col-span-2">
{/* Description */}
{data.overview && (
<p className="text-sm text-white/90 mb-6">{data.overview}</p>
)}
{/* Genres */}
{data.genres && data.genres.length > 0 && (
<div className="flex flex-wrap gap-2 items-center">
{data.genres.map((genre, index) => (
<span
key={genre.id}
className="text-[11px] px-2 py-0.5 rounded-full bg-white/20 text-white/80 transition-all duration-300 hover:scale-110 animate-[scaleIn_0.6s_ease-out_forwards]"
style={{
animationDelay: `${((data.genres?.length ?? 0) - 1 - index) * 60}ms`,
transform: "scale(0)",
opacity: 0,
}}
>
{genre.name}
</span>
))}
</div>
)}
{/* Director and Cast */}
<div className="space-y-4 mb-6">
{data.director && (
@ -190,8 +257,10 @@ export function DetailsContent({ data, minimal = false }: DetailsContentProps) {
</div>
</div>
{/* Right Column - Details */}
<DetailsInfo data={data} imdbData={imdbData} rtData={rtData} />
{/* Right Column - Details Info (1/3) */}
<div className="md:col-span-1">
<DetailsInfo data={data} imdbData={imdbData} rtData={rtData} />
</div>
</div>
{/* Episodes Carousel for TV Shows */}

View file

@ -5,7 +5,6 @@ import { Button } from "@/components/buttons/Button";
import { IconPatch } from "@/components/buttons/IconPatch";
import { Icon, Icons } from "@/components/Icon";
import { MediaBookmarkButton } from "@/components/media/MediaBookmark";
import { usePreferencesStore } from "@/stores/preferences";
import { DetailsHeaderProps } from "./types";
@ -15,127 +14,107 @@ export function DetailsHeader({
onTrailerClick,
onShareClick,
showProgress,
voteAverage,
voteCount,
releaseDate,
seasons,
}: DetailsHeaderProps) {
const enableImageLogos = usePreferencesStore(
(state) => state.enableImageLogos,
);
const formatDate = (dateString?: string) => {
if (!dateString) return null;
return new Date(dateString).getFullYear();
};
return (
<>
{/* Title and Genres Row */}
<div className="pb-2">
{data.logoUrl && enableImageLogos ? (
<img
src={data.logoUrl}
alt={data.title}
className="max-w-[12rem] md:max-w-[20rem] object-contain drop-shadow-lg bg-transparent"
style={{ background: "none" }}
/>
) : (
<h3 className="text-2xl font-bold text-white z-[999]">
{data.title}
</h3>
<div className="space-y-4">
{/* TMDB Rating and Year/Seasons */}
<div className="flex items-center gap-2 text-sm text-white/80">
{voteAverage && (
<div className="flex items-center gap-1">
<Icon icon={Icons.RISING_STAR} className="text-yellow-400" />
<span>{voteAverage.toFixed(1)}</span>
{voteCount && (
<span className="text-white/60">
({voteCount.toLocaleString()})
</span>
)}
</div>
)}
{releaseDate && (
<>
<span className="text-white/60"></span>
<span>{formatDate(releaseDate)}</span>
</>
)}
{seasons && (
<>
<span className="text-white/60"></span>
<span>
{seasons} {t("details.seasons")}
</span>
</>
)}
</div>
<div className="flex flex-col sm:flex-row justify-between items-start mb-6 w-full">
<div className="flex items-center gap-4 w-full sm:w-auto">
<Button
onClick={onPlayClick}
theme="purple"
className={classNames(
"flex-1 sm:flex-initial sm:w-auto",
"gap-2 h-12 rounded-lg px-4 py-2 my-1 transition-transform hover:scale-105 duration-100",
"text-md text-white flex items-center justify-center",
)}
>
<Icon icon={Icons.PLAY} className="text-white" />
<span className="text-white text-sm pr-1">
{data.type === "movie"
? !data.releaseDate || new Date(data.releaseDate) > new Date()
? t("media.unreleased")
: showProgress
? t("details.resume")
: t("details.play")
{/* Action Buttons */}
<div className="flex items-center gap-4">
<Button
onClick={onPlayClick}
theme="purple"
className={classNames(
"flex-1 sm:flex-initial sm:w-auto",
"gap-2 h-12 rounded-lg px-4 py-2 my-1 transition-transform hover:scale-105 duration-100",
"text-md text-white flex items-center justify-center",
)}
>
<Icon icon={Icons.PLAY} className="text-white" />
<span className="text-white text-sm pr-1">
{data.type === "movie"
? !data.releaseDate || new Date(data.releaseDate) > new Date()
? t("media.unreleased")
: showProgress
? t("details.resume")
: t("details.play")}
</span>
</Button>
<div className="flex items-center gap-1 flex-shrink-0">
<button
type="button"
onClick={onTrailerClick}
className="p-2 opacity-75 transition-opacity duration-300 hover:scale-110 hover:cursor-pointer hover:opacity-95"
title={t("details.trailer")}
>
<IconPatch
icon={Icons.FILM}
className="transition-transform duration-300 hover:scale-110 hover:cursor-pointer"
/>
</button>
<MediaBookmarkButton
media={{
id: data.id?.toString() || "",
title: data.title,
year: data.releaseDate
? new Date(data.releaseDate).getFullYear()
: undefined,
poster: data.backdrop,
type: data.type || "movie",
}}
: t("details.play")
: showProgress
? t("details.resume")
: t("details.play")}
</span>
</Button>
<div className="flex items-center gap-1 flex-shrink-0">
<button
type="button"
onClick={onTrailerClick}
className="p-2 opacity-75 transition-opacity duration-300 hover:scale-110 hover:cursor-pointer hover:opacity-95"
title={t("details.trailer")}
>
<IconPatch
icon={Icons.FILM}
className="transition-transform duration-300 hover:scale-110 hover:cursor-pointer"
/>
<button
type="button"
onClick={onShareClick}
className="p-2 opacity-75 transition-opacity duration-300 hover:scale-110 hover:cursor-pointer hover:opacity-95"
title="Share"
>
<IconPatch
icon={Icons.IOS_SHARE}
className="transition-transform duration-300 hover:scale-110 hover:cursor-pointer"
/>
</button>
</div>
</button>
<MediaBookmarkButton
media={{
id: data.id?.toString() || "",
title: data.title,
year: data.releaseDate
? new Date(data.releaseDate).getFullYear()
: undefined,
poster: data.backdrop,
type: data.type || "movie",
}}
/>
<button
type="button"
onClick={onShareClick}
className="p-2 opacity-75 transition-opacity duration-300 hover:scale-110 hover:cursor-pointer hover:opacity-95"
title="Share"
>
<IconPatch
icon={Icons.IOS_SHARE}
className="transition-transform duration-300 hover:scale-110 hover:cursor-pointer"
/>
</button>
</div>
{/* Genres on the right side of the button row for larger screens */}
{data.genres && data.genres.length > 0 && (
<div className="hidden sm:flex flex-wrap gap-2 justify-end items-center">
{data.genres.map((genre, index) => (
<span
key={genre.id}
className="text-[11px] px-2 py-0.5 rounded-full bg-white/20 text-white/80 transition-all duration-300 hover:scale-110 animate-[scaleIn_0.6s_ease-out_forwards]"
style={{
animationDelay: `${((data.genres?.length ?? 0) - 1 - index) * 60}ms`,
transform: "scale(0)",
opacity: 0,
}}
>
{genre.name}
</span>
))}
</div>
)}
</div>
{/* Genres below for small screens */}
{data.genres && data.genres.length > 0 && (
<div className="flex sm:hidden flex-wrap gap-2 justify-start items-center mb-6 -mt-3">
{data.genres.map((genre, index) => (
<span
key={genre.id}
className="text-[11px] px-2 py-0.5 rounded-full bg-white/20 text-white/80 transition-all duration-300 hover:scale-110 animate-[scaleIn_0.6s_ease-out_forwards]"
style={{
animationDelay: `${((data.genres?.length ?? 0) - 1 - index) * 60}ms`,
transform: "scale(0)",
opacity: 0,
}}
>
{genre.name}
</span>
))}
</div>
)}
</>
</div>
);
}

View file

@ -72,15 +72,15 @@ export function DetailsInfo({ data, imdbData, rtData }: DetailsInfoProps) {
</div>
)}
{/* Ratings and External Links */}
{/* External Ratings */}
<DetailsRatings
voteAverage={data.voteAverage}
voteCount={data.voteCount}
imdbData={imdbData}
rtData={rtData}
mediaId={data.id}
mediaType={data.type}
imdbId={data.imdbId}
voteAverage={data.voteAverage}
voteCount={data.voteCount}
/>
</div>
</div>

View file

@ -113,19 +113,22 @@ export function DetailsModal({ id, data, minimal }: DetailsModalProps) {
}, [modal, data, isLoading]);
return (
<OverlayPortal darken close={modal.hide} show={modal.isShown}>
<OverlayPortal
darken
close={modal.hide}
show={modal.isShown}
durationClass="duration-400"
>
<Helmet>
<html data-no-scroll />
</Helmet>
<div className="flex absolute inset-0 items-center justify-center">
<div className="flex absolute inset-0 items-center justify-center pt-safe">
<Flare.Base
className={classNames(
"group -m-[0.705em] rounded-3xl bg-background-main transition-colors duration-300 focus:relative focus:z-10",
"group -m-[0.705em] rounded-3xl bg-background-main",
"max-h-[900px] max-w-[1200px]",
"bg-mediaCard-hoverBackground bg-opacity-60 backdrop-filter backdrop-blur-lg shadow-lg overflow-hidden",
minimal
? "h-[90%] md:h-[70%] lg:h-fit w-[90%] md:w-[70%] lg:w-[50%]"
: "h-[90%] w-[90%] md:w-[70%] lg:w-[60%]",
"h-[97%] w-[95%]",
)}
>
<div className="transition-transform duration-300 h-full">
@ -136,7 +139,7 @@ export function DetailsModal({ id, data, minimal }: DetailsModalProps) {
className="rounded-3xl bg-background-main group-hover:opacity-100"
/>
<Flare.Child className="pointer-events-auto relative h-full overflow-y-auto scrollbar-none select-text">
<div className="absolute right-4 top-4 z-10">
<div className="absolute right-4 top-4 z-50">
<button
type="button"
className="text-s font-semibold text-type-secondary hover:text-white transition-transform hover:scale-95 select-none"
@ -145,7 +148,7 @@ export function DetailsModal({ id, data, minimal }: DetailsModalProps) {
<IconPatch icon={Icons.X} />
</button>
</div>
<div className="pt-12 select-text">
<div className="select-text">
{isLoading || !detailsData ? (
<DetailsSkeleton />
) : (

View file

@ -31,34 +31,6 @@ export function DetailsRatings({
return (
<div className="space-y-1">
{voteAverage !== undefined &&
voteCount !== undefined &&
voteCount > 0 && (
<>
<div className="flex items-center gap-1 text-white/80">
<span className="font-medium">{t("details.rating")}</span>{" "}
<span className="text-white/90">
{imdbData?.imdb_rating
? `${imdbData.imdb_rating.toFixed(1)}/10 (IMDb)`
: `${voteAverage.toFixed(1)}/10 (TMDB)`}
</span>
</div>
{/* Rating Progress Bar */}
<div className="w-full h-2 bg-white/10 rounded-full overflow-hidden">
<div
className={`h-full ${getRatingColor(imdbData?.imdb_rating || voteAverage)} transition-all duration-500`}
style={{
width: `${((imdbData?.imdb_rating || voteAverage) / 10) * 100}%`,
}}
/>
</div>
<div className="text-white/60 text-[10px] text-right">
{formatVoteCount(imdbData?.votes || voteCount)}{" "}
{t("details.votes")}
</div>
</>
)}
{/* External Links */}
<div className="flex gap-3 mt-2">
{mediaId && (
@ -66,7 +38,12 @@ export function DetailsRatings({
href={`https://www.themoviedb.org/${mediaType === "show" ? "tv" : "movie"}/${mediaId}`}
target="_blank"
rel="noopener noreferrer"
className="w-8 h-8 rounded-full bg-[#0d253f] flex items-center justify-center transition-transform hover:scale-110"
className="w-8 h-8 rounded-full bg-[#0d253f] flex items-center justify-center transition-transform hover:scale-110 animate-[scaleIn_0.6s_ease-out_forwards]"
style={{
animationDelay: "0ms",
transform: "scale(0)",
opacity: 0,
}}
title={t("details.tmdb")}
>
<Icon icon={Icons.TMDB} className="text-white" />
@ -77,7 +54,12 @@ export function DetailsRatings({
href={`https://www.imdb.com/title/${imdbId}`}
target="_blank"
rel="noopener noreferrer"
className="w-8 h-8 rounded-full bg-yellow-500 flex items-center justify-center transition-transform hover:scale-110"
className="w-8 h-8 rounded-full bg-yellow-500 flex items-center justify-center transition-transform hover:scale-110 animate-[scaleIn_0.6s_ease-out_forwards]"
style={{
animationDelay: "60ms",
transform: "scale(0)",
opacity: 0,
}}
title={t("details.imdb")}
>
<Icon icon={Icons.IMDB} className="text-black" />
@ -86,7 +68,15 @@ export function DetailsRatings({
{rtData && (
<div className="flex items-center gap-1">
<div className="flex flex-col items-center justify-center gap-1">
<div className="flex items-center gap-1" title="Tomatometer">
<div
className="flex items-center gap-1 animate-[scaleIn_0.6s_ease-out_forwards]"
style={{
animationDelay: "120ms",
transform: "scale(0)",
opacity: 0,
}}
title="Tomatometer"
>
<img
src={getRTIcon(rtData.tomatoIcon)}
alt="Tomatometer"
@ -100,6 +90,27 @@ export function DetailsRatings({
</div>
)}
</div>
<div className="pt-4">
{voteAverage !== undefined &&
voteCount !== undefined &&
voteCount > 0 && (
<>
{/* Rating Progress Bar */}
<div className="w-full h-2 bg-white/10 rounded-full overflow-hidden">
<div
className={`h-full ${getRatingColor(imdbData?.imdb_rating || voteAverage)} transition-all duration-500`}
style={{
width: `${((imdbData?.imdb_rating || voteAverage) / 10) * 100}%`,
}}
/>
</div>
<div className="text-white/60 text-[10px] text-right">
{formatVoteCount(imdbData?.votes || voteCount)}{" "}
{t("details.votes")}
</div>
</>
)}
</div>
</div>
);
}

View file

@ -1,3 +1,5 @@
import { ShowProgressResult } from "@/stores/progress/utils";
export interface DetailsContent {
title: string;
overview?: string;
@ -102,7 +104,11 @@ export interface DetailsHeaderProps {
onPlayClick: () => void;
onTrailerClick: () => void;
onShareClick: () => void;
showProgress?: any;
showProgress: ShowProgressResult | null;
voteAverage?: number;
voteCount?: number;
releaseDate?: string;
seasons?: number;
}
export interface DetailsInfoProps {