diff --git a/src/hooks/useAnimatedScroll.ts b/src/hooks/useAnimatedScroll.ts new file mode 100644 index 0000000..95a1c42 --- /dev/null +++ b/src/hooks/useAnimatedScroll.ts @@ -0,0 +1,125 @@ +import { useCallback } from "react"; +import { useReducedMotion } from "framer-motion"; + +interface AnimatedScrollOptions { + /** + * Duration of the fade animation in milliseconds + * @default 400 + */ + duration?: number; + /** + * Offset to apply to the final scroll position (useful for sticky headers) + * @default 0 + */ + offset?: number; +} + +/** + * Custom hook that provides animated scrolling with fade effects + * to make content appear to "come to you" without showing the scroll journey + */ +export function useAnimatedScroll(options: AnimatedScrollOptions = {}) { + const { duration = 400, offset = 0 } = options; + const prefersReducedMotion = useReducedMotion(); + + const scrollToElement = useCallback( + (targetId: string) => { + const element = document.getElementById(targetId); + if (!element) return; + + // For reduced motion, use instant browser scroll + if (prefersReducedMotion) { + element.scrollIntoView({ behavior: "auto", block: "start" }); + return; + } + + const main = document.querySelector("main"); + if (!main) { + element.scrollIntoView({ behavior: "auto", block: "start" }); + return; + } + + const targetPosition = + element.getBoundingClientRect().top + window.scrollY + offset; + + // Phase 1: Fade out current view + main.style.transition = `opacity ${duration}ms ease-out, transform ${duration}ms ease-out`; + main.style.opacity = "0"; + main.style.transform = "scale(0.95)"; + + // Phase 2: After fade out, scroll instantly and fade in + setTimeout(() => { + // Instant scroll while content is hidden + window.scrollTo({ + top: targetPosition, + behavior: "auto", + }); + + // Trigger reflow to ensure style changes are applied + // eslint-disable-next-line @typescript-eslint/no-unused-expressions + + // Phase 3: Fade in the target content with scale effect + requestAnimationFrame(() => { + main.style.transform = "scale(1.02)"; + main.style.opacity = "1"; + + // Settle to final state + setTimeout(() => { + main.style.transform = "scale(1)"; + + // Clean up after animation completes + setTimeout(() => { + main.style.transition = ""; + main.style.transform = ""; + main.style.opacity = ""; + }, duration); + }, 50); + }); + }, duration); + }, + [duration, offset, prefersReducedMotion], + ); + + const scrollToTop = useCallback(() => { + if (prefersReducedMotion) { + window.scrollTo({ top: 0, behavior: "auto" }); + return; + } + + const main = document.querySelector("main"); + if (!main) { + window.scrollTo({ top: 0, behavior: "auto" }); + return; + } + + // Phase 1: Fade out + main.style.transition = `opacity ${duration}ms ease-out, transform ${duration}ms ease-out`; + main.style.opacity = "0"; + main.style.transform = "scale(0.95)"; + + // Phase 2: Scroll and fade in + setTimeout(() => { + window.scrollTo({ top: 0, behavior: "auto" }); + + // Trigger reflow to ensure style changes are applied + // eslint-disable-next-line @typescript-eslint/no-unused-expressions + + requestAnimationFrame(() => { + main.style.transform = "scale(1.02)"; + main.style.opacity = "1"; + + setTimeout(() => { + main.style.transform = "scale(1)"; + + setTimeout(() => { + main.style.transition = ""; + main.style.transform = ""; + main.style.opacity = ""; + }, duration); + }, 50); + }); + }, duration); + }, [duration, prefersReducedMotion]); + + return { scrollToElement, scrollToTop }; +} diff --git a/src/hooks/useBodyScrollLock.ts b/src/hooks/useBodyScrollLock.ts new file mode 100644 index 0000000..7481795 --- /dev/null +++ b/src/hooks/useBodyScrollLock.ts @@ -0,0 +1,52 @@ +import { useEffect } from "react"; + +/** + * Custom hook to lock/unlock body scroll + * @param isLocked - Whether the body scroll should be locked + */ +export function useBodyScrollLock(isLocked: boolean) { + useEffect(() => { + // Early return if not in browser environment + if (globalThis.window === undefined || typeof document === "undefined") { + return undefined; + } + + if (!isLocked) return undefined; + + // Store original body overflow and padding + const originalOverflow = document.body.style.overflow; + const originalPaddingRight = document.body.style.paddingRight; + + // Get scrollbar width to prevent layout shift + const scrollbarWidth = + globalThis.window.innerWidth - document.documentElement.clientWidth; + + // Lock scroll + document.body.style.overflow = "hidden"; + + // Add padding to compensate for scrollbar disappearance + if (scrollbarWidth > 0) { + // Get computed padding in pixels + const computedStyle = globalThis.window.getComputedStyle(document.body); + const currentPadding = Number.parseFloat(computedStyle.paddingRight) || 0; + document.body.style.paddingRight = `${currentPadding + scrollbarWidth}px`; + } + + // Cleanup function to restore original state + return () => { + // Restore or remove overflow property + if (originalOverflow) { + document.body.style.overflow = originalOverflow; + } else { + document.body.style.removeProperty("overflow"); + } + + // Restore or remove padding-right property + if (originalPaddingRight) { + document.body.style.paddingRight = originalPaddingRight; + } else { + document.body.style.removeProperty("padding-right"); + } + }; + }, [isLocked]); +} diff --git a/src/sections/ContactSection.tsx b/src/sections/ContactSection.tsx index fe059c2..8b74378 100644 --- a/src/sections/ContactSection.tsx +++ b/src/sections/ContactSection.tsx @@ -46,6 +46,8 @@ const TURNSTILE_SCRIPT_SRC = // Default placeholders for template usage. Set VITE_TURNSTILE_SITE_KEY or // VITE_TURNSTYLE_SITE in your environment to enable captcha checks. const DEFAULT_TURNSTYLE_SITE_KEY = ""; +const rawTurnstileSiteKey = + (import.meta.env.VITE_TURNSTILE_SITE_KEY ?? import.meta.env.VITE_TURNSTYLE_SITE ?? DEFAULT_TURNSTYLE_SITE_KEY ?? "") || diff --git a/src/sections/SkillsSection.tsx b/src/sections/SkillsSection.tsx index 8a52b6c..531a08d 100644 --- a/src/sections/SkillsSection.tsx +++ b/src/sections/SkillsSection.tsx @@ -150,6 +150,7 @@ function SkillsBoard({ } export function SkillsSection() { + const { t } = useTranslation(); const { data: remoteSkills, debugAttributes: skillsDebugAttributes } = useRemoteData({ resource: SKILLS_RESOURCE, @@ -221,10 +222,9 @@ export function SkillsSection() {