From d95ec6e03603705f5ddfdadfdfe2c28771ac774d Mon Sep 17 00:00:00 2001 From: Ahmed Al-Taiar Date: Tue, 6 May 2025 22:22:48 -0400 Subject: [PATCH] [#2] Sidemenu for touchscreen devices --- src/components/scene/Buttons.tsx | 42 ----------- src/components/scene/Scene.tsx | 124 +++++++++++++++++++++++-------- src/components/ui/Loader.tsx | 115 ++++++++++++++-------------- src/components/ui/MobileMenu.tsx | 113 ++++++++++++++++++++++++++++ src/types/scene.d.ts | 8 -- src/util/isMobile.ts | 5 ++ 6 files changed, 268 insertions(+), 139 deletions(-) create mode 100644 src/components/ui/MobileMenu.tsx create mode 100644 src/util/isMobile.ts diff --git a/src/components/scene/Buttons.tsx b/src/components/scene/Buttons.tsx index f4541c4..86c6ec5 100644 --- a/src/components/scene/Buttons.tsx +++ b/src/components/scene/Buttons.tsx @@ -106,9 +106,6 @@ export const Buttons = ({ : 0, }); - const printerMenuSpring = useThreeSpring({ - opacity: currentView === View.PrinterView ? 1 : 0, - }); const desktopViewSpring = useThreeSpring({ scale: hovered === View.DesktopView ? 1.25 : 1, }); @@ -398,45 +395,6 @@ export const Buttons = ({ /> - {/* Printer Menu */} - - - - CAD and 3D printing - - - - - Building with electronics - - - - - Gaming - - - ); }; diff --git a/src/components/scene/Scene.tsx b/src/components/scene/Scene.tsx index 45d1a4c..3e59313 100644 --- a/src/components/scene/Scene.tsx +++ b/src/components/scene/Scene.tsx @@ -1,6 +1,6 @@ "use client"; -import { SpotLight } from "three"; +import { MeshStandardMaterial, SpotLight } from "three"; import { useGLTF, PerspectiveCamera, @@ -9,6 +9,7 @@ import { Html, type PerspectiveCameraProps, PerformanceMonitor, + Billboard, } from "@react-three/drei"; import { animated as threeAnimated, @@ -29,6 +30,7 @@ import { } from "react"; import round from "lodash/round"; import Stack from "@/util/stack"; +import isMobile from "@/util/isMobile"; import { Canvas } from "@react-three/fiber"; import { mdiArrowLeftBold } from "@mdi/js"; import { Icon } from "@mdi/react"; @@ -45,7 +47,9 @@ import { isWebGL2Available } from "@react-three/drei"; import { useWindowSize } from "../hooks/useWindowSize"; import { Info } from "../util/Info"; import { Loader } from "../ui/Loader"; +import { MobileMenu } from "../ui/MobileMenu"; +const AnimatedText = threeAnimated(Text); const AnimatedCam = threeAnimated(PerspectiveCamera); const AnimatedButton = webAnimated.button as React.ComponentType< React.ButtonHTMLAttributes & { children?: React.ReactNode } @@ -55,6 +59,7 @@ export function Scene(props: JSX.IntrinsicElements["group"]) { const { nodes, materials } = useGLTF("/scene.glb") as unknown as GLTFResult; const { width, height } = useWindowSize(); const config = useConfig(); + const mobile = isMobile(); // States const [fps, setFps] = useState(0); @@ -116,6 +121,10 @@ export function Scene(props: JSX.IntrinsicElements["group"]) { }, }); + const printerMenuSpring = useThreeSpring({ + opacity: currentView === View.PrinterView ? 1 : 0, + }); + const { backScale, backOpacity } = useWebSpring({ backScale: backHovered ? 1.25 : 1, backOpacity: menuTraversal.size() > 1 ? 1 : 0, @@ -130,30 +139,41 @@ export function Scene(props: JSX.IntrinsicElements["group"]) { <>
- `scale(${s})` - ) as unknown as string, - opacity: backOpacity as unknown as number, - pointerEvents: menuTraversal.size() > 1 ? "auto" : "none", - cursor: backHovered ? "pointer" : "auto", - }} - className="m-4" - onMouseEnter={() => setBackHovered(true)} - onMouseLeave={() => setBackHovered(false)} - onClick={() => { - setBackClicked(true); - goPreviousView(); - }} - > - - + ) : ( + `scale(${s})` + ) as unknown as string, + opacity: backOpacity as unknown as number, + pointerEvents: menuTraversal.size() > 1 ? "auto" : "none", + cursor: backHovered ? "pointer" : "auto", + }} + className="m-2" + onMouseEnter={() => setBackHovered(true)} + onMouseLeave={() => setBackHovered(false)} + onClick={() => { + setBackClicked(true); + goPreviousView(); + }} + > + + + )}
{fps > 0 && ( @@ -179,14 +199,16 @@ export function Scene(props: JSX.IntrinsicElements["group"]) { > - + {!mobile && ( + + )} )} + + + + CAD and 3D printing + + + + + Building with electronics + + + + + Gaming + + + diff --git a/src/components/ui/Loader.tsx b/src/components/ui/Loader.tsx index 8fe4389..7197222 100644 --- a/src/components/ui/Loader.tsx +++ b/src/components/ui/Loader.tsx @@ -2,88 +2,89 @@ import { useProgress } from "@react-three/drei"; import { animated, useSpring, useTransition } from "@react-spring/web"; -import { useEffect, useState, useRef } from "react"; +import { useEffect, useState } from "react"; import { createPortal } from "react-dom"; export const Loader = ({ minDuration = 750, fadeMs = 600 }) => { - const { progress } = useProgress(); - const [loadedAt, setLoadedAt] = useState(null); - - const maxSeen = useRef(0); - if (progress > maxSeen.current) maxSeen.current = progress; - - useEffect(() => { - if (maxSeen.current === 100 && loadedAt === null) setLoadedAt(Date.now()); - }, [loadedAt]); - - const visible = - loadedAt === null || Date.now() - loadedAt < minDuration + fadeMs; - - const barSpring = useSpring({ + const [barDone, setBarDone] = useState(false); + const barStyles = useSpring({ from: { width: "0%" }, to: { width: "100%" }, config: { duration: minDuration }, + onRest: () => setBarDone(true), }); + const { progress } = useProgress(); + const [assetsDone, setAssetsDone] = useState(false); + useEffect(() => { + if (progress === 100) setAssetsDone(true); + }, [progress]); + + const [visible, setVisible] = useState(true); + + useEffect(() => { + if (barDone && assetsDone) { + const id = setTimeout(() => setVisible(false), fadeMs); + return () => clearTimeout(id); + } + }, [barDone, assetsDone, fadeMs]); + const overlay = useTransition(visible, { from: { opacity: 1 }, - enter: { opacity: 1 }, leave: { opacity: 0 }, - config: { duration: fadeMs, easing: (t) => Math.pow(t, 2) }, + config: { duration: fadeMs, easing: (t) => t * t }, }); return createPortal( overlay( (styles, show) => show && ( - // @ts-expect-error children not typed bug - -

Loading…

- -
+ {/* @ts-expect-error children not typed bug */} + - {/* @ts-expect-error children not typed bug */} - Loading…

+ +
{/* @ts-expect-error children not typed bug */} - - {barSpring.width.to((w) => { - const p = parseInt(w) || 0; - return `${p > 5 ? p : ""}`; - })} + + {/* @ts-expect-error children not typed bug */} + + {barStyles.width.to((w) => { + const p = parseInt(w) || 0; + return p > 5 ? p : ""; + })} + - -
-
+
+
+
) ), document.body diff --git a/src/components/ui/MobileMenu.tsx b/src/components/ui/MobileMenu.tsx new file mode 100644 index 0000000..57b7d82 --- /dev/null +++ b/src/components/ui/MobileMenu.tsx @@ -0,0 +1,113 @@ +import type Stack from "@/util/stack"; +import { + useCallback, + useEffect, + useState, + type Dispatch, + type SetStateAction, +} from "react"; +import { View, viewToHash } from "../scene/consts"; +import { animated, useSpring } from "@react-spring/web"; +import Icon from "@mdi/react"; +import { mdiMenu } from "@mdi/js"; + +interface MobileMenuProps { + menuTraversal: Stack; + setMenuTraversal: Dispatch>>; + pendingView: View | null; + setPendingView: Dispatch>; + goPreviousView(): void; + currentView: View; +} + +const AnimatedButton = animated.button as React.ComponentType< + React.ButtonHTMLAttributes & { children?: React.ReactNode } +>; + +export const MobileMenu = ({ + menuTraversal, + setMenuTraversal, + pendingView, + setPendingView, + goPreviousView, + currentView, +}: MobileMenuProps) => { + const [menuOpen, setMenuOpen] = useState(false); + + const goToView = useCallback( + (view: View) => { + setMenuTraversal((prev) => { + const next = prev.clone(); + next.push(view); + return next; + }); + setPendingView(view); + }, + [setMenuTraversal, setPendingView] + ); + + useEffect(() => { + const onPop = () => { + goPreviousView(); + }; + window.addEventListener("popstate", onPop); + return () => window.removeEventListener("popstate", onPop); + }, [goPreviousView]); + + useEffect(() => { + if (pendingView === null) return; + window.history.pushState( + { depth: menuTraversal.size() }, + "", + viewToHash[pendingView] ? `#${viewToHash[pendingView]}` : "/" + ); + setPendingView(null); + }, [menuTraversal, pendingView, setPendingView]); + + const slideProps = useSpring({ + x: menuOpen ? 0 : -128, + config: { tension: 200, friction: 20 }, + }); + + return ( +
+ setMenuOpen((prev) => !prev)} + > + + + {/* @ts-expect-error children not typed bug */} + `translateX(${x}%)`), + }} + className="text-white flex flex-col w-min gap-2 text-stroke-2 text-stroke-black paint-sfm ml-2" + > + {[ + { label: "Home", view: View.MainView }, + { label: "Projects", view: View.PCView }, + { label: "Socials", view: View.CellphoneView }, + { label: "Resume", view: View.ResumeView }, + { label: "Hobbies", view: View.PrinterView }, + { label: "Credits", view: View.CreditsView }, + ].map(({ label, view }) => ( + + ))} + +
+ ); +}; diff --git a/src/types/scene.d.ts b/src/types/scene.d.ts index 1510e7f..aec7acd 100644 --- a/src/types/scene.d.ts +++ b/src/types/scene.d.ts @@ -26,10 +26,6 @@ export type GLTFResult = GLTF & { PenBody: Mesh; PenTip: Mesh; CellphoneMesh: Mesh; - Top: Mesh; - OfficePhoneBody: Mesh; - Screen: Mesh; - Buttons: Mesh; Handle: Mesh; HeadphonesBody: Mesh; HeadphonesAccent: Mesh; @@ -74,10 +70,6 @@ export type GLTFResult = GLTF & { PenBodyMaterial: MeshStandardMaterial; TipMaterial: MeshStandardMaterial; CellphoneMaterial: MeshStandardMaterial; - TopMaterial: MeshStandardMaterial; - OfficePhoneBodyMaterial: MeshStandardMaterial; - ScreenMaterial: MeshStandardMaterial; - HandleAndButtonsMaterial: MeshStandardMaterial; HeadphonesBodyMaterial: MeshStandardMaterial; HeadphonesAccentMaterial: MeshStandardMaterial; CoverMaterial: MeshStandardMaterial; diff --git a/src/util/isMobile.ts b/src/util/isMobile.ts new file mode 100644 index 0000000..398ddfe --- /dev/null +++ b/src/util/isMobile.ts @@ -0,0 +1,5 @@ +export default function isMobile(): boolean { + return typeof window === "undefined" + ? false + : window.matchMedia?.("(pointer: coarse)").matches; +}