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 { 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 = (
<>
<MediaCardContent
{...props}
onShowDetails={handleShowDetails}
forceSkeleton={forceSkeleton}
/>
{detailsData && <DetailsModal id="details" data={detailsData} />}
</>
<MediaCardContent
{...props}
onShowDetails={handleShowDetails}
forceSkeleton={forceSkeleton}
/>
);
if (!canLink) {

View file

@ -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<any>(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 (
<OverlayPortal
darken
close={hide}
show={isShown}
show={shouldShow}
durationClass="duration-500"
zIndex={zIndex}
>

View file

@ -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" }}
>
<MediaCard
media={media}
onShowDetails={() => onShowDetails(Number(media.id))}
linkable
/>
<MediaCard media={media} linkable onShowDetails={_onShowDetails} />
</div>
))}
@ -108,9 +105,10 @@ export function CollectionOverlay({
collectionId,
collectionName,
onClose,
onMovieClick,
onMovieClick: _onMovieClick,
}: CollectionOverlayProps) {
const { t } = useTranslation();
const { showModal } = useOverlayStack();
const [collection, setCollection] = useState<CollectionData | null>(null);
const [loading, setLoading] = useState(true);
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 (
<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"
@ -311,7 +318,7 @@ export function CollectionOverlay({
{!loading && !error && sortedMovies.length > 0 && (
<SimpleCarousel
mediaItems={sortedMovies.map(movieToMediaItem)}
onShowDetails={onMovieClick}
onShowDetails={handleShowDetails}
categorySlug="collection"
/>
)}

View file

@ -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 (
<>
<VideoPlayerButton
icon={Icons.CIRCLE_QUESTION}
iconSizeClass="text-base"
className="p-2 !-mr-2"
onClick={handleClick}
/>
{detailsData && (
<DetailsModal id="player-details" data={detailsData} minimal />
)}
</>
<VideoPlayerButton
icon={Icons.CIRCLE_QUESTION}
iconSizeClass="text-base"
className="p-2 !-mr-2"
onClick={handleClick}
/>
);
}

View file

@ -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<any>();
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() {
</div>
)}
</WideContainer>
{detailsData && <DetailsModal id="details" data={detailsData} />}
</HomeLayout>
);
}

View file

@ -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<string[]>([]);
const backendUrl = useBackendUrl();
const account = useAuthStore((s) => s.account);
const [detailsData, setDetailsData] = useState<any>();
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 && <DetailsModal id="details" data={detailsData} />}
</WideContainer>
</SubPageLayout>
);

View file

@ -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<any>();
const [curatedLists, setCuratedLists] = useState<CuratedMovieList[]>([]);
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() {
</div>
))}
</WideContainer>
{detailsData && <DetailsModal id="discover-details" data={detailsData} />}
</SubPageLayout>
);
}

View file

@ -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<any>();
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() {
<div className="relative z-20 px-4 md:px-10">
<DiscoverContent />
</div>
{detailsData && <DetailsModal id="discover-details" data={detailsData} />}
</SubPageLayout>
);
}

View file

@ -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<any>();
const [selectedProvider, setSelectedProvider] = useState<OptionItem | null>(
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) {
)}
</div>
</WideContainer>
{detailsData && <DetailsModal id="discover-details" data={detailsData} />}
</SubPageLayout>
);
}

View file

@ -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<any>();
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() {
<ScrollToTopButton />
{detailsData && <DetailsModal id="discover-details" data={detailsData} />}
{/* DetailsModal is now managed by overlayStack */}
</div>
);
}

View file

@ -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() {
<LanguageProvider />
<NotificationModal id="notifications" />
<KeyboardCommandsModal id="keyboard-commands" />
<DetailsModal id="details" />
<DetailsModal id="discover-details" />
<DetailsModal id="player-details" />
{!showDowntime && (
<Routes>
{/* functional routes */}

View file

@ -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<string, ModalData | undefined>;
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<OverlayStackStore>()(
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<OverlayStackStore>()(
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]);
}