hero1
This commit is contained in:
parent
0aa214e3eb
commit
ada4349ed4
1 changed files with 420 additions and 0 deletions
420
src/components/HeroPage.tsx
Normal file
420
src/components/HeroPage.tsx
Normal file
|
|
@ -0,0 +1,420 @@
|
|||
'use client'
|
||||
|
||||
import React, { useEffect, useRef } from 'react'
|
||||
import { cn } from '@/utilities/ui'
|
||||
import { Button, buttonVariants } from '@/components/ui/button'
|
||||
import { ArrowRightIcon, PhoneCallIcon } from 'lucide-react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { MenuToggleIcon } from '@/components/ui/menu-toggle-icon'
|
||||
import { useScroll } from '@/components/ui/use-scroll'
|
||||
// ADD this line after the other imports:
|
||||
import { useThemeMode } from '@/hooks/useThemeMode'
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
interface Star {
|
||||
x: number; y: number; r: number
|
||||
v: number; baseAlpha: number
|
||||
phase: number; twinkleSpeed: number
|
||||
vx: number; vy: number
|
||||
}
|
||||
|
||||
interface Wave {
|
||||
amp: number; freq: number; speed: number
|
||||
phase: number; yBase: number; width: number; v: number
|
||||
}
|
||||
|
||||
// ─── Constants ───────────────────────────────────────────────────────────────
|
||||
|
||||
const STAR_TONES = [255, 210, 160, 110, 65]
|
||||
const WAVE_TONES = [55, 90, 125, 165, 205]
|
||||
const WAVE_CONFIGS = [
|
||||
{ amp: 26, freq: 0.011, speed: 0.35, phase: 0.0, yBase: 0.38, width: 1.6, toneIdx: 0 },
|
||||
{ amp: 20, freq: 0.015, speed: 0.50, phase: 1.3, yBase: 0.52, width: 1.8, toneIdx: 1 },
|
||||
{ amp: 30, freq: 0.008, speed: 0.28, phase: 2.6, yBase: 0.63, width: 2.2, toneIdx: 2 },
|
||||
{ amp: 16, freq: 0.019, speed: 0.65, phase: 0.8, yBase: 0.74, width: 2.0, toneIdx: 3 },
|
||||
{ amp: 22, freq: 0.013, speed: 0.42, phase: 3.2, yBase: 0.85, width: 2.6, toneIdx: 4 },
|
||||
]
|
||||
|
||||
// ─── Canvas helpers ───────────────────────────────────────────────────────────
|
||||
|
||||
function makeStars(W: number, H: number): Star[] {
|
||||
return Array.from({ length: 180 }, () => {
|
||||
const i = Math.floor(Math.random() * STAR_TONES.length)
|
||||
return {
|
||||
x: Math.random() * W, y: Math.random() * H,
|
||||
r: 0.4 + Math.random() * 1.6,
|
||||
v: STAR_TONES[i], baseAlpha: 0.5 + i * 0.1,
|
||||
phase: Math.random() * Math.PI * 2,
|
||||
twinkleSpeed: 0.8 + Math.random() * 2.0,
|
||||
vx: (Math.random() - 0.5) * 0.3,
|
||||
vy: (Math.random() - 0.5) * 0.15,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function makeWaves(): Wave[] {
|
||||
return WAVE_CONFIGS.map(c => ({ ...c, v: WAVE_TONES[c.toneIdx] }))
|
||||
}
|
||||
|
||||
function drawStars(ctx: CanvasRenderingContext2D, stars: Star[], W: number, H: number, ga: number) {
|
||||
const now = Date.now() / 1000
|
||||
for (const s of stars) {
|
||||
s.x += s.vx; s.y += s.vy
|
||||
if (s.x < 0) s.x = W; if (s.x > W) s.x = 0
|
||||
if (s.y < 0) s.y = H; if (s.y > H) s.y = 0
|
||||
const tw = 0.35 + 0.65 * ((Math.sin(now * s.twinkleSpeed + s.phase) + 1) / 2)
|
||||
ctx.beginPath()
|
||||
ctx.arc(s.x, s.y, s.r, 0, Math.PI * 2)
|
||||
ctx.fillStyle = `rgba(${s.v},${s.v},${s.v},${s.baseAlpha * tw * ga})`
|
||||
ctx.fill()
|
||||
}
|
||||
}
|
||||
|
||||
function wavePts(w: Wave, W: number, H: number, now: number) {
|
||||
const pts: { x: number; y: number }[] = []
|
||||
for (let x = 0; x <= W; x += 8)
|
||||
pts.push({ x, y: H * w.yBase + Math.sin(x * w.freq + w.phase + now * w.speed) * w.amp })
|
||||
return pts
|
||||
}
|
||||
|
||||
function wavePath(ctx: CanvasRenderingContext2D, pts: { x: number; y: number }[]) {
|
||||
ctx.beginPath()
|
||||
ctx.moveTo(pts[0].x, pts[0].y)
|
||||
for (let i = 0; i < pts.length - 1; i++) {
|
||||
const mx = (pts[i].x + pts[i + 1].x) / 2
|
||||
const my = (pts[i].y + pts[i + 1].y) / 2
|
||||
ctx.quadraticCurveTo(pts[i].x, pts[i].y, mx, my)
|
||||
}
|
||||
ctx.lineTo(pts[pts.length - 1].x, pts[pts.length - 1].y)
|
||||
}
|
||||
|
||||
function drawWaves(ctx: CanvasRenderingContext2D, waves: Wave[], W: number, H: number, ga: number, bgR: number) {
|
||||
const now = Date.now() / 1000
|
||||
const bg = Math.round(bgR)
|
||||
for (const w of waves) {
|
||||
const v = w.v
|
||||
const yBase = H * w.yBase
|
||||
const pts = wavePts(w, W, H, now)
|
||||
const grad = ctx.createLinearGradient(0, yBase - w.amp, 0, yBase + w.amp * 3)
|
||||
grad.addColorStop(0, `rgba(${v},${v},${v},${0.030 * ga})`)
|
||||
grad.addColorStop(0.4, `rgba(${v},${v},${v},${0.012 * ga})`)
|
||||
grad.addColorStop(1, `rgba(${bg},${bg},${bg},0)`)
|
||||
wavePath(ctx, pts)
|
||||
ctx.lineTo(W, H); ctx.lineTo(0, H); ctx.closePath()
|
||||
ctx.fillStyle = grad
|
||||
ctx.fill()
|
||||
wavePath(ctx, pts)
|
||||
ctx.strokeStyle = `rgba(${v},${v},${v},${0.85 * ga})`
|
||||
ctx.lineWidth = w.width
|
||||
ctx.lineJoin = 'round'
|
||||
ctx.lineCap = 'round'
|
||||
ctx.stroke()
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Background canvas ────────────────────────────────────────────────────────
|
||||
|
||||
function Background({ isDark }: { isDark: boolean }) {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null)
|
||||
const darkRef = useRef(isDark)
|
||||
useEffect(() => { darkRef.current = isDark }, [isDark])
|
||||
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current
|
||||
if (!canvas) return
|
||||
const ctx = canvas.getContext('2d')
|
||||
if (!ctx) return
|
||||
let W = 0, H = 0
|
||||
let stars: Star[] = [], waves: Wave[] = []
|
||||
let bgR = 0, bgG = 0, bgB = 0, crossfade = 0
|
||||
let rafId: number
|
||||
|
||||
function resize() {
|
||||
W = canvas!.width = canvas!.offsetWidth
|
||||
H = canvas!.height = canvas!.offsetHeight
|
||||
stars = makeStars(W, H)
|
||||
waves = makeWaves()
|
||||
}
|
||||
|
||||
function loop() {
|
||||
const dark = darkRef.current
|
||||
const tBg = dark ? 0 : 255
|
||||
const tCross = dark ? 0 : 1
|
||||
bgR += (tBg - bgR) * 0.04
|
||||
bgG += (tBg - bgG) * 0.04
|
||||
bgB += (tBg - bgB) * 0.04
|
||||
crossfade += (tCross - crossfade) * 0.03
|
||||
ctx!.fillStyle = `rgb(${Math.round(bgR)},${Math.round(bgG)},${Math.round(bgB)})`
|
||||
ctx!.fillRect(0, 0, W, H)
|
||||
const sa = 1 - crossfade, wa = crossfade
|
||||
if (sa > 0.01) drawStars(ctx!, stars, W, H, sa)
|
||||
if (wa > 0.01) drawWaves(ctx!, waves, W, H, wa, bgR)
|
||||
rafId = requestAnimationFrame(loop)
|
||||
}
|
||||
|
||||
const ro = new ResizeObserver(resize)
|
||||
ro.observe(canvas)
|
||||
resize()
|
||||
rafId = requestAnimationFrame(loop)
|
||||
return () => { cancelAnimationFrame(rafId); ro.disconnect() }
|
||||
}, [])
|
||||
|
||||
return <canvas ref={canvasRef} style={{ display: 'block', width: '100%', height: '100%' }} />
|
||||
}
|
||||
|
||||
// ─── Header ───────────────────────────────────────────────────────────────────
|
||||
|
||||
function Header({ isDark, onToggle }: { isDark: boolean; onToggle: () => void }) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const scrolled = useScroll(10)
|
||||
|
||||
const links = [
|
||||
{ label: 'Work', href: '#work' },
|
||||
{ label: 'About', href: '#about' },
|
||||
{ label: 'Contact', href: '#contact' },
|
||||
]
|
||||
|
||||
useEffect(() => {
|
||||
document.body.style.overflow = open ? 'hidden' : ''
|
||||
return () => { document.body.style.overflow = '' }
|
||||
}, [open])
|
||||
|
||||
const textColor = isDark ? 'text-white/80 hover:text-white' : 'text-black/70 hover:text-black'
|
||||
const borderColor = isDark ? 'border-white/10' : 'border-black/10'
|
||||
|
||||
return (
|
||||
<header className={cn(
|
||||
'sticky top-0 z-50 w-full border-b border-transparent transition-all duration-300',
|
||||
scrolled && (isDark
|
||||
? 'bg-black/60 border-white/10 backdrop-blur-lg'
|
||||
: 'bg-white/60 border-black/10 backdrop-blur-lg')
|
||||
)}>
|
||||
<nav className="mx-auto flex h-14 w-full max-w-5xl items-center justify-between px-6">
|
||||
{/* Wordmark */}
|
||||
<span className={cn('font-semibold tracking-tight text-sm transition-colors', isDark ? 'text-white' : 'text-black')}>
|
||||
mackie.
|
||||
</span>
|
||||
|
||||
{/* Desktop links */}
|
||||
<div className="hidden items-center gap-1 md:flex">
|
||||
{links.map(l => (
|
||||
<a key={l.label}
|
||||
href={l.href}
|
||||
className={cn('px-3 py-1.5 rounded-md text-sm transition-colors', textColor)}
|
||||
>{l.label}</a>
|
||||
))}
|
||||
|
||||
{/* Light / dark toggle */}
|
||||
<button
|
||||
onClick={onToggle}
|
||||
className={cn(
|
||||
'ml-2 flex items-center gap-2 rounded-full border px-3 py-1.5 text-xs transition-all',
|
||||
isDark
|
||||
? 'border-white/20 text-white/70 hover:text-white hover:border-white/40'
|
||||
: 'border-black/15 text-black/60 hover:text-black hover:border-black/30'
|
||||
)}
|
||||
>
|
||||
{isDark ? '☀ Light' : '☾ Dark'}
|
||||
</button>
|
||||
|
||||
<a href="#contact" className={cn(
|
||||
'ml-1 rounded-full border px-4 py-1.5 text-xs font-medium transition-all',
|
||||
isDark
|
||||
? 'border-white/25 text-white hover:bg-white hover:text-black'
|
||||
: 'border-black/20 text-black hover:bg-black hover:text-white'
|
||||
)}>
|
||||
Hire me
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{/* Mobile toggle */}
|
||||
<Button size="icon" variant="outline"
|
||||
onClick={() => setOpen(!open)}
|
||||
className={cn('md:hidden', isDark ? 'border-white/20 text-white bg-transparent' : '')}
|
||||
aria-expanded={open}
|
||||
>
|
||||
<MenuToggleIcon open={open} className="size-5" duration={300} />
|
||||
</Button>
|
||||
</nav>
|
||||
|
||||
{/* Mobile menu */}
|
||||
{open && typeof window !== 'undefined' && createPortal(
|
||||
<div className={cn(
|
||||
'fixed top-14 inset-x-0 bottom-0 z-40 flex flex-col p-6 gap-4 backdrop-blur-lg md:hidden',
|
||||
isDark ? 'bg-black/90' : 'bg-white/90'
|
||||
)}>
|
||||
<div className="grid gap-2">
|
||||
{links.map(l => (
|
||||
<a key={l.label} href={l.href}
|
||||
onClick={() => setOpen(false)}
|
||||
className={cn('px-3 py-2 rounded-md text-sm font-medium', textColor)}
|
||||
>{l.label}</a>
|
||||
))}
|
||||
</div>
|
||||
<div className="mt-auto flex flex-col gap-2">
|
||||
<button onClick={() => { onToggle(); setOpen(false) }}
|
||||
className={cn('w-full rounded-full border py-2 text-sm', isDark ? 'border-white/20 text-white' : 'border-black/20 text-black')}
|
||||
>{isDark ? '☀ Light mode' : '☾ Dark mode'}</button>
|
||||
<a href="#contact"
|
||||
className={cn('w-full rounded-full py-2 text-sm font-medium text-center', isDark ? 'bg-white text-black' : 'bg-black text-white')}
|
||||
>Hire me</a>
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
)}
|
||||
</header>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Hero section ─────────────────────────────────────────────────────────────
|
||||
|
||||
function HeroSection({ isDark }: { isDark: boolean }) {
|
||||
const text = isDark ? 'text-white' : 'text-black'
|
||||
const muted = isDark ? 'text-white/55' : 'text-black/55'
|
||||
const badge = isDark ? 'bg-white/8 border-white/15 text-white/70' : 'bg-black/5 border-black/12 text-black/60'
|
||||
const borderLine = isDark ? 'bg-white/10' : 'bg-black/10'
|
||||
const borderLineFaint = isDark ? 'bg-white/5' : 'bg-black/5'
|
||||
|
||||
return (
|
||||
<section className="relative mx-auto w-full max-w-5xl">
|
||||
{/* Vertical border lines */}
|
||||
<div aria-hidden="true" className="absolute inset-0 mx-auto hidden min-h-screen w-full max-w-5xl lg:block pointer-events-none">
|
||||
<div className={cn('mask-y-from-80% mask-y-to-100% absolute inset-y-0 left-0 z-10 h-full w-px', borderLine)} />
|
||||
<div className={cn('mask-y-from-80% mask-y-to-100% absolute inset-y-0 right-0 z-10 h-full w-px', borderLine)} />
|
||||
</div>
|
||||
|
||||
{/* Inner vertical border lines */}
|
||||
<div aria-hidden="true" className="absolute inset-0 -z-[1] size-full overflow-hidden pointer-events-none">
|
||||
<div className={cn('absolute inset-y-0 left-4 w-px bg-linear-to-b from-transparent via-border to-border md:left-8', borderLine)} />
|
||||
<div className={cn('absolute inset-y-0 right-4 w-px bg-linear-to-b from-transparent via-border to-border md:right-8', borderLine)} />
|
||||
<div className={cn('absolute inset-y-0 left-8 w-px bg-linear-to-b from-transparent via-border/50 to-border/50 md:left-12', borderLineFaint)} />
|
||||
<div className={cn('absolute inset-y-0 right-8 w-px bg-linear-to-b from-transparent via-border/50 to-border/50 md:right-12', borderLineFaint)} />
|
||||
</div>
|
||||
|
||||
<div className="relative flex flex-col items-center justify-center gap-5 pt-32 pb-28 px-6 text-center">
|
||||
|
||||
{/* Badge */}
|
||||
<a
|
||||
href="#work"
|
||||
className={cn(
|
||||
'group mx-auto flex w-fit items-center gap-3 rounded-full border px-3 py-1 shadow-sm',
|
||||
'fade-in slide-in-from-bottom-10 animate-in fill-mode-backwards transition-all delay-500 duration-500 ease-out',
|
||||
badge
|
||||
)}
|
||||
>
|
||||
<span className="text-xs">graphic designer turned developer</span>
|
||||
<span className={cn('block h-4 border-l', isDark ? 'border-white/20' : 'border-black/15')} />
|
||||
<ArrowRightIcon className="size-3 duration-150 ease-out group-hover:translate-x-1" />
|
||||
</a>
|
||||
|
||||
{/* Headline */}
|
||||
<h1 className={cn(
|
||||
'fade-in slide-in-from-bottom-10 animate-in fill-mode-backwards delay-100 duration-500 ease-out',
|
||||
'text-balance text-4xl font-medium tracking-tight md:text-5xl lg:text-6xl',
|
||||
text
|
||||
)}>
|
||||
Hey, I'm Mackie.
|
||||
</h1>
|
||||
|
||||
{/* Subline */}
|
||||
<p className={cn(
|
||||
'fade-in slide-in-from-bottom-10 animate-in fill-mode-backwards delay-200 duration-500 ease-out',
|
||||
'mx-auto max-w-lg text-base tracking-wide md:text-lg',
|
||||
muted
|
||||
)}>
|
||||
I built my way into code from scratch — no shortcuts, no excuses.
|
||||
<br className="hidden sm:block" />
|
||||
Full stack, end to end. I take ideas and ship them.
|
||||
</p>
|
||||
|
||||
{/* CTAs */}
|
||||
<div className="fade-in slide-in-from-bottom-10 animate-in fill-mode-backwards delay-300 duration-500 ease-out flex flex-wrap items-center justify-center gap-3 pt-2">
|
||||
<a
|
||||
href="#contact"
|
||||
className={cn(
|
||||
'flex items-center gap-2 rounded-full px-5 py-2.5 text-sm font-medium transition-all',
|
||||
isDark
|
||||
? 'bg-white text-black hover:bg-white/90'
|
||||
: 'bg-black text-white hover:bg-black/85'
|
||||
)}
|
||||
>
|
||||
<PhoneCallIcon className="size-3.5" />
|
||||
Book a call
|
||||
</a>
|
||||
<a
|
||||
href="#work"
|
||||
className={cn(
|
||||
'flex items-center gap-2 rounded-full border px-5 py-2.5 text-sm font-medium transition-all',
|
||||
isDark
|
||||
? 'border-white/25 text-white hover:bg-white/8'
|
||||
: 'border-black/20 text-black hover:bg-black/5'
|
||||
)}
|
||||
>
|
||||
View my work
|
||||
<ArrowRightIcon className="size-3.5 duration-150 ease-out group-hover:translate-x-1" />
|
||||
</a>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Logo cloud (trusted by section) ─────────────────────────────────────────
|
||||
|
||||
const LOGOS = [
|
||||
{ src: 'https://storage.efferd.com/logo/nvidia-wordmark.svg', alt: 'Nvidia' },
|
||||
{ src: 'https://storage.efferd.com/logo/supabase-wordmark.svg', alt: 'Supabase' },
|
||||
{ src: 'https://storage.efferd.com/logo/openai-wordmark.svg', alt: 'OpenAI' },
|
||||
{ src: 'https://storage.efferd.com/logo/vercel-wordmark.svg', alt: 'Vercel' },
|
||||
{ src: 'https://storage.efferd.com/logo/github-wordmark.svg', alt: 'GitHub' },
|
||||
{ src: 'https://storage.efferd.com/logo/claude-wordmark.svg', alt: 'Claude' },
|
||||
]
|
||||
|
||||
function LogosSection({ isDark }: { isDark: boolean }) {
|
||||
const text = isDark ? 'text-white/40' : 'text-black/40'
|
||||
const bold = isDark ? 'text-white/70' : 'text-black/70'
|
||||
const border = isDark ? 'border-white/10' : 'border-black/10'
|
||||
const logoFilter = isDark ? 'brightness-0 invert opacity-30' : 'brightness-0 opacity-25'
|
||||
|
||||
return (
|
||||
<section className={cn('relative border-t py-8 px-6', border)}>
|
||||
<p className={cn('mb-6 text-center text-xs tracking-widest uppercase', text)}>
|
||||
built with tools trusted by <span className={bold}>world-class teams</span>
|
||||
</p>
|
||||
<div className="mx-auto flex max-w-3xl flex-wrap items-center justify-center gap-8">
|
||||
{LOGOS.map(l => (
|
||||
<img key={l.alt} src={l.src} alt={l.alt} className={cn('h-5 w-auto transition-all', logoFilter)} />
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Main export ──────────────────────────────────────────────────────────────
|
||||
|
||||
// REPLACE WITH:
|
||||
export default function HeroPage() {
|
||||
const { isDark, toggle } = useThemeMode()
|
||||
|
||||
return (
|
||||
<div style={{ position: 'relative', minHeight: '100vh' }}>
|
||||
|
||||
{/* Canvas background — fixed behind everything */}
|
||||
<div style={{ position: 'fixed', inset: 0, zIndex: 0 }}>
|
||||
<Background isDark={isDark} />
|
||||
</div>
|
||||
|
||||
{/* Page content */}
|
||||
<div style={{ position: 'relative', zIndex: 1 }}>
|
||||
<Header isDark={isDark} onToggle={toggle} />
|
||||
<main>
|
||||
<HeroSection isDark={isDark} />
|
||||
<LogosSection isDark={isDark} />
|
||||
</main>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue