diff --git a/app/globals.css b/app/globals.css index 776418f..e29f59a 100644 --- a/app/globals.css +++ b/app/globals.css @@ -105,7 +105,7 @@ --accent-hover: #9dacff; --accent-strong: #7088ff; --accent-bg: rgba(138, 157, 255, 0.08); - --accent-bg-hover: rgba(138, 157, 255, 0.14); + --accent-bg-hover: rgba(249, 250, 255, 0.554); /* Semantic */ --success: #5ec98f; @@ -159,3 +159,15 @@ body { } } +@keyframes wiggle { + 0%, 100% { transform: rotate(0deg); } + 15% { transform: rotate(-1deg); } + 30% { transform: rotate(1deg); } + 45% { transform: rotate(-1deg); } + 60% { transform: rotate(1deg); } + 75% { transform: rotate(-1deg); } +} + +.wiggle { + animation: wiggle 0.4s ease-in-out infinite; +} \ No newline at end of file diff --git a/components/Links.tsx b/components/Links.tsx new file mode 100644 index 0000000..390bb4d --- /dev/null +++ b/components/Links.tsx @@ -0,0 +1,44 @@ +"use client"; + +import { useState } from "react"; +import { PROFILE } from "@/data/content"; +import LinkIcon from "./LinkIcon"; +import { useStaggerReveal } from "@/hooks/useAnimations"; + +export default function Links() { + const [hovered, setHovered] = useState(null); + const visible = useStaggerReveal(PROFILE.links.length + PROFILE.contactMethods.length, 50); + return ( +
+ {PROFILE.links.map((link, i) => ( + setHovered(i)} + onMouseLeave={() => setHovered(null)} + style={{ + display: "inline-flex", + alignItems: "center", + gap: 8, + padding: "10px 18px", + fontFamily: "var(--mono)", + fontSize: 13, + fontWeight: 500, + color: hovered === i ? "var(--accent)" : "var(--fg-secondary)", + background: hovered === i ? "var(--accent-bg)" : "var(--surface)", + border: `1px solid ${hovered === i ? "var(--accent)" : "var(--border)"}`, + borderRadius: 6, + textDecoration: "none", + transition: "all var(--duration-slow)", + transform: hovered === i ? "translateY(-2px)" : "none", + opacity: visible.has(i) ? 1.0 : 0 + }} + > + + {link.label} + + ))} +
+ ); +} diff --git a/components/Profile.tsx b/components/Profile.tsx index faab64c..474f689 100644 --- a/components/Profile.tsx +++ b/components/Profile.tsx @@ -1,13 +1,12 @@ "use client"; -import { useState } from "react"; import { PROFILE } from "@/data/content"; -import LinkIcon from "./LinkIcon"; import ContactMethods from "./ContactMethods"; import { useStaggerReveal } from "@/hooks/useAnimations"; +import Links from "./Links"; +import ProfileMore from "./ProfileMore"; export default function Profile() { - const [hovered, setHovered] = useState(null); const visible = useStaggerReveal(PROFILE.links.length + PROFILE.contactMethods.length, 100); return (
@@ -59,37 +58,8 @@ export default function Profile() { > {PROFILE.bio}

-
- {PROFILE.links.map((link, i) => ( - setHovered(i)} - onMouseLeave={() => setHovered(null)} - style={{ - display: "inline-flex", - alignItems: "center", - gap: 8, - padding: "10px 18px", - fontFamily: "var(--mono)", - fontSize: 13, - fontWeight: 500, - color: hovered === i ? "var(--accent)" : "var(--fg-secondary)", - background: hovered === i ? "var(--accent-bg)" : "var(--surface)", - border: `1px solid ${hovered === i ? "var(--accent)" : "var(--border)"}`, - borderRadius: 6, - textDecoration: "none", - transition: "all var(--duration-slow)", - transform: hovered === i ? "translateY(-2px)" : "none", - opacity: visible.has(i) ? 1.0 : 0 - }} - > - - {link.label} - - ))} -
+ +
); diff --git a/components/ProfileMore.tsx b/components/ProfileMore.tsx new file mode 100644 index 0000000..158d66d --- /dev/null +++ b/components/ProfileMore.tsx @@ -0,0 +1,114 @@ +"use client"; + +import { useState } from "react"; +import { MoreSection, PROFILE } from "@/data/content"; +import { useStaggerReveal, useWiggle } from "@/hooks/useAnimations"; +import "@/styles/more-section.css" + +export default function ProfileMore() { + const [delayTimer, setDelayTimer] = useState(null); + const [hovered, setHovered] = useState(null); + const [morePosition, setMorePosition] = useState(PROFILE.moreSectionsStart ?? 0); + + + + const { wiggling, progress, handlers } = useWiggle(500, () => {}); + + function handleSectionExpand(){ + setMorePosition(morePosition + 1); + } + + return ( +
+ {PROFILE.moreSections.map((item: MoreSection, i)=>{ + return ( +
+ {item.title && (

+ {item.title} +

)} +

{item.text}

+
+ ); + })} + +
{ + if (delayTimer) return; + setHovered(true); + + let timer = window.setTimeout(() => { + handleSectionExpand(); + }, 500); + setDelayTimer(timer); + + }} + onMouseLeave={() => { + if (delayTimer){ + clearTimeout(delayTimer); + setDelayTimer(null); + } + setHovered(false); + }} + style={{ + opacity: morePosition < PROFILE.moreSections.length ? 1.0 : 0.0, + backgroundColor: hovered ? "var(--surface)" : "var(--bg-raised", + width: "100%", + height: "2em", + display: "flex", + gap: 12, + margin: "0 0 40px 0", + border: `1px solid ${hovered ? "var(--accent)" : "var(--border)"}`, + bottom: hovered ? "-10px" : "0px", + position: "relative", + }}> + + + + + + + more? +
+ +
+ ); +} diff --git a/components/SimpleGallery.tsx b/components/SimpleGallery.tsx index 93f7e59..ab2f199 100644 --- a/components/SimpleGallery.tsx +++ b/components/SimpleGallery.tsx @@ -65,18 +65,12 @@ export default function SimpleGallery({ }} onClick={handleEnlarge} title="Enlarge image" - onMouseEnter={(e) => - (e.currentTarget.style.background = "rgba(0,0,0,0.8)") - } - onMouseLeave={(e) => - (e.currentTarget.style.background = "rgba(0,0,0,0.55)") - } > - - - - - + + + + + ); @@ -91,13 +85,7 @@ export default function SimpleGallery({ right: 8 }} onClick={handleShrink} - title="Shrink image" - onMouseEnter={(e) => - (e.currentTarget.style.background = "rgba(0,0,0,0.8)") - } - onMouseLeave={(e) => - (e.currentTarget.style.background = "rgba(0,0,0,0.55)") - } + title="Shrink image" > diff --git a/data/content.ts b/data/content.ts index 9330ea8..61c12e8 100644 --- a/data/content.ts +++ b/data/content.ts @@ -1,3 +1,9 @@ + +export type MoreSection = { + title?: string; + text: string; +} + export type SocialLink = { label: string; url: string; @@ -5,9 +11,9 @@ export type SocialLink = { }; export type ContactMethod = { - masked: boolean, - label: string, - icon: string + masked: boolean; + label: string; + icon: string; } export type Project = { @@ -28,7 +34,39 @@ export const PROFILE = { name: "Hunter W", title: "Software Engineer", email: "contact@hwilliams.dev", - bio: "I build user experiences before I write code. Experience with full-stack development across a variety of frameworks and languages including Vue, React, Next.JS, Nuxt, Node.JS, Express, Flask, FastAPI, Gunicorn, and more.", + bio: `I build user experiences before I write code. + Experience with full-stack development across a variety of frameworks and languages including Vue, + React, Next.JS, Nuxt, Node.JS, Express, Flask, FastAPI, Gunicorn, and more.`, + moreSections: [ + { + title:"A little more about me", + text: `I graduated with a B.S. in Computer Science from Georgia Southern University in May 2025. + I have been working with AWS building analytics and monitoring dashboards with a sprinkle of prompt + engineering on major production projects.` + }, + { + text: "I make mods for video games such as Space Engineers and Rimworld in my free time." + }, + { + title:"My goals", + text: `User experience. \n \n Nothing makes me happier than seeing someone excited to use the software I make, and the way that happens is by practicing + development techniques that make a good user experience. I especially enjoy peeling back the layers of old systems to explore how an existing + user experience can be improved. I love the words 'Wow, it used to be difficult to do this!'. + ` + }, + { + title:"My dreams", + text: `Stop me if you've heard this one before: I left college with a burning passion for machine learning and artificial intelligence. It's still there, and despite + the trouble going on in the world right now I see a bright future for how these incredibly advanced statistical models can still be applied in new ways. + \n + ` + }, + { + title:"Wow, you're still here?", + text: `I appreciate you, but I am totally out of things to talk about.` + } + ] satisfies MoreSection[], + moreSectionsStart: 1, links: [ { label: "GitHub", url: "https://github.com/FerrenF", icon: "gh" }, { label: "LinkedIn", url: "https://www.linkedin.com/in/hwilliamsf/", icon: "li" } diff --git a/hooks/useAnimations.ts b/hooks/useAnimations.ts index 8adb233..04a3fcb 100644 --- a/hooks/useAnimations.ts +++ b/hooks/useAnimations.ts @@ -1,6 +1,6 @@ "use client"; -import { useState, useEffect } from "react"; +import { useState, useEffect, useRef, useCallback } from "react"; export function useStaggerReveal(count: number, baseDelay = 80) { const [visible, setVisible] = useState>(new Set()); @@ -31,3 +31,63 @@ export function useMountTransition(delay = 50) { return mounted; } + + +export function useWiggle(durationMs = 1500, onComplete: () => void) { + const [wiggling, setWiggling] = useState(false); + const [progress, setProgress] = useState(0); + + const startTime = useRef(0); + const rafId = useRef(0); + const completeTimer = useRef>(null); + const completedRef = useRef(false); + + const stop = useCallback(() => { + setWiggling(false); + setProgress(0); + cancelAnimationFrame(rafId.current); + if (completeTimer.current) clearTimeout(completeTimer.current); + }, []); + + const tick = useCallback(() => { + const elapsed = Date.now() - startTime.current; + const p = Math.min(1, elapsed / durationMs); + setProgress(p); + if (p < 1) { + rafId.current = requestAnimationFrame(tick); + } + }, [durationMs]); + + const start = useCallback(() => { + completedRef.current = false; + startTime.current = Date.now(); + setWiggling(true); + setProgress(0); + rafId.current = requestAnimationFrame(tick); + + completeTimer.current = setTimeout(() => { + completedRef.current = true; + stop(); + onComplete(); + }, durationMs); + }, [durationMs, onComplete, tick, stop]); + + const release = useCallback(() => { + if (!completedRef.current) stop(); + }, [stop]); + + // cleanup on unmount + useEffect(() => () => { + cancelAnimationFrame(rafId.current); + if (completeTimer.current) clearTimeout(completeTimer.current); + }, []); + + const handlers = { + onPointerDown: start, + onPointerEnter: start, + onPointerUp: release, + onPointerLeave: release, + }; + + return { wiggling, progress, handlers }; +} \ No newline at end of file diff --git a/styles/gallery.css b/styles/gallery.css index 8441666..27f6a92 100644 --- a/styles/gallery.css +++ b/styles/gallery.css @@ -7,18 +7,27 @@ height: 28; border-radius: var(--radius-sm); border: 1px solid var(--accent); - background: var(--accent-bg); - color: #fff; + background: var(--surface); + color: var(--fg); cursor: pointer; display: flex; align-items: center; justify-content: center; padding: 0; - backdrop-filter: blur(4px); + z-index: 1001; transition: background 0.15s ease, border-color 0.15s ease; } +.gallery-action-button:hover { + background: var(---surface-hover); +} + +.action-button-svg { + stroke: var(--fg-secondary); + +} + @media (max-width: 768px) { .project-card { height: 100%; diff --git a/styles/more-section.css b/styles/more-section.css new file mode 100644 index 0000000..5f9e901 --- /dev/null +++ b/styles/more-section.css @@ -0,0 +1,21 @@ + +.more-section-container { + +} +.drop-handle { + width: 100%; + height: 2em; + display: flex; + gap: 12; + margin: 0 0 40px 0; + flex-wrap: wrap; + justify-content: center; + align-items: center; + align-content: center; + border-radius: var(--radius-md); + transition: all 0.3s ease; + position: relative; + border: 1px solid var(--border); + bottom: 0px; + background-color: var(--bg-raised); +} \ No newline at end of file