add modal stacking support

This commit is contained in:
Pas 2025-07-30 23:06:47 -06:00
parent cbf1d678f2
commit 1d70f002e7
7 changed files with 83 additions and 31 deletions

View file

@ -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.</0>",
@ -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",

View file

@ -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) {

View file

@ -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 (
<OverlayPortal darken close={modal.hide} show={modal.isShown}>
<OverlayPortal
darken
close={modal.hide}
show={modal.isShown}
zIndex={zIndex}
>
<Helmet>
<html data-no-scroll />
</Helmet>

View file

@ -77,10 +77,12 @@ export function OverlayPortal(props: {
show?: boolean;
close?: () => void;
durationClass?: string;
zIndex?: number;
}) {
const [portalElement, setPortalElement] = useState<Element | null>(null);
const ref = useRef<HTMLDivElement>(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(
<Transition show={props.show} animation="none">
<FocusTrap>
<div className="popout-wrapper fixed overflow-hidden pointer-events-auto inset-0 z-[999] select-none">
<div
className="popout-wrapper fixed overflow-hidden pointer-events-auto inset-0 select-none"
style={{ zIndex }}
>
<Transition animation="fade" isChild>
<div
onClick={close}

View file

@ -1,5 +1,5 @@
import classNames from "classnames";
import { useEffect, useState } from "react";
import { useCallback, useEffect, useState } from "react";
import { Helmet } from "react-helmet-async";
import {
@ -16,18 +16,24 @@ import {
import { IconPatch } from "@/components/buttons/IconPatch";
import { Icons } from "@/components/Icon";
import { Flare } from "@/components/utils/Flare";
import { useOverlayStack } from "@/stores/interface/overlayStack";
import { useModal } from "../Modal";
import { OverlayPortal } from "../OverlayDisplay";
import { DetailsContent } from "./DetailsContent";
import { DetailsSkeleton } from "./DetailsSkeleton";
import { DetailsModalProps } from "./types";
export function DetailsModal({ id, data, minimal }: DetailsModalProps) {
const modal = useModal(id);
const { hideModal, isModalVisible, modalStack } = useOverlayStack();
const [detailsData, setDetailsData] = useState<any>(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 (
<OverlayPortal
darken
close={modal.hide}
show={modal.isShown}
close={hide}
show={isShown}
durationClass="duration-500"
zIndex={zIndex}
>
<Helmet>
<html data-no-scroll />
@ -148,7 +155,7 @@ export function DetailsModal({ id, data, minimal }: DetailsModalProps) {
<button
type="button"
className="text-s font-semibold text-type-secondary hover:text-white transition-transform hover:scale-95 select-none"
onClick={modal.hide}
onClick={hide}
>
<IconPatch icon={Icons.X} />
</button>

View file

@ -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<any>();
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 (

View file

@ -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<OverlayStackStore>((set) => ({
currentOverlay: null,
setCurrentOverlay: (overlay) => set({ currentOverlay: overlay }),
}));
export const useOverlayStack = create<OverlayStackStore>()(
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;
},
})),
);