From ba594056129dff95cbe9a64f6e304c743e924aaa Mon Sep 17 00:00:00 2001 From: Pas <74743263+Pasithea0@users.noreply.github.com> Date: Sun, 26 Oct 2025 23:58:04 -0600 Subject: [PATCH] refactor overlay stack and modals to allow multiple and better navigation 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 --- src/components/media/MediaCard.tsx | 22 ++++------- .../components/layout/DetailsModal.tsx | 21 ++++++---- .../components/overlays/CollectionOverlay.tsx | 25 +++++++----- .../player/internals/InfoButton.tsx | 35 ++++++----------- src/pages/HomePage.tsx | 7 +--- src/pages/bookmarks/AllBookmarks.tsx | 7 +--- src/pages/discover/AllMovieLists.tsx | 10 ++--- src/pages/discover/Discover.tsx | 19 ++------- src/pages/discover/MoreContent.tsx | 10 ++--- src/pages/discover/discoverContent.tsx | 13 +++---- src/setup/App.tsx | 6 +++ src/stores/interface/overlayStack.ts | 39 ++++++++++++++++++- 12 files changed, 108 insertions(+), 106 deletions(-) diff --git a/src/components/media/MediaCard.tsx b/src/components/media/MediaCard.tsx index 94cba547..85140103 100644 --- a/src/components/media/MediaCard.tsx +++ b/src/components/media/MediaCard.tsx @@ -16,7 +16,6 @@ import { MediaItem } from "@/utils/mediaTypes"; import { MediaBookmarkButton } from "./MediaBookmark"; import { IconPatch } from "../buttons/IconPatch"; import { Icon, Icons } from "../Icon"; -import { DetailsModal } from "../overlays/detailsModal"; // Intersection Observer Hook function useIntersectionObserver(options: IntersectionObserverInit = {}) { @@ -300,10 +299,6 @@ function MediaCardContent({ export function MediaCard(props: MediaCardProps) { const { media, onShowDetails, forceSkeleton } = props; - const [detailsData, setDetailsData] = useState<{ - id: number; - type: "movie" | "show"; - } | null>(null); const { showModal } = useOverlayStack(); const enableDetailsModal = usePreferencesStore( (state) => state.enableDetailsModal, @@ -335,11 +330,11 @@ export function MediaCard(props: MediaCardProps) { return; } - setDetailsData({ + // Show modal with data through overlayStack + showModal("details", { id: Number(media.id), type: media.type === "movie" ? "movie" : "show", }); - showModal("details"); }, [media, showModal, onShowDetails]); const handleCardClick = (e: React.MouseEvent) => { @@ -355,14 +350,11 @@ export function MediaCard(props: MediaCardProps) { }; const content = ( - <> - - {detailsData && } - + ); if (!canLink) { diff --git a/src/components/overlays/detailsModal/components/layout/DetailsModal.tsx b/src/components/overlays/detailsModal/components/layout/DetailsModal.tsx index 3eb7a5cb..c1a4b1a1 100644 --- a/src/components/overlays/detailsModal/components/layout/DetailsModal.tsx +++ b/src/components/overlays/detailsModal/components/layout/DetailsModal.tsx @@ -23,8 +23,9 @@ import { DetailsSkeleton } from "./DetailsSkeleton"; import { OverlayPortal } from "../../../OverlayDisplay"; import { DetailsModalProps } from "../../types"; -export function DetailsModal({ id, data, minimal }: DetailsModalProps) { - const { hideModal, isModalVisible, modalStack } = useOverlayStack(); +export function DetailsModal({ id, data: _data, minimal }: DetailsModalProps) { + const { hideModal, isModalVisible, modalStack, getModalData } = + useOverlayStack(); const [detailsData, setDetailsData] = useState(null); const [isLoading, setIsLoading] = useState(false); @@ -33,9 +34,15 @@ export function DetailsModal({ id, data, minimal }: DetailsModalProps) { const hide = useCallback(() => hideModal(id), [hideModal, 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(() => { const fetchDetails = async () => { + // Use data from overlayStack or fallback to props for backward compatibility + const data = modalData || _data; if (!data?.id || !data?.type) return; setIsLoading(true); @@ -113,22 +120,22 @@ export function DetailsModal({ id, data, minimal }: DetailsModalProps) { } }; - if (isShown && data?.id) { + if (shouldShow) { fetchDetails(); } - }, [isShown, data]); + }, [shouldShow, modalData, _data]); useEffect(() => { - if (isShown && !data?.id && !isLoading) { + if (isShown && !modalData?.id && !_data?.id && !isLoading) { hide(); } - }, [isShown, data, isLoading, hide]); + }, [isShown, modalData, _data, isLoading, hide]); return ( diff --git a/src/components/overlays/detailsModal/components/overlays/CollectionOverlay.tsx b/src/components/overlays/detailsModal/components/overlays/CollectionOverlay.tsx index f6c8c3fe..b9ce9758 100644 --- a/src/components/overlays/detailsModal/components/overlays/CollectionOverlay.tsx +++ b/src/components/overlays/detailsModal/components/overlays/CollectionOverlay.tsx @@ -9,18 +9,19 @@ import { MediaCard } from "@/components/media/MediaCard"; import { Flare } from "@/components/utils/Flare"; import { useIsMobile } from "@/hooks/useIsMobile"; import { CarouselNavButtons } from "@/pages/discover/components/CarouselNavButtons"; +import { useOverlayStack } from "@/stores/interface/overlayStack"; import { MediaItem } from "@/utils/mediaTypes"; // Simple carousel component for collection overlay interface SimpleCarouselProps { mediaItems: MediaItem[]; - onShowDetails: (movieId: number) => void; + onShowDetails?: (media: MediaItem) => void; categorySlug?: string; } function SimpleCarousel({ mediaItems, - onShowDetails, + onShowDetails: _onShowDetails, categorySlug = "collection", }: SimpleCarouselProps) { 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" style={{ scrollSnapAlign: "start" }} > - onShowDetails(Number(media.id))} - linkable - /> + ))} @@ -108,9 +105,10 @@ export function CollectionOverlay({ collectionId, collectionName, onClose, - onMovieClick, + onMovieClick: _onMovieClick, }: CollectionOverlayProps) { const { t } = useTranslation(); + const { showModal } = useOverlayStack(); const [collection, setCollection] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(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 (
0 && ( )} diff --git a/src/components/player/internals/InfoButton.tsx b/src/components/player/internals/InfoButton.tsx index 093c08fa..0ca76d04 100644 --- a/src/components/player/internals/InfoButton.tsx +++ b/src/components/player/internals/InfoButton.tsx @@ -1,8 +1,7 @@ -import { useEffect, useState } from "react"; +import { useEffect } from "react"; import { Icons } from "@/components/Icon"; -import { DetailsModal } from "@/components/overlays/detailsModal"; -import { useModal } from "@/components/overlays/Modal"; +import { useOverlayStack } from "@/stores/interface/overlayStack"; import { usePlayerStore } from "@/stores/player/store"; import { usePreferencesStore } from "@/stores/preferences"; @@ -10,25 +9,20 @@ import { VideoPlayerButton } from "./Button"; export function InfoButton() { const meta = usePlayerStore((s) => s.meta); - const modal = useModal("player-details"); + const { showModal, isModalVisible } = useOverlayStack(); const setHasOpenOverlay = usePlayerStore((s) => s.setHasOpenOverlay); - const [detailsData, setDetailsData] = useState<{ - id: number; - type: "movie" | "show"; - } | null>(null); useEffect(() => { - setHasOpenOverlay(modal.isShown); - }, [setHasOpenOverlay, modal.isShown]); + setHasOpenOverlay(isModalVisible("player-details")); + }, [setHasOpenOverlay, isModalVisible]); const handleClick = async () => { if (!meta?.tmdbId) return; - setDetailsData({ + showModal("player-details", { id: Number(meta.tmdbId), type: meta.type === "movie" ? "movie" : "show", }); - modal.show(); }; const enableLowPerformanceMode = usePreferencesStore( @@ -40,16 +34,11 @@ export function InfoButton() { } return ( - <> - - {detailsData && ( - - )} - + ); } diff --git a/src/pages/HomePage.tsx b/src/pages/HomePage.tsx index f9b0c520..88a97ee0 100644 --- a/src/pages/HomePage.tsx +++ b/src/pages/HomePage.tsx @@ -4,7 +4,6 @@ import { useTranslation } from "react-i18next"; import { To, useNavigate } from "react-router-dom"; import { WideContainer } from "@/components/layout/WideContainer"; -import { DetailsModal } from "@/components/overlays/detailsModal"; import { useDebounce } from "@/hooks/useDebounce"; import { useRandomTranslation } from "@/hooks/useRandomTranslation"; import { useSearchQuery } from "@/hooks/useSearchQuery"; @@ -62,7 +61,6 @@ export function HomePage() { const s = useSearch(search); const [showBookmarks, setShowBookmarks] = useState(false); const [showWatching, setShowWatching] = useState(false); - const [detailsData, setDetailsData] = useState(); const { showModal } = useOverlayStack(); const enableDiscover = usePreferencesStore((state) => state.enableDiscover); const enableFeatured = usePreferencesStore((state) => state.enableFeatured); @@ -83,11 +81,10 @@ export function HomePage() { }; const handleShowDetails = async (media: MediaItem | FeaturedMedia) => { - setDetailsData({ + showModal("details", { id: Number(media.id), type: media.type === "movie" ? "movie" : "show", }); - showModal("details"); }; const renderHomeSections = () => { @@ -235,8 +232,6 @@ export function HomePage() {
)} - - {detailsData && } ); } diff --git a/src/pages/bookmarks/AllBookmarks.tsx b/src/pages/bookmarks/AllBookmarks.tsx index c71e4f02..028e4126 100644 --- a/src/pages/bookmarks/AllBookmarks.tsx +++ b/src/pages/bookmarks/AllBookmarks.tsx @@ -12,7 +12,6 @@ import { SectionHeading } from "@/components/layout/SectionHeading"; import { WideContainer } from "@/components/layout/WideContainer"; import { MediaGrid } from "@/components/media/MediaGrid"; import { WatchedMediaCard } from "@/components/media/WatchedMediaCard"; -import { DetailsModal } from "@/components/overlays/detailsModal"; import { EditGroupOrderModal } from "@/components/overlays/EditGroupOrderModal"; import { useModal } from "@/components/overlays/Modal"; import { UserIcon, UserIcons } from "@/components/UserIcon"; @@ -58,18 +57,16 @@ export function AllBookmarks({ onShowDetails }: AllBookmarksProps) { const [tempGroupOrder, setTempGroupOrder] = useState([]); const backendUrl = useBackendUrl(); const account = useAuthStore((s) => s.account); - const [detailsData, setDetailsData] = useState(); const { showModal } = useOverlayStack(); const handleShowDetails = async (media: MediaItem) => { if (onShowDetails) { onShowDetails(media); } else { - setDetailsData({ + showModal("details", { id: Number(media.id), type: media.type === "movie" ? "movie" : "show", }); - showModal("details"); } }; @@ -431,8 +428,6 @@ export function AllBookmarks({ onShowDetails }: AllBookmarksProps) { setTempGroupOrder(newOrder); }} /> - - {detailsData && } ); diff --git a/src/pages/discover/AllMovieLists.tsx b/src/pages/discover/AllMovieLists.tsx index c2ff5c87..7d663267 100644 --- a/src/pages/discover/AllMovieLists.tsx +++ b/src/pages/discover/AllMovieLists.tsx @@ -11,24 +11,22 @@ import { TMDBMovieData } from "@/backend/metadata/types/tmdb"; import { Icon, Icons } from "@/components/Icon"; import { WideContainer } from "@/components/layout/WideContainer"; 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 { useIsMobile } from "@/hooks/useIsMobile"; import { CarouselNavButtons } from "@/pages/discover/components/CarouselNavButtons"; import { SubPageLayout } from "@/pages/layouts/SubPageLayout"; import { useDiscoverStore } from "@/stores/discover"; +import { useOverlayStack } from "@/stores/interface/overlayStack"; import { MediaItem } from "@/utils/mediaTypes"; import { MediaCarousel } from "./components/MediaCarousel"; export function DiscoverMore() { - const [detailsData, setDetailsData] = useState(); const [curatedLists, setCuratedLists] = useState([]); const [movieDetails, setMovieDetails] = useState<{ [listSlug: string]: TMDBMovieData[]; }>({}); - const detailsModal = useModal("discover-details"); + const { showModal } = useOverlayStack(); const carouselRefs = useRef<{ [key: string]: HTMLDivElement | null }>({}); const navigate = useNavigate(); const { lastView } = useDiscoverStore(); @@ -65,11 +63,10 @@ export function DiscoverMore() { }, []); const handleShowDetails = async (media: MediaItem) => { - setDetailsData({ + showModal("discover-details", { id: Number(media.id), type: media.type === "movie" ? "movie" : "show", }); - detailsModal.show(); }; const handleBack = () => { @@ -183,7 +180,6 @@ export function DiscoverMore() { ))} - {detailsData && } ); } diff --git a/src/pages/discover/Discover.tsx b/src/pages/discover/Discover.tsx index 0c3a8b81..07da54ca 100644 --- a/src/pages/discover/Discover.tsx +++ b/src/pages/discover/Discover.tsx @@ -1,8 +1,6 @@ -import { useEffect, useState } from "react"; import { Helmet } from "react-helmet-async"; -import { DetailsModal } from "@/components/overlays/detailsModal"; -import { useModal } from "@/components/overlays/Modal"; +import { useOverlayStack } from "@/stores/interface/overlayStack"; import { SubPageLayout } from "../layouts/SubPageLayout"; import { FeaturedCarousel } from "./components/FeaturedCarousel"; @@ -11,22 +9,13 @@ import DiscoverContent from "./discoverContent"; import { PageTitle } from "../parts/util/PageTitle"; export function Discover() { - const [detailsData, setDetailsData] = useState(); - const detailsModal = useModal("discover-details"); - - // Clear details data when modal is closed - useEffect(() => { - if (!detailsModal.isShown) { - setDetailsData(undefined); - } - }, [detailsModal.isShown]); + const { showModal } = useOverlayStack(); const handleShowDetails = (media: FeaturedMedia) => { - setDetailsData({ + showModal("discover-details", { id: Number(media.id), type: media.type, }); - detailsModal.show(); }; return ( @@ -52,8 +41,6 @@ export function Discover() {
- - {detailsData && } ); } diff --git a/src/pages/discover/MoreContent.tsx b/src/pages/discover/MoreContent.tsx index 5869b854..29f86efb 100644 --- a/src/pages/discover/MoreContent.tsx +++ b/src/pages/discover/MoreContent.tsx @@ -9,8 +9,6 @@ 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 { DiscoverContentType, @@ -20,6 +18,7 @@ import { } from "@/pages/discover/hooks/useDiscoverMedia"; import { SubPageLayout } from "@/pages/layouts/SubPageLayout"; import { useDiscoverStore } from "@/stores/discover"; +import { useOverlayStack } from "@/stores/interface/overlayStack"; import { useProgressStore } from "@/stores/progress"; import { MediaItem } from "@/utils/mediaTypes"; @@ -30,7 +29,6 @@ interface MoreContentProps { export function MoreContent({ onShowDetails }: MoreContentProps) { const { mediaType = "movie", contentType, id, category } = useParams(); const [currentPage, setCurrentPage] = useState(1); - const [detailsData, setDetailsData] = useState(); const [selectedProvider, setSelectedProvider] = useState( null, ); @@ -40,7 +38,7 @@ export function MoreContent({ onShowDetails }: MoreContentProps) { const [isContentVisible, setIsContentVisible] = useState(false); const { t } = useTranslation(); const navigate = useNavigate(); - const detailsModal = useModal("discover-details"); + const { showModal } = useOverlayStack(); const { lastView } = useDiscoverStore(); const { width: windowWidth } = useWindowSize(); const progressStore = useProgressStore(); @@ -113,11 +111,10 @@ export function MoreContent({ onShowDetails }: MoreContentProps) { onShowDetails(media); return; } - setDetailsData({ + showModal("discover-details", { id: Number(media.id), type: media.type === "movie" ? "movie" : "show", }); - detailsModal.show(); }; const handleLoadMore = async () => { @@ -386,7 +383,6 @@ export function MoreContent({ onShowDetails }: MoreContentProps) { )} - {detailsData && } ); } diff --git a/src/pages/discover/discoverContent.tsx b/src/pages/discover/discoverContent.tsx index fee88691..b060bb9c 100644 --- a/src/pages/discover/discoverContent.tsx +++ b/src/pages/discover/discoverContent.tsx @@ -1,13 +1,12 @@ import classNames from "classnames"; import { t } from "i18next"; -import { useRef, useState } from "react"; +import { useRef } from "react"; import { useNavigate } from "react-router-dom"; import { Button } from "@/components/buttons/Button"; import { WideContainer } from "@/components/layout/WideContainer"; -import { DetailsModal } from "@/components/overlays/detailsModal"; -import { useModal } from "@/components/overlays/Modal"; import { useDiscoverStore } from "@/stores/discover"; +import { useOverlayStack } from "@/stores/interface/overlayStack"; import { useProgressStore } from "@/stores/progress"; import { MediaItem } from "@/utils/mediaTypes"; @@ -18,9 +17,8 @@ import { ScrollToTopButton } from "./components/ScrollToTopButton"; export function DiscoverContent() { const { selectedCategory, setSelectedCategory } = useDiscoverStore(); - const [detailsData, setDetailsData] = useState(); const navigate = useNavigate(); - const detailsModal = useModal("discover-details"); + const { showModal } = useOverlayStack(); const carouselRefs = useRef<{ [key: string]: HTMLDivElement | null }>({}); const progressItems = useProgressStore((state) => state.items); @@ -34,11 +32,10 @@ export function DiscoverContent() { }; const handleShowDetails = async (media: MediaItem | FeaturedMedia) => { - setDetailsData({ + showModal("discover-details", { id: Number(media.id), type: media.type === "movie" ? "movie" : "show", }); - detailsModal.show(); }; const movieProgressItems = Object.entries(progressItems || {}).filter( @@ -240,7 +237,7 @@ export function DiscoverContent() { - {detailsData && } + {/* DetailsModal is now managed by overlayStack */} ); } diff --git a/src/setup/App.tsx b/src/setup/App.tsx index 0876f79f..cfc49df3 100644 --- a/src/setup/App.tsx +++ b/src/setup/App.tsx @@ -11,6 +11,7 @@ import { import { convertLegacyUrl, isLegacyUrl } from "@/backend/metadata/getmeta"; import { generateQuickSearchMediaUrl } from "@/backend/metadata/tmdb"; +import { DetailsModal } from "@/components/overlays/detailsModal"; import { KeyboardCommandsModal } from "@/components/overlays/KeyboardCommandsModal"; import { NotificationModal } from "@/components/overlays/notificationsModal"; import { useGlobalKeyboardEvents } from "@/hooks/useGlobalKeyboardEvents"; @@ -39,6 +40,7 @@ import { RegisterPage } from "@/pages/Register"; import { SupportPage } from "@/pages/Support"; import { Layout } from "@/setup/Layout"; import { useHistoryListener } from "@/stores/history"; +import { useClearModalsOnNavigation } from "@/stores/interface/overlayStack"; import { LanguageProvider } from "@/stores/language"; const DeveloperPage = lazy(() => import("@/pages/DeveloperPage")); @@ -103,6 +105,7 @@ function App() { useHistoryListener(); useOnlineListener(); useGlobalKeyboardEvents(); + useClearModalsOnNavigation(); const maintenance = false; // Shows maintance page const [showDowntime, setShowDowntime] = useState(maintenance); @@ -123,6 +126,9 @@ function App() { + + + {!showDowntime && ( {/* functional routes */} diff --git a/src/stores/interface/overlayStack.ts b/src/stores/interface/overlayStack.ts index 22d9c280..c84a9f90 100644 --- a/src/stores/interface/overlayStack.ts +++ b/src/stores/interface/overlayStack.ts @@ -1,35 +1,51 @@ +import { useEffect } from "react"; +import { useLocation } from "react-router-dom"; import { create } from "zustand"; import { immer } from "zustand/middleware/immer"; type OverlayType = "volume" | "subtitle" | "speed" | null; +interface ModalData { + id: number; + type: "movie" | "show"; + [key: string]: any; +} + interface OverlayStackStore { currentOverlay: OverlayType; modalStack: string[]; + modalData: Record; setCurrentOverlay: (overlay: OverlayType) => void; - showModal: (id: string) => void; + showModal: (id: string, data?: ModalData) => void; hideModal: (id: string) => void; isModalVisible: (id: string) => boolean; getTopModal: () => string | null; + getModalData: (id: string) => ModalData | undefined; + clearAllModals: () => void; } export const useOverlayStack = create()( immer((set, get) => ({ currentOverlay: null, modalStack: [], + modalData: {}, setCurrentOverlay: (overlay) => set((state) => { state.currentOverlay = overlay; }), - showModal: (id: string) => + showModal: (id: string, data?: ModalData) => set((state) => { if (!state.modalStack.includes(id)) { state.modalStack.push(id); } + if (data) { + state.modalData[id] = data; + } }), hideModal: (id: string) => set((state) => { state.modalStack = state.modalStack.filter((modalId) => modalId !== id); + delete state.modalData[id]; }), isModalVisible: (id: string) => { return get().modalStack.includes(id); @@ -38,5 +54,24 @@ export const useOverlayStack = create()( const stack = get().modalStack; 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]); +}