refactor overlay stack and modals to allow multiple and better navigation
Some checks failed
Linting and Testing / Run Linters (push) Has been cancelled
Linting and Testing / Build project (push) Has been cancelled
Linting and Testing / Build Docker (push) Has been cancelled

overlays will not close previous ones so that they don't conflict and there can essentially be unlimited modal navigations. Done by passing the modal data to each modal using a global hook instead of relying on local data for each.
When navigating to a new path, it closes all modals.
On CollectionOverlay, when opening a new details modal overlay, it closes the previous using a custom ShowDetails handler.

This isn't the cleanest approach, but offers the greatest flexibility in the future
This commit is contained in:
Pas 2025-10-26 23:58:04 -06:00
parent 61593e7fe5
commit ba59405612
12 changed files with 108 additions and 106 deletions

View file

@ -16,7 +16,6 @@ import { MediaItem } from "@/utils/mediaTypes";
import { MediaBookmarkButton } from "./MediaBookmark"; import { MediaBookmarkButton } from "./MediaBookmark";
import { IconPatch } from "../buttons/IconPatch"; import { IconPatch } from "../buttons/IconPatch";
import { Icon, Icons } from "../Icon"; import { Icon, Icons } from "../Icon";
import { DetailsModal } from "../overlays/detailsModal";
// Intersection Observer Hook // Intersection Observer Hook
function useIntersectionObserver(options: IntersectionObserverInit = {}) { function useIntersectionObserver(options: IntersectionObserverInit = {}) {
@ -300,10 +299,6 @@ function MediaCardContent({
export function MediaCard(props: MediaCardProps) { export function MediaCard(props: MediaCardProps) {
const { media, onShowDetails, forceSkeleton } = props; const { media, onShowDetails, forceSkeleton } = props;
const [detailsData, setDetailsData] = useState<{
id: number;
type: "movie" | "show";
} | null>(null);
const { showModal } = useOverlayStack(); const { showModal } = useOverlayStack();
const enableDetailsModal = usePreferencesStore( const enableDetailsModal = usePreferencesStore(
(state) => state.enableDetailsModal, (state) => state.enableDetailsModal,
@ -335,11 +330,11 @@ export function MediaCard(props: MediaCardProps) {
return; return;
} }
setDetailsData({ // Show modal with data through overlayStack
showModal("details", {
id: Number(media.id), id: Number(media.id),
type: media.type === "movie" ? "movie" : "show", type: media.type === "movie" ? "movie" : "show",
}); });
showModal("details");
}, [media, showModal, onShowDetails]); }, [media, showModal, onShowDetails]);
const handleCardClick = (e: React.MouseEvent) => { const handleCardClick = (e: React.MouseEvent) => {
@ -355,14 +350,11 @@ export function MediaCard(props: MediaCardProps) {
}; };
const content = ( const content = (
<> <MediaCardContent
<MediaCardContent {...props}
{...props} onShowDetails={handleShowDetails}
onShowDetails={handleShowDetails} forceSkeleton={forceSkeleton}
forceSkeleton={forceSkeleton} />
/>
{detailsData && <DetailsModal id="details" data={detailsData} />}
</>
); );
if (!canLink) { if (!canLink) {

View file

@ -23,8 +23,9 @@ import { DetailsSkeleton } from "./DetailsSkeleton";
import { OverlayPortal } from "../../../OverlayDisplay"; import { OverlayPortal } from "../../../OverlayDisplay";
import { DetailsModalProps } from "../../types"; import { DetailsModalProps } from "../../types";
export function DetailsModal({ id, data, minimal }: DetailsModalProps) { export function DetailsModal({ id, data: _data, minimal }: DetailsModalProps) {
const { hideModal, isModalVisible, modalStack } = useOverlayStack(); const { hideModal, isModalVisible, modalStack, getModalData } =
useOverlayStack();
const [detailsData, setDetailsData] = useState<any>(null); const [detailsData, setDetailsData] = useState<any>(null);
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
@ -33,9 +34,15 @@ export function DetailsModal({ id, data, minimal }: DetailsModalProps) {
const hide = useCallback(() => hideModal(id), [hideModal, id]); const hide = useCallback(() => hideModal(id), [hideModal, id]);
const isShown = isModalVisible(id); const isShown = isModalVisible(id);
const modalData = getModalData(id);
// Only show modal if there's data to display
const shouldShow = Boolean(isShown && (modalData?.id || _data?.id));
useEffect(() => { useEffect(() => {
const fetchDetails = async () => { const fetchDetails = async () => {
// Use data from overlayStack or fallback to props for backward compatibility
const data = modalData || _data;
if (!data?.id || !data?.type) return; if (!data?.id || !data?.type) return;
setIsLoading(true); setIsLoading(true);
@ -113,22 +120,22 @@ export function DetailsModal({ id, data, minimal }: DetailsModalProps) {
} }
}; };
if (isShown && data?.id) { if (shouldShow) {
fetchDetails(); fetchDetails();
} }
}, [isShown, data]); }, [shouldShow, modalData, _data]);
useEffect(() => { useEffect(() => {
if (isShown && !data?.id && !isLoading) { if (isShown && !modalData?.id && !_data?.id && !isLoading) {
hide(); hide();
} }
}, [isShown, data, isLoading, hide]); }, [isShown, modalData, _data, isLoading, hide]);
return ( return (
<OverlayPortal <OverlayPortal
darken darken
close={hide} close={hide}
show={isShown} show={shouldShow}
durationClass="duration-500" durationClass="duration-500"
zIndex={zIndex} zIndex={zIndex}
> >

View file

@ -9,18 +9,19 @@ import { MediaCard } from "@/components/media/MediaCard";
import { Flare } from "@/components/utils/Flare"; import { Flare } from "@/components/utils/Flare";
import { useIsMobile } from "@/hooks/useIsMobile"; import { useIsMobile } from "@/hooks/useIsMobile";
import { CarouselNavButtons } from "@/pages/discover/components/CarouselNavButtons"; import { CarouselNavButtons } from "@/pages/discover/components/CarouselNavButtons";
import { useOverlayStack } from "@/stores/interface/overlayStack";
import { MediaItem } from "@/utils/mediaTypes"; import { MediaItem } from "@/utils/mediaTypes";
// Simple carousel component for collection overlay // Simple carousel component for collection overlay
interface SimpleCarouselProps { interface SimpleCarouselProps {
mediaItems: MediaItem[]; mediaItems: MediaItem[];
onShowDetails: (movieId: number) => void; onShowDetails?: (media: MediaItem) => void;
categorySlug?: string; categorySlug?: string;
} }
function SimpleCarousel({ function SimpleCarousel({
mediaItems, mediaItems,
onShowDetails, onShowDetails: _onShowDetails,
categorySlug = "collection", categorySlug = "collection",
}: SimpleCarouselProps) { }: SimpleCarouselProps) {
const { isMobile } = useIsMobile(); const { isMobile } = useIsMobile();
@ -56,11 +57,7 @@ function SimpleCarousel({
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" 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"
style={{ scrollSnapAlign: "start" }} style={{ scrollSnapAlign: "start" }}
> >
<MediaCard <MediaCard media={media} linkable onShowDetails={_onShowDetails} />
media={media}
onShowDetails={() => onShowDetails(Number(media.id))}
linkable
/>
</div> </div>
))} ))}
@ -108,9 +105,10 @@ export function CollectionOverlay({
collectionId, collectionId,
collectionName, collectionName,
onClose, onClose,
onMovieClick, onMovieClick: _onMovieClick,
}: CollectionOverlayProps) { }: CollectionOverlayProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const { showModal } = useOverlayStack();
const [collection, setCollection] = useState<CollectionData | null>(null); const [collection, setCollection] = useState<CollectionData | null>(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
@ -163,6 +161,15 @@ export function CollectionOverlay({
}; };
}; };
const handleShowDetails = (media: MediaItem) => {
// Show details modal and close collection overlay
showModal("details", {
id: Number(media.id),
type: media.type === "movie" ? "movie" : "show",
});
onClose();
};
return ( return (
<div <div
className="fixed inset-0 bg-black/90 backdrop-blur-sm z-50 flex items-center justify-center p-4 sm:p-6 lg:p-8 transition-opacity duration-300" className="fixed inset-0 bg-black/90 backdrop-blur-sm z-50 flex items-center justify-center p-4 sm:p-6 lg:p-8 transition-opacity duration-300"
@ -311,7 +318,7 @@ export function CollectionOverlay({
{!loading && !error && sortedMovies.length > 0 && ( {!loading && !error && sortedMovies.length > 0 && (
<SimpleCarousel <SimpleCarousel
mediaItems={sortedMovies.map(movieToMediaItem)} mediaItems={sortedMovies.map(movieToMediaItem)}
onShowDetails={onMovieClick} onShowDetails={handleShowDetails}
categorySlug="collection" categorySlug="collection"
/> />
)} )}

View file

@ -1,8 +1,7 @@
import { useEffect, useState } from "react"; import { useEffect } from "react";
import { Icons } from "@/components/Icon"; import { Icons } from "@/components/Icon";
import { DetailsModal } from "@/components/overlays/detailsModal"; import { useOverlayStack } from "@/stores/interface/overlayStack";
import { useModal } from "@/components/overlays/Modal";
import { usePlayerStore } from "@/stores/player/store"; import { usePlayerStore } from "@/stores/player/store";
import { usePreferencesStore } from "@/stores/preferences"; import { usePreferencesStore } from "@/stores/preferences";
@ -10,25 +9,20 @@ import { VideoPlayerButton } from "./Button";
export function InfoButton() { export function InfoButton() {
const meta = usePlayerStore((s) => s.meta); const meta = usePlayerStore((s) => s.meta);
const modal = useModal("player-details"); const { showModal, isModalVisible } = useOverlayStack();
const setHasOpenOverlay = usePlayerStore((s) => s.setHasOpenOverlay); const setHasOpenOverlay = usePlayerStore((s) => s.setHasOpenOverlay);
const [detailsData, setDetailsData] = useState<{
id: number;
type: "movie" | "show";
} | null>(null);
useEffect(() => { useEffect(() => {
setHasOpenOverlay(modal.isShown); setHasOpenOverlay(isModalVisible("player-details"));
}, [setHasOpenOverlay, modal.isShown]); }, [setHasOpenOverlay, isModalVisible]);
const handleClick = async () => { const handleClick = async () => {
if (!meta?.tmdbId) return; if (!meta?.tmdbId) return;
setDetailsData({ showModal("player-details", {
id: Number(meta.tmdbId), id: Number(meta.tmdbId),
type: meta.type === "movie" ? "movie" : "show", type: meta.type === "movie" ? "movie" : "show",
}); });
modal.show();
}; };
const enableLowPerformanceMode = usePreferencesStore( const enableLowPerformanceMode = usePreferencesStore(
@ -40,16 +34,11 @@ export function InfoButton() {
} }
return ( return (
<> <VideoPlayerButton
<VideoPlayerButton icon={Icons.CIRCLE_QUESTION}
icon={Icons.CIRCLE_QUESTION} iconSizeClass="text-base"
iconSizeClass="text-base" className="p-2 !-mr-2"
className="p-2 !-mr-2" onClick={handleClick}
onClick={handleClick} />
/>
{detailsData && (
<DetailsModal id="player-details" data={detailsData} minimal />
)}
</>
); );
} }

View file

@ -4,7 +4,6 @@ import { useTranslation } from "react-i18next";
import { To, useNavigate } from "react-router-dom"; import { To, useNavigate } from "react-router-dom";
import { WideContainer } from "@/components/layout/WideContainer"; import { WideContainer } from "@/components/layout/WideContainer";
import { DetailsModal } from "@/components/overlays/detailsModal";
import { useDebounce } from "@/hooks/useDebounce"; import { useDebounce } from "@/hooks/useDebounce";
import { useRandomTranslation } from "@/hooks/useRandomTranslation"; import { useRandomTranslation } from "@/hooks/useRandomTranslation";
import { useSearchQuery } from "@/hooks/useSearchQuery"; import { useSearchQuery } from "@/hooks/useSearchQuery";
@ -62,7 +61,6 @@ export function HomePage() {
const s = useSearch(search); const s = useSearch(search);
const [showBookmarks, setShowBookmarks] = useState(false); const [showBookmarks, setShowBookmarks] = useState(false);
const [showWatching, setShowWatching] = useState(false); const [showWatching, setShowWatching] = useState(false);
const [detailsData, setDetailsData] = useState<any>();
const { showModal } = useOverlayStack(); const { showModal } = useOverlayStack();
const enableDiscover = usePreferencesStore((state) => state.enableDiscover); const enableDiscover = usePreferencesStore((state) => state.enableDiscover);
const enableFeatured = usePreferencesStore((state) => state.enableFeatured); const enableFeatured = usePreferencesStore((state) => state.enableFeatured);
@ -83,11 +81,10 @@ export function HomePage() {
}; };
const handleShowDetails = async (media: MediaItem | FeaturedMedia) => { const handleShowDetails = async (media: MediaItem | FeaturedMedia) => {
setDetailsData({ showModal("details", {
id: Number(media.id), id: Number(media.id),
type: media.type === "movie" ? "movie" : "show", type: media.type === "movie" ? "movie" : "show",
}); });
showModal("details");
}; };
const renderHomeSections = () => { const renderHomeSections = () => {
@ -235,8 +232,6 @@ export function HomePage() {
</div> </div>
)} )}
</WideContainer> </WideContainer>
{detailsData && <DetailsModal id="details" data={detailsData} />}
</HomeLayout> </HomeLayout>
); );
} }

View file

@ -12,7 +12,6 @@ import { SectionHeading } from "@/components/layout/SectionHeading";
import { WideContainer } from "@/components/layout/WideContainer"; import { WideContainer } from "@/components/layout/WideContainer";
import { MediaGrid } from "@/components/media/MediaGrid"; import { MediaGrid } from "@/components/media/MediaGrid";
import { WatchedMediaCard } from "@/components/media/WatchedMediaCard"; import { WatchedMediaCard } from "@/components/media/WatchedMediaCard";
import { DetailsModal } from "@/components/overlays/detailsModal";
import { EditGroupOrderModal } from "@/components/overlays/EditGroupOrderModal"; import { EditGroupOrderModal } from "@/components/overlays/EditGroupOrderModal";
import { useModal } from "@/components/overlays/Modal"; import { useModal } from "@/components/overlays/Modal";
import { UserIcon, UserIcons } from "@/components/UserIcon"; import { UserIcon, UserIcons } from "@/components/UserIcon";
@ -58,18 +57,16 @@ export function AllBookmarks({ onShowDetails }: AllBookmarksProps) {
const [tempGroupOrder, setTempGroupOrder] = useState<string[]>([]); const [tempGroupOrder, setTempGroupOrder] = useState<string[]>([]);
const backendUrl = useBackendUrl(); const backendUrl = useBackendUrl();
const account = useAuthStore((s) => s.account); const account = useAuthStore((s) => s.account);
const [detailsData, setDetailsData] = useState<any>();
const { showModal } = useOverlayStack(); const { showModal } = useOverlayStack();
const handleShowDetails = async (media: MediaItem) => { const handleShowDetails = async (media: MediaItem) => {
if (onShowDetails) { if (onShowDetails) {
onShowDetails(media); onShowDetails(media);
} else { } else {
setDetailsData({ showModal("details", {
id: Number(media.id), id: Number(media.id),
type: media.type === "movie" ? "movie" : "show", type: media.type === "movie" ? "movie" : "show",
}); });
showModal("details");
} }
}; };
@ -431,8 +428,6 @@ export function AllBookmarks({ onShowDetails }: AllBookmarksProps) {
setTempGroupOrder(newOrder); setTempGroupOrder(newOrder);
}} }}
/> />
{detailsData && <DetailsModal id="details" data={detailsData} />}
</WideContainer> </WideContainer>
</SubPageLayout> </SubPageLayout>
); );

View file

@ -11,24 +11,22 @@ import { TMDBMovieData } from "@/backend/metadata/types/tmdb";
import { Icon, Icons } from "@/components/Icon"; import { Icon, Icons } from "@/components/Icon";
import { WideContainer } from "@/components/layout/WideContainer"; import { WideContainer } from "@/components/layout/WideContainer";
import { MediaCard } from "@/components/media/MediaCard"; import { MediaCard } from "@/components/media/MediaCard";
import { DetailsModal } from "@/components/overlays/detailsModal";
import { useModal } from "@/components/overlays/Modal";
import { Heading1 } from "@/components/utils/Text"; import { Heading1 } from "@/components/utils/Text";
import { useIsMobile } from "@/hooks/useIsMobile"; import { useIsMobile } from "@/hooks/useIsMobile";
import { CarouselNavButtons } from "@/pages/discover/components/CarouselNavButtons"; import { CarouselNavButtons } from "@/pages/discover/components/CarouselNavButtons";
import { SubPageLayout } from "@/pages/layouts/SubPageLayout"; import { SubPageLayout } from "@/pages/layouts/SubPageLayout";
import { useDiscoverStore } from "@/stores/discover"; import { useDiscoverStore } from "@/stores/discover";
import { useOverlayStack } from "@/stores/interface/overlayStack";
import { MediaItem } from "@/utils/mediaTypes"; import { MediaItem } from "@/utils/mediaTypes";
import { MediaCarousel } from "./components/MediaCarousel"; import { MediaCarousel } from "./components/MediaCarousel";
export function DiscoverMore() { export function DiscoverMore() {
const [detailsData, setDetailsData] = useState<any>();
const [curatedLists, setCuratedLists] = useState<CuratedMovieList[]>([]); const [curatedLists, setCuratedLists] = useState<CuratedMovieList[]>([]);
const [movieDetails, setMovieDetails] = useState<{ const [movieDetails, setMovieDetails] = useState<{
[listSlug: string]: TMDBMovieData[]; [listSlug: string]: TMDBMovieData[];
}>({}); }>({});
const detailsModal = useModal("discover-details"); const { showModal } = useOverlayStack();
const carouselRefs = useRef<{ [key: string]: HTMLDivElement | null }>({}); const carouselRefs = useRef<{ [key: string]: HTMLDivElement | null }>({});
const navigate = useNavigate(); const navigate = useNavigate();
const { lastView } = useDiscoverStore(); const { lastView } = useDiscoverStore();
@ -65,11 +63,10 @@ export function DiscoverMore() {
}, []); }, []);
const handleShowDetails = async (media: MediaItem) => { const handleShowDetails = async (media: MediaItem) => {
setDetailsData({ showModal("discover-details", {
id: Number(media.id), id: Number(media.id),
type: media.type === "movie" ? "movie" : "show", type: media.type === "movie" ? "movie" : "show",
}); });
detailsModal.show();
}; };
const handleBack = () => { const handleBack = () => {
@ -183,7 +180,6 @@ export function DiscoverMore() {
</div> </div>
))} ))}
</WideContainer> </WideContainer>
{detailsData && <DetailsModal id="discover-details" data={detailsData} />}
</SubPageLayout> </SubPageLayout>
); );
} }

View file

@ -1,8 +1,6 @@
import { useEffect, useState } from "react";
import { Helmet } from "react-helmet-async"; import { Helmet } from "react-helmet-async";
import { DetailsModal } from "@/components/overlays/detailsModal"; import { useOverlayStack } from "@/stores/interface/overlayStack";
import { useModal } from "@/components/overlays/Modal";
import { SubPageLayout } from "../layouts/SubPageLayout"; import { SubPageLayout } from "../layouts/SubPageLayout";
import { FeaturedCarousel } from "./components/FeaturedCarousel"; import { FeaturedCarousel } from "./components/FeaturedCarousel";
@ -11,22 +9,13 @@ import DiscoverContent from "./discoverContent";
import { PageTitle } from "../parts/util/PageTitle"; import { PageTitle } from "../parts/util/PageTitle";
export function Discover() { export function Discover() {
const [detailsData, setDetailsData] = useState<any>(); const { showModal } = useOverlayStack();
const detailsModal = useModal("discover-details");
// Clear details data when modal is closed
useEffect(() => {
if (!detailsModal.isShown) {
setDetailsData(undefined);
}
}, [detailsModal.isShown]);
const handleShowDetails = (media: FeaturedMedia) => { const handleShowDetails = (media: FeaturedMedia) => {
setDetailsData({ showModal("discover-details", {
id: Number(media.id), id: Number(media.id),
type: media.type, type: media.type,
}); });
detailsModal.show();
}; };
return ( return (
@ -52,8 +41,6 @@ export function Discover() {
<div className="relative z-20 px-4 md:px-10"> <div className="relative z-20 px-4 md:px-10">
<DiscoverContent /> <DiscoverContent />
</div> </div>
{detailsData && <DetailsModal id="discover-details" data={detailsData} />}
</SubPageLayout> </SubPageLayout>
); );
} }

View file

@ -9,8 +9,6 @@ import { Icon, Icons } from "@/components/Icon";
import { WideContainer } from "@/components/layout/WideContainer"; import { WideContainer } from "@/components/layout/WideContainer";
import { MediaCard } from "@/components/media/MediaCard"; import { MediaCard } from "@/components/media/MediaCard";
import { MediaGrid } from "@/components/media/MediaGrid"; 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 { Heading1 } from "@/components/utils/Text";
import { import {
DiscoverContentType, DiscoverContentType,
@ -20,6 +18,7 @@ import {
} from "@/pages/discover/hooks/useDiscoverMedia"; } from "@/pages/discover/hooks/useDiscoverMedia";
import { SubPageLayout } from "@/pages/layouts/SubPageLayout"; import { SubPageLayout } from "@/pages/layouts/SubPageLayout";
import { useDiscoverStore } from "@/stores/discover"; import { useDiscoverStore } from "@/stores/discover";
import { useOverlayStack } from "@/stores/interface/overlayStack";
import { useProgressStore } from "@/stores/progress"; import { useProgressStore } from "@/stores/progress";
import { MediaItem } from "@/utils/mediaTypes"; import { MediaItem } from "@/utils/mediaTypes";
@ -30,7 +29,6 @@ interface MoreContentProps {
export function MoreContent({ onShowDetails }: MoreContentProps) { export function MoreContent({ onShowDetails }: MoreContentProps) {
const { mediaType = "movie", contentType, id, category } = useParams(); const { mediaType = "movie", contentType, id, category } = useParams();
const [currentPage, setCurrentPage] = useState(1); const [currentPage, setCurrentPage] = useState(1);
const [detailsData, setDetailsData] = useState<any>();
const [selectedProvider, setSelectedProvider] = useState<OptionItem | null>( const [selectedProvider, setSelectedProvider] = useState<OptionItem | null>(
null, null,
); );
@ -40,7 +38,7 @@ export function MoreContent({ onShowDetails }: MoreContentProps) {
const [isContentVisible, setIsContentVisible] = useState(false); const [isContentVisible, setIsContentVisible] = useState(false);
const { t } = useTranslation(); const { t } = useTranslation();
const navigate = useNavigate(); const navigate = useNavigate();
const detailsModal = useModal("discover-details"); const { showModal } = useOverlayStack();
const { lastView } = useDiscoverStore(); const { lastView } = useDiscoverStore();
const { width: windowWidth } = useWindowSize(); const { width: windowWidth } = useWindowSize();
const progressStore = useProgressStore(); const progressStore = useProgressStore();
@ -113,11 +111,10 @@ export function MoreContent({ onShowDetails }: MoreContentProps) {
onShowDetails(media); onShowDetails(media);
return; return;
} }
setDetailsData({ showModal("discover-details", {
id: Number(media.id), id: Number(media.id),
type: media.type === "movie" ? "movie" : "show", type: media.type === "movie" ? "movie" : "show",
}); });
detailsModal.show();
}; };
const handleLoadMore = async () => { const handleLoadMore = async () => {
@ -386,7 +383,6 @@ export function MoreContent({ onShowDetails }: MoreContentProps) {
)} )}
</div> </div>
</WideContainer> </WideContainer>
{detailsData && <DetailsModal id="discover-details" data={detailsData} />}
</SubPageLayout> </SubPageLayout>
); );
} }

View file

@ -1,13 +1,12 @@
import classNames from "classnames"; import classNames from "classnames";
import { t } from "i18next"; import { t } from "i18next";
import { useRef, useState } from "react"; import { useRef } from "react";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { Button } from "@/components/buttons/Button"; import { Button } from "@/components/buttons/Button";
import { WideContainer } from "@/components/layout/WideContainer"; import { WideContainer } from "@/components/layout/WideContainer";
import { DetailsModal } from "@/components/overlays/detailsModal";
import { useModal } from "@/components/overlays/Modal";
import { useDiscoverStore } from "@/stores/discover"; import { useDiscoverStore } from "@/stores/discover";
import { useOverlayStack } from "@/stores/interface/overlayStack";
import { useProgressStore } from "@/stores/progress"; import { useProgressStore } from "@/stores/progress";
import { MediaItem } from "@/utils/mediaTypes"; import { MediaItem } from "@/utils/mediaTypes";
@ -18,9 +17,8 @@ import { ScrollToTopButton } from "./components/ScrollToTopButton";
export function DiscoverContent() { export function DiscoverContent() {
const { selectedCategory, setSelectedCategory } = useDiscoverStore(); const { selectedCategory, setSelectedCategory } = useDiscoverStore();
const [detailsData, setDetailsData] = useState<any>();
const navigate = useNavigate(); const navigate = useNavigate();
const detailsModal = useModal("discover-details"); const { showModal } = useOverlayStack();
const carouselRefs = useRef<{ [key: string]: HTMLDivElement | null }>({}); const carouselRefs = useRef<{ [key: string]: HTMLDivElement | null }>({});
const progressItems = useProgressStore((state) => state.items); const progressItems = useProgressStore((state) => state.items);
@ -34,11 +32,10 @@ export function DiscoverContent() {
}; };
const handleShowDetails = async (media: MediaItem | FeaturedMedia) => { const handleShowDetails = async (media: MediaItem | FeaturedMedia) => {
setDetailsData({ showModal("discover-details", {
id: Number(media.id), id: Number(media.id),
type: media.type === "movie" ? "movie" : "show", type: media.type === "movie" ? "movie" : "show",
}); });
detailsModal.show();
}; };
const movieProgressItems = Object.entries(progressItems || {}).filter( const movieProgressItems = Object.entries(progressItems || {}).filter(
@ -240,7 +237,7 @@ export function DiscoverContent() {
<ScrollToTopButton /> <ScrollToTopButton />
{detailsData && <DetailsModal id="discover-details" data={detailsData} />} {/* DetailsModal is now managed by overlayStack */}
</div> </div>
); );
} }

View file

@ -11,6 +11,7 @@ import {
import { convertLegacyUrl, isLegacyUrl } from "@/backend/metadata/getmeta"; import { convertLegacyUrl, isLegacyUrl } from "@/backend/metadata/getmeta";
import { generateQuickSearchMediaUrl } from "@/backend/metadata/tmdb"; import { generateQuickSearchMediaUrl } from "@/backend/metadata/tmdb";
import { DetailsModal } from "@/components/overlays/detailsModal";
import { KeyboardCommandsModal } from "@/components/overlays/KeyboardCommandsModal"; import { KeyboardCommandsModal } from "@/components/overlays/KeyboardCommandsModal";
import { NotificationModal } from "@/components/overlays/notificationsModal"; import { NotificationModal } from "@/components/overlays/notificationsModal";
import { useGlobalKeyboardEvents } from "@/hooks/useGlobalKeyboardEvents"; import { useGlobalKeyboardEvents } from "@/hooks/useGlobalKeyboardEvents";
@ -39,6 +40,7 @@ import { RegisterPage } from "@/pages/Register";
import { SupportPage } from "@/pages/Support"; import { SupportPage } from "@/pages/Support";
import { Layout } from "@/setup/Layout"; import { Layout } from "@/setup/Layout";
import { useHistoryListener } from "@/stores/history"; import { useHistoryListener } from "@/stores/history";
import { useClearModalsOnNavigation } from "@/stores/interface/overlayStack";
import { LanguageProvider } from "@/stores/language"; import { LanguageProvider } from "@/stores/language";
const DeveloperPage = lazy(() => import("@/pages/DeveloperPage")); const DeveloperPage = lazy(() => import("@/pages/DeveloperPage"));
@ -103,6 +105,7 @@ function App() {
useHistoryListener(); useHistoryListener();
useOnlineListener(); useOnlineListener();
useGlobalKeyboardEvents(); useGlobalKeyboardEvents();
useClearModalsOnNavigation();
const maintenance = false; // Shows maintance page const maintenance = false; // Shows maintance page
const [showDowntime, setShowDowntime] = useState(maintenance); const [showDowntime, setShowDowntime] = useState(maintenance);
@ -123,6 +126,9 @@ function App() {
<LanguageProvider /> <LanguageProvider />
<NotificationModal id="notifications" /> <NotificationModal id="notifications" />
<KeyboardCommandsModal id="keyboard-commands" /> <KeyboardCommandsModal id="keyboard-commands" />
<DetailsModal id="details" />
<DetailsModal id="discover-details" />
<DetailsModal id="player-details" />
{!showDowntime && ( {!showDowntime && (
<Routes> <Routes>
{/* functional routes */} {/* functional routes */}

View file

@ -1,35 +1,51 @@
import { useEffect } from "react";
import { useLocation } from "react-router-dom";
import { create } from "zustand"; import { create } from "zustand";
import { immer } from "zustand/middleware/immer"; import { immer } from "zustand/middleware/immer";
type OverlayType = "volume" | "subtitle" | "speed" | null; type OverlayType = "volume" | "subtitle" | "speed" | null;
interface ModalData {
id: number;
type: "movie" | "show";
[key: string]: any;
}
interface OverlayStackStore { interface OverlayStackStore {
currentOverlay: OverlayType; currentOverlay: OverlayType;
modalStack: string[]; modalStack: string[];
modalData: Record<string, ModalData | undefined>;
setCurrentOverlay: (overlay: OverlayType) => void; setCurrentOverlay: (overlay: OverlayType) => void;
showModal: (id: string) => void; showModal: (id: string, data?: ModalData) => void;
hideModal: (id: string) => void; hideModal: (id: string) => void;
isModalVisible: (id: string) => boolean; isModalVisible: (id: string) => boolean;
getTopModal: () => string | null; getTopModal: () => string | null;
getModalData: (id: string) => ModalData | undefined;
clearAllModals: () => void;
} }
export const useOverlayStack = create<OverlayStackStore>()( export const useOverlayStack = create<OverlayStackStore>()(
immer((set, get) => ({ immer((set, get) => ({
currentOverlay: null, currentOverlay: null,
modalStack: [], modalStack: [],
modalData: {},
setCurrentOverlay: (overlay) => setCurrentOverlay: (overlay) =>
set((state) => { set((state) => {
state.currentOverlay = overlay; state.currentOverlay = overlay;
}), }),
showModal: (id: string) => showModal: (id: string, data?: ModalData) =>
set((state) => { set((state) => {
if (!state.modalStack.includes(id)) { if (!state.modalStack.includes(id)) {
state.modalStack.push(id); state.modalStack.push(id);
} }
if (data) {
state.modalData[id] = data;
}
}), }),
hideModal: (id: string) => hideModal: (id: string) =>
set((state) => { set((state) => {
state.modalStack = state.modalStack.filter((modalId) => modalId !== id); state.modalStack = state.modalStack.filter((modalId) => modalId !== id);
delete state.modalData[id];
}), }),
isModalVisible: (id: string) => { isModalVisible: (id: string) => {
return get().modalStack.includes(id); return get().modalStack.includes(id);
@ -38,5 +54,24 @@ export const useOverlayStack = create<OverlayStackStore>()(
const stack = get().modalStack; const stack = get().modalStack;
return stack.length > 0 ? stack[stack.length - 1] : null; return stack.length > 0 ? stack[stack.length - 1] : null;
}, },
getModalData: (id: string) => {
return get().modalData[id];
},
clearAllModals: () =>
set((state) => {
state.modalStack = [];
state.modalData = {};
state.currentOverlay = null;
}),
})), })),
); );
// Hook to clear modals on navigation
export function useClearModalsOnNavigation() {
const location = useLocation();
const clearAllModals = useOverlayStack((state) => state.clearAllModals);
useEffect(() => {
clearAllModals();
}, [location.pathname, clearAllModals]);
}