diff --git a/web/package-lock.json b/web/package-lock.json
index 15bd003f3..de42a1ac7 100644
--- a/web/package-lock.json
+++ b/web/package-lock.json
@@ -37,6 +37,7 @@
"copy-to-clipboard": "^3.3.3",
"date-fns": "^3.6.0",
"embla-carousel-react": "^8.2.0",
+ "framer-motion": "^11.5.4",
"hls.js": "^1.5.14",
"idb-keyval": "^6.2.1",
"immer": "^10.1.1",
@@ -4717,6 +4718,31 @@
"url": "https://github.com/sponsors/rawify"
}
},
+ "node_modules/framer-motion": {
+ "version": "11.5.4",
+ "resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-11.5.4.tgz",
+ "integrity": "sha512-E+tb3/G6SO69POkdJT+3EpdMuhmtCh9EWuK4I1DnIC23L7tFPrl8vxP+LSovwaw6uUr73rUbpb4FgK011wbRJQ==",
+ "license": "MIT",
+ "dependencies": {
+ "tslib": "^2.4.0"
+ },
+ "peerDependencies": {
+ "@emotion/is-prop-valid": "*",
+ "react": "^18.0.0",
+ "react-dom": "^18.0.0"
+ },
+ "peerDependenciesMeta": {
+ "@emotion/is-prop-valid": {
+ "optional": true
+ },
+ "react": {
+ "optional": true
+ },
+ "react-dom": {
+ "optional": true
+ }
+ }
+ },
"node_modules/fs.realpath": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz",
diff --git a/web/package.json b/web/package.json
index 148d74919..bb15ea4b8 100644
--- a/web/package.json
+++ b/web/package.json
@@ -43,6 +43,7 @@
"copy-to-clipboard": "^3.3.3",
"date-fns": "^3.6.0",
"embla-carousel-react": "^8.2.0",
+ "framer-motion": "^11.5.4",
"hls.js": "^1.5.14",
"idb-keyval": "^6.2.1",
"immer": "^10.1.1",
diff --git a/web/src/components/mobile/MobilePage.tsx b/web/src/components/mobile/MobilePage.tsx
new file mode 100644
index 000000000..8dbcc2f61
--- /dev/null
+++ b/web/src/components/mobile/MobilePage.tsx
@@ -0,0 +1,121 @@
+import { cn } from "@/lib/utils";
+import { isPWA } from "@/utils/isPWA";
+import { ReactNode, useEffect, useState } from "react";
+import { Button } from "../ui/button";
+import { IoMdArrowRoundBack } from "react-icons/io";
+import { motion, AnimatePresence } from "framer-motion";
+
+type MobilePageProps = {
+ children: ReactNode;
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+};
+
+export function MobilePage({ children, open, onOpenChange }: MobilePageProps) {
+ const [isVisible, setIsVisible] = useState(open);
+
+ useEffect(() => {
+ if (open) {
+ setIsVisible(true);
+ }
+ }, [open]);
+
+ const handleAnimationComplete = () => {
+ if (!open) {
+ setIsVisible(false);
+ onOpenChange(false);
+ }
+ };
+
+ return (
+
+ {isVisible && (
+
+ {children}
+
+ )}
+
+ );
+}
+
+type MobileComponentProps = {
+ children: ReactNode;
+ className?: string;
+};
+
+export function MobilePageContent({
+ children,
+ className,
+ ...props
+}: MobileComponentProps) {
+ return (
+
+ {children}
+
+ );
+}
+
+export function MobilePageDescription({
+ children,
+ className,
+ ...props
+}: MobileComponentProps) {
+ return (
+
+ {children}
+
+ );
+}
+
+interface MobilePageHeaderProps extends React.HTMLAttributes {
+ onClose: () => void;
+}
+
+export function MobilePageHeader({
+ children,
+ className,
+ onClose,
+ ...props
+}: MobilePageHeaderProps) {
+ return (
+
+ );
+}
+
+export function MobilePageTitle({
+ children,
+ className,
+ ...props
+}: MobileComponentProps) {
+ return (
+
+ {children}
+
+ );
+}
diff --git a/web/src/components/overlay/detail/ObjectLifecycle.tsx b/web/src/components/overlay/detail/ObjectLifecycle.tsx
index ca8851b92..377710b09 100644
--- a/web/src/components/overlay/detail/ObjectLifecycle.tsx
+++ b/web/src/components/overlay/detail/ObjectLifecycle.tsx
@@ -230,7 +230,7 @@ export default function ObjectLifecycle({
{!fullscreen && (
)}
-
+
)}
-
+
(