diff --git a/src/app/(portfolio)/[variant]/projects/_components/ProjectsHero/Client.tsx b/src/app/(portfolio)/[variant]/projects/_components/ProjectsHero/Client.tsx index 794f277..7b0b33d 100644 --- a/src/app/(portfolio)/[variant]/projects/_components/ProjectsHero/Client.tsx +++ b/src/app/(portfolio)/[variant]/projects/_components/ProjectsHero/Client.tsx @@ -67,7 +67,7 @@ export function Client({ projects, ...props }: ProjectsHeroClientProps) {
@@ -95,7 +95,7 @@ export function Client({ projects, ...props }: ProjectsHeroClientProps) { {/* Highlighted Projects */} -
+
{projects.map((project) => ( ))} diff --git a/src/components/features/ThemeSwitcher/ui/WipeAnimationOverlay.tsx b/src/components/features/ThemeSwitcher/ui/WipeAnimationOverlay.tsx index 12de498..94955f7 100644 --- a/src/components/features/ThemeSwitcher/ui/WipeAnimationOverlay.tsx +++ b/src/components/features/ThemeSwitcher/ui/WipeAnimationOverlay.tsx @@ -27,7 +27,7 @@ export function WipeAnimationOverlay({ > {/* Static Background Layer (to prevent target theme flash before wipe starts) */}
diff --git a/src/hooks/useThemeWipe.ts b/src/hooks/useThemeWipe.ts index 4f9a683..7cc4a3b 100644 --- a/src/hooks/useThemeWipe.ts +++ b/src/hooks/useThemeWipe.ts @@ -1,7 +1,8 @@ "use client"; -import { useState, useCallback, Dispatch, SetStateAction } from "react"; +import { useState, useCallback, Dispatch, SetStateAction, useMemo } from "react"; import { useTheme } from "next-themes"; +import { usePathname } from "next/navigation"; import { domToPng } from "modern-screenshot"; import { getFullPageHTML } from "@/utils/dom-serializer"; import { useWipeAnimation } from "@/hooks/useWipeAnimation"; @@ -26,16 +27,39 @@ export function useThemeWipe({ setWipeDirection, }: UseThemeWipeProps) { const { setTheme, resolvedTheme } = useTheme(); + const pathname = usePathname(); const [snapshots, setSnapshots] = useState(null); const [isCapturing, setIsCapturing] = useState(false); const [animationTargetTheme, setAnimationTargetTheme] = useState(null); const [originalTheme, setOriginalTheme] = useState(null); + const isPuppeteerPage = useMemo(() => { + // We want to use Puppeteer ONLY on: + // 1. Homepage: / or /tim or /tiger + // 2. Projects listing: /projects or /tim/projects or /tiger/projects + + const parts = pathname.split('/').filter(Boolean); + + // Homepage: / + if (parts.length === 0) return true; + + // /tim or /tiger or /projects + if (parts.length === 1) { + const p0 = parts[0]; + return p0 === 'tim' || p0 === 'tiger' || p0 === 'projects'; + } + + // /tim/projects or /tiger/projects + if (parts.length === 2) { + const [p0, p1] = parts; + return (p0 === 'tim' || p0 === 'tiger') && p1 === 'projects'; + } + + return false; + }, [pathname]); + const setScrollLock = (isLocked: boolean) => { - const hasScrollbar = window.innerWidth > document.documentElement.clientWidth; document.documentElement.style.overflow = isLocked ? 'hidden' : ''; - // Only reserve gutter space if a scrollbar was actually present to prevent layout shift on mobile - document.documentElement.style.scrollbarGutter = (isLocked && hasScrollbar) ? 'stable' : ''; }; const handleAnimationComplete = useCallback(() => { @@ -72,7 +96,7 @@ export function useThemeWipe({ }); const toggleTheme = useCallback(async () => { - const currentTheme = resolvedTheme as Theme; + const currentTheme = (resolvedTheme as Theme) || "light"; const newTheme: Theme = currentTheme === "dark" ? "light" : "dark"; if (snapshots || isCapturing || wipeDirection) { @@ -81,16 +105,6 @@ export function useThemeWipe({ return; } - setIsCapturing(true); - setScrollLock(true); - document.documentElement.classList.add('disable-transitions'); - - setOriginalTheme(currentTheme); - setAnimationTargetTheme(newTheme); - - const direction: WipeDirection = - currentTheme === "dark" ? "bottom-up" : "top-down"; - const captureMask = async () => { const vh = window.innerHeight; const scrollY = window.scrollY; @@ -106,7 +120,7 @@ export function useThemeWipe({ return true; }, style: { - width: `${document.documentElement.clientWidth}px`, + width: `${window.innerWidth}px`, height: `${document.documentElement.scrollHeight}px`, transform: `translateY(-${scrollY}px)`, transformOrigin: 'top left', @@ -115,9 +129,24 @@ export function useThemeWipe({ return await domToPng(document.documentElement, options); }; - const fetchSnapshotsBatch = async (newTheme: Theme) => { + // PHASE 0: Capture Mask to prevent theme flash + // We do this immediately before any React state changes to ensure a clean capture + const mask = await captureMask(); + setSnapshots({ a: mask, b: mask, method: "Capturing..." }); + + setIsCapturing(true); + setScrollLock(true); + document.documentElement.classList.add('disable-transitions'); + + setOriginalTheme(currentTheme); + setAnimationTargetTheme(newTheme); + + const direction: WipeDirection = + currentTheme === "dark" ? "bottom-up" : "top-down"; + + const fetchSnapshotsBatch = async (currentTheme: Theme, newTheme: Theme) => { // 1. Snapshot A (current) - const htmlA = await getFullPageHTML(); + const htmlA = await getFullPageHTML(currentTheme); // 2. Switch theme (to handle layouts that require re-render) setTheme(newTheme); @@ -126,7 +155,7 @@ export function useThemeWipe({ await new Promise(r => setTimeout(r, 250)); // 3. Snapshot B (newly rendered theme) - const htmlB = await getFullPageHTML(); + const htmlB = await getFullPageHTML(newTheme); // 4. Restore original theme state before sending to API // This ensures the live page matches Snapshot A when the wipe animation starts. @@ -205,25 +234,25 @@ export function useThemeWipe({ }; try { - // PHASE 0: Capture Mask to prevent theme flash - const mask = await captureMask(); - setSnapshots({ a: mask, b: mask, method: "Capturing..." }); - - // PHASE 1: Try Puppeteer (20s timeout as per instructions) - console.log("Attempting Puppeteer snapshot..."); - const [snapshotA, snapshotB] = await withTimeout( - fetchSnapshotsBatch(newTheme), - 20000, - "Puppeteer timeout" - ) as [string, string]; - - setSnapshots({ a: snapshotA, b: snapshotB, method: "Puppeteer" }); - await new Promise(r => requestAnimationFrame(() => requestAnimationFrame(r))); - setTheme(newTheme); - setWipeDirection(direction); - - } catch (e: any) { - console.warn("Puppeteer failed or timed out, falling back to modern-screenshot:", e.message); + if (isPuppeteerPage) { + // PHASE 1: Try Puppeteer (20s timeout as per instructions) + console.log("Attempting Puppeteer snapshot..."); + try { + const [snapshotA, snapshotB] = await withTimeout( + fetchSnapshotsBatch(currentTheme, newTheme), + 20000, + "Puppeteer timeout" + ) as [string, string]; + + setSnapshots({ a: snapshotA, b: snapshotB, method: "Puppeteer" }); + await new Promise(r => requestAnimationFrame(() => requestAnimationFrame(r))); + setTheme(newTheme); + setWipeDirection(direction); + return; // Exit on success + } catch (e: any) { + console.warn("Puppeteer failed or timed out, falling back to modern-screenshot:", e.message); + } + } try { // PHASE 2: Try modern-screenshot (15s timeout as per instructions) diff --git a/src/hooks/useWipeAnimation.ts b/src/hooks/useWipeAnimation.ts index 4719d31..81c83a1 100644 --- a/src/hooks/useWipeAnimation.ts +++ b/src/hooks/useWipeAnimation.ts @@ -59,7 +59,7 @@ export function useWipeAnimation({ const dividerTop = useTransform( wipeProgress, [0, 100], - wipeDirection === "top-down" ? ["0vh", "100vh"] : ["100vh", "0vh"] + wipeDirection === "top-down" ? ["0%", "100%"] : ["100%", "0%"] ); return { clipPath, dividerTop, wipeProgress }; diff --git a/src/utils/dom-serializer.ts b/src/utils/dom-serializer.ts index 5b658a0..873e609 100644 --- a/src/utils/dom-serializer.ts +++ b/src/utils/dom-serializer.ts @@ -181,15 +181,12 @@ export async function getFullPageHTML(themeOverride?: "light" | "dark"): Promise if (themeOverride) { // next-themes typically uses class="dark" or class="light" on html - if (themeOverride === "dark") { - doc.classList.add("dark"); - doc.classList.remove("light"); - } else { - doc.classList.add("light"); - doc.classList.remove("dark"); - } + doc.classList.remove("light", "dark"); + doc.classList.add(themeOverride); // Also handle data-theme if present doc.setAttribute("data-theme", themeOverride); + // Ensure the background color matches the theme + doc.style.colorScheme = themeOverride; } const body = doc.querySelector('body'); @@ -298,9 +295,9 @@ export async function getFullPageHTML(themeOverride?: "light" | "dark"): Promise const scripts = doc.querySelectorAll('script, noscript, template, iframe'); scripts.forEach(s => s.remove()); - // Hide the switcher and overlay + // Hide the switcher and overlay - REMOVE them instead of just display: none const itemsToHide = doc.querySelectorAll('[data-html2canvas-ignore]'); - itemsToHide.forEach(el => (el as HTMLElement).style.display = 'none'); + itemsToHide.forEach(el => el.remove()); const htmlAttrs = Array.from(doc.attributes).map(a => `${a.name}="${a.value}"`).join(' '); return `${doc.innerHTML}`;