tangled
alpha
login
or
join now
dunkirk.sh
/
pstream-ng
1
fork
atom
pstream is dead; long live pstream
taciturnaxolotl.github.io/pstream-ng/
1
fork
atom
overview
issues
pulls
pipelines
add similar media carousel to details modal
Pas
3 months ago
e7e49f81
6997acd7
+140
3 changed files
expand all
collapse all
unified
split
src
assets
locales
en.json
components
overlays
detailsModal
components
carousels
SimilarMediaCarousel.tsx
layout
DetailsContent.tsx
+1
src/assets/locales/en.json
···
423
423
"airs": "Airs",
424
424
"endsAt": "Ends at {{time}}",
425
425
"trailer": "Trailer",
426
426
+
"similar": "Similar",
426
427
"collection": {
427
428
"movies": "Movies",
428
429
"movie": "Movie",
+126
src/components/overlays/detailsModal/components/carousels/SimilarMediaCarousel.tsx
···
1
1
+
import { useEffect, useRef, useState } from "react";
2
2
+
import { useTranslation } from "react-i18next";
3
3
+
4
4
+
import { getMediaPoster, getRelatedMedia } from "@/backend/metadata/tmdb";
5
5
+
import { TMDBContentTypes } from "@/backend/metadata/types/tmdb";
6
6
+
import { MediaCard } from "@/components/media/MediaCard";
7
7
+
import { useIsMobile } from "@/hooks/useIsMobile";
8
8
+
import { CarouselNavButtons } from "@/pages/discover/components/CarouselNavButtons";
9
9
+
import { useOverlayStack } from "@/stores/interface/overlayStack";
10
10
+
import { MediaItem } from "@/utils/mediaTypes";
11
11
+
12
12
+
interface SimilarMediaCarouselProps {
13
13
+
mediaId: string;
14
14
+
mediaType: TMDBContentTypes;
15
15
+
}
16
16
+
17
17
+
export function SimilarMediaCarousel({
18
18
+
mediaId,
19
19
+
mediaType,
20
20
+
}: SimilarMediaCarouselProps) {
21
21
+
const { t } = useTranslation();
22
22
+
const { isMobile } = useIsMobile();
23
23
+
const { showModal } = useOverlayStack();
24
24
+
const [similarMedia, setSimilarMedia] = useState<MediaItem[]>([]);
25
25
+
const carouselRef = useRef<HTMLDivElement>(null);
26
26
+
const carouselRefs = useRef<{ [key: string]: HTMLDivElement | null }>({
27
27
+
similar: null,
28
28
+
});
29
29
+
30
30
+
useEffect(() => {
31
31
+
const loadSimilarMedia = async () => {
32
32
+
try {
33
33
+
const results = await getRelatedMedia(mediaId, mediaType, 12);
34
34
+
const mediaItems: MediaItem[] = results.map((result) => {
35
35
+
const isMovie = "title" in result;
36
36
+
return {
37
37
+
id: result.id.toString(),
38
38
+
title: isMovie ? result.title : result.name,
39
39
+
poster: getMediaPoster(result.poster_path) || "/placeholder.png",
40
40
+
type: mediaType === TMDBContentTypes.MOVIE ? "movie" : "show",
41
41
+
year: isMovie
42
42
+
? result.release_date
43
43
+
? new Date(result.release_date).getFullYear()
44
44
+
: 0
45
45
+
: result.first_air_date
46
46
+
? new Date(result.first_air_date).getFullYear()
47
47
+
: 0,
48
48
+
release_date: isMovie
49
49
+
? result.release_date
50
50
+
? new Date(result.release_date)
51
51
+
: undefined
52
52
+
: result.first_air_date
53
53
+
? new Date(result.first_air_date)
54
54
+
: undefined,
55
55
+
};
56
56
+
});
57
57
+
setSimilarMedia(mediaItems);
58
58
+
} catch (err) {
59
59
+
console.error("Failed to load similar media:", err);
60
60
+
}
61
61
+
};
62
62
+
63
63
+
loadSimilarMedia();
64
64
+
}, [mediaId, mediaType]);
65
65
+
66
66
+
useEffect(() => {
67
67
+
if (carouselRef.current) {
68
68
+
carouselRefs.current.similar = carouselRef.current;
69
69
+
}
70
70
+
}, []);
71
71
+
72
72
+
const handleShowDetails = (media: MediaItem) => {
73
73
+
showModal("details", {
74
74
+
id: Number(media.id),
75
75
+
type: media.type === "movie" ? "movie" : "show",
76
76
+
});
77
77
+
};
78
78
+
79
79
+
if (similarMedia.length === 0) return null;
80
80
+
81
81
+
return (
82
82
+
<div className="space-y-4 pt-8">
83
83
+
<h3 className="text-lg font-semibold text-white/90">
84
84
+
{t("details.similar")}
85
85
+
</h3>
86
86
+
87
87
+
<div className="relative">
88
88
+
{/* Carousel Container */}
89
89
+
<div
90
90
+
ref={carouselRef}
91
91
+
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"
92
92
+
style={{
93
93
+
scrollSnapType: "x mandatory",
94
94
+
scrollBehavior: "smooth",
95
95
+
}}
96
96
+
>
97
97
+
<div className="md:w-12" />
98
98
+
99
99
+
{similarMedia.map((media) => (
100
100
+
<div
101
101
+
key={media.id}
102
102
+
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"
103
103
+
style={{ scrollSnapAlign: "start" }}
104
104
+
>
105
105
+
<MediaCard
106
106
+
media={media}
107
107
+
linkable
108
108
+
onShowDetails={handleShowDetails}
109
109
+
/>
110
110
+
</div>
111
111
+
))}
112
112
+
113
113
+
<div className="md:w-12" />
114
114
+
</div>
115
115
+
116
116
+
{/* Navigation Buttons */}
117
117
+
{!isMobile && (
118
118
+
<CarouselNavButtons
119
119
+
categorySlug="similar"
120
120
+
carouselRefs={carouselRefs}
121
121
+
/>
122
122
+
)}
123
123
+
</div>
124
124
+
</div>
125
125
+
);
126
126
+
}
+13
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
19
+
import { SimilarMediaCarousel } from "../carousels/SimilarMediaCarousel";
19
20
import { TrailerCarousel } from "../carousels/TrailerCarousel";
20
21
import { CollectionOverlay } from "../overlays/CollectionOverlay";
21
22
import { TrailerOverlay } from "../overlays/TrailerOverlay";
···
386
387
trailer_url: trailerUrl,
387
388
}));
388
389
}}
390
390
+
/>
391
391
+
)}
392
392
+
393
393
+
{/* Similar Media Carousel */}
394
394
+
{data.id && (
395
395
+
<SimilarMediaCarousel
396
396
+
mediaId={data.id.toString()}
397
397
+
mediaType={
398
398
+
data.type === "movie"
399
399
+
? TMDBContentTypes.MOVIE
400
400
+
: TMDBContentTypes.TV
401
401
+
}
389
402
/>
390
403
)}
391
404
</div>