diff --git a/app/contact/contact-link.css b/app/contact/contact-link.css new file mode 100644 index 0000000..cbf0865 --- /dev/null +++ b/app/contact/contact-link.css @@ -0,0 +1,17 @@ +@keyframes blink { + 0%, 49% { + opacity: 1; + } + 50%, 100% { + opacity: 0; + } +} + +.cursor-block { + display: inline-block; + width: 0.6em; + height: 0.9em; + margin-left: 0.1em; + background-color: currentColor; + animation: blink 1s infinite; +} diff --git a/app/contact/contact-link.tsx b/app/contact/contact-link.tsx new file mode 100644 index 0000000..00c870f --- /dev/null +++ b/app/contact/contact-link.tsx @@ -0,0 +1,128 @@ +"use client" + +import { useState, useEffect, useRef } from "react" +import Link from "next/link" +import { motion, AnimatePresence } from "framer-motion" +import "./contact-link.css" + +function generateRandomKeyframes(): string { + const numSteps = Math.floor(Math.random() * 15) + 15 + const increments = Array.from({ length: numSteps }, () => Math.random() * 4 + 1) + const total = increments.reduce((a, b) => a + b, 0) + const normalized = increments.map(inc => (inc / total) * 100) + + let keyframes = "0% { width: 0; }" + let currentWidth = 0 + normalized.forEach((increment, index) => { + currentWidth += increment + const keyframePercent = ((index + 1) / numSteps) * 100 + keyframes += ` ${keyframePercent.toFixed(1)}% { width: ${currentWidth.toFixed(1)}%; }` + }) + + return keyframes +} + +interface ContactLinkProps { + url: string + display: string + easterEgg?: string +} + +export function ContactLink({ url, display, easterEgg }: ContactLinkProps) { + const [isHovered, setIsHovered] = useState(false) + const styleRef = useRef(null) + const animationIdRef = useRef("") + + useEffect(() => { + if (isHovered && easterEgg) { + const animationId = `typing-${Date.now()}-${Math.random().toString(36).substr(2, 9)}` + animationIdRef.current = animationId + + const keyframes = generateRandomKeyframes() + const css = `@keyframes ${animationId} { ${keyframes} }` + + // Remove old style element if it exists + if (styleRef.current) { + styleRef.current.remove() + } + + // Create new style element each time + styleRef.current = document.createElement("style") + styleRef.current.textContent = css + document.head.appendChild(styleRef.current) + } + + return () => { + // Cleanup on unmount + if (styleRef.current) { + styleRef.current.remove() + styleRef.current = null + } + } + }, [isHovered, easterEgg]) + + return ( +
setIsHovered(true)} + onMouseLeave={() => setIsHovered(false)} + > +
+ $ + + open + {display} + +
+ + {isHovered && easterEgg && ( + + + # + + + {easterEgg} + + + + + + )} + +
+ ) +} diff --git a/app/contact/page.tsx b/app/contact/page.tsx new file mode 100644 index 0000000..0574582 --- /dev/null +++ b/app/contact/page.tsx @@ -0,0 +1,54 @@ +"use client" + +import { PageTitle } from "@/components/layout/page-title" +import { ContactLink } from "./contact-link" + +const contactLinks = [ + { + url: "mailto:jakebodea@gmail.com", + display: "jakebodea@gmail.com", + easterEgg: "no spam pls" + }, + { + url: "https://x.com/jakebodea", + display: "x.com/jakebodea" + }, + { + url: "https://www.linkedin.com/in/jakebodea/", + display: "linkedin.com/in/jakebodea", + easterEgg: "unfortunately i still need this platform" + }, + { + url: "https://github.com/jakebodea", + display: "github.com/jakebodea", + easterEgg: "don't DM me here but check out my commit history map" + } +] + +export default function ContactPage() { + + return ( +
+
+ contact + +

+ i'm always interested in connecting. here's where you can find me: +

+ +
+
+ {contactLinks.map((link) => ( + + ))} +
+
+
+
+ ) +} diff --git a/app/globals.css b/app/globals.css index a4f2e38..89611ab 100644 --- a/app/globals.css +++ b/app/globals.css @@ -301,6 +301,33 @@ } } +/* Theme transition - circular reveal from toggle button */ +@property --reveal-size { + syntax: ""; + initial-value: 0px; + inherits: false; +} + +::view-transition-old(root), +::view-transition-new(root) { + animation: none; + mix-blend-mode: normal; +} + +::view-transition-old(root) { + z-index: 1; +} + +::view-transition-new(root) { + z-index: 9999; + mask: radial-gradient( + circle at var(--reveal-x, 50%) var(--reveal-y, 50%), + black 0, + black calc(var(--reveal-size) - 160px), + transparent var(--reveal-size) + ); +} + @layer base { * { @apply border-border; diff --git a/app/layout.tsx b/app/layout.tsx index 7d26443..9e1b66a 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -79,7 +79,6 @@ export default function RootLayout({ attribute="class" defaultTheme="dark" enableSystem - disableTransitionOnChange > {process.env.VERCEL_GIT_COMMIT_REF === "dev" && ( diff --git a/components/layout/mobile-menu.tsx b/components/layout/mobile-menu.tsx index 691746b..e4a98be 100644 --- a/components/layout/mobile-menu.tsx +++ b/components/layout/mobile-menu.tsx @@ -60,7 +60,7 @@ export function MobileMenu({ navItems, isOpen, onClose }: MobileMenuProps) { initial={{ opacity: 0 }} animate={{ opacity: 1, transition: { duration: 0.15 } }} exit={{ opacity: 0, transition: { duration: 0.1, delay: 0.1 } }} - className="fixed inset-0 z-[70] bg-background md:hidden" + className="fixed inset-0 z-[70] bg-background lg:hidden" > {/* Spacer for nav header */}
diff --git a/components/layout/page-transition.tsx b/components/layout/page-transition.tsx index 6ebc2c6..8dc498c 100644 --- a/components/layout/page-transition.tsx +++ b/components/layout/page-transition.tsx @@ -14,7 +14,7 @@ function useIsMobile() { const [isMobile, setIsMobile] = useState(false) useEffect(() => { - const check = () => setIsMobile(window.innerWidth < 768) + const check = () => setIsMobile(window.innerWidth < 1024) check() window.addEventListener("resize", check) return () => window.removeEventListener("resize", check) diff --git a/components/layout/theme-toggle.tsx b/components/layout/theme-toggle.tsx index d3631ff..ac8143c 100644 --- a/components/layout/theme-toggle.tsx +++ b/components/layout/theme-toggle.tsx @@ -1,6 +1,7 @@ "use client" -import { useState, useEffect, useRef } from "react" +import { useState, useEffect, useRef, useCallback } from "react" +import { flushSync } from "react-dom" import { useTheme } from "next-themes" import { AnimatePresence, motion } from "framer-motion" import { Moon, Sun, Monitor } from "lucide-react" @@ -46,26 +47,96 @@ export function ThemeToggle({ const [open, setOpen] = useState(false) const [mounted, setMounted] = useState(false) const leaveTimer = useRef | null>(null) + const triggerRef = useRef(null) + const triggerRectRef = useRef(null) + + // Capture button position on pointer down, before dropdown interaction moves things + const capturePosition = useCallback(() => { + if (triggerRef.current) { + triggerRectRef.current = triggerRef.current.getBoundingClientRect() + } + }, []) + + const setThemeWithTransition = useCallback( + (newTheme: string) => { + const supportsViewTransition = + typeof document !== "undefined" && + "startViewTransition" in document + + if (!supportsViewTransition) { + setTheme(newTheme) + return + } + + // Use captured position, fall back to live measurement + const rect = + triggerRectRef.current ?? + triggerRef.current?.getBoundingClientRect() + + if (!rect || (rect.x === 0 && rect.y === 0 && rect.width === 0)) { + setTheme(newTheme) + return + } + + const x = rect.left + rect.width / 2 + const y = rect.top + rect.height / 2 + const endRadius = Math.hypot( + Math.max(x, window.innerWidth - x), + Math.max(y, window.innerHeight - y), + ) + + // Set the circle origin for the CSS mask + const root = document.documentElement + root.style.setProperty("--reveal-x", `${x}px`) + root.style.setProperty("--reveal-y", `${y}px`) + + const transition = (document as any).startViewTransition(() => { + flushSync(() => { + setTheme(newTheme) + }) + }) + + transition.ready + .then(() => { + root.animate( + { "--reveal-size": [`0px`, `${endRadius + 80}px`] }, + { + duration: 350, + easing: "ease-out", + fill: "forwards", + pseudoElement: "::view-transition-new(root)", + }, + ) + }) + .catch(() => {}) + + // Clear captured position after use + triggerRectRef.current = null + }, + [setTheme], + ) useEffect(() => { setMounted(true) }, []) - // 't' keyboard shortcut for quick toggle + // 't' keyboard shortcut for quick toggle (only one instance registers) useEffect(() => { + if (!shortcut) return + const handleKeyDown = (e: KeyboardEvent) => { const target = e.target as HTMLElement if (target.tagName === "INPUT" || target.tagName === "TEXTAREA") return if (e.key === "t") { - setTheme(resolvedTheme === "dark" ? "light" : "dark") + setThemeWithTransition(resolvedTheme === "dark" ? "light" : "dark") setOpen(false) } } window.addEventListener("keydown", handleKeyDown) return () => window.removeEventListener("keydown", handleKeyDown) - }, [resolvedTheme, setTheme]) + }, [shortcut, resolvedTheme, setThemeWithTransition]) // Cleanup leave timer on unmount useEffect(() => { @@ -91,9 +162,14 @@ export function ThemeToggle({ const trigger = (