From 1d70f002e73790aae4fdd5df0ca35b4ec2c1e179 Mon Sep 17 00:00:00 2001 From: Pas <74743263+Pasithea0@users.noreply.github.com> Date: Wed, 30 Jul 2025 23:06:47 -0600 Subject: [PATCH] add modal stacking support --- src/assets/locales/en.json | 7 +++- src/components/media/MediaCard.tsx | 8 ++-- src/components/overlays/Modal.tsx | 20 +++++++--- src/components/overlays/OverlayDisplay.tsx | 7 +++- .../overlays/details/DetailsModal.tsx | 29 +++++++++------ src/pages/HomePage.tsx | 6 +-- src/stores/interface/overlayStack.ts | 37 +++++++++++++++++-- 7 files changed, 83 insertions(+), 31 deletions(-) diff --git a/src/assets/locales/en.json b/src/assets/locales/en.json index a8d5c598..f52d37f0 100644 --- a/src/assets/locales/en.json +++ b/src/assets/locales/en.json @@ -141,7 +141,8 @@ "actions": { "copied": "Copied", "copy": "Copy", - "cancel": "Cancel" + "cancel": "Cancel", + "confirm": "Confirm" }, "auth": { "createAccount": "Don't have an account yet 😬 <0>Create an account.", @@ -333,7 +334,9 @@ "show": "Show" }, "episodeShort": "E", - "seasonShort": "S" + "seasonShort": "S", + "seasonWatched": "Are you sure you want to mark the season as watched?", + "seasonUnwatched": "Are you sure you want to mark the season as unwatched?" }, "details": { "resume": "Resume", diff --git a/src/components/media/MediaCard.tsx b/src/components/media/MediaCard.tsx index b9453a17..c19e79d2 100644 --- a/src/components/media/MediaCard.tsx +++ b/src/components/media/MediaCard.tsx @@ -9,6 +9,7 @@ import { mediaItemToId } from "@/backend/metadata/tmdb"; import { DotList } from "@/components/text/DotList"; import { Flare } from "@/components/utils/Flare"; import { useSearchQuery } from "@/hooks/useSearchQuery"; +import { useOverlayStack } from "@/stores/interface/overlayStack"; import { usePreferencesStore } from "@/stores/preferences"; import { MediaItem } from "@/utils/mediaTypes"; @@ -16,7 +17,6 @@ import { MediaBookmarkButton } from "./MediaBookmark"; import { IconPatch } from "../buttons/IconPatch"; import { Icon, Icons } from "../Icon"; import { DetailsModal } from "../overlays/details/DetailsModal"; -import { useModal } from "../overlays/Modal"; export interface MediaCardProps { media: MediaItem; @@ -223,7 +223,7 @@ export function MediaCard(props: MediaCardProps) { id: number; type: "movie" | "show"; } | null>(null); - const detailsModal = useModal("details"); + const { showModal } = useOverlayStack(); const enableDetailsModal = usePreferencesStore( (state) => state.enableDetailsModal, ); @@ -258,8 +258,8 @@ export function MediaCard(props: MediaCardProps) { id: Number(media.id), type: media.type === "movie" ? "movie" : "show", }); - detailsModal.show(); - }, [media, detailsModal, onShowDetails]); + showModal("details"); + }, [media, showModal, onShowDetails]); const handleCardClick = (e: React.MouseEvent) => { if (enableDetailsModal && canLink) { diff --git a/src/components/overlays/Modal.tsx b/src/components/overlays/Modal.tsx index cec5f000..11f0f0b9 100644 --- a/src/components/overlays/Modal.tsx +++ b/src/components/overlays/Modal.tsx @@ -7,15 +7,15 @@ import { Icons } from "@/components/Icon"; import { OverlayPortal } from "@/components/overlays/OverlayDisplay"; import { Flare } from "@/components/utils/Flare"; import { Heading2 } from "@/components/utils/Text"; -import { useQueryParam } from "@/hooks/useQueryParams"; +import { useOverlayStack } from "@/stores/interface/overlayStack"; export function useModal(id: string) { - const [currentModal, setCurrentModal] = useQueryParam("m"); - const show = useCallback(() => setCurrentModal(id), [id, setCurrentModal]); - const hide = useCallback(() => setCurrentModal(null), [setCurrentModal]); + const { showModal, hideModal, isModalVisible } = useOverlayStack(); + const show = useCallback(() => showModal(id), [id, showModal]); + const hide = useCallback(() => hideModal(id), [id, hideModal]); return { id, - isShown: currentModal === id, + isShown: isModalVisible(id), show, hide, }; @@ -33,9 +33,17 @@ export function ModalCard(props: { children?: ReactNode }) { export function Modal(props: { id: string; children?: ReactNode }) { const modal = useModal(props.id); + const { modalStack } = useOverlayStack(); + const modalIndex = modalStack.indexOf(props.id); + const zIndex = modalIndex >= 0 ? 1000 + modalIndex : 999; return ( - + diff --git a/src/components/overlays/OverlayDisplay.tsx b/src/components/overlays/OverlayDisplay.tsx index 78827bf2..57c695f4 100644 --- a/src/components/overlays/OverlayDisplay.tsx +++ b/src/components/overlays/OverlayDisplay.tsx @@ -77,10 +77,12 @@ export function OverlayPortal(props: { show?: boolean; close?: () => void; durationClass?: string; + zIndex?: number; }) { const [portalElement, setPortalElement] = useState(null); const ref = useRef(null); const close = props.close; + const zIndex = props.zIndex ?? 999; useEffect(() => { const element = ref.current?.closest(".popout-location"); @@ -93,7 +95,10 @@ export function OverlayPortal(props: { ? createPortal( -
+
(null); const [isLoading, setIsLoading] = useState(false); + const modalIndex = modalStack.indexOf(id); + const zIndex = modalIndex >= 0 ? 1000 + modalIndex : 999; + + const hide = useCallback(() => hideModal(id), [hideModal, id]); + const isShown = isModalVisible(id); + useEffect(() => { const fetchDetails = async () => { if (!data?.id || !data?.type) return; @@ -106,23 +112,24 @@ export function DetailsModal({ id, data, minimal }: DetailsModalProps) { } }; - if (modal.isShown && data?.id) { + if (isShown && data?.id) { fetchDetails(); } - }, [modal.isShown, data]); + }, [isShown, data]); useEffect(() => { - if (modal.isShown && !data?.id && !isLoading) { - modal.hide(); + if (isShown && !data?.id && !isLoading) { + hide(); } - }, [modal, data, isLoading]); + }, [isShown, data, isLoading, hide]); return ( @@ -148,7 +155,7 @@ export function DetailsModal({ id, data, minimal }: DetailsModalProps) { diff --git a/src/pages/HomePage.tsx b/src/pages/HomePage.tsx index 4cd2c181..a1e32c9e 100644 --- a/src/pages/HomePage.tsx +++ b/src/pages/HomePage.tsx @@ -5,7 +5,6 @@ import { To, useNavigate } from "react-router-dom"; import { WideContainer } from "@/components/layout/WideContainer"; import { DetailsModal } from "@/components/overlays/details/DetailsModal"; -import { useModal } from "@/components/overlays/Modal"; import { useDebounce } from "@/hooks/useDebounce"; import { useRandomTranslation } from "@/hooks/useRandomTranslation"; import { useSearchQuery } from "@/hooks/useSearchQuery"; @@ -21,6 +20,7 @@ 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 { useOverlayStack } from "@/stores/interface/overlayStack"; import { usePreferencesStore } from "@/stores/preferences"; import { MediaItem } from "@/utils/mediaTypes"; @@ -63,7 +63,7 @@ export function HomePage() { const [showBookmarks, setShowBookmarks] = useState(false); const [showWatching, setShowWatching] = useState(false); const [detailsData, setDetailsData] = useState(); - const detailsModal = useModal("details"); + const { showModal } = useOverlayStack(); const enableDiscover = usePreferencesStore((state) => state.enableDiscover); const enableFeatured = usePreferencesStore((state) => state.enableFeatured); const carouselRefs = useRef<{ [key: string]: HTMLDivElement | null }>({}); @@ -84,7 +84,7 @@ export function HomePage() { id: Number(media.id), type: media.type === "movie" ? "movie" : "show", }); - detailsModal.show(); + showModal("details"); }; return ( diff --git a/src/stores/interface/overlayStack.ts b/src/stores/interface/overlayStack.ts index ccbf2e7b..49febd40 100644 --- a/src/stores/interface/overlayStack.ts +++ b/src/stores/interface/overlayStack.ts @@ -1,13 +1,42 @@ import { create } from "zustand"; +import { immer } from "zustand/middleware/immer"; type OverlayType = "volume" | "subtitle" | null; interface OverlayStackStore { currentOverlay: OverlayType; + modalStack: string[]; setCurrentOverlay: (overlay: OverlayType) => void; + showModal: (id: string) => void; + hideModal: (id: string) => void; + isModalVisible: (id: string) => boolean; + getTopModal: () => string | null; } -export const useOverlayStack = create((set) => ({ - currentOverlay: null, - setCurrentOverlay: (overlay) => set({ currentOverlay: overlay }), -})); +export const useOverlayStack = create()( + immer((set, get) => ({ + currentOverlay: null, + modalStack: [], + setCurrentOverlay: (overlay) => + set((state) => { + state.currentOverlay = overlay; + }), + showModal: (id: string) => + set((state) => { + if (!state.modalStack.includes(id)) { + state.modalStack.push(id); + } + }), + hideModal: (id: string) => + set((state) => { + state.modalStack = state.modalStack.filter((modalId) => modalId !== id); + }), + isModalVisible: (id: string) => { + return get().modalStack.includes(id); + }, + getTopModal: () => { + const stack = get().modalStack; + return stack.length > 0 ? stack[stack.length - 1] : null; + }, + })), +);