mirror of
https://github.com/p-stream/p-stream.git
synced 2026-03-29 01:38:43 +00:00
Overhaul Discover Page and Featured
Add Featured Modal Removed Individual Carousels for each genre Recommended Carousel View More page for viewing all Improve several minor visuals Update search and navigation Full Commit Log: add more carousel skeleton dots bug fix and languages remove provider translations Add change button for recommended more content add buttons to moreContent page dropdown for changing recommended Increase genres and providers add home/search button to discover Update FeaturedCarousel.tsx fix recommended load more pages increase number of featured items clean up featured image fetch maybe fix ff bug? add dynamic blur to header Update Dropdown.tsx fix dropdown add recommended carousel animate dropdown fix some visuals random button fix padding reset timer when manually switching slides fix editor picks more titles add store for discover fix editor picks Update FeaturedCarousel.tsx add view more card move view more link update carousel buttons and dropdown finish 5 carousels use 5 carousels init carousel nav buttons update dropdown update featured sizing update blurs add clear blur to navigation update padding and sizing Update FeaturedCarousel.tsx add loading skeleton update discover navigation again simplify featured media Update SearchBar.tsx tweak some minor visual stuff fix button sizes update carousel gradient fix sticky fix safari overlay bug make search transparent use secondary buttons on featured fix up negative margins fix searching classes fix buttons because of the overlay make it shorter add featured section to home page add toggle for image logos fix details modal title overlay position clean up some buttons improve fed setup status check update grid Update FeaturedCarousel.tsx dont show more content for providers more stuff clean and bugfix update editor picks more content page Update DetailsModal.tsx more more more! shuffle editor picks discover update part 2 fix more info button init discover v3
This commit is contained in:
parent
12b3002b5a
commit
3ce5053af5
35 changed files with 3854 additions and 1580 deletions
|
|
@ -821,5 +821,50 @@
|
|||
"episodes": "Folgen",
|
||||
"season": "Staffel",
|
||||
"runtime": "Laufzeit:"
|
||||
},
|
||||
"discover": {
|
||||
"tabs": {
|
||||
"movies": "Filme",
|
||||
"tvshows": "Serien",
|
||||
"editorpicks": "Empfehlungen der Redaktion"
|
||||
},
|
||||
"carousel": {
|
||||
"title": {
|
||||
"movies": "{{category}} Filme",
|
||||
"tvshows": "{{category}} Serien",
|
||||
"inCinemas": "Jetzt im Kino",
|
||||
"popularOn": "Beliebte {{type}} auf {{provider}}",
|
||||
"editorPicksMovies": "Redaktionsempfehlungen Filme",
|
||||
"editorPicksShows": "Redaktionsempfehlungen Serien",
|
||||
"moviesOn": "Filme auf {{provider}}",
|
||||
"tvshowsOn": "Serien auf {{provider}}",
|
||||
"recommended": "Weil du geschaut hast: {{title}}",
|
||||
"genreMovies": "{{genre}} Filme",
|
||||
"genreShows": "{{genre}} Serien",
|
||||
"categoryMovies": "{{category}} Filme",
|
||||
"categoryShows": "{{category}} Serien"
|
||||
},
|
||||
"change": "Ändern",
|
||||
"more": "Mehr anzeigen"
|
||||
},
|
||||
"featured": {
|
||||
"playNow": "Jetzt abspielen",
|
||||
"moreInfo": "Mehr Infos"
|
||||
},
|
||||
"randomMovie": {
|
||||
"button": "Zufälligen Titel abspielen",
|
||||
"cancel": "Countdown abbrechen",
|
||||
"countdown": "{{countdown}}s",
|
||||
"nowPlaying": "Jetzt läuft",
|
||||
"in": "in"
|
||||
},
|
||||
"page": {
|
||||
"title": "Filme & Serien entdecken",
|
||||
"subtitle": "Entdecke aktuelle Highlights und zeitlose Klassiker.",
|
||||
"loadMore": "Mehr laden",
|
||||
"loading": "Lade...",
|
||||
"back": "Zurück"
|
||||
},
|
||||
"scrollToTop": "Nach oben"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -114,7 +114,10 @@ export function LinksDropdown(props: { children: React.ReactNode }) {
|
|||
return (
|
||||
<div className="relative is-dropdown">
|
||||
<div
|
||||
className="cursor-pointer tabbable rounded-full flex gap-2 text-white items-center py-2 px-3 bg-pill-background bg-opacity-50 hover:bg-pill-backgroundHover backdrop-blur-lg transition-[background,transform] duration-100 hover:scale-105"
|
||||
className={classNames(
|
||||
"cursor-pointer tabbable rounded-full flex gap-2 text-white items-center py-2 px-3 bg-pill-background hover:bg-pill-backgroundHover backdrop-blur-lg transition-all duration-100 hover:scale-105",
|
||||
open ? "bg-opacity-100" : "bg-opacity-50",
|
||||
)}
|
||||
tabIndex={0}
|
||||
onClick={toggleOpen}
|
||||
onKeyUp={(evt) => evt.key === "Enter" && toggleOpen()}
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ interface Props {
|
|||
event: React.MouseEvent<HTMLAnchorElement | HTMLButtonElement, MouseEvent>,
|
||||
) => void;
|
||||
children?: ReactNode;
|
||||
theme?: "white" | "purple" | "secondary" | "danger";
|
||||
theme?: "white" | "purple" | "secondary" | "danger" | "glass";
|
||||
padding?: string;
|
||||
className?: string;
|
||||
href?: string;
|
||||
|
|
@ -45,12 +45,17 @@ export function Button(props: Props) {
|
|||
|
||||
let colorClasses = "bg-white hover:bg-gray-200 text-black";
|
||||
if (props.theme === "purple")
|
||||
colorClasses = "bg-buttons-purple hover:bg-buttons-purpleHover text-white";
|
||||
colorClasses =
|
||||
"bg-buttons-purple hover:bg-buttons-purpleHover text-white gap-2";
|
||||
if (props.theme === "secondary")
|
||||
colorClasses =
|
||||
"bg-buttons-cancel hover:bg-buttons-cancelHover transition-colors duration-100 text-white";
|
||||
"bg-buttons-cancel hover:bg-buttons-cancelHover transition-colors duration-100 text-white gap-2";
|
||||
if (props.theme === "danger")
|
||||
colorClasses = "bg-buttons-danger hover:bg-buttons-dangerHover text-white";
|
||||
colorClasses =
|
||||
"bg-buttons-danger hover:bg-buttons-dangerHover text-white gap-2";
|
||||
if (props.theme === "glass")
|
||||
colorClasses =
|
||||
"text-white hover:scale-105 bg-buttons-purple hover:bg-buttons-purpleHover bg-opacity-45 hover:bg-opacity-60 !backdrop-blur-md border-2 border-gray-400 border-opacity-20 gap-2";
|
||||
|
||||
let classes = classNames(
|
||||
"tabbable cursor-pointer inline-flex items-center justify-center rounded-lg font-medium transition-[transform,background-color] duration-100 active:scale-105 md:px-8",
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ export interface IconPatchProps {
|
|||
icon: Icons;
|
||||
transparent?: boolean;
|
||||
downsized?: boolean;
|
||||
navigation?: boolean;
|
||||
}
|
||||
|
||||
export function IconPatch(props: IconPatchProps) {
|
||||
|
|
@ -17,6 +18,9 @@ export function IconPatch(props: IconPatchProps) {
|
|||
const transparentClasses = props.transparent
|
||||
? "bg-opacity-0 hover:bg-opacity-50"
|
||||
: "";
|
||||
const navigationClasses = props.navigation
|
||||
? "bg-opacity-50 hover:bg-opacity-100"
|
||||
: "";
|
||||
const activeClasses = props.active
|
||||
? "bg-pill-backgroundHover text-white"
|
||||
: "";
|
||||
|
|
@ -25,7 +29,7 @@ export function IconPatch(props: IconPatchProps) {
|
|||
return (
|
||||
<div className={props.className || undefined} onClick={props.onClick}>
|
||||
<div
|
||||
className={`flex items-center justify-center rounded-full border-2 border-transparent bg-pill-background bg-opacity-100 transition-[background-color,color,transform,border-color] duration-75 ${transparentClasses} ${clickableClasses} ${activeClasses} ${sizeClasses}`}
|
||||
className={`flex items-center justify-center rounded-full border-2 border-transparent bg-pill-background bg-opacity-100 transition-[background-color,color,transform,border-color] duration-75 ${transparentClasses} ${navigationClasses} ${clickableClasses} ${activeClasses} ${sizeClasses}`}
|
||||
>
|
||||
<Icon icon={props.icon} />
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,7 +1,8 @@
|
|||
import { Listbox, Transition } from "@headlessui/react";
|
||||
import { Listbox } from "@headlessui/react";
|
||||
import React, { Fragment } from "react";
|
||||
|
||||
import { Icon, Icons } from "@/components/Icon";
|
||||
import { Transition } from "@/components/utils/Transition";
|
||||
|
||||
export interface OptionItem {
|
||||
id: string;
|
||||
|
|
@ -14,56 +15,70 @@ interface DropdownProps {
|
|||
setSelectedItem: (value: OptionItem) => void;
|
||||
options: Array<OptionItem>;
|
||||
direction?: "up" | "down";
|
||||
side?: "left" | "right";
|
||||
customButton?: React.ReactNode;
|
||||
customMenu?: React.ReactNode;
|
||||
className?: string;
|
||||
preventWrap?: boolean;
|
||||
}
|
||||
|
||||
export function Dropdown(props: DropdownProps) {
|
||||
const { direction = "down" } = props;
|
||||
const { direction = "down", customButton, customMenu } = props;
|
||||
|
||||
return (
|
||||
<div className="relative my-4 max-w-[25rem]">
|
||||
<div className={`relative my-4 w-fit max-w-[25rem] ${props.className}`}>
|
||||
<Listbox value={props.selectedItem} onChange={props.setSelectedItem}>
|
||||
{() => (
|
||||
{({ open }) => (
|
||||
<>
|
||||
<Listbox.Button className="relative w-full rounded-lg bg-dropdown-background hover:bg-dropdown-hoverBackground py-3 pl-3 pr-10 text-left text-white shadow-md focus:outline-none tabbable cursor-pointer">
|
||||
<span className="flex gap-4 items-center truncate">
|
||||
{props.selectedItem.leftIcon
|
||||
? props.selectedItem.leftIcon
|
||||
: null}
|
||||
{props.selectedItem.name}
|
||||
</span>
|
||||
<span className="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-2">
|
||||
<Icon
|
||||
icon={Icons.UP_DOWN_ARROW}
|
||||
className={`transform transition-transform text-xl text-dropdown-secondary ${direction === "up" ? "rotate-180" : ""}`}
|
||||
/>
|
||||
</span>
|
||||
</Listbox.Button>
|
||||
{customButton ? (
|
||||
<Listbox.Button as={Fragment}>{customButton}</Listbox.Button>
|
||||
) : (
|
||||
<Listbox.Button className="relative z-[101] w-full rounded-lg bg-dropdown-background hover:bg-dropdown-hoverBackground py-3 pl-3 pr-10 text-left text-white shadow-md focus:outline-none tabbable cursor-pointer">
|
||||
<span className="flex gap-4 items-center truncate">
|
||||
{props.selectedItem.leftIcon
|
||||
? props.selectedItem.leftIcon
|
||||
: null}
|
||||
{props.selectedItem.name}
|
||||
</span>
|
||||
<span className="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-2">
|
||||
<Icon
|
||||
icon={Icons.UP_DOWN_ARROW}
|
||||
className={`transform transition-transform text-xl text-dropdown-secondary ${direction === "up" ? "rotate-180" : ""}`}
|
||||
/>
|
||||
</span>
|
||||
</Listbox.Button>
|
||||
)}
|
||||
<Transition
|
||||
as={Fragment}
|
||||
leave="transition ease-in duration-100"
|
||||
leaveFrom="opacity-100"
|
||||
leaveTo="opacity-0"
|
||||
animation="slide-down"
|
||||
show={open}
|
||||
className={`absolute z-[102] min-w-[20px] w-fit max-h-60 overflow-auto rounded-lg bg-dropdown-background py-1 text-white shadow-lg ring-1 ring-black ring-opacity-5 scrollbar-thin scrollbar-track-background-secondary scrollbar-thumb-type-secondary focus:outline-none ${
|
||||
direction === "up" ? "bottom-full mb-4" : "top-full mt-1"
|
||||
} ${props.side === "right" ? "right-0" : "left-0"}`}
|
||||
>
|
||||
<Listbox.Options
|
||||
className={`absolute left-0 right-0 z-[100] mt-4 max-h-60 overflow-auto rounded-md bg-dropdown-background py-1 text-white shadow-lg ring-1 ring-black ring-opacity-5 scrollbar-thin scrollbar-track-background-secondary scrollbar-thumb-type-secondary focus:outline-none ${direction === "up" ? "bottom-full mb-4" : "top-full"}`}
|
||||
>
|
||||
{props.options.map((opt) => (
|
||||
<Listbox.Option
|
||||
className={({ active }) =>
|
||||
`cursor-pointer flex gap-4 items-center relative select-none py-3 pl-4 pr-4 ${
|
||||
active
|
||||
? "bg-background-secondaryHover text-type-link"
|
||||
: "text-white"
|
||||
}`
|
||||
}
|
||||
key={opt.id}
|
||||
value={opt}
|
||||
>
|
||||
{opt.leftIcon ? opt.leftIcon : null}
|
||||
{opt.name}
|
||||
</Listbox.Option>
|
||||
))}
|
||||
</Listbox.Options>
|
||||
{customMenu ? (
|
||||
<Listbox.Options static as={Fragment}>
|
||||
{customMenu}
|
||||
</Listbox.Options>
|
||||
) : (
|
||||
<Listbox.Options static className="py-1">
|
||||
{props.options.map((opt) => (
|
||||
<Listbox.Option
|
||||
className={({ active }) =>
|
||||
`cursor-pointer flex gap-4 items-center relative select-none py-2 px-4 mx-1 rounded-lg ${
|
||||
active
|
||||
? "bg-background-secondaryHover text-type-link"
|
||||
: "text-type-secondary"
|
||||
} ${props.preventWrap ? "whitespace-nowrap" : ""}`
|
||||
}
|
||||
key={opt.id}
|
||||
value={opt}
|
||||
>
|
||||
{opt.leftIcon ? opt.leftIcon : null}
|
||||
{opt.name}
|
||||
</Listbox.Option>
|
||||
))}
|
||||
</Listbox.Options>
|
||||
)}
|
||||
</Transition>
|
||||
</>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import c from "classnames";
|
||||
import { forwardRef, useRef, useState } from "react";
|
||||
import { forwardRef, useEffect, useRef, useState } from "react";
|
||||
|
||||
import { Flare } from "@/components/utils/Flare";
|
||||
|
||||
|
|
@ -11,11 +11,16 @@ export interface SearchBarProps {
|
|||
onChange: (value: string, force: boolean) => void;
|
||||
onUnFocus: (newSearch?: string) => void;
|
||||
value: string;
|
||||
isSticky?: boolean;
|
||||
isInFeatured?: boolean;
|
||||
}
|
||||
|
||||
export const SearchBarInput = forwardRef<HTMLInputElement, SearchBarProps>(
|
||||
(props, ref) => {
|
||||
const [focused, setFocused] = useState(false);
|
||||
const [lightTheme, setLightTheme] = useState(
|
||||
Boolean(props.isInFeatured) && window.scrollY < 600,
|
||||
);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [showTooltip, setShowTooltip] = useState(false);
|
||||
|
||||
|
|
@ -23,14 +28,24 @@ export const SearchBarInput = forwardRef<HTMLInputElement, SearchBarProps>(
|
|||
props.onChange(value, true);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const handleScroll = () => {
|
||||
setLightTheme(Boolean(props.isInFeatured) && window.scrollY < 600);
|
||||
};
|
||||
window.addEventListener("scroll", handleScroll);
|
||||
return () => window.removeEventListener("scroll", handleScroll);
|
||||
}, [props.isInFeatured]);
|
||||
|
||||
return (
|
||||
<div ref={containerRef}>
|
||||
<Flare.Base
|
||||
className={c({
|
||||
"hover:flare-enabled group flex flex-col rounded-[28px] transition-colors sm:flex-row sm:items-center relative":
|
||||
"hover:flare-enabled group flex flex-col rounded-[28px] transition-colors sm:flex-row sm:items-center relative backdrop-blur-sm":
|
||||
true,
|
||||
"bg-search-background": !focused,
|
||||
"bg-search-focused": focused,
|
||||
"transition-colors duration-300": true,
|
||||
"bg-search-background/50": !focused && lightTheme,
|
||||
"bg-search-background":
|
||||
focused || props.isSticky || !props.isInFeatured,
|
||||
})}
|
||||
>
|
||||
<Flare.Light
|
||||
|
|
@ -45,7 +60,15 @@ export const SearchBarInput = forwardRef<HTMLInputElement, SearchBarProps>(
|
|||
/>
|
||||
<Flare.Child className="flex flex-1 flex-col">
|
||||
<div
|
||||
className="absolute bottom-0 left-5 top-0 flex max-h-14 items-center text-search-icon cursor-pointer z-10"
|
||||
className={c(
|
||||
"absolute bottom-0 left-5 top-0 flex max-h-14 items-center text-search-icon cursor-pointer z-10",
|
||||
"transition-colors duration-300",
|
||||
props.isInFeatured
|
||||
? lightTheme
|
||||
? "text-white/50"
|
||||
: ""
|
||||
: "text-search-icon",
|
||||
)}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
setShowTooltip(!showTooltip);
|
||||
|
|
@ -66,7 +89,15 @@ export const SearchBarInput = forwardRef<HTMLInputElement, SearchBarProps>(
|
|||
onFocus={() => setFocused(true)}
|
||||
onChange={(val) => setSearch(val)}
|
||||
value={props.value}
|
||||
className="w-full flex-1 bg-transparent px-4 py-4 pl-12 text-search-text placeholder-search-placeholder focus:outline-none sm:py-4 sm:pr-2"
|
||||
className={c(
|
||||
"w-full flex-1 bg-transparent px-4 py-4 pl-12 !text-search-text focus:outline-none sm:py-4 sm:pr-2 transition-colors duration-300",
|
||||
"transition-colors duration-300",
|
||||
props.isInFeatured
|
||||
? lightTheme
|
||||
? "text-white/50"
|
||||
: "placeholder-search-placeholder"
|
||||
: "placeholder-search-placeholder",
|
||||
)}
|
||||
placeholder={props.placeholder}
|
||||
/>
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import classNames from "classnames";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Link, To, useNavigate } from "react-router-dom";
|
||||
|
||||
import { NoUserAvatar, UserAvatar } from "@/components/Avatar";
|
||||
|
|
@ -17,18 +18,40 @@ export interface NavigationProps {
|
|||
bg?: boolean;
|
||||
noLightbar?: boolean;
|
||||
doBackground?: boolean;
|
||||
clearBackground?: boolean;
|
||||
}
|
||||
|
||||
export function Navigation(props: NavigationProps) {
|
||||
const bannerHeight = useBannerSize();
|
||||
const navigate = useNavigate();
|
||||
const { loggedIn } = useAuth();
|
||||
const [scrollPosition, setScrollPosition] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
const handleScroll = () => {
|
||||
setScrollPosition(window.scrollY);
|
||||
};
|
||||
|
||||
window.addEventListener("scroll", handleScroll);
|
||||
return () => window.removeEventListener("scroll", handleScroll);
|
||||
}, []);
|
||||
|
||||
const handleClick = (path: To) => {
|
||||
window.scrollTo(0, 0);
|
||||
navigate(path);
|
||||
};
|
||||
|
||||
// Calculate mask length based on scroll position
|
||||
const getMaskLength = () => {
|
||||
// When at top (0), use longer mask (200px)
|
||||
// When scrolled down (300px+), use shorter mask (100px)
|
||||
const maxScroll = 300;
|
||||
const minLength = 100;
|
||||
const maxLength = 180;
|
||||
const scrollFactor = Math.min(scrollPosition, maxScroll) / maxScroll;
|
||||
return minLength + (maxLength - minLength) * (1 - scrollFactor);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* lightbar */}
|
||||
|
|
@ -54,10 +77,13 @@ export function Navigation(props: NavigationProps) {
|
|||
>
|
||||
<div
|
||||
className={classNames(
|
||||
"fixed left-0 right-0 top-0 flex items-center",
|
||||
"fixed left-0 right-0 top-0 flex items-center", // border-b border-utils-divider border-opacity-50
|
||||
"transition-[background-color,backdrop-filter] duration-300 ease-in-out",
|
||||
props.doBackground
|
||||
? "bg-background-main border-b border-utils-divider border-opacity-50"
|
||||
: null,
|
||||
? props.clearBackground
|
||||
? "backdrop-blur-md bg-transparent"
|
||||
: "bg-background-main"
|
||||
: "bg-transparent",
|
||||
)}
|
||||
>
|
||||
{props.doBackground ? (
|
||||
|
|
@ -67,12 +93,29 @@ export function Navigation(props: NavigationProps) {
|
|||
) : null}
|
||||
<div className="opacity-0 absolute inset-0 block h-20 pointer-events-auto" />
|
||||
<div
|
||||
className={`${
|
||||
props.bg ? "opacity-100" : "opacity-0"
|
||||
} absolute inset-0 block h-24 bg-background-main transition-opacity duration-300`}
|
||||
>
|
||||
<div className="absolute -bottom-24 h-24 w-full bg-gradient-to-b from-background-main to-transparent" />
|
||||
</div>
|
||||
className={classNames(
|
||||
"transition-[background-color,backdrop-filter,opacity] duration-300 ease-in-out",
|
||||
props.bg ? "opacity-100" : "opacity-0",
|
||||
"absolute inset-0 block h-[11rem]",
|
||||
props.clearBackground
|
||||
? "backdrop-blur-md bg-transparent"
|
||||
: "bg-background-main",
|
||||
)}
|
||||
style={{
|
||||
maskImage: `linear-gradient(
|
||||
to bottom,
|
||||
rgba(0, 0, 0, 1),
|
||||
rgba(0, 0, 0, 1) calc(100% - ${getMaskLength()}px),
|
||||
rgba(0, 0, 0, 0) 100%
|
||||
)`,
|
||||
WebkitMaskImage: `linear-gradient(
|
||||
to bottom,
|
||||
rgba(0, 0, 0, 1),
|
||||
rgba(0, 0, 0, 1) calc(100% - ${getMaskLength()}px),
|
||||
rgba(0, 0, 0, 0) 100%
|
||||
)`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -99,15 +142,40 @@ export function Navigation(props: NavigationProps) {
|
|||
rel="noreferrer"
|
||||
className="text-xl text-white tabbable rounded-full"
|
||||
>
|
||||
<IconPatch icon={Icons.DISCORD} clickable downsized />
|
||||
</a>
|
||||
<a
|
||||
onClick={() => handleClick("/discover")}
|
||||
rel="noreferrer"
|
||||
className="text-xl text-white tabbable rounded-full"
|
||||
>
|
||||
<IconPatch icon={Icons.RISING_STAR} clickable downsized />
|
||||
<IconPatch
|
||||
icon={Icons.DISCORD}
|
||||
clickable
|
||||
downsized
|
||||
navigation
|
||||
/>
|
||||
</a>
|
||||
{window.location.pathname !== "/discover" ? (
|
||||
<a
|
||||
onClick={() => handleClick("/discover")}
|
||||
rel="noreferrer"
|
||||
className="text-xl text-white tabbable rounded-full"
|
||||
>
|
||||
<IconPatch
|
||||
icon={Icons.RISING_STAR}
|
||||
clickable
|
||||
downsized
|
||||
navigation
|
||||
/>
|
||||
</a>
|
||||
) : (
|
||||
<a
|
||||
onClick={() => handleClick("/")}
|
||||
rel="noreferrer"
|
||||
className="text-lg text-white tabbable rounded-full"
|
||||
>
|
||||
<IconPatch
|
||||
icon={Icons.SEARCH}
|
||||
clickable
|
||||
downsized
|
||||
navigation
|
||||
/>
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
<div className="relative pointer-events-auto">
|
||||
<LinksDropdown>
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ export function SectionHeading(props: SectionHeadingProps) {
|
|||
return (
|
||||
<div className={props.className}>
|
||||
<div className="mb-5 flex items-center">
|
||||
<p className="flex flex-1 items-center font-bold uppercase text-type-text">
|
||||
<p className="flex flex-1 items-center font-bold uppercase text-type-text z-[19]">
|
||||
{props.icon ? (
|
||||
<span className="mr-2 text-xl">
|
||||
<Icon icon={props.icon} />
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ export function WideContainer(props: WideContainerProps) {
|
|||
className={`mx-auto max-w-full px-8 ${
|
||||
props.ultraWide
|
||||
? "w-[1300px] xl:w-[18000px] 3xl:w-[2400px] 4xl:w-[2800px]"
|
||||
: "w-[900px] xl:w-[1200px] 3xl:w-[1600px] 4xl:w-[1800px]"
|
||||
: "w-[950px] xl:w-[1250px] 3xl:w-[1650px] 4xl:w-[1850px]"
|
||||
} ${props.classNames || ""}`}
|
||||
>
|
||||
{props.children}
|
||||
|
|
|
|||
|
|
@ -254,13 +254,8 @@ function MediaCardContent({
|
|||
<div>
|
||||
<div className="absolute inset-0 flex flex-col items-center justify-start gap-y-2 pt-8 md:pt-12">
|
||||
<Button
|
||||
theme="secondary"
|
||||
className={classNames(
|
||||
"w-[86%] md:w-[90%] 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",
|
||||
"bg-buttons-purple bg-opacity-15 hover:bg-buttons-purpleHover hover:bg-opacity-25 backdrop-blur-md",
|
||||
"border-2 border-gray-400 border-opacity-20",
|
||||
)}
|
||||
theme="glass"
|
||||
className="w-[86%] md:w-[90%] h-12 rounded-lg px-4 py-2 my-1"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
|
@ -272,13 +267,8 @@ function MediaCardContent({
|
|||
|
||||
{canLink ? (
|
||||
<Button
|
||||
theme="secondary"
|
||||
className={classNames(
|
||||
"w-[86%] md:w-[90%] 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",
|
||||
"bg-buttons-purple bg-opacity-15 hover:bg-buttons-purpleHover hover:bg-opacity-25 backdrop-blur-md",
|
||||
"border-2 border-gray-400 border-opacity-20",
|
||||
)}
|
||||
theme="glass"
|
||||
className="w-[86%] md:w-[90%] h-12 rounded-lg px-4 py-2 my-1"
|
||||
href={link}
|
||||
onClick={handleCopyClick}
|
||||
>
|
||||
|
|
@ -294,13 +284,8 @@ function MediaCardContent({
|
|||
) : null}
|
||||
|
||||
<Button
|
||||
theme="secondary"
|
||||
className={classNames(
|
||||
"w-[86%] md:w-[90%] 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",
|
||||
"bg-buttons-purple bg-opacity-15 hover:bg-buttons-purpleHover hover:bg-opacity-25 backdrop-blur-md",
|
||||
"border-2 border-gray-400 border-opacity-20",
|
||||
)}
|
||||
theme="glass"
|
||||
className="w-[86%] md:w-[90%] h-12 rounded-lg px-4 py-2 my-1"
|
||||
onClick={() => setOverlayVisible(false)}
|
||||
>
|
||||
{t("home.mediaCard.close")}
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ export const MediaGrid = forwardRef<HTMLDivElement, MediaGridProps>(
|
|||
(props, ref) => {
|
||||
return (
|
||||
<div
|
||||
className="grid grid-cols-2 gap-6 sm:grid-cols-3 md:grid-cols-4 xl:grid-cols-6 3xl:grid-cols-8 4xl:grid-cols-10"
|
||||
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"
|
||||
ref={ref}
|
||||
>
|
||||
{props.children}
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ import { Icon, Icons } from "@/components/Icon";
|
|||
import { hasAired } from "@/components/player/utils/aired";
|
||||
import { useBookmarkStore } from "@/stores/bookmarks";
|
||||
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";
|
||||
|
|
@ -151,6 +152,9 @@ function DetailsContent({
|
|||
const removeBookmark = useBookmarkStore((s) => s.removeBookmark);
|
||||
const bookmarks = useBookmarkStore((s) => s.bookmarks);
|
||||
const isBookmarked = !!bookmarks[data.id?.toString() ?? ""];
|
||||
const enableImageLogos = usePreferencesStore(
|
||||
(state) => state.enableImageLogos,
|
||||
);
|
||||
|
||||
const showProgress = useMemo(() => {
|
||||
if (!data.id) return null;
|
||||
|
|
@ -533,23 +537,28 @@ function DetailsContent({
|
|||
)}
|
||||
</div>
|
||||
{/* Content */}
|
||||
<div className="px-6 pb-6 mt-[-70px] flex-grow">
|
||||
<div
|
||||
className={classNames(
|
||||
"px-6 pb-6 flex-grow relative",
|
||||
enableImageLogos ? "-mt-32" : "",
|
||||
)}
|
||||
>
|
||||
{/* Title and Genres Row */}
|
||||
<div className="pb-2">
|
||||
{data.logoUrl ? (
|
||||
<div className="pb-4 relative z-10">
|
||||
{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"
|
||||
className="max-w-[12rem] md:max-w-[20rem] max-h-[14vh] object-contain drop-shadow-lg bg-transparent"
|
||||
style={{ background: "none" }}
|
||||
/>
|
||||
) : (
|
||||
<h3 className="text-2xl font-bold text-white z-[999]">
|
||||
<h3 className="text-3xl font-bold text-white z-[999]">
|
||||
{data.title}
|
||||
</h3>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-col sm:flex-row justify-between items-start mb-6">
|
||||
<div className="flex flex-col sm:flex-row justify-between items-start mb-6 relative z-10">
|
||||
<div className="flex items-center gap-4">
|
||||
{!minimal && (
|
||||
<Button
|
||||
|
|
@ -571,13 +580,7 @@ function DetailsContent({
|
|||
}
|
||||
}
|
||||
}}
|
||||
theme="secondary"
|
||||
className={classNames(
|
||||
"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",
|
||||
"bg-buttons-purple bg-opacity-45 hover:bg-buttons-purpleHover hover:bg-opacity-25 backdrop-blur-md",
|
||||
"border-2 border-gray-400 border-opacity-20",
|
||||
)}
|
||||
theme="glass"
|
||||
>
|
||||
<Icon icon={Icons.PLAY} className="text-white" />
|
||||
<span className="text-white text-sm pr-1">
|
||||
|
|
|
|||
|
|
@ -339,44 +339,42 @@ export function CaptionSettingsView({
|
|||
<Menu.FieldTitle>
|
||||
{t("settings.subtitles.textStyle.title") || "Font Style"}
|
||||
</Menu.FieldTitle>
|
||||
<div className="w-64">
|
||||
<Dropdown
|
||||
options={[
|
||||
{
|
||||
id: "default",
|
||||
name: t("settings.subtitles.textStyle.default"),
|
||||
},
|
||||
{
|
||||
id: "raised",
|
||||
name: t("settings.subtitles.textStyle.raised"),
|
||||
},
|
||||
{
|
||||
id: "depressed",
|
||||
name: t("settings.subtitles.textStyle.depressed"),
|
||||
},
|
||||
{
|
||||
id: "uniform",
|
||||
name: t("settings.subtitles.textStyle.uniform"),
|
||||
},
|
||||
{
|
||||
id: "dropShadow",
|
||||
name: t("settings.subtitles.textStyle.dropShadow"),
|
||||
},
|
||||
]}
|
||||
selectedItem={{
|
||||
id: styling.fontStyle,
|
||||
name:
|
||||
t(`settings.subtitles.textStyle.${styling.fontStyle}`) ||
|
||||
styling.fontStyle,
|
||||
}}
|
||||
setSelectedItem={(item) =>
|
||||
handleStylingChange({
|
||||
...styling,
|
||||
fontStyle: item.id,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<Dropdown
|
||||
options={[
|
||||
{
|
||||
id: "default",
|
||||
name: t("settings.subtitles.textStyle.default"),
|
||||
},
|
||||
{
|
||||
id: "raised",
|
||||
name: t("settings.subtitles.textStyle.raised"),
|
||||
},
|
||||
{
|
||||
id: "depressed",
|
||||
name: t("settings.subtitles.textStyle.depressed"),
|
||||
},
|
||||
{
|
||||
id: "uniform",
|
||||
name: t("settings.subtitles.textStyle.uniform"),
|
||||
},
|
||||
{
|
||||
id: "dropShadow",
|
||||
name: t("settings.subtitles.textStyle.dropShadow"),
|
||||
},
|
||||
]}
|
||||
selectedItem={{
|
||||
id: styling.fontStyle,
|
||||
name:
|
||||
t(`settings.subtitles.textStyle.${styling.fontStyle}`) ||
|
||||
styling.fontStyle,
|
||||
}}
|
||||
setSelectedItem={(item) =>
|
||||
handleStylingChange({
|
||||
...styling,
|
||||
fontStyle: item.id,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex justify-between items-center">
|
||||
<Menu.FieldTitle>
|
||||
|
|
|
|||
|
|
@ -54,11 +54,13 @@ export function useSettingsState(
|
|||
enableThumbnails: boolean,
|
||||
enableAutoplay: boolean,
|
||||
enableDiscover: boolean,
|
||||
enableFeatured: boolean,
|
||||
enableDetailsModal: boolean,
|
||||
sourceOrder: string[],
|
||||
enableSourceOrder: boolean,
|
||||
proxyTmdb: boolean,
|
||||
enableSkipCredits: boolean,
|
||||
enableImageLogos: boolean,
|
||||
) {
|
||||
const [proxyUrlsState, setProxyUrls, resetProxyUrls, proxyUrlsChanged] =
|
||||
useDerived(proxyUrls);
|
||||
|
|
@ -116,12 +118,24 @@ export function useSettingsState(
|
|||
resetEnableDiscover,
|
||||
enableDiscoverChanged,
|
||||
] = useDerived(enableDiscover);
|
||||
const [
|
||||
enableFeaturedState,
|
||||
setEnableFeaturedState,
|
||||
resetEnableFeatured,
|
||||
enableFeaturedChanged,
|
||||
] = useDerived(enableFeatured);
|
||||
const [
|
||||
enableDetailsModalState,
|
||||
setEnableDetailsModalState,
|
||||
resetEnableDetailsModal,
|
||||
enableDetailsModalChanged,
|
||||
] = useDerived(enableDetailsModal);
|
||||
const [
|
||||
enableImageLogosState,
|
||||
setEnableImageLogosState,
|
||||
resetEnableImageLogos,
|
||||
enableImageLogosChanged,
|
||||
] = useDerived(enableImageLogos);
|
||||
const [
|
||||
sourceOrderState,
|
||||
setSourceOrderState,
|
||||
|
|
@ -151,7 +165,9 @@ export function useSettingsState(
|
|||
resetEnableAutoplay();
|
||||
resetEnableSkipCredits();
|
||||
resetEnableDiscover();
|
||||
resetEnableFeatured();
|
||||
resetEnableDetailsModal();
|
||||
resetEnableImageLogos();
|
||||
resetSourceOrder();
|
||||
resetEnableSourceOrder();
|
||||
resetProxyTmdb();
|
||||
|
|
@ -170,7 +186,9 @@ export function useSettingsState(
|
|||
enableAutoplayChanged ||
|
||||
enableSkipCreditsChanged ||
|
||||
enableDiscoverChanged ||
|
||||
enableFeaturedChanged ||
|
||||
enableDetailsModalChanged ||
|
||||
enableImageLogosChanged ||
|
||||
sourceOrderChanged ||
|
||||
enableSourceOrderChanged ||
|
||||
proxyTmdbChanged;
|
||||
|
|
@ -238,11 +256,21 @@ export function useSettingsState(
|
|||
set: setEnableDiscoverState,
|
||||
changed: enableDiscoverChanged,
|
||||
},
|
||||
enableFeatured: {
|
||||
state: enableFeaturedState,
|
||||
set: setEnableFeaturedState,
|
||||
changed: enableFeaturedChanged,
|
||||
},
|
||||
enableDetailsModal: {
|
||||
state: enableDetailsModalState,
|
||||
set: setEnableDetailsModalState,
|
||||
changed: enableDetailsModalChanged,
|
||||
},
|
||||
enableImageLogos: {
|
||||
state: enableImageLogosState,
|
||||
set: setEnableImageLogosState,
|
||||
changed: enableImageLogosChanged,
|
||||
},
|
||||
sourceOrder: {
|
||||
state: sourceOrderState,
|
||||
set: setSourceOrderState,
|
||||
|
|
|
|||
|
|
@ -9,6 +9,8 @@ import { useModal } from "@/components/overlays/Modal";
|
|||
import { useDebounce } from "@/hooks/useDebounce";
|
||||
import { useRandomTranslation } from "@/hooks/useRandomTranslation";
|
||||
import { useSearchQuery } from "@/hooks/useSearchQuery";
|
||||
import { FeaturedCarousel } from "@/pages/discover/components/FeaturedCarousel";
|
||||
import type { FeaturedMedia } from "@/pages/discover/components/FeaturedCarousel";
|
||||
import DiscoverContent from "@/pages/discover/discoverContent";
|
||||
import { HomeLayout } from "@/pages/layouts/HomeLayout";
|
||||
import { BookmarksPart } from "@/pages/parts/home/BookmarksPart";
|
||||
|
|
@ -16,10 +18,12 @@ import { HeroPart } from "@/pages/parts/home/HeroPart";
|
|||
import { WatchingPart } from "@/pages/parts/home/WatchingPart";
|
||||
import { SearchListPart } from "@/pages/parts/search/SearchListPart";
|
||||
import { SearchLoadingPart } from "@/pages/parts/search/SearchLoadingPart";
|
||||
import { conf } from "@/setup/config";
|
||||
import { usePreferencesStore } from "@/stores/preferences";
|
||||
import { MediaItem } from "@/utils/mediaTypes";
|
||||
|
||||
import { Button } from "./About";
|
||||
import { AdsPart } from "./parts/home/AdsPart";
|
||||
|
||||
function useSearch(search: string) {
|
||||
const [searching, setSearching] = useState<boolean>(false);
|
||||
|
|
@ -29,6 +33,9 @@ function useSearch(search: string) {
|
|||
useEffect(() => {
|
||||
setSearching(search !== "");
|
||||
setLoading(search !== "");
|
||||
if (search !== "") {
|
||||
window.scrollTo(0, 0);
|
||||
}
|
||||
}, [search]);
|
||||
useEffect(() => {
|
||||
setLoading(false);
|
||||
|
|
@ -54,17 +61,16 @@ export function HomePage() {
|
|||
const [showBookmarks, setShowBookmarks] = useState(false);
|
||||
const [showWatching, setShowWatching] = useState(false);
|
||||
const [detailsData, setDetailsData] = useState<any>();
|
||||
// const [isLoadingDetails, setIsLoadingDetails] = useState(false);
|
||||
const detailsModal = useModal("details");
|
||||
const enableDiscover = usePreferencesStore((state) => state.enableDiscover);
|
||||
const enableFeatured = usePreferencesStore((state) => state.enableFeatured);
|
||||
|
||||
const handleClick = (path: To) => {
|
||||
window.scrollTo(0, 0);
|
||||
navigate(path);
|
||||
};
|
||||
|
||||
const enableDiscover = usePreferencesStore((state) => state.enableDiscover);
|
||||
|
||||
const handleShowDetails = async (media: MediaItem) => {
|
||||
const handleShowDetails = async (media: MediaItem | FeaturedMedia) => {
|
||||
setDetailsData({
|
||||
id: Number(media.id),
|
||||
type: media.type === "movie" ? "movie" : "show",
|
||||
|
|
@ -89,7 +95,7 @@ export function HomePage() {
|
|||
<span className="font-bold select-none">READ</span>
|
||||
</IconPill>
|
||||
</a> */}
|
||||
<div className="mb-16 sm:mb-24">
|
||||
<div className="mb-2">
|
||||
<Helmet>
|
||||
<style type="text/css">{`
|
||||
html, body {
|
||||
|
|
@ -190,8 +196,27 @@ export function HomePage() {
|
|||
</div>
|
||||
</FancyModal>
|
||||
*/}
|
||||
|
||||
<HeroPart searchParams={searchParams} setIsSticky={setShowBg} />
|
||||
{enableFeatured ? (
|
||||
<FeaturedCarousel
|
||||
forcedCategory="editorpicks"
|
||||
onShowDetails={handleShowDetails}
|
||||
searching={s.searching}
|
||||
shorter
|
||||
>
|
||||
<HeroPart
|
||||
searchParams={searchParams}
|
||||
setIsSticky={setShowBg}
|
||||
isInFeatured
|
||||
/>
|
||||
</FeaturedCarousel>
|
||||
) : (
|
||||
<HeroPart
|
||||
searchParams={searchParams}
|
||||
setIsSticky={setShowBg}
|
||||
showTitle
|
||||
/>
|
||||
)}
|
||||
{conf().SHOW_AD ? <AdsPart /> : null}
|
||||
</div>
|
||||
<WideContainer>
|
||||
{s.loading ? (
|
||||
|
|
@ -214,24 +239,35 @@ export function HomePage() {
|
|||
</div>
|
||||
)}
|
||||
{!(showBookmarks || showWatching) && !enableDiscover ? (
|
||||
<div className="flex flex-col translate-y-[-30px] items-center justify-center">
|
||||
<div className="flex flex-col translate-y-[-30px] items-center justify-center pt-20">
|
||||
<p className="text-[18.5px] pb-3">{emptyText}</p>
|
||||
</div>
|
||||
) : null}
|
||||
{enableDiscover &&
|
||||
(enableFeatured ? (
|
||||
<div className="pb-4" />
|
||||
) : showBookmarks || showWatching ? (
|
||||
<div className="pb-10" />
|
||||
) : (
|
||||
<div className="pb-20" />
|
||||
))}
|
||||
{/* there... perfect. */}
|
||||
</WideContainer>
|
||||
{enableDiscover ? (
|
||||
<div className="pt-12 w-full max-w-[100dvw] justify-center items-center">
|
||||
{enableDiscover && !search ? (
|
||||
<div className="w-full max-w-[100dvw] justify-center items-center">
|
||||
<DiscoverContent />
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col justify-center items-center h-40 space-y-4">
|
||||
<div className="flex flex-col items-center justify-center">
|
||||
<Button
|
||||
className="px-py p-[0.35em] mt-3 rounded-xl text-type-dimmed box-content text-[18px] bg-largeCard-background justify-center items-center"
|
||||
onClick={() => handleClick("/discover")}
|
||||
>
|
||||
{t("home.search.discover")}
|
||||
</Button>
|
||||
{!search && (
|
||||
<Button
|
||||
className="px-py p-[0.35em] mt-3 rounded-xl text-type-dimmed box-content text-[18px] bg-largeCard-background justify-center items-center"
|
||||
onClick={() => handleClick("/discover")}
|
||||
>
|
||||
{t("home.search.discover")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -151,11 +151,17 @@ export function SettingsPage() {
|
|||
const enableDiscover = usePreferencesStore((s) => s.enableDiscover);
|
||||
const setEnableDiscover = usePreferencesStore((s) => s.setEnableDiscover);
|
||||
|
||||
const enableFeatured = usePreferencesStore((s) => s.enableFeatured);
|
||||
const setEnableFeatured = usePreferencesStore((s) => s.setEnableFeatured);
|
||||
|
||||
const enableDetailsModal = usePreferencesStore((s) => s.enableDetailsModal);
|
||||
const setEnableDetailsModal = usePreferencesStore(
|
||||
(s) => s.setEnableDetailsModal,
|
||||
);
|
||||
|
||||
const enableImageLogos = usePreferencesStore((s) => s.enableImageLogos);
|
||||
const setEnableImageLogos = usePreferencesStore((s) => s.setEnableImageLogos);
|
||||
|
||||
const enableSourceOrder = usePreferencesStore((s) => s.enableSourceOrder);
|
||||
const setEnableSourceOrder = usePreferencesStore(
|
||||
(s) => s.setEnableSourceOrder,
|
||||
|
|
@ -201,11 +207,13 @@ export function SettingsPage() {
|
|||
enableThumbnails,
|
||||
enableAutoplay,
|
||||
enableDiscover,
|
||||
enableFeatured,
|
||||
enableDetailsModal,
|
||||
sourceOrder,
|
||||
enableSourceOrder,
|
||||
proxyTmdb,
|
||||
enableSkipCredits,
|
||||
enableImageLogos,
|
||||
);
|
||||
|
||||
const availableSources = useMemo(() => {
|
||||
|
|
@ -279,7 +287,9 @@ export function SettingsPage() {
|
|||
setEnableAutoplay(state.enableAutoplay.state);
|
||||
setEnableSkipCredits(state.enableSkipCredits.state);
|
||||
setEnableDiscover(state.enableDiscover.state);
|
||||
setEnableFeatured(state.enableFeatured.state);
|
||||
setEnableDetailsModal(state.enableDetailsModal.state);
|
||||
setEnableImageLogos(state.enableImageLogos.state);
|
||||
setSourceOrder(state.sourceOrder.state);
|
||||
setAppLanguage(state.appLanguage.state);
|
||||
setTheme(state.theme.state);
|
||||
|
|
@ -313,7 +323,9 @@ export function SettingsPage() {
|
|||
setEnableAutoplay,
|
||||
setEnableSkipCredits,
|
||||
setEnableDiscover,
|
||||
setEnableFeatured,
|
||||
setEnableDetailsModal,
|
||||
setEnableImageLogos,
|
||||
setSourceOrder,
|
||||
setAppLanguage,
|
||||
setTheme,
|
||||
|
|
@ -382,8 +394,12 @@ export function SettingsPage() {
|
|||
setTheme={setThemeWithPreview}
|
||||
enableDiscover={state.enableDiscover.state}
|
||||
setEnableDiscover={state.enableDiscover.set}
|
||||
enableFeatured={state.enableFeatured.state}
|
||||
setEnableFeatured={state.enableFeatured.set}
|
||||
enableDetailsModal={state.enableDetailsModal.state}
|
||||
setEnableDetailsModal={state.enableDetailsModal.set}
|
||||
enableImageLogos={state.enableImageLogos.state}
|
||||
setEnableImageLogos={state.enableImageLogos.set}
|
||||
/>
|
||||
</div>
|
||||
<div id="settings-captions" className="mt-28">
|
||||
|
|
|
|||
|
|
@ -1,13 +1,33 @@
|
|||
import { useEffect, useState } from "react";
|
||||
import { Helmet } from "react-helmet-async";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import DiscoverContent from "@/pages/discover/discoverContent";
|
||||
import { DetailsModal } from "@/components/overlays/DetailsModal";
|
||||
import { useModal } from "@/components/overlays/Modal";
|
||||
|
||||
import { SubPageLayout } from "../layouts/SubPageLayout";
|
||||
import { FeaturedCarousel } from "./components/FeaturedCarousel";
|
||||
import type { FeaturedMedia } from "./components/FeaturedCarousel";
|
||||
import DiscoverContent from "./discoverContent";
|
||||
import { PageTitle } from "../parts/util/PageTitle";
|
||||
|
||||
export function Discover() {
|
||||
const { t } = useTranslation();
|
||||
const [detailsData, setDetailsData] = useState<any>();
|
||||
const detailsModal = useModal("discover-details");
|
||||
|
||||
// Clear details data when modal is closed
|
||||
useEffect(() => {
|
||||
if (!detailsModal.isShown) {
|
||||
setDetailsData(undefined);
|
||||
}
|
||||
}, [detailsModal.isShown]);
|
||||
|
||||
const handleShowDetails = (media: FeaturedMedia) => {
|
||||
setDetailsData({
|
||||
id: Number(media.id),
|
||||
type: media.type,
|
||||
});
|
||||
detailsModal.show();
|
||||
};
|
||||
|
||||
return (
|
||||
<SubPageLayout>
|
||||
|
|
@ -23,27 +43,17 @@ export function Discover() {
|
|||
|
||||
<PageTitle subpage k="global.pages.discover" />
|
||||
|
||||
<div className="relative w-full max-w-screen-xl mx-auto px-4 text-center mt-12 mb-12">
|
||||
<div
|
||||
className="absolute inset-0 mx-auto h-[400px] max-w-[800px] rounded-full blur-[100px] opacity-20 transform -translate-y-[100px] pointer-events-none"
|
||||
style={{
|
||||
backgroundImage: `linear-gradient(to right, rgba(var(--colors-buttons-purpleHover)), rgba(var(--colors-progress-filled)))`,
|
||||
}}
|
||||
/>
|
||||
<h1
|
||||
className="relative text-4xl md:text-5xl font-extrabold text-transparent bg-clip-text z-10"
|
||||
style={{
|
||||
backgroundImage: `linear-gradient(to right, rgba(var(--colors-buttons-purpleHover)), rgba(var(--colors-progress-filled)))`,
|
||||
}}
|
||||
>
|
||||
{t("discover.page.title")}
|
||||
</h1>
|
||||
<p className="relative text-lg mt-4 text-gray-400 z-10">
|
||||
{t("discover.page.subtitle")}
|
||||
</p>
|
||||
<div className="!mt-[-170px]">
|
||||
{/* Featured Carousel */}
|
||||
<FeaturedCarousel onShowDetails={handleShowDetails} />
|
||||
</div>
|
||||
|
||||
<DiscoverContent />
|
||||
{/* Main Content */}
|
||||
<div className="relative z-20">
|
||||
<DiscoverContent />
|
||||
</div>
|
||||
|
||||
{detailsData && <DetailsModal id="discover-details" data={detailsData} />}
|
||||
</SubPageLayout>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
669
src/pages/discover/MoreContent.tsx
Normal file
669
src/pages/discover/MoreContent.tsx
Normal file
|
|
@ -0,0 +1,669 @@
|
|||
import { Listbox } from "@headlessui/react";
|
||||
import React, { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import { useWindowSize } from "react-use";
|
||||
|
||||
import { get } from "@/backend/metadata/tmdb";
|
||||
import { Button } from "@/components/buttons/Button";
|
||||
import { Dropdown, OptionItem } from "@/components/form/Dropdown";
|
||||
import { Icon, Icons } from "@/components/Icon";
|
||||
import { WideContainer } from "@/components/layout/WideContainer";
|
||||
import { MediaCard } from "@/components/media/MediaCard";
|
||||
import { MediaGrid } from "@/components/media/MediaGrid";
|
||||
import { DetailsModal } from "@/components/overlays/DetailsModal";
|
||||
import { useModal } from "@/components/overlays/Modal";
|
||||
import { Heading1 } from "@/components/utils/Text";
|
||||
import { SubPageLayout } from "@/pages/layouts/SubPageLayout";
|
||||
import { conf } from "@/setup/config";
|
||||
import { useDiscoverStore } from "@/stores/discover";
|
||||
import { useLanguageStore } from "@/stores/language";
|
||||
import { ProgressMediaItem, useProgressStore } from "@/stores/progress";
|
||||
import { getTmdbLanguageCode } from "@/utils/language";
|
||||
import { MediaItem } from "@/utils/mediaTypes";
|
||||
|
||||
import { Genre, categories, tvCategories } from "./common";
|
||||
import {
|
||||
EDITOR_PICKS_MOVIES,
|
||||
EDITOR_PICKS_TV_SHOWS,
|
||||
MOVIE_PROVIDERS,
|
||||
TV_PROVIDERS,
|
||||
} from "./discoverContent";
|
||||
|
||||
interface MoreContentProps {
|
||||
onShowDetails?: (media: MediaItem) => void;
|
||||
}
|
||||
|
||||
interface Provider {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export function MoreContent({ onShowDetails }: MoreContentProps) {
|
||||
const { category, type: contentType, id, mediaType } = useParams();
|
||||
const [medias, setMedias] = useState<any[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [loadingMore, setLoadingMore] = useState(false);
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [hasMore, setHasMore] = useState(true);
|
||||
const [detailsData, setDetailsData] = useState<any>();
|
||||
const [genres, setGenres] = useState<Genre[]>([]);
|
||||
const [tvGenres, setTVGenres] = useState<Genre[]>([]);
|
||||
const [selectedProvider, setSelectedProvider] = useState<OptionItem | null>(
|
||||
null,
|
||||
);
|
||||
const [selectedGenre, setSelectedGenre] = useState<OptionItem | null>(null);
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const detailsModal = useModal("discover-details");
|
||||
const { lastView } = useDiscoverStore();
|
||||
const userLanguage = useLanguageStore.getState().language;
|
||||
const formattedLanguage = getTmdbLanguageCode(userLanguage);
|
||||
const [sourceTitle, setSourceTitle] = useState("");
|
||||
const progressStore = useProgressStore();
|
||||
const { width: windowWidth } = useWindowSize();
|
||||
const [recommendationSources, setRecommendationSources] = useState<
|
||||
Array<{ id: string; title: string }>
|
||||
>([]);
|
||||
const [selectedRecommendationSource, setSelectedRecommendationSource] =
|
||||
useState<string>("");
|
||||
|
||||
const handleBack = () => {
|
||||
if (lastView) {
|
||||
navigate(lastView.url);
|
||||
window.scrollTo(0, lastView.scrollPosition);
|
||||
} else {
|
||||
navigate(-1);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
window.scrollTo(0, 0);
|
||||
}, []);
|
||||
|
||||
// Fetch genres when component mounts
|
||||
useEffect(() => {
|
||||
const fetchGenres = async () => {
|
||||
try {
|
||||
const [movieData, tvData] = await Promise.all([
|
||||
get<any>("/genre/movie/list", {
|
||||
api_key: conf().TMDB_READ_API_KEY,
|
||||
language: formattedLanguage,
|
||||
}),
|
||||
get<any>("/genre/tv/list", {
|
||||
api_key: conf().TMDB_READ_API_KEY,
|
||||
language: formattedLanguage,
|
||||
}),
|
||||
]);
|
||||
setGenres(movieData.genres);
|
||||
setTVGenres(tvData.genres);
|
||||
} catch (error) {
|
||||
console.error("Error fetching genres:", error);
|
||||
}
|
||||
};
|
||||
|
||||
fetchGenres();
|
||||
}, [formattedLanguage]);
|
||||
|
||||
const handleShowDetails = async (media: MediaItem) => {
|
||||
if (onShowDetails) {
|
||||
onShowDetails(media);
|
||||
return;
|
||||
}
|
||||
setDetailsData({
|
||||
id: Number(media.id),
|
||||
type: media.type === "movie" ? "movie" : "show",
|
||||
});
|
||||
detailsModal.show();
|
||||
};
|
||||
|
||||
const fetchContent = useCallback(
|
||||
async (page: number, append: boolean = false) => {
|
||||
try {
|
||||
const isTVShow = mediaType === "tv";
|
||||
let endpoint = "";
|
||||
|
||||
// Handle recommendations separately
|
||||
if (contentType === "recommendations") {
|
||||
// Get title from progress store instead of fetching details
|
||||
const progressItem = progressStore.items[id || ""];
|
||||
if (progressItem) {
|
||||
setSourceTitle(progressItem.title || "");
|
||||
}
|
||||
|
||||
// Get recommendations with proper page number
|
||||
const results = await get<any>(
|
||||
`/${isTVShow ? "tv" : "movie"}/${id}/recommendations`,
|
||||
{
|
||||
api_key: conf().TMDB_READ_API_KEY,
|
||||
language: formattedLanguage,
|
||||
page,
|
||||
},
|
||||
);
|
||||
|
||||
if (append) {
|
||||
setMedias((prev) => [...prev, ...results.results]);
|
||||
} else {
|
||||
setMedias(results.results);
|
||||
}
|
||||
setHasMore(page < results.total_pages);
|
||||
setCurrentPage(page);
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle editor picks separately
|
||||
if (category?.includes("editor-picks")) {
|
||||
const editorPicks = isTVShow
|
||||
? EDITOR_PICKS_TV_SHOWS
|
||||
: EDITOR_PICKS_MOVIES;
|
||||
|
||||
// Fetch details for all editor picks
|
||||
const promises = editorPicks.map((item) =>
|
||||
get<any>(`/${isTVShow ? "tv" : "movie"}/${item.id}`, {
|
||||
api_key: conf().TMDB_READ_API_KEY,
|
||||
language: formattedLanguage,
|
||||
}),
|
||||
);
|
||||
|
||||
const results = await Promise.all(promises);
|
||||
setMedias(results);
|
||||
setHasMore(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Determine the correct endpoint based on the type
|
||||
if (contentType === "category") {
|
||||
const categoryList = isTVShow ? tvCategories : categories;
|
||||
const categoryData = categoryList.find((c) => c.urlPath === id);
|
||||
if (categoryData) {
|
||||
endpoint = categoryData.endpoint;
|
||||
} else {
|
||||
endpoint = isTVShow ? "/discover/tv" : "/discover/movie";
|
||||
}
|
||||
} else {
|
||||
endpoint = isTVShow ? "/discover/tv" : "/discover/movie";
|
||||
}
|
||||
|
||||
const allResults: any[] = [];
|
||||
const pagesToFetch = 2; // Fetch 2 pages at a time
|
||||
|
||||
for (let i = 0; i < pagesToFetch; i += 1) {
|
||||
const currentPageNum = page + i;
|
||||
const params: any = {
|
||||
api_key: conf().TMDB_READ_API_KEY,
|
||||
language: formattedLanguage,
|
||||
page: currentPageNum,
|
||||
};
|
||||
|
||||
if (contentType === "provider") {
|
||||
params.with_watch_providers = id;
|
||||
params.watch_region = "US";
|
||||
} else if (contentType === "genre") {
|
||||
params.with_genres = id;
|
||||
}
|
||||
|
||||
const data = await get<any>(endpoint, params);
|
||||
allResults.push(...data.results);
|
||||
|
||||
// Check if we've reached the end
|
||||
if (currentPageNum >= data.total_pages) {
|
||||
setHasMore(false);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (append) {
|
||||
setMedias((prev) => [...prev, ...allResults]);
|
||||
} else {
|
||||
setMedias(allResults);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error fetching content:", error);
|
||||
}
|
||||
},
|
||||
[
|
||||
contentType,
|
||||
id,
|
||||
mediaType,
|
||||
category,
|
||||
formattedLanguage,
|
||||
progressStore.items,
|
||||
],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const loadInitialContent = async () => {
|
||||
setLoading(true);
|
||||
await fetchContent(1);
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
loadInitialContent();
|
||||
}, [contentType, id, mediaType, category, formattedLanguage, fetchContent]);
|
||||
|
||||
const handleLoadMore = async () => {
|
||||
setLoadingMore(true);
|
||||
const nextPage =
|
||||
contentType === "recommendations" ? currentPage + 1 : currentPage + 2;
|
||||
await fetchContent(nextPage, true);
|
||||
setCurrentPage(nextPage);
|
||||
setLoadingMore(false);
|
||||
};
|
||||
|
||||
const getDisplayTitle = () => {
|
||||
const isTVShow = mediaType === "tv";
|
||||
|
||||
if (contentType === "recommendations") {
|
||||
return t("discover.carousel.title.recommended", {
|
||||
title: sourceTitle,
|
||||
});
|
||||
}
|
||||
|
||||
if (category === "editor-picks-tv" || category === "editor-picks-movie") {
|
||||
return category === "editor-picks-tv"
|
||||
? t("discover.carousel.title.editorPicksShows")
|
||||
: t("discover.carousel.title.editorPicksMovies");
|
||||
}
|
||||
|
||||
if (!contentType || !id) return "";
|
||||
|
||||
if (contentType === "provider") {
|
||||
const providers = isTVShow ? TV_PROVIDERS : MOVIE_PROVIDERS;
|
||||
const provider = providers.find((p: Provider) => p.id === id);
|
||||
return isTVShow
|
||||
? t("discover.carousel.title.tvshowsOn", {
|
||||
provider: provider?.name,
|
||||
})
|
||||
: t("discover.carousel.title.moviesOn", {
|
||||
provider: provider?.name,
|
||||
});
|
||||
}
|
||||
|
||||
if (contentType === "genre") {
|
||||
const genreList = isTVShow ? tvGenres : genres;
|
||||
const genre = genreList.find((g: Genre) => g.id.toString() === id);
|
||||
return isTVShow
|
||||
? t("discover.carousel.title.genreShows", { genre: genre?.name || id })
|
||||
: t("discover.carousel.title.genreMovies", {
|
||||
genre: genre?.name || id,
|
||||
});
|
||||
}
|
||||
|
||||
if (contentType === "category") {
|
||||
const categoryList = isTVShow ? tvCategories : categories;
|
||||
const categoryData = categoryList.find((c) => c.urlPath === id);
|
||||
if (categoryData) {
|
||||
return isTVShow
|
||||
? t("discover.carousel.title.categoryShows", {
|
||||
category: categoryData.name,
|
||||
})
|
||||
: t("discover.carousel.title.categoryMovies", {
|
||||
category: categoryData.name,
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (contentType === "provider" && selectedProvider) {
|
||||
navigate(`/discover/more/provider/${selectedProvider.id}/${mediaType}`);
|
||||
} else if (contentType === "genre" && selectedGenre) {
|
||||
navigate(`/discover/more/genre/${selectedGenre.id}/${mediaType}`);
|
||||
}
|
||||
}, [selectedProvider, selectedGenre, contentType, mediaType, navigate]);
|
||||
|
||||
useEffect(() => {
|
||||
if (contentType === "provider" && id) {
|
||||
const providers = mediaType === "tv" ? TV_PROVIDERS : MOVIE_PROVIDERS;
|
||||
const provider = providers.find((p) => p.id === id);
|
||||
if (provider) {
|
||||
setSelectedProvider({ id: provider.id, name: provider.name });
|
||||
}
|
||||
} else if (contentType === "genre" && id) {
|
||||
const genreList = mediaType === "tv" ? tvGenres : genres;
|
||||
const genre = genreList.find((g) => g.id.toString() === id);
|
||||
if (genre) {
|
||||
setSelectedGenre({ id: genre.id.toString(), name: genre.name });
|
||||
}
|
||||
}
|
||||
}, [contentType, id, mediaType, genres, tvGenres]);
|
||||
|
||||
const providerButtons = useMemo(() => {
|
||||
if (contentType !== "provider")
|
||||
return { visibleButtons: [], dropdownButtons: [] };
|
||||
const providers = mediaType === "tv" ? TV_PROVIDERS : MOVIE_PROVIDERS;
|
||||
const visible =
|
||||
windowWidth > 850 ? providers.slice(0, 7) : providers.slice(0, 2);
|
||||
const dropdown =
|
||||
windowWidth > 850 ? providers.slice(5) : providers.slice(0);
|
||||
return { visibleButtons: visible, dropdownButtons: dropdown };
|
||||
}, [contentType, mediaType, windowWidth]);
|
||||
|
||||
const genreButtons = useMemo(() => {
|
||||
if (contentType !== "genre")
|
||||
return { visibleButtons: [], dropdownButtons: [] };
|
||||
const genreList = mediaType === "tv" ? tvGenres : genres;
|
||||
const visible =
|
||||
windowWidth > 850 ? genreList.slice(0, 7) : genreList.slice(0, 2);
|
||||
const dropdown =
|
||||
windowWidth > 850 ? genreList.slice(5) : genreList.slice(0);
|
||||
return { visibleButtons: visible, dropdownButtons: dropdown };
|
||||
}, [contentType, mediaType, windowWidth, tvGenres, genres]);
|
||||
|
||||
const renderProviderButtons = () => {
|
||||
if (contentType !== "provider") return null;
|
||||
const { visibleButtons, dropdownButtons } = providerButtons;
|
||||
|
||||
return (
|
||||
<div className="flex items-center space-x-2">
|
||||
{visibleButtons.map((provider) => (
|
||||
<button
|
||||
type="button"
|
||||
key={provider.id}
|
||||
onClick={() =>
|
||||
setSelectedProvider({ id: provider.id, name: provider.name })
|
||||
}
|
||||
className="px-3 py-1 text-sm rounded-full transition-colors whitespace-nowrap flex-shrink-0 bg-mediaCard-hoverBackground hover:bg-mediaCard-background"
|
||||
>
|
||||
{provider.name}
|
||||
</button>
|
||||
))}
|
||||
{dropdownButtons.length > 0 && (
|
||||
<div className="relative">
|
||||
<Dropdown
|
||||
selectedItem={selectedProvider || { id: "", name: "..." }}
|
||||
setSelectedItem={(item) => setSelectedProvider(item)}
|
||||
options={dropdownButtons.map((p) => ({ id: p.id, name: p.name }))}
|
||||
customButton={
|
||||
<button
|
||||
type="button"
|
||||
className="px-3 py-1 text-sm bg-mediaCard-hoverBackground hover:bg-mediaCard-background rounded-full transition-colors flex items-center gap-1"
|
||||
>
|
||||
<span>...</span>
|
||||
<Icon
|
||||
icon={Icons.UP_DOWN_ARROW}
|
||||
className="text-xs text-dropdown-secondary"
|
||||
/>
|
||||
</button>
|
||||
}
|
||||
side="right"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const renderGenreButtons = () => {
|
||||
if (contentType !== "genre") return null;
|
||||
const { visibleButtons, dropdownButtons } = genreButtons;
|
||||
|
||||
return (
|
||||
<div className="flex items-center space-x-2">
|
||||
{visibleButtons.map((genre) => (
|
||||
<button
|
||||
type="button"
|
||||
key={genre.id}
|
||||
onClick={() =>
|
||||
setSelectedGenre({ id: genre.id.toString(), name: genre.name })
|
||||
}
|
||||
className="px-3 py-1 text-sm rounded-full transition-colors whitespace-nowrap flex-shrink-0 bg-mediaCard-hoverBackground hover:bg-mediaCard-background"
|
||||
>
|
||||
{genre.name}
|
||||
</button>
|
||||
))}
|
||||
{dropdownButtons.length > 0 && (
|
||||
<div className="relative">
|
||||
<Dropdown
|
||||
selectedItem={selectedGenre || { id: "", name: "..." }}
|
||||
setSelectedItem={(item) => setSelectedGenre(item)}
|
||||
options={dropdownButtons.map((g) => ({
|
||||
id: g.id.toString(),
|
||||
name: g.name,
|
||||
}))}
|
||||
customButton={
|
||||
<button
|
||||
type="button"
|
||||
className="px-3 py-1 text-sm bg-mediaCard-hoverBackground hover:bg-mediaCard-background rounded-full transition-colors flex items-center gap-1"
|
||||
>
|
||||
<span>...</span>
|
||||
<Icon
|
||||
icon={Icons.UP_DOWN_ARROW}
|
||||
className="text-xs text-dropdown-secondary"
|
||||
/>
|
||||
</button>
|
||||
}
|
||||
side="right"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// Add effect to set up recommendation sources
|
||||
useEffect(() => {
|
||||
const setupRecommendationSources = async () => {
|
||||
if (
|
||||
contentType !== "recommendations" ||
|
||||
!progressStore.items ||
|
||||
Object.keys(progressStore.items).length === 0
|
||||
)
|
||||
return;
|
||||
|
||||
try {
|
||||
const progressItems = Object.entries(progressStore.items) as [
|
||||
string,
|
||||
ProgressMediaItem,
|
||||
][];
|
||||
const items = progressItems.filter(
|
||||
([_, item]) => item.type === (mediaType === "tv" ? "show" : "movie"),
|
||||
);
|
||||
|
||||
if (items.length > 0) {
|
||||
const sources = items.map(([itemId, item]) => ({
|
||||
id: itemId,
|
||||
title: item.title || "",
|
||||
}));
|
||||
setRecommendationSources(sources);
|
||||
|
||||
// Set initial source if not set
|
||||
if (!selectedRecommendationSource && sources.length > 0) {
|
||||
setSelectedRecommendationSource(sources[0].id);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error setting up recommendation sources:", error);
|
||||
}
|
||||
};
|
||||
|
||||
setupRecommendationSources();
|
||||
}, [
|
||||
contentType,
|
||||
mediaType,
|
||||
progressStore.items,
|
||||
selectedRecommendationSource,
|
||||
]);
|
||||
|
||||
// Add effect to handle recommendation source changes
|
||||
useEffect(() => {
|
||||
if (contentType === "recommendations" && selectedRecommendationSource) {
|
||||
navigate(
|
||||
`/discover/more/recommendations/${selectedRecommendationSource}/${mediaType}`,
|
||||
);
|
||||
}
|
||||
}, [selectedRecommendationSource, contentType, mediaType, navigate]);
|
||||
|
||||
const renderRecommendationSourceDropdown = () => {
|
||||
if (contentType !== "recommendations" || recommendationSources.length === 0)
|
||||
return null;
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="relative pr-4">
|
||||
<Dropdown
|
||||
selectedItem={
|
||||
recommendationSources.find(
|
||||
(s) => s.id === selectedRecommendationSource,
|
||||
)
|
||||
? {
|
||||
id: selectedRecommendationSource || "",
|
||||
name:
|
||||
recommendationSources.find(
|
||||
(s) => s.id === selectedRecommendationSource,
|
||||
)?.title || "",
|
||||
}
|
||||
: {
|
||||
id: "",
|
||||
name: recommendationSources[0]?.title || "",
|
||||
}
|
||||
}
|
||||
setSelectedItem={(item) => setSelectedRecommendationSource(item.id)}
|
||||
options={recommendationSources.map((source) => ({
|
||||
id: source.id,
|
||||
name: source.title,
|
||||
}))}
|
||||
customButton={
|
||||
<button
|
||||
type="button"
|
||||
className="px-2 py-1 text-sm bg-mediaCard-hoverBackground rounded-full hover:bg-mediaCard-background transition-colors flex items-center gap-1"
|
||||
>
|
||||
<span>{t("discover.carousel.change")}</span>
|
||||
<Icon
|
||||
icon={Icons.UP_DOWN_ARROW}
|
||||
className="text-xs text-dropdown-secondary"
|
||||
/>
|
||||
</button>
|
||||
}
|
||||
side="right"
|
||||
customMenu={
|
||||
<Listbox.Options static className="py-1">
|
||||
{recommendationSources.map((opt) => (
|
||||
<Listbox.Option
|
||||
className={({ active }) =>
|
||||
`cursor-pointer min-w-60 flex gap-4 items-center relative select-none py-2 px-4 mx-1 rounded-lg ${
|
||||
active
|
||||
? "bg-background-secondaryHover text-type-link"
|
||||
: "text-type-secondary"
|
||||
}`
|
||||
}
|
||||
key={opt.id}
|
||||
value={{ id: opt.id, name: opt.title }}
|
||||
>
|
||||
{({ selected }) => (
|
||||
<>
|
||||
<span
|
||||
className={`block ${selected ? "font-medium" : "font-normal"}`}
|
||||
>
|
||||
{opt.title}
|
||||
</span>
|
||||
{selected && (
|
||||
<Icon
|
||||
icon={Icons.CHECKMARK}
|
||||
className="text-xs text-type-link"
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Listbox.Option>
|
||||
))}
|
||||
</Listbox.Options>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<SubPageLayout>
|
||||
<WideContainer>
|
||||
<div className="animate-pulse">
|
||||
<div className="h-8 bg-gray-700 rounded w-1/4 mb-8" />
|
||||
<MediaGrid>
|
||||
{Array.from({ length: 20 }).map(() => (
|
||||
<div
|
||||
key={crypto.randomUUID()}
|
||||
className="aspect-[2/3] bg-gray-700 rounded-lg"
|
||||
/>
|
||||
))}
|
||||
</MediaGrid>
|
||||
</div>
|
||||
</WideContainer>
|
||||
</SubPageLayout>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<SubPageLayout>
|
||||
<WideContainer>
|
||||
<div className="flex items-center justify-between gap-8">
|
||||
<Heading1 className="text-2xl font-bold text-white">
|
||||
{getDisplayTitle()}
|
||||
</Heading1>
|
||||
{renderRecommendationSourceDropdown()}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4 mb-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleBack}
|
||||
className="flex items-center text-white hover:text-gray-300 transition-colors"
|
||||
>
|
||||
<Icon className="text-xl" icon={Icons.ARROW_LEFT} />
|
||||
<span className="ml-2">{t("discover.page.back")}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{renderProviderButtons()}
|
||||
{renderGenreButtons()}
|
||||
|
||||
<div className="grid grid-cols-2 gap-8 sm:grid-cols-3 md:grid-cols-4 xl:grid-cols-6 3xl:grid-cols-8 4xl:grid-cols-10 pt-8">
|
||||
{medias.map((media) => (
|
||||
<div
|
||||
key={media.id}
|
||||
style={{ userSelect: "none" }}
|
||||
onContextMenu={(e: React.MouseEvent<HTMLDivElement>) =>
|
||||
e.preventDefault()
|
||||
}
|
||||
>
|
||||
<MediaCard
|
||||
media={{
|
||||
id: media.id.toString(),
|
||||
title: media.title || media.name || "",
|
||||
poster: `https://image.tmdb.org/t/p/w342${media.poster_path}`,
|
||||
type: mediaType === "tv" ? "show" : "movie",
|
||||
year:
|
||||
mediaType === "tv"
|
||||
? media.first_air_date
|
||||
? parseInt(media.first_air_date.split("-")[0], 10)
|
||||
: undefined
|
||||
: media.release_date
|
||||
? parseInt(media.release_date.split("-")[0], 10)
|
||||
: undefined,
|
||||
}}
|
||||
onShowDetails={handleShowDetails}
|
||||
linkable={!category?.includes("upcoming")}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{hasMore && (
|
||||
<div className="flex justify-center mt-8">
|
||||
<Button
|
||||
theme="purple"
|
||||
onClick={handleLoadMore}
|
||||
disabled={loadingMore}
|
||||
>
|
||||
{loadingMore
|
||||
? t("discover.page.loading")
|
||||
: t("discover.page.loadMore")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</WideContainer>
|
||||
{detailsData && <DetailsModal id="discover-details" data={detailsData} />}
|
||||
</SubPageLayout>
|
||||
);
|
||||
}
|
||||
|
|
@ -29,31 +29,49 @@ export interface Genre {
|
|||
export interface Category {
|
||||
name: string;
|
||||
endpoint: string;
|
||||
urlPath: string;
|
||||
mediaType: "movie" | "tv";
|
||||
}
|
||||
|
||||
// Define the categories
|
||||
export const categories: Category[] = [
|
||||
{
|
||||
name: "Now Playing",
|
||||
endpoint: "/movie/now_playing?language=en-US",
|
||||
endpoint: "/movie/now_playing",
|
||||
urlPath: "now-playing",
|
||||
mediaType: "movie",
|
||||
},
|
||||
{
|
||||
name: "Top Rated",
|
||||
endpoint: "/movie/top_rated?language=en-US",
|
||||
endpoint: "/movie/top_rated",
|
||||
urlPath: "top-rated",
|
||||
mediaType: "movie",
|
||||
},
|
||||
{
|
||||
name: "Most Popular",
|
||||
endpoint: "/movie/popular?language=en-US",
|
||||
endpoint: "/movie/popular",
|
||||
urlPath: "popular",
|
||||
mediaType: "movie",
|
||||
},
|
||||
];
|
||||
|
||||
export const tvCategories: Category[] = [
|
||||
{
|
||||
name: "On The Air",
|
||||
endpoint: "/tv/on_the_air",
|
||||
urlPath: "on-air",
|
||||
mediaType: "tv",
|
||||
},
|
||||
{
|
||||
name: "Top Rated",
|
||||
endpoint: "/tv/top_rated?language=en-US",
|
||||
endpoint: "/tv/top_rated",
|
||||
urlPath: "top-rated",
|
||||
mediaType: "tv",
|
||||
},
|
||||
{
|
||||
name: "Most Popular",
|
||||
endpoint: "/tv/popular?language=en-US",
|
||||
endpoint: "/tv/popular",
|
||||
urlPath: "popular",
|
||||
mediaType: "tv",
|
||||
},
|
||||
];
|
||||
|
|
|
|||
36
src/pages/discover/components/DiscoverNavigation.tsx
Normal file
36
src/pages/discover/components/DiscoverNavigation.tsx
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
import { useTranslation } from "react-i18next";
|
||||
|
||||
interface DiscoverNavigationProps {
|
||||
selectedCategory: string;
|
||||
onCategoryChange: (category: string) => void;
|
||||
}
|
||||
|
||||
export function DiscoverNavigation({
|
||||
selectedCategory,
|
||||
onCategoryChange,
|
||||
}: DiscoverNavigationProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<div className="pb-4 w-full max-w-screen-xl mx-auto">
|
||||
<div className="relative flex justify-center">
|
||||
<div className="flex space-x-4">
|
||||
{["movies", "tvshows", "editorpicks"].map((category) => (
|
||||
<button
|
||||
key={category}
|
||||
type="button"
|
||||
className={`text-xl md:text-2xl font-bold p-2 bg-transparent text-center rounded-full cursor-pointer flex items-center transition-transform duration-200 ${
|
||||
selectedCategory === category
|
||||
? "transform scale-105 text-type-link"
|
||||
: "text-type-secondary"
|
||||
}`}
|
||||
onClick={() => onCategoryChange(category)}
|
||||
>
|
||||
{t(`discover.tabs.${category}`)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
521
src/pages/discover/components/FeaturedCarousel.tsx
Normal file
521
src/pages/discover/components/FeaturedCarousel.tsx
Normal file
|
|
@ -0,0 +1,521 @@
|
|||
import classNames from "classnames";
|
||||
import { t } from "i18next";
|
||||
import { ReactNode, useEffect, useRef, useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useWindowSize } from "react-use";
|
||||
|
||||
import { get, getMediaLogo } from "@/backend/metadata/tmdb";
|
||||
import { TMDBContentTypes } from "@/backend/metadata/types/tmdb";
|
||||
import { Button } from "@/components/buttons/Button";
|
||||
import { Icon, Icons } from "@/components/Icon";
|
||||
import { Movie, TVShow } from "@/pages/discover/common";
|
||||
import { conf } from "@/setup/config";
|
||||
import { useDiscoverStore } from "@/stores/discover";
|
||||
import { useLanguageStore } from "@/stores/language";
|
||||
import { usePreferencesStore } from "@/stores/preferences";
|
||||
import { getTmdbLanguageCode } from "@/utils/language";
|
||||
|
||||
import { EDITOR_PICKS_MOVIES, EDITOR_PICKS_TV_SHOWS } from "../discoverContent";
|
||||
import { RandomMovieButton } from "./RandomMovieButton";
|
||||
|
||||
export interface FeaturedMedia extends Partial<Movie & TVShow> {
|
||||
children?: ReactNode;
|
||||
backdrop_path: string;
|
||||
overview: string;
|
||||
title?: string;
|
||||
name?: string;
|
||||
type: "movie" | "show";
|
||||
}
|
||||
|
||||
interface FeaturedCarouselProps {
|
||||
onShowDetails: (media: FeaturedMedia) => void;
|
||||
children?: ReactNode;
|
||||
searching?: boolean;
|
||||
shorter?: boolean;
|
||||
forcedCategory?: "movies" | "tvshows" | "editorpicks";
|
||||
}
|
||||
|
||||
function FeaturedCarouselSkeleton({ shorter }: { shorter?: boolean }) {
|
||||
return (
|
||||
<div
|
||||
className={classNames(
|
||||
"relative w-full transition-[height] duration-300 ease-in-out",
|
||||
shorter ? "h-[75vh]" : "h-[75vh] md:h-[100vh]",
|
||||
)}
|
||||
>
|
||||
<div className="relative w-full h-full overflow-hidden">
|
||||
<div
|
||||
className="absolute inset-0 bg-gray-800"
|
||||
style={{
|
||||
maskImage:
|
||||
"linear-gradient(to top, rgba(0, 0, 0, 0), rgba(0, 0, 0, 1) 500px)",
|
||||
WebkitMaskImage:
|
||||
"linear-gradient(to top, rgba(0, 0, 0, 0), rgba(0, 0, 0, 1) 500px)",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Navigation Buttons Skeleton */}
|
||||
<div className="absolute left-4 top-1/2 -translate-y-1/2 z-20 p-2 rounded-full bg-black/30">
|
||||
<div className="w-8 h-8 bg-gray-600 rounded-full animate-pulse" />
|
||||
</div>
|
||||
<div className="absolute right-4 top-1/2 -translate-y-1/2 z-20 p-2 rounded-full bg-black/30">
|
||||
<div className="w-8 h-8 bg-gray-600 rounded-full animate-pulse" />
|
||||
</div>
|
||||
|
||||
{/* Navigation Dots Skeleton */}
|
||||
<div className="absolute bottom-8 left-1/2 -translate-x-1/2 z-[19] flex gap-2">
|
||||
{[1, 2, 3, 4, 5, 6, 7, 8, 9, 10].map((i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="w-2.5 h-2.5 rounded-full bg-gray-600 animate-pulse"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Content Overlay Skeleton */}
|
||||
<div className="absolute inset-0 flex items-end pb-20 z-10">
|
||||
<div className="container mx-auto px-8 md:px-4">
|
||||
<div className="max-w-3xl">
|
||||
<div className="h-12 w-48 bg-gray-600 rounded animate-pulse mb-6" />
|
||||
<div className="space-y-2 mb-6">
|
||||
<div className="h-4 bg-gray-600 rounded animate-pulse w-3/4" />
|
||||
<div className="h-4 bg-gray-600 rounded animate-pulse w-1/2" />
|
||||
</div>
|
||||
<div className="flex gap-4 justify-center items-center sm:justify-start">
|
||||
<div className="h-10 w-32 bg-gray-600 rounded animate-pulse" />
|
||||
<div className="h-10 w-32 bg-gray-600 rounded animate-pulse" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function FeaturedCarousel({
|
||||
onShowDetails,
|
||||
children,
|
||||
searching,
|
||||
shorter,
|
||||
forcedCategory,
|
||||
}: FeaturedCarouselProps) {
|
||||
const { selectedCategory } = useDiscoverStore();
|
||||
const effectiveCategory = forcedCategory || selectedCategory;
|
||||
const [currentIndex, setCurrentIndex] = useState(0);
|
||||
const [isAutoPlaying, setIsAutoPlaying] = useState(true);
|
||||
const [media, setMedia] = useState<FeaturedMedia[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [logoUrl, setLogoUrl] = useState<string | undefined>();
|
||||
const [touchStart, setTouchStart] = useState<number | null>(null);
|
||||
const [touchEnd, setTouchEnd] = useState<number | null>(null);
|
||||
const logoFetchController = useRef<AbortController | null>(null);
|
||||
const autoPlayInterval = useRef<NodeJS.Timeout | null>(null);
|
||||
const navigate = useNavigate();
|
||||
const enableImageLogos = usePreferencesStore(
|
||||
(state) => state.enableImageLogos,
|
||||
);
|
||||
const userLanguage = useLanguageStore.getState().language;
|
||||
const formattedLanguage = getTmdbLanguageCode(userLanguage);
|
||||
const { width: windowWidth, height: windowHeight } = useWindowSize();
|
||||
|
||||
const SLIDE_QUANTITY = 10;
|
||||
const SLIDE_QUANTITY_EDITOR_PICKS_MOVIES = 6;
|
||||
const SLIDE_QUANTITY_EDITOR_PICKS_TV_SHOWS = 4;
|
||||
const SLIDE_DURATION = 8000;
|
||||
|
||||
// Fetch featured media
|
||||
useEffect(() => {
|
||||
const fetchFeaturedMedia = async () => {
|
||||
setIsLoading(true);
|
||||
setLogoUrl(undefined); // Clear logo when media changes
|
||||
if (logoFetchController.current) {
|
||||
logoFetchController.current.abort(); // Cancel any in-progress logo fetches
|
||||
}
|
||||
try {
|
||||
if (effectiveCategory === "movies") {
|
||||
const data = await get<any>("/movie/popular", {
|
||||
api_key: conf().TMDB_READ_API_KEY,
|
||||
language: formattedLanguage,
|
||||
});
|
||||
setMedia(
|
||||
data.results.slice(0, SLIDE_QUANTITY).map((movie: any) => ({
|
||||
...movie,
|
||||
type: "movie" as const,
|
||||
})),
|
||||
);
|
||||
} else if (effectiveCategory === "tvshows") {
|
||||
const data = await get<any>("/tv/popular", {
|
||||
api_key: conf().TMDB_READ_API_KEY,
|
||||
language: formattedLanguage,
|
||||
});
|
||||
setMedia(
|
||||
data.results.slice(0, SLIDE_QUANTITY).map((show: any) => ({
|
||||
...show,
|
||||
type: "show" as const,
|
||||
})),
|
||||
);
|
||||
} else if (effectiveCategory === "editorpicks") {
|
||||
// Fetch editor picks movies
|
||||
const moviePromises = EDITOR_PICKS_MOVIES.slice(
|
||||
0,
|
||||
SLIDE_QUANTITY_EDITOR_PICKS_MOVIES,
|
||||
).map((item) =>
|
||||
get<any>(`/movie/${item.id}`, {
|
||||
api_key: conf().TMDB_READ_API_KEY,
|
||||
language: formattedLanguage,
|
||||
}),
|
||||
);
|
||||
|
||||
// Fetch editor picks TV shows
|
||||
const showPromises = EDITOR_PICKS_TV_SHOWS.slice(
|
||||
0,
|
||||
SLIDE_QUANTITY_EDITOR_PICKS_TV_SHOWS,
|
||||
).map((item) =>
|
||||
get<any>(`/tv/${item.id}`, {
|
||||
api_key: conf().TMDB_READ_API_KEY,
|
||||
language: formattedLanguage,
|
||||
}),
|
||||
);
|
||||
|
||||
const [movieResults, showResults] = await Promise.all([
|
||||
Promise.all(moviePromises),
|
||||
Promise.all(showPromises),
|
||||
]);
|
||||
|
||||
const movies = movieResults.map((movie) => ({
|
||||
...movie,
|
||||
type: "movie" as const,
|
||||
}));
|
||||
const shows = showResults.map((show) => ({
|
||||
...show,
|
||||
type: "show" as const,
|
||||
}));
|
||||
|
||||
// Combine and shuffle
|
||||
const combined = [...movies, ...shows].sort(
|
||||
() => 0.5 - Math.random(),
|
||||
);
|
||||
setMedia(combined);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error fetching featured media:", error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchFeaturedMedia();
|
||||
}, [formattedLanguage, effectiveCategory]);
|
||||
|
||||
const handlePrevSlide = () => {
|
||||
setCurrentIndex((prev) => (prev - 1 + media.length) % media.length);
|
||||
// Reset autoplay timer
|
||||
if (autoPlayInterval.current) {
|
||||
clearInterval(autoPlayInterval.current);
|
||||
}
|
||||
if (isAutoPlaying) {
|
||||
autoPlayInterval.current = setInterval(() => {
|
||||
setCurrentIndex((prev) => (prev + 1) % media.length);
|
||||
}, 5000);
|
||||
}
|
||||
};
|
||||
|
||||
const handleNextSlide = () => {
|
||||
setCurrentIndex((prev) => (prev + 1) % media.length);
|
||||
// Reset autoplay timer
|
||||
if (autoPlayInterval.current) {
|
||||
clearInterval(autoPlayInterval.current);
|
||||
}
|
||||
if (isAutoPlaying) {
|
||||
autoPlayInterval.current = setInterval(() => {
|
||||
setCurrentIndex((prev) => (prev + 1) % media.length);
|
||||
}, 5000);
|
||||
}
|
||||
};
|
||||
|
||||
const handleTouchStart = (e: React.TouchEvent) => {
|
||||
setTouchStart(e.targetTouches[0].clientX);
|
||||
};
|
||||
|
||||
const handleTouchMove = (e: React.TouchEvent) => {
|
||||
setTouchEnd(e.targetTouches[0].clientX);
|
||||
};
|
||||
|
||||
const handleTouchEnd = () => {
|
||||
if (!touchStart || !touchEnd) return;
|
||||
|
||||
const distance = touchStart - touchEnd;
|
||||
const minSwipeDistance = 50;
|
||||
|
||||
if (Math.abs(distance) > minSwipeDistance) {
|
||||
if (distance > 0) {
|
||||
handleNextSlide();
|
||||
} else {
|
||||
handlePrevSlide();
|
||||
}
|
||||
}
|
||||
|
||||
setTouchStart(null);
|
||||
setTouchEnd(null);
|
||||
};
|
||||
|
||||
// Fetch logo when current media changes
|
||||
useEffect(() => {
|
||||
const fetchLogo = async () => {
|
||||
// Cancel any in-progress logo fetch
|
||||
if (logoFetchController.current) {
|
||||
logoFetchController.current.abort();
|
||||
}
|
||||
|
||||
// Create new abort controller for this fetch
|
||||
logoFetchController.current = new AbortController();
|
||||
|
||||
const currentMediaId = media[currentIndex]?.id;
|
||||
if (!currentMediaId) {
|
||||
setLogoUrl(undefined);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const logo = await getMediaLogo(
|
||||
currentMediaId.toString(),
|
||||
media[currentIndex].type === "movie"
|
||||
? TMDBContentTypes.MOVIE
|
||||
: TMDBContentTypes.TV,
|
||||
);
|
||||
// Only update if this is still the current media
|
||||
if (media[currentIndex]?.id === currentMediaId) {
|
||||
setLogoUrl(logo);
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
if (error instanceof Error && error.name === "AbortError") {
|
||||
// Ignore abort errors
|
||||
return;
|
||||
}
|
||||
console.error("Error fetching logo:", error);
|
||||
setLogoUrl(undefined);
|
||||
}
|
||||
};
|
||||
|
||||
fetchLogo();
|
||||
|
||||
return () => {
|
||||
if (logoFetchController.current) {
|
||||
logoFetchController.current.abort();
|
||||
}
|
||||
};
|
||||
}, [currentIndex, media]);
|
||||
|
||||
// Cleanup on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (logoFetchController.current) {
|
||||
logoFetchController.current.abort();
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (isAutoPlaying && media.length > 0) {
|
||||
autoPlayInterval.current = setInterval(() => {
|
||||
setCurrentIndex((prev) => (prev + 1) % media.length);
|
||||
}, SLIDE_DURATION);
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (autoPlayInterval.current) {
|
||||
clearInterval(autoPlayInterval.current);
|
||||
}
|
||||
};
|
||||
}, [isAutoPlaying, media.length]);
|
||||
|
||||
if (isLoading) {
|
||||
return <FeaturedCarouselSkeleton shorter={shorter} />;
|
||||
}
|
||||
|
||||
if (media.length === 0) {
|
||||
return <FeaturedCarouselSkeleton shorter={shorter} />;
|
||||
}
|
||||
|
||||
const currentMedia = media[currentIndex];
|
||||
const mediaTitle = currentMedia.title || currentMedia.name;
|
||||
|
||||
let searchClasses = "";
|
||||
if (searching) searchClasses = "opacity-0 transition-opacity duration-300";
|
||||
else searchClasses = "opacity-100 transition-opacity duration-300";
|
||||
|
||||
return (
|
||||
<div
|
||||
className={classNames(
|
||||
"relative w-full transition-[height] duration-300 ease-in-out",
|
||||
searching
|
||||
? "h-24"
|
||||
: shorter
|
||||
? windowHeight > 600
|
||||
? "h-[40rem] md:h-[85vh]"
|
||||
: "h-[100vh]"
|
||||
: "h-[40rem] md:h-[100vh]",
|
||||
)}
|
||||
onTouchStart={handleTouchStart}
|
||||
onTouchMove={handleTouchMove}
|
||||
onTouchEnd={handleTouchEnd}
|
||||
>
|
||||
<div
|
||||
className={classNames(
|
||||
"relative w-full h-full overflow-hidden",
|
||||
searchClasses,
|
||||
)}
|
||||
>
|
||||
{media.map((item, index) => (
|
||||
<div
|
||||
key={item.id}
|
||||
className={`absolute inset-0 transition-opacity duration-1000 ${
|
||||
index === currentIndex ? "opacity-100" : "opacity-0"
|
||||
}`}
|
||||
style={{
|
||||
backgroundImage: `url(https://image.tmdb.org/t/p/original${item.backdrop_path})`,
|
||||
backgroundSize: "cover",
|
||||
backgroundPosition: "center top",
|
||||
maskImage:
|
||||
"linear-gradient(to top, rgba(0, 0, 0, 0), rgba(0, 0, 0, 1) 700px)",
|
||||
WebkitMaskImage:
|
||||
"linear-gradient(to top, rgba(0, 0, 0, 0), rgba(0, 0, 0, 1) 700px)",
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Navigation Buttons */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={handlePrevSlide}
|
||||
className={classNames(
|
||||
"absolute left-4 top-1/2 -translate-y-1/2 z-20 p-2 rounded-full bg-black/30 hover:bg-black/50 transition-colors",
|
||||
searchClasses,
|
||||
)}
|
||||
aria-label="Previous slide"
|
||||
>
|
||||
<Icon icon={Icons.CHEVRON_LEFT} className="text-white w-8 h-8" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleNextSlide}
|
||||
className={classNames(
|
||||
"absolute right-4 top-1/2 -translate-y-1/2 z-20 p-2 rounded-full bg-black/30 hover:bg-black/50 transition-colors",
|
||||
searchClasses,
|
||||
)}
|
||||
aria-label="Next slide"
|
||||
>
|
||||
<Icon icon={Icons.CHEVRON_RIGHT} className="text-white w-8 h-8" />
|
||||
</button>
|
||||
|
||||
{/* Navigation Dots */}
|
||||
<div
|
||||
className={classNames(
|
||||
"absolute bottom-8 left-1/2 -translate-x-1/2 z-[19] flex gap-2",
|
||||
searchClasses,
|
||||
)}
|
||||
>
|
||||
{media.map((item, index) => (
|
||||
<button
|
||||
key={`dot-${item.id}`}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setCurrentIndex(index);
|
||||
// Reset autoplay timer when clicking dots
|
||||
if (autoPlayInterval.current) {
|
||||
clearInterval(autoPlayInterval.current);
|
||||
}
|
||||
if (isAutoPlaying) {
|
||||
autoPlayInterval.current = setInterval(() => {
|
||||
setCurrentIndex((prev) => (prev + 1) % media.length);
|
||||
}, 5000);
|
||||
}
|
||||
}}
|
||||
className={`w-2.5 h-2.5 rounded-full transition-all ${
|
||||
index === currentIndex
|
||||
? "bg-white scale-125"
|
||||
: "bg-white/50 hover:bg-white/75"
|
||||
}`}
|
||||
aria-label={`Go to slide ${index + 1}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Content Overlay */}
|
||||
<div
|
||||
className={classNames(
|
||||
"absolute inset-0 flex items-end pb-20 z-10",
|
||||
searchClasses,
|
||||
)}
|
||||
>
|
||||
<div className="container mx-auto px-8 md:px-4 flex justify-between items-end w-full">
|
||||
<div className="max-w-3xl">
|
||||
{logoUrl && enableImageLogos ? (
|
||||
<img
|
||||
src={logoUrl}
|
||||
alt={mediaTitle}
|
||||
className="max-w-[14rem] md:max-w-[22rem] max-h-[20vh] object-contain drop-shadow-lg bg-transparent mb-6"
|
||||
style={{ background: "none" }}
|
||||
/>
|
||||
) : (
|
||||
<h1 className="text-4xl md:text-6xl font-bold text-white mb-4">
|
||||
{mediaTitle}
|
||||
</h1>
|
||||
)}
|
||||
<p className="text-lg text-white mb-6 line-clamp-3 md:line-clamp-4">
|
||||
{currentMedia.overview}
|
||||
</p>
|
||||
<div
|
||||
className="flex gap-4 justify-center items-center sm:justify-start"
|
||||
onMouseEnter={() => setIsAutoPlaying(false)}
|
||||
onMouseLeave={() => setIsAutoPlaying(true)}
|
||||
>
|
||||
<Button
|
||||
onClick={() =>
|
||||
navigate(
|
||||
`/media/tmdb-${currentMedia.type}-${currentMedia.id}-${mediaTitle?.toLowerCase().replace(/[^a-z0-9]+/g, "-")}`,
|
||||
)
|
||||
}
|
||||
theme="secondary"
|
||||
className="w-full sm:w-auto text-base"
|
||||
>
|
||||
<Icon icon={Icons.PLAY} className="text-white" />
|
||||
<span className="text-white">
|
||||
{t("discover.featured.playNow")}
|
||||
</span>
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => onShowDetails(currentMedia)}
|
||||
theme="secondary"
|
||||
className="w-full sm:w-auto text-base"
|
||||
>
|
||||
<Icon
|
||||
icon={Icons.CIRCLE_QUESTION}
|
||||
className="text-white scale-100"
|
||||
/>
|
||||
<span className="text-white">
|
||||
{t("discover.featured.moreInfo")}
|
||||
</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="hidden lg:block">
|
||||
<RandomMovieButton />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{children && (
|
||||
<div
|
||||
className={classNames(
|
||||
"absolute inset-0 pointer-events-none",
|
||||
windowWidth > 1280 ? "pt-0" : "pt-14",
|
||||
)}
|
||||
>
|
||||
<div className="pointer-events-auto z-50">{children}</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,23 +1,36 @@
|
|||
import { useEffect, useState } from "react";
|
||||
|
||||
import { Category, Genre, Media } from "@/pages/discover/common";
|
||||
import { get } from "@/backend/metadata/tmdb";
|
||||
import { useIntersectionObserver } from "@/pages/discover/hooks/useIntersectionObserver";
|
||||
import { useLazyTMDBData } from "@/pages/discover/hooks/useTMDBData";
|
||||
import { MediaItem } from "@/utils/mediaTypes";
|
||||
|
||||
import { MediaCarousel } from "./MediaCarousel";
|
||||
import {
|
||||
Category,
|
||||
Genre,
|
||||
Media,
|
||||
Movie,
|
||||
TVShow,
|
||||
categories,
|
||||
tvCategories,
|
||||
} from "../common";
|
||||
|
||||
interface LazyMediaCarouselProps {
|
||||
category?: Category | null;
|
||||
genre?: Genre | null;
|
||||
category?: Category;
|
||||
genre?: Genre;
|
||||
mediaType: "movie" | "tv";
|
||||
isMobile: boolean;
|
||||
carouselRefs: React.MutableRefObject<{
|
||||
[key: string]: HTMLDivElement | null;
|
||||
}>;
|
||||
preloadedMedia?: Media[];
|
||||
title?: string;
|
||||
onShowDetails?: (media: MediaItem) => void;
|
||||
preloadedMedia?: Movie[] | TVShow[];
|
||||
genreId?: number;
|
||||
title?: string;
|
||||
relatedButtons?: Array<{ name: string; id: string }>;
|
||||
onButtonClick?: (id: string, name: string) => void;
|
||||
moreContent?: boolean;
|
||||
}
|
||||
|
||||
export function LazyMediaCarousel({
|
||||
|
|
@ -26,11 +39,20 @@ export function LazyMediaCarousel({
|
|||
mediaType,
|
||||
isMobile,
|
||||
carouselRefs,
|
||||
preloadedMedia,
|
||||
title,
|
||||
onShowDetails,
|
||||
preloadedMedia,
|
||||
genreId,
|
||||
title,
|
||||
relatedButtons,
|
||||
onButtonClick,
|
||||
moreContent,
|
||||
}: LazyMediaCarouselProps) {
|
||||
const [medias, setMedias] = useState<Media[]>([]);
|
||||
const [loading, setLoading] = useState(!preloadedMedia);
|
||||
|
||||
const categoryData = (mediaType === "movie" ? categories : tvCategories).find(
|
||||
(c: Category) => c.name === (category?.name || genre?.name || title || ""),
|
||||
);
|
||||
|
||||
// Use intersection observer to detect when this component is visible
|
||||
const { targetRef, isIntersecting } = useIntersectionObserver(
|
||||
|
|
@ -38,7 +60,7 @@ export function LazyMediaCarousel({
|
|||
);
|
||||
|
||||
// Use the lazy loading hook only if we don't have preloaded media
|
||||
const { media, isLoading } = useLazyTMDBData(
|
||||
const { media } = useLazyTMDBData(
|
||||
!preloadedMedia ? genre || null : null,
|
||||
!preloadedMedia ? category || null : null,
|
||||
mediaType,
|
||||
|
|
@ -49,21 +71,48 @@ export function LazyMediaCarousel({
|
|||
useEffect(() => {
|
||||
if (preloadedMedia) {
|
||||
setMedias(preloadedMedia);
|
||||
setLoading(false);
|
||||
} else if (media.length > 0) {
|
||||
setMedias(media);
|
||||
setLoading(false);
|
||||
}
|
||||
}, [media, preloadedMedia]);
|
||||
|
||||
const categoryName = title || category?.name || genre?.name || "";
|
||||
// Only fetch category content if we don't have preloaded media
|
||||
useEffect(() => {
|
||||
if (preloadedMedia || !categoryData) return;
|
||||
|
||||
const fetchContent = async () => {
|
||||
try {
|
||||
const data = await get<any>(categoryData.endpoint, {
|
||||
api_key: process.env.TMDB_READ_API_KEY,
|
||||
language: "en-US",
|
||||
});
|
||||
setMedias(data.results);
|
||||
} catch (error) {
|
||||
console.error("Error fetching content:", error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchContent();
|
||||
}, [categoryData, preloadedMedia]);
|
||||
|
||||
const categoryName = category?.name || genre?.name || title || "";
|
||||
const categorySlug = `${categoryName.toLowerCase().replace(/[^a-z0-9]+/g, "-")}-${mediaType}`;
|
||||
|
||||
// Test intersection observer
|
||||
// useEffect(() => {
|
||||
// // eslint-disable-next-line no-console
|
||||
// console.log(
|
||||
// `Carousel ${categoryName}: ${isIntersecting ? "loaded ✅" : "unloaded ❌"}`,
|
||||
// );
|
||||
// }, [isIntersecting, categoryName]);
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-between ml-2 md:ml-8 mt-2">
|
||||
<div className="flex gap-4 items-center">
|
||||
<h2 className="text-2xl cursor-default font-bold text-white md:text-2xl pl-5 text-balance">
|
||||
{categoryName}
|
||||
</h2>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div ref={targetRef as React.RefObject<HTMLDivElement>}>
|
||||
|
|
@ -75,6 +124,15 @@ export function LazyMediaCarousel({
|
|||
isMobile={isMobile}
|
||||
carouselRefs={carouselRefs}
|
||||
onShowDetails={onShowDetails}
|
||||
genreId={genreId}
|
||||
relatedButtons={relatedButtons}
|
||||
onButtonClick={onButtonClick}
|
||||
moreContent={moreContent}
|
||||
moreLink={
|
||||
categoryData
|
||||
? `/discover/more/category/${categoryData.urlPath}/${categoryData.mediaType}`
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<div className="relative overflow-hidden carousel-container">
|
||||
|
|
@ -84,7 +142,7 @@ export function LazyMediaCarousel({
|
|||
</h2>
|
||||
<div className="flex whitespace-nowrap pt-0 pb-4 overflow-auto scrollbar rounded-xl overflow-y-hidden h-[300px] animate-pulse bg-background-secondary/20">
|
||||
<div className="w-full text-center flex items-center justify-center">
|
||||
{isLoading ? "Loading..." : ""}
|
||||
Loading...
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,9 +1,18 @@
|
|||
import { Listbox } from "@headlessui/react";
|
||||
import React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Link } from "react-router-dom";
|
||||
import { useWindowSize } from "react-use";
|
||||
|
||||
import { Dropdown, OptionItem } from "@/components/form/Dropdown";
|
||||
import { Icon, Icons } from "@/components/Icon";
|
||||
import { MediaCard } from "@/components/media/MediaCard";
|
||||
import { Flare } from "@/components/utils/Flare";
|
||||
import { Media } from "@/pages/discover/common";
|
||||
import { useDiscoverStore } from "@/stores/discover";
|
||||
import { MediaItem } from "@/utils/mediaTypes";
|
||||
|
||||
import { MOVIE_PROVIDERS, TV_PROVIDERS } from "../discoverContent";
|
||||
import { CarouselNavButtons } from "./CarouselNavButtons";
|
||||
|
||||
interface MediaCarouselProps {
|
||||
|
|
@ -15,6 +24,14 @@ interface MediaCarouselProps {
|
|||
[key: string]: HTMLDivElement | null;
|
||||
}>;
|
||||
onShowDetails?: (media: MediaItem) => void;
|
||||
genreId?: number;
|
||||
moreContent?: boolean;
|
||||
moreLink?: string;
|
||||
relatedButtons?: Array<{ name: string; id: string }>;
|
||||
onButtonClick?: (id: string, name: string) => void;
|
||||
recommendationSources?: Array<{ id: string; title: string }>;
|
||||
selectedRecommendationSource?: string;
|
||||
onRecommendationSourceChange?: (id: string) => void;
|
||||
}
|
||||
|
||||
function MediaCardSkeleton() {
|
||||
|
|
@ -28,6 +45,36 @@ function MediaCardSkeleton() {
|
|||
);
|
||||
}
|
||||
|
||||
function MoreCard({ link }: { link: string }) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<div 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">
|
||||
<Link to={link} className="block">
|
||||
<Flare.Base className="group -m-[0.705em] h-[20rem] hover:scale-95 transition-all rounded-xl bg-background-main duration-300 hover:bg-mediaCard-hoverBackground tabbable">
|
||||
<Flare.Light
|
||||
flareSize={300}
|
||||
cssColorVar="--colors-mediaCard-hoverAccent"
|
||||
backgroundClass="bg-mediaCard-hoverBackground duration-100"
|
||||
className="rounded-xl bg-background-main group-hover:opacity-100"
|
||||
/>
|
||||
<Flare.Child className="pointer-events-auto h-[20rem] relative mb-2 p-[0.4em] transition-transform duration-300">
|
||||
<div className="flex absolute inset-0 flex-col items-center justify-center">
|
||||
<Icon
|
||||
icon={Icons.ARROW_RIGHT}
|
||||
className="text-4xl mb-2 transition-transform duration-300"
|
||||
/>
|
||||
<span className="text-sm text-center px-2">
|
||||
{t("discover.carousel.more")}
|
||||
</span>
|
||||
</div>
|
||||
</Flare.Child>
|
||||
</Flare.Base>
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function MediaCarousel({
|
||||
medias,
|
||||
category,
|
||||
|
|
@ -35,8 +82,21 @@ export function MediaCarousel({
|
|||
isMobile,
|
||||
carouselRefs,
|
||||
onShowDetails,
|
||||
genreId,
|
||||
moreContent,
|
||||
moreLink,
|
||||
relatedButtons,
|
||||
onButtonClick,
|
||||
recommendationSources,
|
||||
selectedRecommendationSource,
|
||||
onRecommendationSourceChange,
|
||||
}: MediaCarouselProps) {
|
||||
const { t } = useTranslation();
|
||||
const { width: windowWidth } = useWindowSize();
|
||||
const { setLastView } = useDiscoverStore();
|
||||
const [selectedGenre, setSelectedGenre] = React.useState<OptionItem | null>(
|
||||
null,
|
||||
);
|
||||
const categorySlug = `${category.toLowerCase().replace(/[^a-z0-9]+/g, "-")}-${isTVShow ? "tv" : "movie"}`;
|
||||
const browser = !!window.chrome;
|
||||
let isScrolling = false;
|
||||
|
|
@ -81,7 +141,36 @@ export function MediaCarousel({
|
|||
}
|
||||
|
||||
if (categoryName === "Editor Picks") {
|
||||
return t("discover.carousel.title.editorPicks");
|
||||
return isTVShow
|
||||
? t("discover.carousel.title.editorPicksShows")
|
||||
: t("discover.carousel.title.editorPicksMovies");
|
||||
}
|
||||
|
||||
if (
|
||||
categoryName.includes("Movies on") ||
|
||||
categoryName.includes("Shows on")
|
||||
) {
|
||||
const providerName = categoryName.split(" on ")[1];
|
||||
const providers = isTVShowCondition ? TV_PROVIDERS : MOVIE_PROVIDERS;
|
||||
const provider = providers.find(
|
||||
(p) => p.name.toLowerCase() === providerName.toLowerCase(),
|
||||
);
|
||||
|
||||
if (provider) {
|
||||
return isTVShowCondition
|
||||
? t("discover.carousel.title.tvshowsOn", { provider: provider.name })
|
||||
: t("discover.carousel.title.moviesOn", { provider: provider.name });
|
||||
}
|
||||
// If provider not found, fall back to using the raw provider name
|
||||
return isTVShowCondition
|
||||
? t("discover.carousel.title.tvshowsOn", { provider: providerName })
|
||||
: t("discover.carousel.title.moviesOn", { provider: providerName });
|
||||
}
|
||||
|
||||
if (categoryName.includes("Because You Watched")) {
|
||||
return t("discover.carousel.title.recommended", {
|
||||
title: categoryName.split("Because You Watched:")[1],
|
||||
});
|
||||
}
|
||||
|
||||
return isTVShowCondition
|
||||
|
|
@ -101,15 +190,208 @@ export function MediaCarousel({
|
|||
|
||||
const SKELETON_COUNT = 10;
|
||||
|
||||
const { visibleButtons, dropdownButtons } = React.useMemo(() => {
|
||||
if (!relatedButtons) return { visibleButtons: [], dropdownButtons: [] };
|
||||
|
||||
const visible =
|
||||
windowWidth > 850
|
||||
? relatedButtons.slice(0, 5)
|
||||
: relatedButtons.slice(0, 0);
|
||||
|
||||
const dropdown =
|
||||
windowWidth > 850 ? relatedButtons.slice(5) : relatedButtons.slice(0);
|
||||
|
||||
return { visibleButtons: visible, dropdownButtons: dropdown };
|
||||
}, [relatedButtons, windowWidth]);
|
||||
|
||||
const activeButton = relatedButtons?.find(
|
||||
(btn) => btn.name === category.split(" on ")[1] || btn.name === category,
|
||||
);
|
||||
|
||||
const dropdownOptions: OptionItem[] = dropdownButtons.map((button) => ({
|
||||
id: button.id,
|
||||
name: button.name,
|
||||
}));
|
||||
|
||||
React.useEffect(() => {
|
||||
if (
|
||||
activeButton &&
|
||||
!visibleButtons.find((btn) => btn.id === activeButton.id)
|
||||
) {
|
||||
setSelectedGenre({ id: activeButton.id, name: activeButton.name });
|
||||
}
|
||||
}, [activeButton, visibleButtons]);
|
||||
|
||||
const handleMoreClick = () => {
|
||||
setLastView({
|
||||
url: window.location.pathname,
|
||||
scrollPosition: window.scrollY,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<h2 className="ml-2 md:ml-8 mt-2 text-2xl cursor-default font-bold text-white md:text-2xl mx-auto pl-5 text-balance">
|
||||
{displayCategory}
|
||||
</h2>
|
||||
<div className="relative overflow-hidden carousel-container">
|
||||
<div className="flex items-center justify-between ml-2 md:ml-8 mt-2">
|
||||
<div className="flex flex-col">
|
||||
<div className="flex items-center gap-4">
|
||||
<h2 className="text-2xl cursor-default font-bold text-white md:text-2xl pl-5 text-balance">
|
||||
{displayCategory}
|
||||
</h2>
|
||||
{recommendationSources &&
|
||||
recommendationSources.length > 0 &&
|
||||
onRecommendationSourceChange && (
|
||||
<div className="relative pr-4">
|
||||
<Dropdown
|
||||
selectedItem={
|
||||
recommendationSources.find(
|
||||
(s) => s.id === selectedRecommendationSource,
|
||||
)
|
||||
? {
|
||||
id: selectedRecommendationSource || "",
|
||||
name:
|
||||
recommendationSources.find(
|
||||
(s) => s.id === selectedRecommendationSource,
|
||||
)?.title || "",
|
||||
}
|
||||
: {
|
||||
id: "",
|
||||
name: recommendationSources[0]?.title || "",
|
||||
}
|
||||
}
|
||||
setSelectedItem={(item) =>
|
||||
onRecommendationSourceChange(item.id)
|
||||
}
|
||||
options={recommendationSources.map((source) => ({
|
||||
id: source.id,
|
||||
name: source.title,
|
||||
}))}
|
||||
customButton={
|
||||
<button
|
||||
type="button"
|
||||
className="px-2 py-1 text-sm bg-mediaCard-hoverBackground rounded-full hover:bg-mediaCard-background transition-colors flex items-center gap-1"
|
||||
>
|
||||
<span>{t("discover.carousel.change")}</span>
|
||||
<Icon
|
||||
icon={Icons.UP_DOWN_ARROW}
|
||||
className="text-xs text-dropdown-secondary"
|
||||
/>
|
||||
</button>
|
||||
}
|
||||
side="right"
|
||||
customMenu={
|
||||
<Listbox.Options static className="py-1">
|
||||
{recommendationSources.map((opt) => (
|
||||
<Listbox.Option
|
||||
className={({ active }) =>
|
||||
`cursor-pointer min-w-60 flex gap-4 items-center relative select-none py-2 px-4 mx-1 rounded-lg ${
|
||||
active
|
||||
? "bg-background-secondaryHover text-type-link"
|
||||
: "text-type-secondary"
|
||||
}`
|
||||
}
|
||||
key={opt.id}
|
||||
value={{ id: opt.id, name: opt.title }}
|
||||
>
|
||||
{({ selected }) => (
|
||||
<>
|
||||
<span
|
||||
className={`block ${selected ? "font-medium" : "font-normal"}`}
|
||||
>
|
||||
{opt.title}
|
||||
</span>
|
||||
{selected && (
|
||||
<Icon
|
||||
icon={Icons.CHECKMARK}
|
||||
className="text-xs text-type-link"
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Listbox.Option>
|
||||
))}
|
||||
</Listbox.Options>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{moreContent && (
|
||||
<Link
|
||||
to={
|
||||
moreLink ||
|
||||
`/discover/more/${categorySlug}${genreId ? `/${genreId}` : ""}`
|
||||
}
|
||||
onClick={handleMoreClick}
|
||||
className="flex px-5 items-center hover:text-type-link transition-colors"
|
||||
>
|
||||
<span className="text-sm">{t("discover.carousel.more")}</span>
|
||||
<Icon className="text-sm ml-1" icon={Icons.ARROW_RIGHT} />
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
{relatedButtons && relatedButtons.length > 0 && (
|
||||
<div className="flex items-center space-x-2 mr-6">
|
||||
{visibleButtons?.map((button) => (
|
||||
<button
|
||||
type="button"
|
||||
key={button.id}
|
||||
onClick={() => onButtonClick?.(button.id, button.name)}
|
||||
className="px-3 py-1 text-sm bg-mediaCard-hoverBackground rounded-full hover:bg-mediaCard-background transition-colors whitespace-nowrap flex-shrink-0"
|
||||
>
|
||||
{button.name}
|
||||
</button>
|
||||
))}
|
||||
{dropdownButtons && dropdownButtons.length > 0 && (
|
||||
<div className="relative my-0">
|
||||
<Dropdown
|
||||
selectedItem={
|
||||
selectedGenre || {
|
||||
id: "",
|
||||
name:
|
||||
activeButton &&
|
||||
!visibleButtons.find(
|
||||
(btn) => btn.id === activeButton.id,
|
||||
)
|
||||
? activeButton.name
|
||||
: "...",
|
||||
}
|
||||
}
|
||||
setSelectedItem={(item) => {
|
||||
setSelectedGenre(item);
|
||||
onButtonClick?.(item.id, item.name);
|
||||
}}
|
||||
options={dropdownOptions}
|
||||
customButton={
|
||||
<button
|
||||
type="button"
|
||||
className="px-3 py-1 text-sm bg-mediaCard-hoverBackground rounded-full hover:bg-mediaCard-background transition-colors flex items-center gap-1"
|
||||
>
|
||||
<span>
|
||||
{activeButton &&
|
||||
!visibleButtons.find(
|
||||
(btn) => btn.id === activeButton.id,
|
||||
)
|
||||
? activeButton.name
|
||||
: "..."}
|
||||
</span>
|
||||
<Icon
|
||||
icon={Icons.UP_DOWN_ARROW}
|
||||
className="text-xs text-dropdown-secondary"
|
||||
/>
|
||||
</button>
|
||||
}
|
||||
side="right"
|
||||
preventWrap
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="relative overflow-hidden carousel-container md:pb-4">
|
||||
<div
|
||||
id={`carousel-${categorySlug}`}
|
||||
className="grid grid-flow-col auto-cols-max gap-4 pt-0 pb-4 overflow-x-scroll scrollbar rounded-xl overflow-y-hidden md:pl-8 md:pr-8"
|
||||
className="grid grid-flow-col auto-cols-max gap-4 pt-0 overflow-x-scroll scrollbar rounded-xl overflow-y-hidden md:pl-8 md:pr-8"
|
||||
ref={(el) => {
|
||||
carouselRefs.current[categorySlug] = el;
|
||||
}}
|
||||
|
|
@ -152,6 +434,15 @@ export function MediaCarousel({
|
|||
/>
|
||||
))}
|
||||
|
||||
{moreContent && (
|
||||
<MoreCard
|
||||
link={
|
||||
moreLink ||
|
||||
`/discover/more/${categorySlug}${genreId ? `/${genreId}` : ""}`
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="md:w-12" />
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -1,63 +1,124 @@
|
|||
import React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
import { Icon, Icons } from "@/components/Icon";
|
||||
import { get } from "@/backend/metadata/tmdb";
|
||||
import { Movie } from "@/pages/discover/common";
|
||||
import { conf } from "@/setup/config";
|
||||
import { useLanguageStore } from "@/stores/language";
|
||||
import { getTmdbLanguageCode } from "@/utils/language";
|
||||
|
||||
interface RandomMovieButtonProps {
|
||||
countdown: number | null;
|
||||
onClick: () => void;
|
||||
randomMovieTitle: string | null;
|
||||
interface TMDBMovieResponse {
|
||||
results: Movie[];
|
||||
}
|
||||
|
||||
export function RandomMovieButton({
|
||||
countdown,
|
||||
onClick,
|
||||
randomMovieTitle,
|
||||
}: RandomMovieButtonProps) {
|
||||
const { t } = useTranslation();
|
||||
export function RandomMovieButton() {
|
||||
const [randomMovie, setRandomMovie] = useState<Movie | null>(null);
|
||||
const [countdown, setCountdown] = useState<number | null>(null);
|
||||
const [countdownTimeout, setCountdownTimeout] =
|
||||
useState<NodeJS.Timeout | null>(null);
|
||||
const [movies, setMovies] = useState<Movie[]>([]);
|
||||
const navigate = useNavigate();
|
||||
const userLanguage = useLanguageStore.getState().language;
|
||||
const formattedLanguage = getTmdbLanguageCode(userLanguage);
|
||||
|
||||
// Fetch popular movies for random selection
|
||||
useEffect(() => {
|
||||
const fetchMovies = async () => {
|
||||
try {
|
||||
const data = await get<TMDBMovieResponse>("/movie/popular", {
|
||||
api_key: conf().TMDB_READ_API_KEY,
|
||||
language: formattedLanguage,
|
||||
page: 2,
|
||||
});
|
||||
setMovies(data.results);
|
||||
} catch (error) {
|
||||
console.error("Error fetching popular movies:", error);
|
||||
}
|
||||
};
|
||||
|
||||
fetchMovies();
|
||||
}, [formattedLanguage]);
|
||||
|
||||
useEffect(() => {
|
||||
let countdownInterval: NodeJS.Timeout;
|
||||
if (countdown !== null && countdown > 0) {
|
||||
countdownInterval = setInterval(() => {
|
||||
setCountdown((prev) => (prev !== null ? prev - 1 : prev));
|
||||
}, 1000);
|
||||
}
|
||||
return () => clearInterval(countdownInterval);
|
||||
}, [countdown]);
|
||||
|
||||
const handleRandomMovieClick = () => {
|
||||
if (movies.length === 0) return;
|
||||
|
||||
const uniqueTitles = new Set(movies.map((movie) => movie.title));
|
||||
const uniqueTitlesArray = Array.from(uniqueTitles);
|
||||
const randomIndex = Math.floor(Math.random() * uniqueTitlesArray.length);
|
||||
const selectedMovie = movies.find(
|
||||
(movie) => movie.title === uniqueTitlesArray[randomIndex],
|
||||
);
|
||||
|
||||
if (selectedMovie) {
|
||||
if (countdown !== null && countdown > 0) {
|
||||
setCountdown(null);
|
||||
if (countdownTimeout) {
|
||||
clearTimeout(countdownTimeout);
|
||||
setCountdownTimeout(null);
|
||||
setRandomMovie(null);
|
||||
}
|
||||
} else {
|
||||
setRandomMovie(selectedMovie);
|
||||
setCountdown(5);
|
||||
const timeoutId = setTimeout(() => {
|
||||
navigate(`/media/tmdb-movie-${selectedMovie.id}-random`);
|
||||
}, 5000);
|
||||
setCountdownTimeout(timeoutId);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="w-full max-w-screen-xl mx-auto px-4">
|
||||
<div className="flex items-center justify-center">
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center space-x-2 rounded-full px-4 text-white py-2 bg-pill-background bg-opacity-50 hover:bg-pill-backgroundHover transition-[background,transform] duration-100 hover:scale-105"
|
||||
onClick={onClick}
|
||||
<div className="flex justify-center items-center">
|
||||
<button
|
||||
type="button"
|
||||
className={`
|
||||
relative flex items-center overflow-hidden
|
||||
rounded-full text-white h-10
|
||||
bg-pill-background bg-opacity-50 hover:bg-pill-backgroundHover
|
||||
transition-all duration-300 ease-in-out
|
||||
${countdown !== null && countdown > 0 ? "min-w-[10px] pl-3" : "w-10"}
|
||||
`}
|
||||
onClick={handleRandomMovieClick}
|
||||
>
|
||||
{/* Title container that slides in */}
|
||||
<div
|
||||
className={`
|
||||
relative whitespace-nowrap
|
||||
transition-all duration-300 ease-in-out
|
||||
${countdown !== null && countdown > 0 ? "opacity-100 translate-x-0" : "opacity-0 -translate-x-4"}
|
||||
`}
|
||||
>
|
||||
<span className="flex items-center">
|
||||
{countdown !== null && countdown > 0 ? (
|
||||
<div className="flex items-center">
|
||||
<span>{t("discover.randomMovie.cancel")}</span>
|
||||
<Icon
|
||||
icon={Icons.X}
|
||||
className="text-2xl ml-[4.5px] mb-[-0.7px]"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center">
|
||||
<span>{t("discover.randomMovie.button")}</span>
|
||||
<img
|
||||
src="/lightbar-images/dice.svg"
|
||||
alt="Dice"
|
||||
style={{ marginLeft: "8px" }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Random Movie Countdown */}
|
||||
{randomMovieTitle && countdown !== null && (
|
||||
<div className="mt-4 mb-4 text-center">
|
||||
<p>
|
||||
{t("discover.randomMovie.nowPlaying")}{" "}
|
||||
<span className="font-bold">{randomMovieTitle}</span>{" "}
|
||||
{t("discover.randomMovie.in")}{" "}
|
||||
{t("discover.randomMovie.countdown", { countdown })}
|
||||
</p>
|
||||
{countdown !== null && countdown > 0 && (
|
||||
<span className="font-bold">{randomMovie?.title}</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Icon container that stays fixed on the right */}
|
||||
<div className="ml-auto flex items-center justify-center w-10 h-10">
|
||||
{countdown !== null && countdown > 0 ? (
|
||||
<div className="animate-[pulse_1s_ease-in-out_infinite] text-lg font-bold">
|
||||
{countdown}
|
||||
</div>
|
||||
) : (
|
||||
<img
|
||||
src="/lightbar-images/dice.svg"
|
||||
alt="Dice"
|
||||
className="w-6 h-6"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -40,7 +40,7 @@ export function ScrollToTopButton() {
|
|||
<button
|
||||
type="button"
|
||||
onClick={scrollToTop}
|
||||
className={`relative flex items-center justify-center space-x-2 rounded-full px-4 py-3 text-lg font-semibold text-white bg-pill-background bg-opacity-80 hover:bg-pill-backgroundHover transition-opacity hover:scale-105 duration-500 ease-in-out ${
|
||||
className={`relative backdrop-blur-sm flex items-center justify-center space-x-2 rounded-full px-4 py-3 text-lg font-semibold text-white bg-pill-background bg-opacity-80 hover:bg-pill-backgroundHover transition-opacity hover:scale-105 duration-500 ease-in-out ${
|
||||
isVisible ? "opacity-100 visible" : "opacity-0 invisible"
|
||||
}`}
|
||||
style={{
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
import { useEffect, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
import { get } from "@/backend/metadata/tmdb";
|
||||
import { DetailsModal } from "@/components/overlays/DetailsModal";
|
||||
|
|
@ -9,46 +8,72 @@ import { useIsMobile } from "@/hooks/useIsMobile";
|
|||
import {
|
||||
Genre,
|
||||
Movie,
|
||||
TVShow,
|
||||
categories,
|
||||
tvCategories,
|
||||
} from "@/pages/discover/common";
|
||||
import { conf } from "@/setup/config";
|
||||
import { useDiscoverStore } from "@/stores/discover";
|
||||
import { useLanguageStore } from "@/stores/language";
|
||||
import { ProgressMediaItem, useProgressStore } from "@/stores/progress";
|
||||
import { getTmdbLanguageCode } from "@/utils/language";
|
||||
import { MediaItem } from "@/utils/mediaTypes";
|
||||
|
||||
import { CategoryButtons } from "./components/CategoryButtons";
|
||||
import { DiscoverNavigation } from "./components/DiscoverNavigation";
|
||||
import type { FeaturedMedia } from "./components/FeaturedCarousel";
|
||||
import { LazyMediaCarousel } from "./components/LazyMediaCarousel";
|
||||
import { LazyTabContent } from "./components/LazyTabContent";
|
||||
import { MediaCarousel } from "./components/MediaCarousel";
|
||||
import { RandomMovieButton } from "./components/RandomMovieButton";
|
||||
import { ScrollToTopButton } from "./components/ScrollToTopButton";
|
||||
import { useTMDBData } from "./hooks/useTMDBData";
|
||||
|
||||
const MOVIE_PROVIDERS = [
|
||||
// Provider constants moved from DiscoverNavigation
|
||||
export const MOVIE_PROVIDERS = [
|
||||
{ name: "Netflix", id: "8" },
|
||||
{ name: "Apple TV+", id: "2" },
|
||||
{ name: "Amazon Prime Video", id: "10" },
|
||||
{ name: "Hulu", id: "15" },
|
||||
{ name: "Disney Plus", id: "337" },
|
||||
{ name: "Max", id: "1899" },
|
||||
{ name: "Paramount Plus", id: "531" },
|
||||
{ name: "Disney Plus", id: "337" },
|
||||
{ name: "Shudder", id: "99" },
|
||||
{ name: "Crunchyroll", id: "283" },
|
||||
{ name: "fuboTV", id: "257" },
|
||||
{ name: "AMC+", id: "526" },
|
||||
{ name: "Starz", id: "43" },
|
||||
{ name: "PBS", id: "209" },
|
||||
{ name: "Lifetime", id: "157" },
|
||||
{ name: "National Geographic", id: "1964" },
|
||||
];
|
||||
|
||||
const TV_PROVIDERS = [
|
||||
export const TV_PROVIDERS = [
|
||||
{ name: "Netflix", id: "8" },
|
||||
{ name: "Apple TV+", id: "350" },
|
||||
{ name: "Amazon Prime Video", id: "10" },
|
||||
{ name: "Paramount Plus", id: "531" },
|
||||
{ name: "Hulu", id: "15" },
|
||||
{ name: "Max", id: "1899" },
|
||||
{ name: "Adult Swim", id: "318" },
|
||||
{ name: "Disney Plus", id: "337" },
|
||||
{ name: "fubuTV", id: "257" },
|
||||
{ name: "Crunchyroll", id: "283" },
|
||||
{ name: "fuboTV", id: "257" },
|
||||
{ name: "Shudder", id: "99" },
|
||||
{ name: "Discovery +", id: "520" },
|
||||
{ name: "National Geographic", id: "1964" },
|
||||
{ name: "Fox", id: "328" },
|
||||
];
|
||||
|
||||
const shuffleArray = <T,>(array: T[]): T[] => {
|
||||
const shuffled = [...array];
|
||||
for (let i = shuffled.length - 1; i > 0; i -= 1) {
|
||||
const j = Math.floor(Math.random() * (i + 1));
|
||||
[shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]];
|
||||
}
|
||||
return shuffled;
|
||||
};
|
||||
|
||||
// Editor Picks lists
|
||||
const EDITOR_PICKS_MOVIES = [
|
||||
export const EDITOR_PICKS_MOVIES = shuffleArray([
|
||||
{ id: 9342, type: "movie" }, // The Mask of Zorro
|
||||
{ id: 293, type: "movie" }, // A River Runs Through It
|
||||
{ id: 370172, type: "movie" }, // No Time To Die
|
||||
|
|
@ -80,9 +105,9 @@ const EDITOR_PICKS_MOVIES = [
|
|||
{ id: 18971, type: "movie" }, // Rosencrantz and Guildenstern Are Dead
|
||||
{ id: 26388, type: "movie" }, // Buried
|
||||
{ id: 152601, type: "movie" }, // Her
|
||||
];
|
||||
]);
|
||||
|
||||
const EDITOR_PICKS_TV_SHOWS = [
|
||||
export const EDITOR_PICKS_TV_SHOWS = shuffleArray([
|
||||
{ id: 456, type: "show" }, // The Simpsons
|
||||
{ id: 73021, type: "show" }, // Disenchantment
|
||||
{ id: 1434, type: "show" }, // Family Guy
|
||||
|
|
@ -99,41 +124,50 @@ const EDITOR_PICKS_TV_SHOWS = [
|
|||
{ id: 93405, type: "show" }, // Squid Game
|
||||
{ id: 87108, type: "show" }, // Chernobyl
|
||||
{ id: 105248, type: "show" }, // Cyberpunk: Edgerunners
|
||||
];
|
||||
]);
|
||||
|
||||
export function DiscoverContent() {
|
||||
// State management
|
||||
const [selectedCategory, setSelectedCategory] = useState("movies");
|
||||
const [genres, setGenres] = useState<Genre[]>([]);
|
||||
const [tvGenres, setTVGenres] = useState<Genre[]>([]);
|
||||
const [randomMovie, setRandomMovie] = useState<Movie | null>(null);
|
||||
const [countdown, setCountdown] = useState<number | null>(null);
|
||||
const [countdownTimeout, setCountdownTimeout] =
|
||||
useState<NodeJS.Timeout | null>(null);
|
||||
const { selectedCategory, setSelectedCategory } = useDiscoverStore();
|
||||
const [selectedProvider, setSelectedProvider] = useState({
|
||||
name: "",
|
||||
id: "",
|
||||
});
|
||||
const [selectedGenre, setSelectedGenre] = useState({
|
||||
name: "",
|
||||
id: "",
|
||||
});
|
||||
const [genres, setGenres] = useState<Genre[]>([]);
|
||||
const [tvGenres, setTVGenres] = useState<Genre[]>([]);
|
||||
const [providerMovies, setProviderMovies] = useState<Movie[]>([]);
|
||||
const [providerTVShows, setProviderTVShows] = useState<any[]>([]);
|
||||
const [editorPicksMovies, setEditorPicksMovies] = useState<Movie[]>([]);
|
||||
const [editorPicksTVShows, setEditorPicksTVShows] = useState<any[]>([]);
|
||||
const [providerTVShows, setProviderTVShows] = useState<TVShow[]>([]);
|
||||
const [filteredGenreMovies, setFilteredGenreMovies] = useState<Movie[]>([]);
|
||||
const [filteredGenreTVShows, setFilteredGenreTVShows] = useState<TVShow[]>(
|
||||
[],
|
||||
);
|
||||
const [detailsData, setDetailsData] = useState<any>();
|
||||
const detailsModal = useModal("discover-details");
|
||||
const [movieRecommendations, setMovieRecommendations] = useState<any[]>([]);
|
||||
const [tvRecommendations, setTVRecommendations] = useState<any[]>([]);
|
||||
const [movieRecommendationTitle, setMovieRecommendationTitle] = useState("");
|
||||
const [tvRecommendationTitle, setTVRecommendationTitle] = useState("");
|
||||
const [movieRecommendationSourceId, setMovieRecommendationSourceId] =
|
||||
useState<string>("");
|
||||
const [tvRecommendationSourceId, setTVRecommendationSourceId] =
|
||||
useState<string>("");
|
||||
const [movieRecommendationSources, setMovieRecommendationSources] = useState<
|
||||
Array<{ id: string; title: string }>
|
||||
>([]);
|
||||
const [tvRecommendationSources, setTVRecommendationSources] = useState<
|
||||
Array<{ id: string; title: string }>
|
||||
>([]);
|
||||
const [selectedMovieSource, setSelectedMovieSource] = useState<string>("");
|
||||
const [selectedTVSource, setSelectedTVSource] = useState<string>("");
|
||||
const progressStore = useProgressStore();
|
||||
const { t } = useTranslation();
|
||||
|
||||
// Refs
|
||||
const carouselRefs = useRef<{ [key: string]: HTMLDivElement | null }>({});
|
||||
|
||||
// Hooks
|
||||
const navigate = useNavigate();
|
||||
const { isMobile } = useIsMobile();
|
||||
const { genreMedia: genreMovies } = useTMDBData(genres, categories, "movie");
|
||||
// const { genreMedia: genreTVShows } = useTMDBData(
|
||||
// tvGenres,
|
||||
// tvCategories,
|
||||
// "tv",
|
||||
// );
|
||||
const { t } = useTranslation();
|
||||
|
||||
const userLanguage = useLanguageStore.getState().language;
|
||||
const formattedLanguage = getTmdbLanguageCode(userLanguage);
|
||||
|
|
@ -143,6 +177,93 @@ export function DiscoverContent() {
|
|||
const isTVShowsTab = selectedCategory === "tvshows";
|
||||
const isEditorPicksTab = selectedCategory === "editorpicks";
|
||||
|
||||
const handleCategoryChange = (category: string) => {
|
||||
setSelectedCategory(category as "movies" | "tvshows" | "editorpicks");
|
||||
};
|
||||
|
||||
// Set initial provider when component mounts or category changes
|
||||
useEffect(() => {
|
||||
const providers =
|
||||
selectedCategory === "movies" ? MOVIE_PROVIDERS : TV_PROVIDERS;
|
||||
if (providers.length > 0 && !selectedProvider.id) {
|
||||
setSelectedProvider({
|
||||
name: providers[0].name,
|
||||
id: providers[0].id,
|
||||
});
|
||||
}
|
||||
}, [selectedCategory, selectedProvider.id]);
|
||||
|
||||
// Set initial genre when component mounts or category changes
|
||||
useEffect(() => {
|
||||
const genreList = selectedCategory === "movies" ? genres : tvGenres;
|
||||
if (genreList.length > 0) {
|
||||
// Always reset genre when switching categories to ensure we use the correct genre IDs
|
||||
if (selectedCategory === "movies") {
|
||||
setSelectedGenre({
|
||||
name: genres[0].name,
|
||||
id: genres[0].id.toString(),
|
||||
});
|
||||
} else if (selectedCategory === "tvshows") {
|
||||
setSelectedGenre({
|
||||
name: tvGenres[0].name,
|
||||
id: tvGenres[0].id.toString(),
|
||||
});
|
||||
}
|
||||
}
|
||||
}, [selectedCategory, genres, tvGenres]);
|
||||
|
||||
// Fetch provider content when selectedProvider changes
|
||||
useEffect(() => {
|
||||
const fetchProviderContent = async () => {
|
||||
if (!selectedProvider.id) return;
|
||||
|
||||
try {
|
||||
const endpoint =
|
||||
selectedCategory === "movies" ? "/discover/movie" : "/discover/tv";
|
||||
const setData =
|
||||
selectedCategory === "movies"
|
||||
? setProviderMovies
|
||||
: setProviderTVShows;
|
||||
const data = await get<any>(endpoint, {
|
||||
api_key: conf().TMDB_READ_API_KEY,
|
||||
with_watch_providers: selectedProvider.id,
|
||||
watch_region: "US",
|
||||
language: formattedLanguage,
|
||||
});
|
||||
setData(data.results);
|
||||
} catch (error) {
|
||||
console.error("Error fetching provider movies/shows:", error);
|
||||
}
|
||||
};
|
||||
|
||||
fetchProviderContent();
|
||||
}, [selectedProvider, selectedCategory, formattedLanguage]);
|
||||
|
||||
// Fetch genre content when selectedGenre changes
|
||||
useEffect(() => {
|
||||
const fetchGenreContent = async () => {
|
||||
if (!selectedGenre.id) return;
|
||||
try {
|
||||
const endpoint =
|
||||
selectedCategory === "movies" ? "/discover/movie" : "/discover/tv";
|
||||
const setData =
|
||||
selectedCategory === "movies"
|
||||
? setFilteredGenreMovies
|
||||
: setFilteredGenreTVShows;
|
||||
const data = await get<any>(endpoint, {
|
||||
api_key: conf().TMDB_READ_API_KEY,
|
||||
with_genres: selectedGenre.id,
|
||||
language: formattedLanguage,
|
||||
});
|
||||
setData(data.results);
|
||||
} catch (error) {
|
||||
console.error("Error fetching genre movies/shows:", error);
|
||||
}
|
||||
};
|
||||
|
||||
fetchGenreContent();
|
||||
}, [selectedGenre, selectedCategory, formattedLanguage]);
|
||||
|
||||
// Fetch TV show genres
|
||||
useEffect(() => {
|
||||
if (!isTVShowsTab) return;
|
||||
|
|
@ -153,8 +274,7 @@ export function DiscoverContent() {
|
|||
api_key: conf().TMDB_READ_API_KEY,
|
||||
language: formattedLanguage,
|
||||
});
|
||||
// Fetch only the first 10 TV show genres
|
||||
setTVGenres(data.genres.slice(0, 10));
|
||||
setTVGenres(data.genres.slice(0, 50));
|
||||
} catch (error) {
|
||||
console.error("Error fetching TV show genres:", error);
|
||||
}
|
||||
|
|
@ -173,9 +293,7 @@ export function DiscoverContent() {
|
|||
api_key: conf().TMDB_READ_API_KEY,
|
||||
language: formattedLanguage,
|
||||
});
|
||||
|
||||
// Fetch only the first 12 genres
|
||||
setGenres(data.genres.slice(0, 12));
|
||||
setGenres(data.genres.slice(0, 50));
|
||||
} catch (error) {
|
||||
console.error("Error fetching genres:", error);
|
||||
}
|
||||
|
|
@ -199,9 +317,11 @@ export function DiscoverContent() {
|
|||
);
|
||||
|
||||
const results = await Promise.all(moviePromises);
|
||||
// Shuffle the results to display them randomly
|
||||
const shuffled = [...results].sort(() => 0.5 - Math.random());
|
||||
setEditorPicksMovies(shuffled);
|
||||
const moviesWithType = results.map((movie) => ({
|
||||
...movie,
|
||||
type: "movie" as const,
|
||||
}));
|
||||
setFilteredGenreMovies(moviesWithType);
|
||||
} catch (error) {
|
||||
console.error("Error fetching editor picks movies:", error);
|
||||
}
|
||||
|
|
@ -225,9 +345,11 @@ export function DiscoverContent() {
|
|||
);
|
||||
|
||||
const results = await Promise.all(tvShowPromises);
|
||||
// Shuffle the results to display them randomly
|
||||
const shuffled = [...results].sort(() => 0.5 - Math.random());
|
||||
setEditorPicksTVShows(shuffled);
|
||||
const showsWithType = results.map((show) => ({
|
||||
...show,
|
||||
type: "show" as const,
|
||||
}));
|
||||
setFilteredGenreTVShows(showsWithType);
|
||||
} catch (error) {
|
||||
console.error("Error fetching editor picks TV shows:", error);
|
||||
}
|
||||
|
|
@ -236,95 +358,137 @@ export function DiscoverContent() {
|
|||
fetchEditorPicksTVShows();
|
||||
}, [isEditorPicksTab, formattedLanguage]);
|
||||
|
||||
// Update recommendations effect to store multiple sources
|
||||
useEffect(() => {
|
||||
let countdownInterval: NodeJS.Timeout;
|
||||
if (countdown !== null && countdown > 0) {
|
||||
countdownInterval = setInterval(() => {
|
||||
setCountdown((prev) => (prev !== null ? prev - 1 : prev));
|
||||
}, 1000);
|
||||
}
|
||||
return () => clearInterval(countdownInterval);
|
||||
}, [countdown]);
|
||||
const fetchRecommendations = async () => {
|
||||
if (!progressStore.items || Object.keys(progressStore.items).length === 0)
|
||||
return;
|
||||
|
||||
// Handlers
|
||||
const handleCategoryChange = (
|
||||
eventOrValue: React.ChangeEvent<HTMLSelectElement> | string,
|
||||
) => {
|
||||
const value =
|
||||
typeof eventOrValue === "string"
|
||||
? eventOrValue
|
||||
: eventOrValue.target.value;
|
||||
setSelectedCategory(value);
|
||||
};
|
||||
try {
|
||||
// Get all movies and TV shows from progress
|
||||
const progressItems = Object.entries(progressStore.items) as [
|
||||
string,
|
||||
ProgressMediaItem,
|
||||
][];
|
||||
const movies = progressItems.filter(
|
||||
([_, item]) => item.type === "movie",
|
||||
);
|
||||
const tvShows = progressItems.filter(
|
||||
([_, item]) => item.type === "show",
|
||||
);
|
||||
|
||||
const handleRandomMovieClick = () => {
|
||||
const allMovies = Object.values(genreMovies).flat();
|
||||
const uniqueTitles = new Set(allMovies.map((movie) => movie.title));
|
||||
const uniqueTitlesArray = Array.from(uniqueTitles);
|
||||
const randomIndex = Math.floor(Math.random() * uniqueTitlesArray.length);
|
||||
const selectedMovie = allMovies.find(
|
||||
(movie) => movie.title === uniqueTitlesArray[randomIndex],
|
||||
);
|
||||
// Store all movie sources
|
||||
if (movies.length > 0) {
|
||||
const movieSources = movies.map(([id, item]) => ({
|
||||
id,
|
||||
title: item.title || "",
|
||||
}));
|
||||
setMovieRecommendationSources(movieSources);
|
||||
|
||||
if (selectedMovie) {
|
||||
if (countdown !== null && countdown > 0) {
|
||||
setCountdown(null);
|
||||
if (countdownTimeout) {
|
||||
clearTimeout(countdownTimeout);
|
||||
setCountdownTimeout(null);
|
||||
setRandomMovie(null);
|
||||
// Set initial source if not set
|
||||
if (!selectedMovieSource && movieSources.length > 0) {
|
||||
setSelectedMovieSource(movieSources[0].id);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
setRandomMovie(selectedMovie as Movie);
|
||||
setCountdown(5);
|
||||
const timeoutId = setTimeout(() => {
|
||||
navigate(`/media/tmdb-movie-${selectedMovie.id}-discover-random`);
|
||||
}, 5000);
|
||||
setCountdownTimeout(timeoutId);
|
||||
|
||||
// Store all TV show sources
|
||||
if (tvShows.length > 0) {
|
||||
const tvSources = tvShows.map(([id, item]) => ({
|
||||
id,
|
||||
title: item.title || "",
|
||||
}));
|
||||
setTVRecommendationSources(tvSources);
|
||||
|
||||
// Set initial source if not set
|
||||
if (!selectedTVSource && tvSources.length > 0) {
|
||||
setSelectedTVSource(tvSources[0].id);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error setting up recommendation sources:", error);
|
||||
}
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
const handleProviderClick = async (id: string, name: string) => {
|
||||
try {
|
||||
setSelectedProvider({ name, id });
|
||||
const endpoint =
|
||||
selectedCategory === "movies" ? "/discover/movie" : "/discover/tv";
|
||||
const setData =
|
||||
selectedCategory === "movies" ? setProviderMovies : setProviderTVShows;
|
||||
const data = await get<any>(endpoint, {
|
||||
api_key: conf().TMDB_READ_API_KEY,
|
||||
with_watch_providers: id,
|
||||
watch_region: "US",
|
||||
language: formattedLanguage,
|
||||
});
|
||||
setData(data.results);
|
||||
} catch (error) {
|
||||
console.error("Error fetching provider movies/shows:", error);
|
||||
}
|
||||
};
|
||||
fetchRecommendations();
|
||||
}, [progressStore.items, selectedMovieSource, selectedTVSource]);
|
||||
|
||||
const handleCategoryClick = (id: string, name: string) => {
|
||||
// Try both movie and tv versions of the category slug
|
||||
const categorySlugBase = name.toLowerCase().replace(/[^a-z0-9]+/g, "-");
|
||||
const movieElement = document.getElementById(
|
||||
`carousel-${categorySlugBase}-movie`,
|
||||
);
|
||||
const tvElement = document.getElementById(
|
||||
`carousel-${categorySlugBase}-tv`,
|
||||
);
|
||||
// Add new effect to fetch recommendations when source changes
|
||||
useEffect(() => {
|
||||
const fetchRecommendationsForSource = async () => {
|
||||
if (!selectedMovieSource && !selectedTVSource) return;
|
||||
|
||||
// Scroll to the first element that exists
|
||||
const element = movieElement || tvElement;
|
||||
if (element) {
|
||||
element.scrollIntoView({
|
||||
behavior: "smooth",
|
||||
block: "center",
|
||||
});
|
||||
}
|
||||
};
|
||||
try {
|
||||
// Fetch movie recommendations if we have a selected movie source
|
||||
if (selectedMovieSource) {
|
||||
const movieResults = await get<any>(
|
||||
`/movie/${selectedMovieSource}/recommendations`,
|
||||
{
|
||||
api_key: conf().TMDB_READ_API_KEY,
|
||||
language: formattedLanguage,
|
||||
},
|
||||
);
|
||||
|
||||
const handleShowDetails = async (media: MediaItem) => {
|
||||
if (movieResults.results?.length > 0) {
|
||||
setMovieRecommendations(movieResults.results);
|
||||
const sourceMovie = movieRecommendationSources.find(
|
||||
(m) => m.id === selectedMovieSource,
|
||||
);
|
||||
if (sourceMovie) {
|
||||
setMovieRecommendationTitle(
|
||||
t("discover.carousel.title.recommended", {
|
||||
title: sourceMovie.title,
|
||||
}),
|
||||
);
|
||||
setMovieRecommendationSourceId(selectedMovieSource);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch TV show recommendations if we have a selected TV source
|
||||
if (selectedTVSource) {
|
||||
const tvResults = await get<any>(
|
||||
`/tv/${selectedTVSource}/recommendations`,
|
||||
{
|
||||
api_key: conf().TMDB_READ_API_KEY,
|
||||
language: formattedLanguage,
|
||||
},
|
||||
);
|
||||
|
||||
if (tvResults.results?.length > 0) {
|
||||
setTVRecommendations(tvResults.results);
|
||||
const sourceTV = tvRecommendationSources.find(
|
||||
(show) => show.id === selectedTVSource,
|
||||
);
|
||||
if (sourceTV) {
|
||||
setTVRecommendationTitle(
|
||||
t("discover.carousel.title.recommended", {
|
||||
title: sourceTV.title,
|
||||
}),
|
||||
);
|
||||
setTVRecommendationSourceId(selectedTVSource);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error fetching recommendations:", error);
|
||||
}
|
||||
};
|
||||
|
||||
fetchRecommendationsForSource();
|
||||
}, [
|
||||
selectedMovieSource,
|
||||
selectedTVSource,
|
||||
movieRecommendationSources,
|
||||
tvRecommendationSources,
|
||||
formattedLanguage,
|
||||
setMovieRecommendationTitle,
|
||||
setTVRecommendationTitle,
|
||||
setMovieRecommendationSourceId,
|
||||
setTVRecommendationSourceId,
|
||||
t,
|
||||
]);
|
||||
|
||||
const handleShowDetails = async (media: MediaItem | FeaturedMedia) => {
|
||||
setDetailsData({
|
||||
id: Number(media.id),
|
||||
type: media.type === "movie" ? "movie" : "show",
|
||||
|
|
@ -337,20 +501,22 @@ export function DiscoverContent() {
|
|||
return (
|
||||
<>
|
||||
<LazyMediaCarousel
|
||||
preloadedMedia={editorPicksMovies}
|
||||
preloadedMedia={filteredGenreMovies}
|
||||
title="Editor Picks"
|
||||
mediaType="movie"
|
||||
isMobile={isMobile}
|
||||
carouselRefs={carouselRefs}
|
||||
onShowDetails={handleShowDetails}
|
||||
moreContent
|
||||
/>
|
||||
<LazyMediaCarousel
|
||||
preloadedMedia={editorPicksTVShows}
|
||||
preloadedMedia={filteredGenreTVShows}
|
||||
title="Editor Picks"
|
||||
mediaType="tv"
|
||||
isMobile={isMobile}
|
||||
carouselRefs={carouselRefs}
|
||||
onShowDetails={handleShowDetails}
|
||||
moreContent
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
|
@ -360,41 +526,86 @@ export function DiscoverContent() {
|
|||
const renderMoviesContent = () => {
|
||||
return (
|
||||
<>
|
||||
{/* Provider Movies */}
|
||||
{providerMovies.length > 0 && (
|
||||
{/* Movie Recommendations */}
|
||||
{movieRecommendations.length > 0 && (
|
||||
<MediaCarousel
|
||||
medias={providerMovies}
|
||||
category={selectedProvider.name}
|
||||
medias={movieRecommendations}
|
||||
category={movieRecommendationTitle}
|
||||
isTVShow={false}
|
||||
isMobile={isMobile}
|
||||
carouselRefs={carouselRefs}
|
||||
onShowDetails={handleShowDetails}
|
||||
moreLink={`/discover/more/recommendations/${movieRecommendationSourceId}/movie`}
|
||||
moreContent
|
||||
recommendationSources={movieRecommendationSources}
|
||||
selectedRecommendationSource={selectedMovieSource}
|
||||
onRecommendationSourceChange={setSelectedMovieSource}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Categories */}
|
||||
{categories.map((category) => (
|
||||
<LazyMediaCarousel
|
||||
key={category.name}
|
||||
category={category}
|
||||
mediaType="movie"
|
||||
isMobile={isMobile}
|
||||
carouselRefs={carouselRefs}
|
||||
onShowDetails={handleShowDetails}
|
||||
/>
|
||||
))}
|
||||
{/* In Cinemas */}
|
||||
<LazyMediaCarousel
|
||||
category={categories[0]}
|
||||
mediaType="movie"
|
||||
isMobile={isMobile}
|
||||
carouselRefs={carouselRefs}
|
||||
onShowDetails={handleShowDetails}
|
||||
moreContent
|
||||
/>
|
||||
|
||||
{/* Genres */}
|
||||
{genres.map((genre) => (
|
||||
<LazyMediaCarousel
|
||||
key={genre.id}
|
||||
genre={genre}
|
||||
mediaType="movie"
|
||||
isMobile={isMobile}
|
||||
carouselRefs={carouselRefs}
|
||||
onShowDetails={handleShowDetails}
|
||||
/>
|
||||
))}
|
||||
{/* Top Rated */}
|
||||
<LazyMediaCarousel
|
||||
category={categories[1]}
|
||||
mediaType="movie"
|
||||
isMobile={isMobile}
|
||||
carouselRefs={carouselRefs}
|
||||
onShowDetails={handleShowDetails}
|
||||
moreContent
|
||||
/>
|
||||
|
||||
{/* Popular */}
|
||||
<LazyMediaCarousel
|
||||
category={categories[2]}
|
||||
mediaType="movie"
|
||||
isMobile={isMobile}
|
||||
carouselRefs={carouselRefs}
|
||||
onShowDetails={handleShowDetails}
|
||||
moreContent
|
||||
/>
|
||||
|
||||
{/* Provider Movies */}
|
||||
<MediaCarousel
|
||||
medias={providerMovies}
|
||||
category={`Movies on ${selectedProvider.name || ""}`}
|
||||
isTVShow={false}
|
||||
isMobile={isMobile}
|
||||
carouselRefs={carouselRefs}
|
||||
onShowDetails={handleShowDetails}
|
||||
relatedButtons={MOVIE_PROVIDERS.map((p) => ({
|
||||
name: p.name,
|
||||
id: p.id,
|
||||
}))}
|
||||
onButtonClick={(id, name) => setSelectedProvider({ id, name })}
|
||||
moreLink={`/discover/more/provider/${selectedProvider.id}/movie`}
|
||||
moreContent
|
||||
/>
|
||||
|
||||
{/* Genre Movies */}
|
||||
<MediaCarousel
|
||||
medias={filteredGenreMovies}
|
||||
category={`${selectedGenre.name || ""}`}
|
||||
isTVShow={false}
|
||||
isMobile={isMobile}
|
||||
carouselRefs={carouselRefs}
|
||||
onShowDetails={handleShowDetails}
|
||||
relatedButtons={genres.map((g) => ({
|
||||
name: g.name,
|
||||
id: g.id.toString(),
|
||||
}))}
|
||||
onButtonClick={(id, name) => setSelectedGenre({ id, name })}
|
||||
moreLink={`/discover/more/genre/${selectedGenre.id}/movie`}
|
||||
moreContent
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
@ -403,106 +614,96 @@ export function DiscoverContent() {
|
|||
const renderTVShowsContent = () => {
|
||||
return (
|
||||
<>
|
||||
{/* Provider TV Shows */}
|
||||
{providerTVShows.length > 0 && (
|
||||
{/* TV Show Recommendations */}
|
||||
{tvRecommendations.length > 0 && (
|
||||
<MediaCarousel
|
||||
medias={providerTVShows}
|
||||
category={selectedProvider.name}
|
||||
medias={tvRecommendations}
|
||||
category={tvRecommendationTitle}
|
||||
isTVShow
|
||||
isMobile={isMobile}
|
||||
carouselRefs={carouselRefs}
|
||||
onShowDetails={handleShowDetails}
|
||||
moreLink={`/discover/more/recommendations/${tvRecommendationSourceId}/tv`}
|
||||
moreContent
|
||||
recommendationSources={tvRecommendationSources}
|
||||
selectedRecommendationSource={selectedTVSource}
|
||||
onRecommendationSourceChange={setSelectedTVSource}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Categories */}
|
||||
{tvCategories.map((category) => (
|
||||
<LazyMediaCarousel
|
||||
key={category.name}
|
||||
category={category}
|
||||
mediaType="tv"
|
||||
isMobile={isMobile}
|
||||
carouselRefs={carouselRefs}
|
||||
onShowDetails={handleShowDetails}
|
||||
/>
|
||||
))}
|
||||
{/* On Air */}
|
||||
<LazyMediaCarousel
|
||||
category={tvCategories[0]}
|
||||
mediaType="tv"
|
||||
isMobile={isMobile}
|
||||
carouselRefs={carouselRefs}
|
||||
onShowDetails={handleShowDetails}
|
||||
moreContent
|
||||
/>
|
||||
|
||||
{/* Genres */}
|
||||
{tvGenres.map((genre) => (
|
||||
<LazyMediaCarousel
|
||||
key={genre.id}
|
||||
genre={genre}
|
||||
mediaType="tv"
|
||||
isMobile={isMobile}
|
||||
carouselRefs={carouselRefs}
|
||||
onShowDetails={handleShowDetails}
|
||||
/>
|
||||
))}
|
||||
{/* Top Rated */}
|
||||
<LazyMediaCarousel
|
||||
category={tvCategories[1]}
|
||||
mediaType="tv"
|
||||
isMobile={isMobile}
|
||||
carouselRefs={carouselRefs}
|
||||
onShowDetails={handleShowDetails}
|
||||
moreContent
|
||||
/>
|
||||
|
||||
{/* Popular */}
|
||||
<LazyMediaCarousel
|
||||
category={tvCategories[2]}
|
||||
mediaType="tv"
|
||||
isMobile={isMobile}
|
||||
carouselRefs={carouselRefs}
|
||||
onShowDetails={handleShowDetails}
|
||||
moreContent
|
||||
/>
|
||||
|
||||
{/* Provider TV Shows */}
|
||||
<MediaCarousel
|
||||
medias={providerTVShows}
|
||||
category={`Shows on ${selectedProvider.name || ""}`}
|
||||
isTVShow
|
||||
isMobile={isMobile}
|
||||
carouselRefs={carouselRefs}
|
||||
onShowDetails={handleShowDetails}
|
||||
relatedButtons={TV_PROVIDERS.map((p) => ({
|
||||
name: p.name,
|
||||
id: p.id,
|
||||
}))}
|
||||
onButtonClick={(id, name) => setSelectedProvider({ id, name })}
|
||||
moreLink={`/discover/more/provider/${selectedProvider.id}/tv`}
|
||||
moreContent
|
||||
/>
|
||||
|
||||
{/* Genre TV Shows */}
|
||||
<MediaCarousel
|
||||
medias={filteredGenreTVShows}
|
||||
category={`${selectedGenre.name || ""}`}
|
||||
isTVShow
|
||||
isMobile={isMobile}
|
||||
carouselRefs={carouselRefs}
|
||||
onShowDetails={handleShowDetails}
|
||||
relatedButtons={tvGenres.map((g) => ({
|
||||
name: g.name,
|
||||
id: g.id.toString(),
|
||||
}))}
|
||||
onButtonClick={(id, name) => setSelectedGenre({ id, name })}
|
||||
moreLink={`/discover/more/genre/${selectedGenre.id}/tv`}
|
||||
moreContent
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="pt-6">
|
||||
{/* Random Movie Button */}
|
||||
<RandomMovieButton
|
||||
countdown={countdown}
|
||||
onClick={handleRandomMovieClick}
|
||||
randomMovieTitle={randomMovie ? randomMovie.title : null}
|
||||
<div className="relative min-h-screen">
|
||||
<DiscoverNavigation
|
||||
selectedCategory={selectedCategory}
|
||||
onCategoryChange={handleCategoryChange}
|
||||
/>
|
||||
|
||||
{/* Category Tabs */}
|
||||
<div className="mt-8 pb-2 w-full max-w-screen-xl mx-auto">
|
||||
<div className="relative flex justify-center mb-4">
|
||||
<div className="flex space-x-4">
|
||||
{["movies", "tvshows", "editorpicks"].map((category) => (
|
||||
<button
|
||||
key={category}
|
||||
type="button"
|
||||
className={`text-xl md:text-2xl font-bold p-2 bg-transparent text-center rounded-full cursor-pointer flex items-center transition-transform duration-200 ${
|
||||
selectedCategory === category
|
||||
? "transform scale-105 text-type-link"
|
||||
: "text-type-secondary"
|
||||
}`}
|
||||
onClick={() => handleCategoryChange(category)}
|
||||
>
|
||||
{t(`discover.tabs.${category}`)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Only show provider and genre buttons for movies and tvshows categories */}
|
||||
{selectedCategory !== "editorpicks" && (
|
||||
<>
|
||||
<div className="flex justify-center overflow-x-auto">
|
||||
<CategoryButtons
|
||||
categories={
|
||||
selectedCategory === "movies" ? MOVIE_PROVIDERS : TV_PROVIDERS
|
||||
}
|
||||
onCategoryClick={handleProviderClick}
|
||||
categoryType="providers"
|
||||
isMobile={isMobile}
|
||||
showAlwaysScroll={false}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex overflow-x-auto">
|
||||
<CategoryButtons
|
||||
categories={
|
||||
selectedCategory === "movies"
|
||||
? [...categories, ...genres]
|
||||
: [...tvCategories, ...tvGenres]
|
||||
}
|
||||
onCategoryClick={handleCategoryClick}
|
||||
categoryType="movies"
|
||||
isMobile={isMobile}
|
||||
showAlwaysScroll
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Content Section with Lazy Loading Tabs */}
|
||||
<div className="w-full md:w-[90%] max-w-[2400px] mx-auto">
|
||||
{/* Movies Tab */}
|
||||
|
|
|
|||
|
|
@ -1,13 +1,36 @@
|
|||
import { useEffect, useState } from "react";
|
||||
|
||||
import { FooterView } from "@/components/layout/Footer";
|
||||
import { Navigation } from "@/components/layout/Navigation";
|
||||
import { usePreferencesStore } from "@/stores/preferences";
|
||||
|
||||
export function HomeLayout(props: {
|
||||
showBg: boolean;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const enableFeatured = usePreferencesStore((state) => state.enableFeatured);
|
||||
const [clearBackground, setClearBackground] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const handleScroll = () => {
|
||||
setClearBackground(Boolean(enableFeatured) && window.scrollY < 600);
|
||||
};
|
||||
window.addEventListener("scroll", handleScroll);
|
||||
// Initial check
|
||||
handleScroll();
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("scroll", handleScroll);
|
||||
};
|
||||
}, [enableFeatured]);
|
||||
|
||||
return (
|
||||
<FooterView>
|
||||
<Navigation bg={props.showBg} />
|
||||
<Navigation
|
||||
bg={enableFeatured ? true : props.showBg}
|
||||
clearBackground={clearBackground}
|
||||
noLightbar={enableFeatured}
|
||||
/>
|
||||
{props.children}
|
||||
</FooterView>
|
||||
);
|
||||
|
|
|
|||
140
src/pages/parts/home/AdsPart.tsx
Normal file
140
src/pages/parts/home/AdsPart.tsx
Normal file
|
|
@ -0,0 +1,140 @@
|
|||
import { useCallback, useState } from "react";
|
||||
|
||||
import { Icon, Icons } from "@/components/Icon";
|
||||
import { conf } from "@/setup/config";
|
||||
|
||||
function getCookie(name: string): string | null {
|
||||
const cookies = document.cookie.split(";");
|
||||
for (let i = 0; i < cookies.length; i += 1) {
|
||||
const cookie = cookies[i].trim();
|
||||
if (cookie.startsWith(`${name}=`)) {
|
||||
return cookie.substring(name.length + 1);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function setCookie(name: string, value: string, expiryDays: number): void {
|
||||
const date = new Date();
|
||||
date.setTime(date.getTime() + expiryDays * 24 * 60 * 60 * 1000);
|
||||
const expires = `expires=${date.toUTCString()}`;
|
||||
document.cookie = `${name}=${value};${expires};path=/`;
|
||||
}
|
||||
|
||||
export function AdsPart(): JSX.Element | null {
|
||||
const [isAdDismissed, setIsAdDismissed] = useState(() => {
|
||||
return getCookie("adDismissed") === "true";
|
||||
});
|
||||
|
||||
const dismissAd = useCallback(() => {
|
||||
setIsAdDismissed(true);
|
||||
setCookie("adDismissed", "true", 2); // Expires after 2 days
|
||||
}, []);
|
||||
|
||||
if (isAdDismissed) return null;
|
||||
|
||||
return (
|
||||
<div className="w-fit max-w-[32rem] mx-auto relative group pb-4">
|
||||
{(() => {
|
||||
const adContentUrl = conf().AD_CONTENT_URL;
|
||||
|
||||
// VITE_AD_CONTENT_URL=default message (null will be nothing),referal link,image link, card message
|
||||
// Ensure adContentUrl is an array. If not, render nothing for ads.
|
||||
if (!Array.isArray(adContentUrl)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const ad1LinkIsValid =
|
||||
typeof adContentUrl[1] === "string" && adContentUrl[1].length > 0;
|
||||
const ad1ImageIsProvided = typeof adContentUrl[2] === "string";
|
||||
const showAd1 =
|
||||
adContentUrl.length >= 2 && ad1LinkIsValid && ad1ImageIsProvided;
|
||||
|
||||
const ad2LinkIsValid =
|
||||
typeof adContentUrl[3] === "string" && adContentUrl[3].length > 0;
|
||||
const ad2ImageIsProvided = typeof adContentUrl[4] === "string";
|
||||
const showAd2 =
|
||||
adContentUrl.length >= 5 && ad2LinkIsValid && ad2ImageIsProvided;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex flex-col md:flex-row md:space-x-4 space-y-4 md:space-y-0 justify-center w-full items-center md:items-start">
|
||||
{showAd1 ? (
|
||||
<div className="rounded-xl bg-background-main hover:scale-[1.02] max-w-[16rem] transition-all duration-300 md:flex-1 relative group">
|
||||
<div className="bg-opacity-10 bg-buttons-purple rounded-xl border-2 border-buttons-purple border-opacity-30 hover:border-opacity-70 hover:shadow-lg hover:shadow-buttons-purple/20">
|
||||
{" "}
|
||||
<button
|
||||
onClick={dismissAd}
|
||||
type="button"
|
||||
className="absolute z-50 -top-2 -right-2 w-6 h-6 bg-mediaCard-hoverBackground rounded-full flex items-center justify-center md:opacity-0 group-hover:opacity-100 transition-opacity duration-300"
|
||||
aria-label="Dismiss ad"
|
||||
>
|
||||
<Icon
|
||||
className="text-xs font-semibold text-type-secondary"
|
||||
icon={Icons.X}
|
||||
/>
|
||||
</button>
|
||||
<a href={adContentUrl[1]} className="block">
|
||||
<div className="overflow-hidden rounded-t-xl">
|
||||
<img
|
||||
src={adContentUrl[2]}
|
||||
alt="ad banner"
|
||||
className="w-full h-auto transition-transform duration-300"
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-type-dimmed text-center py-2 transition-colors duration-300 group-hover:text-type-secondary">
|
||||
<span>{adContentUrl[3]}</span>
|
||||
</p>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
{showAd2 ? (
|
||||
<div className="rounded-xl bg-background-main hover:scale-[1.02] max-w-[16rem] transition-all duration-300 md:flex-1 relative group">
|
||||
<div className="bg-opacity-10 bg-buttons-purple rounded-xl border-2 border-buttons-purple border-opacity-30 hover:border-opacity-70 hover:shadow-lg hover:shadow-buttons-purple/20">
|
||||
<button
|
||||
onClick={dismissAd}
|
||||
type="button"
|
||||
className="absolute z-50 -top-2 -right-2 w-6 h-6 bg-mediaCard-hoverBackground rounded-full flex items-center justify-center md:opacity-0 group-hover:opacity-100 transition-opacity duration-300"
|
||||
aria-label="Dismiss ad"
|
||||
>
|
||||
<Icon
|
||||
className="text-xs font-semibold text-type-secondary"
|
||||
icon={Icons.X}
|
||||
/>
|
||||
</button>
|
||||
<a href={adContentUrl[4]} className="block">
|
||||
<div className="overflow-hidden rounded-t-xl">
|
||||
<img
|
||||
src={adContentUrl[5]}
|
||||
alt="ad banner"
|
||||
className="w-full h-auto transition-transform duration-300"
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-type-dimmed text-center py-2 transition-colors duration-300 group-hover:text-type-secondary">
|
||||
<span>{adContentUrl[6]}</span>
|
||||
</p>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
{adContentUrl[0] !== "null" && (
|
||||
<div>
|
||||
<p className="text-xs text-type-dimmed text-center pt-2 mx-4">
|
||||
<a
|
||||
href="https://discord.gg/mcjnJK98Gd"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
{adContentUrl[0]}
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,21 +1,22 @@
|
|||
import classNames from "classnames";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import Sticky from "react-sticky-el";
|
||||
import { useWindowSize } from "react-use";
|
||||
|
||||
import { SearchBarInput } from "@/components/form/SearchBar";
|
||||
import { Icon, Icons } from "@/components/Icon";
|
||||
import { ThinContainer } from "@/components/layout/ThinContainer";
|
||||
import { useSlashFocus } from "@/components/player/hooks/useSlashFocus";
|
||||
import { HeroTitle } from "@/components/text/HeroTitle";
|
||||
import { useIsTV } from "@/hooks/useIsTv";
|
||||
import { useRandomTranslation } from "@/hooks/useRandomTranslation";
|
||||
import { useSearchQuery } from "@/hooks/useSearchQuery";
|
||||
import { conf } from "@/setup/config";
|
||||
import { useBannerSize } from "@/stores/banner";
|
||||
|
||||
export interface HeroPartProps {
|
||||
setIsSticky: (val: boolean) => void;
|
||||
searchParams: ReturnType<typeof useSearchQuery>;
|
||||
showTitle?: boolean;
|
||||
isInFeatured?: boolean;
|
||||
}
|
||||
|
||||
function getTimeOfDay(date: Date): "night" | "morning" | "day" | "420" | "69" {
|
||||
|
|
@ -30,32 +31,17 @@ function getTimeOfDay(date: Date): "night" | "morning" | "day" | "420" | "69" {
|
|||
return "night";
|
||||
}
|
||||
|
||||
function getCookie(name: string): string | null {
|
||||
const cookies = document.cookie.split(";");
|
||||
for (let i = 0; i < cookies.length; i += 1) {
|
||||
const cookie = cookies[i].trim();
|
||||
if (cookie.startsWith(`${name}=`)) {
|
||||
return cookie.substring(name.length + 1);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function setCookie(name: string, value: string, expiryDays: number): void {
|
||||
const date = new Date();
|
||||
date.setTime(date.getTime() + expiryDays * 24 * 60 * 60 * 1000);
|
||||
const expires = `expires=${date.toUTCString()}`;
|
||||
document.cookie = `${name}=${value};${expires};path=/`;
|
||||
}
|
||||
|
||||
export function HeroPart({ setIsSticky, searchParams }: HeroPartProps) {
|
||||
export function HeroPart({
|
||||
setIsSticky,
|
||||
searchParams,
|
||||
showTitle,
|
||||
isInFeatured,
|
||||
}: HeroPartProps) {
|
||||
const { t: randomT } = useRandomTranslation();
|
||||
const [search, setSearch, setSearchUnFocus] = searchParams;
|
||||
const [, setShowBg] = useState(false);
|
||||
const [isAdDismissed, setIsAdDismissed] = useState(() => {
|
||||
return getCookie("adDismissed") === "true";
|
||||
});
|
||||
const [showBg, setShowBg] = useState(false);
|
||||
const bannerSize = useBannerSize();
|
||||
|
||||
const stickStateChanged = useCallback(
|
||||
(isFixed: boolean) => {
|
||||
setShowBg(isFixed);
|
||||
|
|
@ -63,6 +49,7 @@ export function HeroPart({ setIsSticky, searchParams }: HeroPartProps) {
|
|||
},
|
||||
[setShowBg, setIsSticky],
|
||||
);
|
||||
|
||||
const { width: windowWidth, height: windowHeight } = useWindowSize();
|
||||
|
||||
const { isTV } = useIsTV();
|
||||
|
|
@ -80,11 +67,6 @@ export function HeroPart({ setIsSticky, searchParams }: HeroPartProps) {
|
|||
? -40 // landscape
|
||||
: 0; // portrait
|
||||
|
||||
const dismissAd = useCallback(() => {
|
||||
setIsAdDismissed(true);
|
||||
setCookie("adDismissed", "true", 2); // Expires after 2 days
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (windowWidth > 1280) {
|
||||
// On large screens the bar goes inline with the nav elements
|
||||
|
|
@ -103,12 +85,18 @@ export function HeroPart({ setIsSticky, searchParams }: HeroPartProps) {
|
|||
|
||||
return (
|
||||
<ThinContainer>
|
||||
<div className="mt-44 space-y-16 text-center">
|
||||
<div className="relative z-10 mb-16">
|
||||
{isTV && search.length > 0 ? null : (
|
||||
<div
|
||||
className={classNames(
|
||||
"space-y-16 text-center",
|
||||
showTitle ? "mt-44" : `mt-4`,
|
||||
)}
|
||||
>
|
||||
{showTitle && (!isTV || search.length === 0) ? (
|
||||
<div className="relative z-10 mb-16">
|
||||
<HeroTitle className="mx-auto max-w-md">{title}</HeroTitle>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="relative h-20 z-30">
|
||||
<Sticky
|
||||
topOffset={stickyOffset * -1 + bannerSize}
|
||||
|
|
@ -123,112 +111,12 @@ export function HeroPart({ setIsSticky, searchParams }: HeroPartProps) {
|
|||
value={search}
|
||||
onUnFocus={setSearchUnFocus}
|
||||
placeholder={placeholder ?? ""}
|
||||
isSticky={showBg}
|
||||
isInFeatured={isInFeatured}
|
||||
/>
|
||||
</Sticky>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Optional ad */}
|
||||
{conf().SHOW_AD && !isAdDismissed ? (
|
||||
<div className="-mb-10 md:-mb-20 w-fit max-w-[32rem] mx-auto relative group pb-4">
|
||||
{(() => {
|
||||
const adContentUrl = conf().AD_CONTENT_URL;
|
||||
|
||||
// VITE_AD_CONTENT_URL=default message (null will be nothing),referal link,image link, card message
|
||||
// Ensure adContentUrl is an array. If not, render nothing for ads.
|
||||
if (!Array.isArray(adContentUrl)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const ad1LinkIsValid =
|
||||
typeof adContentUrl[1] === "string" && adContentUrl[1].length > 0;
|
||||
const ad1ImageIsProvided = typeof adContentUrl[2] === "string";
|
||||
const showAd1 =
|
||||
adContentUrl.length >= 2 && ad1LinkIsValid && ad1ImageIsProvided;
|
||||
|
||||
const ad2LinkIsValid =
|
||||
typeof adContentUrl[3] === "string" && adContentUrl[3].length > 0;
|
||||
const ad2ImageIsProvided = typeof adContentUrl[4] === "string";
|
||||
const showAd2 =
|
||||
adContentUrl.length >= 5 && ad2LinkIsValid && ad2ImageIsProvided;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex flex-col md:flex-row md:space-x-4 space-y-4 md:space-y-0 justify-center w-full items-center md:items-start">
|
||||
{showAd1 ? (
|
||||
<div className="hover:scale-[1.02] max-w-[16rem] bg-opacity-10 bg-buttons-purple backdrop-blur-sm rounded-xl border-2 border-buttons-purple border-opacity-30 transition-all duration-300 hover:border-opacity-70 hover:shadow-lg hover:shadow-buttons-purple/20 md:flex-1 relative group">
|
||||
<button
|
||||
onClick={dismissAd}
|
||||
type="button"
|
||||
className="absolute z-50 -top-2 -right-2 w-6 h-6 bg-mediaCard-hoverBackground rounded-full flex items-center justify-center md:opacity-0 group-hover:opacity-100 transition-opacity duration-300"
|
||||
aria-label="Dismiss ad"
|
||||
>
|
||||
<Icon
|
||||
className="text-xs font-semibold text-type-secondary"
|
||||
icon={Icons.X}
|
||||
/>
|
||||
</button>
|
||||
<a href={adContentUrl[1]} className="block">
|
||||
<div className="overflow-hidden rounded-t-xl">
|
||||
<img
|
||||
src={adContentUrl[2]}
|
||||
alt="ad banner"
|
||||
className="w-full h-auto transition-transform duration-300"
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-type-dimmed text-center py-2 transition-colors duration-300 group-hover:text-type-secondary">
|
||||
<span>{adContentUrl[3]}</span>
|
||||
</p>
|
||||
</a>
|
||||
</div>
|
||||
) : null}
|
||||
{showAd2 ? (
|
||||
<div className="hover:scale-[1.02] max-w-[16rem] bg-opacity-10 bg-buttons-purple backdrop-blur-sm rounded-xl border-2 border-buttons-purple border-opacity-30 transition-all duration-300 hover:border-opacity-70 hover:shadow-lg hover:shadow-buttons-purple/20 md:flex-1 relative group">
|
||||
<button
|
||||
onClick={dismissAd}
|
||||
type="button"
|
||||
className="absolute z-50 -top-2 -right-2 w-6 h-6 bg-mediaCard-hoverBackground rounded-full flex items-center justify-center md:opacity-0 group-hover:opacity-100 transition-opacity duration-300"
|
||||
aria-label="Dismiss ad"
|
||||
>
|
||||
<Icon
|
||||
className="text-xs font-semibold text-type-secondary"
|
||||
icon={Icons.X}
|
||||
/>
|
||||
</button>
|
||||
<a href={adContentUrl[4]} className="block">
|
||||
<div className="overflow-hidden rounded-t-xl">
|
||||
<img
|
||||
src={adContentUrl[5]}
|
||||
alt="ad banner"
|
||||
className="w-full h-auto transition-transform duration-300"
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-type-dimmed text-center py-2 transition-colors duration-300 group-hover:text-type-secondary">
|
||||
<span>{adContentUrl[6]}</span>
|
||||
</p>
|
||||
</a>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
{adContentUrl[0] !== "null" && (
|
||||
<div>
|
||||
<p className="text-xs text-type-dimmed text-center pt-2 mx-4">
|
||||
<a
|
||||
href="https://discord.gg/mcjnJK98Gd"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
{adContentUrl[0]}
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
) : null}
|
||||
{/* End of ad */}
|
||||
</ThinContainer>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -205,8 +205,14 @@ export function AppearancePart(props: {
|
|||
enableDiscover: boolean;
|
||||
setEnableDiscover: (v: boolean) => void;
|
||||
|
||||
enableFeatured: boolean;
|
||||
setEnableFeatured: (v: boolean) => void;
|
||||
|
||||
enableDetailsModal: boolean;
|
||||
setEnableDetailsModal: (v: boolean) => void;
|
||||
|
||||
enableImageLogos: boolean;
|
||||
setEnableImageLogos: (v: boolean) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
|
|
@ -263,6 +269,7 @@ export function AppearancePart(props: {
|
|||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
|
||||
{/* First Column - Preferences */}
|
||||
<div className="space-y-8">
|
||||
{/* Discover */}
|
||||
<div>
|
||||
<p className="text-white font-bold mb-3">
|
||||
{t("settings.appearance.options.discover")}
|
||||
|
|
@ -271,7 +278,13 @@ export function AppearancePart(props: {
|
|||
{t("settings.appearance.options.discoverDescription")}
|
||||
</p>
|
||||
<div
|
||||
onClick={() => props.setEnableDiscover(!props.enableDiscover)}
|
||||
onClick={() => {
|
||||
const newDiscoverValue = !props.enableDiscover;
|
||||
props.setEnableDiscover(newDiscoverValue);
|
||||
if (!newDiscoverValue) {
|
||||
props.setEnableFeatured(false);
|
||||
}
|
||||
}}
|
||||
className="bg-dropdown-background hover:bg-dropdown-hoverBackground select-none my-4 cursor-pointer space-x-3 flex items-center max-w-[25rem] py-3 px-4 rounded-lg"
|
||||
>
|
||||
<Toggle enabled={props.enableDiscover} />
|
||||
|
|
@ -280,6 +293,27 @@ export function AppearancePart(props: {
|
|||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{/* Featured Carousel */}
|
||||
{props.enableDiscover && (
|
||||
<div className="pt-4 pl-4 border-l-8 border-dropdown-background">
|
||||
<p className="text-white font-bold mb-3">
|
||||
{t("settings.appearance.options.featured")}
|
||||
</p>
|
||||
<p className="max-w-[25rem] font-medium">
|
||||
{t("settings.appearance.options.featuredDescription")}
|
||||
</p>
|
||||
<div
|
||||
onClick={() => props.setEnableFeatured(!props.enableFeatured)}
|
||||
className="bg-dropdown-background hover:bg-dropdown-hoverBackground select-none my-4 cursor-pointer space-x-3 flex items-center max-w-[25rem] py-3 px-4 rounded-lg"
|
||||
>
|
||||
<Toggle enabled={props.enableFeatured} />
|
||||
<p className="flex-1 text-white font-bold">
|
||||
{t("settings.appearance.options.featuredLabel")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{/* Detials Modal */}
|
||||
<div>
|
||||
<p className="text-white font-bold mb-3">
|
||||
{t("settings.appearance.options.modal")}
|
||||
|
|
@ -302,6 +336,32 @@ export function AppearancePart(props: {
|
|||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{/* Image Logos */}
|
||||
<div>
|
||||
<p className="text-white font-bold mb-3">
|
||||
{t("settings.appearance.options.logos")}
|
||||
</p>
|
||||
<p className="max-w-[25rem] font-medium">
|
||||
{t("settings.appearance.options.logosDescription")}
|
||||
</p>
|
||||
<p className="max-w-[25rem] font-medium pt-2 items-center flex gap-4">
|
||||
<Icon icon={Icons.CIRCLE_EXCLAMATION} className="" />
|
||||
|
||||
{t("settings.appearance.options.logosNotice")}
|
||||
</p>
|
||||
<div
|
||||
onClick={() => props.setEnableImageLogos(!props.enableImageLogos)}
|
||||
className={classNames(
|
||||
"bg-dropdown-background hover:bg-dropdown-hoverBackground select-none my-4 cursor-pointer space-x-3 flex items-center max-w-[25rem] py-3 px-4 rounded-lg",
|
||||
"cursor-pointer opacity-100 pointer-events-auto",
|
||||
)}
|
||||
>
|
||||
<Toggle enabled={props.enableImageLogos} />
|
||||
<p className="flex-1 text-white font-bold">
|
||||
{t("settings.appearance.options.logosLabel")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Second Column - Themes */}
|
||||
|
|
|
|||
|
|
@ -73,6 +73,7 @@ export function PreferencesPart(props: {
|
|||
{t("settings.preferences.languageDescription")}
|
||||
</p>
|
||||
<Dropdown
|
||||
className="w-full"
|
||||
options={options}
|
||||
selectedItem={selected || options[0]}
|
||||
setSelectedItem={(opt) => props.setLanguage(opt.id)}
|
||||
|
|
@ -127,7 +128,7 @@ export function PreferencesPart(props: {
|
|||
|
||||
{/* Skip End Credits Preference */}
|
||||
{props.enableAutoplay && allowAutoplay && (
|
||||
<div className="pt-4">
|
||||
<div className="pt-4 pl-4 border-l-8 border-dropdown-background">
|
||||
<p className="text-white font-bold mb-3">
|
||||
{t("settings.preferences.skipCredits")}
|
||||
</p>
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ import { AboutPage } from "@/pages/About";
|
|||
import { AdminPage } from "@/pages/admin/AdminPage";
|
||||
import VideoTesterView from "@/pages/developer/VideoTesterView";
|
||||
import { Discover } from "@/pages/discover/Discover";
|
||||
import { MoreContent } from "@/pages/discover/MoreContent";
|
||||
import { DmcaPage } from "@/pages/Dmca";
|
||||
import MaintenancePage from "@/pages/errors/MaintenancePage";
|
||||
import { NotFoundPage } from "@/pages/errors/NotFoundPage";
|
||||
|
|
@ -166,8 +167,16 @@ function App() {
|
|||
{/* Support page */}
|
||||
<Route path="/support" element={<SupportPage />} />
|
||||
<Route path="/jip" element={<JipPage />} />
|
||||
{/* Discover page */}
|
||||
{/* Discover pages */}
|
||||
<Route path="/discover" element={<Discover />} />
|
||||
<Route
|
||||
path="/discover/more/:type/:id/:mediaType"
|
||||
element={<MoreContent />}
|
||||
/>
|
||||
<Route
|
||||
path="/discover/more/:category/:genreId?"
|
||||
element={<MoreContent />}
|
||||
/>
|
||||
{/* Settings page */}
|
||||
<Route
|
||||
path="/settings"
|
||||
|
|
|
|||
32
src/stores/discover/index.ts
Normal file
32
src/stores/discover/index.ts
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
import { create } from "zustand";
|
||||
import { persist } from "zustand/middleware";
|
||||
|
||||
type Category = "movies" | "tvshows" | "editorpicks";
|
||||
|
||||
interface DiscoverView {
|
||||
url: string;
|
||||
scrollPosition: number;
|
||||
}
|
||||
|
||||
interface DiscoverState {
|
||||
selectedCategory: Category;
|
||||
lastView: DiscoverView | null;
|
||||
setSelectedCategory: (category: Category) => void;
|
||||
setLastView: (view: DiscoverView) => void;
|
||||
clearLastView: () => void;
|
||||
}
|
||||
|
||||
export const useDiscoverStore = create<DiscoverState>()(
|
||||
persist(
|
||||
(set) => ({
|
||||
selectedCategory: "movies",
|
||||
lastView: null,
|
||||
setSelectedCategory: (category) => set({ selectedCategory: category }),
|
||||
setLastView: (view) => set({ lastView: view }),
|
||||
clearLastView: () => set({ lastView: null }),
|
||||
}),
|
||||
{
|
||||
name: "__MW::discover",
|
||||
},
|
||||
),
|
||||
);
|
||||
|
|
@ -7,7 +7,9 @@ export interface PreferencesStore {
|
|||
enableAutoplay: boolean;
|
||||
enableSkipCredits: boolean;
|
||||
enableDiscover: boolean;
|
||||
enableFeatured: boolean;
|
||||
enableDetailsModal: boolean;
|
||||
enableImageLogos: boolean;
|
||||
sourceOrder: string[];
|
||||
enableSourceOrder: boolean;
|
||||
proxyTmdb: boolean;
|
||||
|
|
@ -16,7 +18,9 @@ export interface PreferencesStore {
|
|||
setEnableAutoplay(v: boolean): void;
|
||||
setEnableSkipCredits(v: boolean): void;
|
||||
setEnableDiscover(v: boolean): void;
|
||||
setEnableFeatured(v: boolean): void;
|
||||
setEnableDetailsModal(v: boolean): void;
|
||||
setEnableImageLogos(v: boolean): void;
|
||||
setSourceOrder(v: string[]): void;
|
||||
setEnableSourceOrder(v: boolean): void;
|
||||
setProxyTmdb(v: boolean): void;
|
||||
|
|
@ -29,7 +33,9 @@ export const usePreferencesStore = create(
|
|||
enableAutoplay: true,
|
||||
enableSkipCredits: true,
|
||||
enableDiscover: true,
|
||||
enableFeatured: true, // enabled for testing
|
||||
enableDetailsModal: false,
|
||||
enableImageLogos: true,
|
||||
sourceOrder: [],
|
||||
enableSourceOrder: false,
|
||||
proxyTmdb: false,
|
||||
|
|
@ -53,11 +59,21 @@ export const usePreferencesStore = create(
|
|||
s.enableDiscover = v;
|
||||
});
|
||||
},
|
||||
setEnableFeatured(v) {
|
||||
set((s) => {
|
||||
s.enableFeatured = v;
|
||||
});
|
||||
},
|
||||
setEnableDetailsModal(v) {
|
||||
set((s) => {
|
||||
s.enableDetailsModal = v;
|
||||
});
|
||||
},
|
||||
setEnableImageLogos(v) {
|
||||
set((s) => {
|
||||
s.enableImageLogos = v;
|
||||
});
|
||||
},
|
||||
setSourceOrder(v) {
|
||||
set((s) => {
|
||||
s.sourceOrder = v;
|
||||
|
|
|
|||
Loading…
Reference in a new issue