diff --git a/README.md b/README.md index b3b246b..1b7f411 100644 --- a/README.md +++ b/README.md @@ -125,6 +125,12 @@ Site icons live in `public/icons/`: To regenerate PNGs after editing the SVG: `npm run generate:icons` (requires `sharp` as a devDependency). +## 🖼️ Social Share Image + +`public/images/og-image.png` is the Open Graph / Twitter Card image shown when a link to the site is shared. It's generated from `public/images/profile.jpg` plus the site's brand colors. + +To regenerate it after updating the profile photo or brand colors: `npm run generate:og-image` (requires `sharp` as a devDependency). + ## 📝 Content Management - **Blog Posts**: Written in Markdown and stored in `content/blog/` diff --git a/app/about/page.tsx b/app/about/page.tsx index decd1c9..c23d35e 100644 --- a/app/about/page.tsx +++ b/app/about/page.tsx @@ -1,5 +1,7 @@ import Image from 'next/image' import type { Metadata } from 'next' +import { RoleTypewriter } from '@/components/RoleTypewriter' +import { ScrollReveal } from '@/components/ScrollReveal' export const metadata: Metadata = { title: 'About', @@ -16,7 +18,9 @@ export default function About() {
{/* Header section with role tag */}
-

PRODUCT MANAGER & SOFTWARE DESIGNER

+

+ +

Thomas Bohn @@ -54,7 +58,8 @@ export default function About() {

{/* Right column - About content */} -
+
+

{'⟩'} @@ -108,7 +113,9 @@ export default function About() {

+ +

{'⟩'} @@ -145,7 +152,9 @@ export default function About() {

+ +

{'⟩'} @@ -182,7 +191,9 @@ export default function About() {

+ +

{'⟩'} @@ -235,7 +246,9 @@ export default function About() {

+
+

{'⟩'} @@ -270,7 +283,9 @@ export default function About() {

+
+

{'⟩'} @@ -291,6 +306,7 @@ export default function About() { Contact Me

+
diff --git a/app/blog/[slug]/page.tsx b/app/blog/[slug]/page.tsx index efe7041..25e5521 100644 --- a/app/blog/[slug]/page.tsx +++ b/app/blog/[slug]/page.tsx @@ -3,6 +3,8 @@ import { getBlogPost, getBlogPosts } from '@/lib/blog' import { notFound } from 'next/navigation' import Link from 'next/link' import { FaMedium } from 'react-icons/fa' +import { ReadingProgressBar } from '@/components/blog/ReadingProgressBar' +import { ShareButton } from '@/components/blog/ShareButton' export const dynamicParams = false @@ -30,10 +32,11 @@ export default async function BlogPost({ params }: { params: Promise<{ slug: str return (
+

{'>'} SOFTWARE ENGINEER

-
+

{'// BLOG'}

@@ -44,6 +47,7 @@ export default async function BlogPost({ params }: { params: Promise<{ slug: str > ← Back to Blog + {post.mediumUrl && (
-

- {new Date(post.date).toLocaleDateString('en-US', { - year: 'numeric', - month: 'long', - day: 'numeric', - })} +

+ + {new Date(post.date).toLocaleDateString('en-US', { + year: 'numeric', + month: 'long', + day: 'numeric', + })} + + + {post.readingTimeMinutes} min read

{post.title.includes(':') ? ( diff --git a/app/blog/page.tsx b/app/blog/page.tsx index 3b51d3f..134f40d 100644 --- a/app/blog/page.tsx +++ b/app/blog/page.tsx @@ -2,6 +2,8 @@ import Link from 'next/link' import type { Metadata } from 'next' import { getBlogPosts } from '@/lib/blog' import { FaMedium } from 'react-icons/fa' +import { RoleTypewriter } from '@/components/RoleTypewriter' +import { ScrollReveal } from '@/components/ScrollReveal' export const metadata: Metadata = { title: 'Blog', @@ -19,7 +21,9 @@ export default async function Blog() {
-

PRODUCT MANAGER & SOFTWARE DESIGNER

+

+ +

{'⟩'} {'BLOG'} @@ -37,10 +41,10 @@ export default async function Blog() {

) : ( -
- {posts.map((post) => ( +
+ {posts.map((post, index) => ( +
-

- {new Date(post.date).toLocaleDateString('en-US', { - year: 'numeric', - month: 'long', - day: 'numeric', - })} +

+ + {new Date(post.date).toLocaleDateString('en-US', { + year: 'numeric', + month: 'long', + day: 'numeric', + })} + + + {post.readingTimeMinutes} min read

{post.excerpt} @@ -93,6 +101,7 @@ export default async function Blog() {

+ ))}
)} diff --git a/app/certifications/page.tsx b/app/certifications/page.tsx index a90e367..0e54dbd 100644 --- a/app/certifications/page.tsx +++ b/app/certifications/page.tsx @@ -2,6 +2,8 @@ import type { Metadata } from 'next' import { getLearningData } from '@/lib/hub' import { LearningSection } from '@/components/hub/LearningSection' import { LearningPageHeader } from '@/components/hub/LearningPageHeader' +import { RoleTypewriter } from '@/components/RoleTypewriter' +import { ScrollReveal } from '@/components/ScrollReveal' export const metadata: Metadata = { title: 'Certifications', @@ -19,7 +21,9 @@ export default async function Certifications() {
-

PRODUCT MANAGER & SOFTWARE DESIGNER

+

+ +

{'⟩'} @@ -32,13 +36,13 @@ export default async function Certifications() {

-
+ -
+
diff --git a/app/contact/page.tsx b/app/contact/page.tsx index 4a81905..c65d9ee 100644 --- a/app/contact/page.tsx +++ b/app/contact/page.tsx @@ -2,6 +2,8 @@ import { useState } from 'react' import { FaLinkedin, FaEnvelope, FaCopy, FaCheck, FaLock } from 'react-icons/fa' +import { RoleTypewriter } from '@/components/RoleTypewriter' +import { ScrollReveal } from '@/components/ScrollReveal' export default function Contact() { const [copied, setCopied] = useState(false) @@ -27,18 +29,21 @@ export default function Contact() {
-

PRODUCT MANAGER & SOFTWARE DESIGNER

+

+ +

{'⟩'} {'CONTACT'}

- I'm always open to discussing new opportunities, technical challenges, or potential collaborations. Reach out through any of the channels below. + I'm always open to discussing new opportunities, technical challenges, or potential collaborations. Reach out through any of the channels below.

-
+
{/* Email Section */} +
@@ -72,8 +77,10 @@ export default function Contact() {
+ {/* LinkedIn Section */} +
@@ -97,8 +104,10 @@ export default function Contact() {
+ {/* Other Section */} +

More Ways to Connect

@@ -118,6 +127,7 @@ export default function Contact() {

+
diff --git a/app/layout.tsx b/app/layout.tsx index 7d75af5..d5b915b 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -6,6 +6,7 @@ import { Header } from '@/components/Header' import { Footer } from '@/components/Footer' import { XangaShell } from '@/components/XangaShell' import { XangaLayoutWrapper } from '@/components/XangaLayoutWrapper' +import { BackToTop } from '@/components/BackToTop' const openSans = Open_Sans({ subsets: ['latin'], @@ -36,11 +37,20 @@ export const metadata: Metadata = { url: 'https://thomaslbohn.com', siteName: 'Thomas Bohn', type: 'website', + images: [ + { + url: '/images/og-image.png', + width: 1200, + height: 630, + alt: 'Thomas Bohn - Product Manager & Software Designer', + }, + ], }, twitter: { - card: 'summary', + card: 'summary_large_image', title: 'Thomas Bohn', description: 'Personal website and blog', + images: ['/images/og-image.png'], }, } @@ -84,14 +94,21 @@ export default function RootLayout({ + + Skip to content +
-
+
{children}
+
diff --git a/app/page.tsx b/app/page.tsx index da7d76a..91559fa 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -1,5 +1,7 @@ import Image from 'next/image' import mediumHomepage from '@/data/medium-homepage.json' +import { RoleTypewriter } from '@/components/RoleTypewriter' +import { ScrollReveal } from '@/components/ScrollReveal' export default function Home() { const featuredMediumArticles = Array.isArray(mediumHomepage.featuredArticles) @@ -19,7 +21,9 @@ export default function Home() {
{/* Role tag with fade-in animation */} -

PRODUCT MANAGER & SOFTWARE DESIGNER

+

+ +

{/* Main content area - two column layout */}
@@ -85,7 +89,7 @@ export default function Home() {
{/* Project Cards Section */} -
+

{'⟩'} {'MY WORK'} @@ -205,13 +209,13 @@ export default function Home() {

-
+ {/* Gradient Divider */} -
+
{/* Dig Deeper Section */} -
+

{'⟩'} {'DIG DEEPER'} @@ -242,7 +246,7 @@ export default function Home() { Request VIP Access

-
+
diff --git a/app/projects/page.tsx b/app/projects/page.tsx index 8de7e19..d5b8795 100644 --- a/app/projects/page.tsx +++ b/app/projects/page.tsx @@ -2,6 +2,8 @@ import type { Metadata } from 'next' import { getProjects } from '@/lib/projects' import { ProjectsSection } from '@/components/projects/ProjectsSection' import { ProjectsPageHeader } from '@/components/projects/ProjectsPageHeader' +import { RoleTypewriter } from '@/components/RoleTypewriter' +import { ScrollReveal } from '@/components/ScrollReveal' export const metadata: Metadata = { title: 'Projects', @@ -19,7 +21,9 @@ export default async function Projects() {
-

PRODUCT MANAGER & SOFTWARE DESIGNER

+

+ +

{'⟩'} @@ -32,9 +36,9 @@ export default async function Projects() {

-
+ -
+
diff --git a/components/BackToTop.tsx b/components/BackToTop.tsx new file mode 100644 index 0000000..814975f --- /dev/null +++ b/components/BackToTop.tsx @@ -0,0 +1,48 @@ +'use client' + +import { useCallback, useEffect, useState } from 'react' + +const SHOW_AFTER_PX = 400 + +export function BackToTop() { + const [visible, setVisible] = useState(false) + + useEffect(() => { + const handleScroll = () => setVisible(window.scrollY > SHOW_AFTER_PX) + handleScroll() + window.addEventListener('scroll', handleScroll, { passive: true }) + return () => window.removeEventListener('scroll', handleScroll) + }, []) + + const scrollToTop = useCallback(() => { + const prefersReducedMotion = + typeof window !== 'undefined' && + window.matchMedia && + window.matchMedia('(prefers-reduced-motion: reduce)').matches + window.scrollTo({ top: 0, behavior: prefersReducedMotion ? 'auto' : 'smooth' }) + }, []) + + return ( + + ) +} diff --git a/components/Header.tsx b/components/Header.tsx index 2e74a93..1fc87e1 100644 --- a/components/Header.tsx +++ b/components/Header.tsx @@ -1,5 +1,6 @@ 'use client' +import { useEffect, useState } from 'react' import Link from 'next/link' import { usePathname } from 'next/navigation' import { useTheme } from './ThemeProvider' @@ -123,14 +124,76 @@ function LockIcon({ className }: { className?: string }) { ) } +// Desktop nav link with an animated underline indicator that grows in from +// the center on hover, and stays put (in the accent color) on the active page. +function DesktopNavLink({ + href, + label, + isActive, + vip, +}: { + href: string + label: string + isActive: boolean + vip?: boolean +}) { + return ( + + {vip && } + {label} + {vip && ( + + VIP + + )} +
+ {/* Hamburger toggle for mobile navigation */} +
- {/* Mobile menu */} -
-
- {/* Public navigation items */} -
- {publicNavItems.map((item) => { - const isActive = pathname === item.href || - (item.href !== '/' && pathname?.startsWith(item.href)) - return ( - - {item.label} - - ) - })} -
- - {/* Visual separator */} -
-