├── .github └── SECURITY.md ├── .gitignore ├── .vscode └── settings.json ├── README.md ├── app ├── (home) │ └── page.tsx ├── actions │ ├── contact.ts │ └── subscribe.ts ├── apple-touch-icon.png ├── blog │ ├── [slug] │ │ └── page.tsx │ └── page.tsx ├── contact │ ├── components │ │ └── form.tsx │ └── page.tsx ├── globals.css ├── icon.png ├── layout.tsx └── og │ ├── Geist-Bold.ttf │ ├── Geist-Regular.ttf │ └── route.tsx ├── biome.json ├── components ├── avatar │ ├── avatar.jpg │ └── index.tsx ├── feature.tsx ├── features.tsx ├── footnote.tsx ├── input.tsx ├── json-ld.tsx ├── link.tsx ├── location-card.tsx ├── mailing-list.tsx ├── mdx.tsx ├── navigation.tsx ├── post.tsx ├── section.tsx ├── social.tsx ├── stack.tsx ├── textarea.tsx ├── theme-switcher.tsx ├── tool.tsx ├── video.tsx └── windows-emoji-polyfill.tsx ├── content-collections.ts ├── content ├── figma-autolayout-flexbox.mdx ├── firebase-local-emulator-expo.mdx ├── from-product-designer-to-agency-director.mdx ├── google-apple-auth-firebase-expo.mdx ├── handy-parsing-utilities.mdx ├── jellypepper.mdx ├── making-react-syntax-highlighter-editable.mdx ├── mysql-row-level-security.mdx ├── neutral.mdx ├── next-hsts-preload.mdx ├── next-seo-jsonld-personal.mdx ├── no-auth-spotify-player.mdx ├── oauth-next-rsc.mdx ├── on-javascript-errors.mdx ├── optimizing-next-image.mdx ├── ota-updates-typescript-expo-44.mdx ├── presumi.mdx ├── push-notifications-expo-firebase-cloud.mdx ├── refraction.mdx ├── shadcn.mdx ├── smart-analytics-hook-firebase-amplitude.mdx ├── streaming-openai-completions-vercel-edge.mdx ├── stripe-credit-billing.mdx ├── stripe-paymentsheet-apple-google-pay-expo-firebase.mdx ├── tailwind-active-next-13.mdx ├── unreal-engine-macbook.mdx ├── upload-image-firebase-expo.mdx ├── useful-js-library-replacements.mdx ├── user-contacts-firebase-expo.mdx ├── user-profile-hooks-firebase-9.mdx ├── using-clsx-tailwind-composition.mdx └── vibecoding.mdx ├── lib ├── env.ts ├── fonts │ ├── index.ts │ ├── soehne-buch-kursiv.woff2 │ ├── soehne-buch.woff2 │ ├── soehne-kraftig-kursiv.woff2 │ ├── soehne-kraftig.woff2 │ ├── soehne-mono-buch-kursiv.woff2 │ └── soehne-mono-buch.woff2 ├── live.ts ├── metadata.ts ├── projects.ts ├── resend.ts ├── social │ ├── bluesky.svg │ ├── dribbble.svg │ ├── figma.svg │ ├── github.svg │ ├── index.ts │ ├── instagram.svg │ ├── linkedin.svg │ ├── mastodon.svg │ ├── producthunt.svg │ ├── threads.svg │ ├── tiktok.svg │ ├── x.svg │ └── youtube.svg ├── stack.ts └── utils.ts ├── next.config.ts ├── package.json ├── pnpm-lock.yaml ├── postcss.config.mjs ├── public ├── blog │ ├── figma-autolayout.gif │ ├── jellypepper.png │ ├── on-javascript-errors │ │ └── try-catch.png │ ├── presumi │ │ ├── presumi-accounting.svg │ │ ├── presumi-activity.svg │ │ ├── presumi-all.svg │ │ ├── presumi-app.svg │ │ ├── presumi-artist.svg │ │ ├── presumi-checkout.svg │ │ └── presumi-colleagues.svg │ ├── refraction.jpg │ ├── shadcn.png │ └── stripe-credit-billing │ │ ├── checkout.png │ │ ├── meter.png │ │ └── products.png ├── presumi-icc.jpeg ├── presumi-uts.pdf └── profile.jpg └── tsconfig.json /.github/SECURITY.md: -------------------------------------------------------------------------------- 1 | # Security Policy 2 | 3 | ## Supported Versions 4 | 5 | Currently, only the latest on `main` branch is supported with security updates. 6 | 7 | ## Reporting a Vulnerability 8 | 9 | To report a vulnerability, open a new issue or DM me on [X](https://x.com/haydenbleasel). 10 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.* 7 | .yarn/* 8 | !.yarn/patches 9 | !.yarn/plugins 10 | !.yarn/releases 11 | !.yarn/versions 12 | 13 | # testing 14 | /coverage 15 | 16 | # next.js 17 | /.next/ 18 | /out/ 19 | 20 | # production 21 | /build 22 | 23 | # misc 24 | .DS_Store 25 | *.pem 26 | 27 | # debug 28 | npm-debug.log* 29 | yarn-debug.log* 30 | yarn-error.log* 31 | .pnpm-debug.log* 32 | 33 | # env files (can opt-in for committing if needed) 34 | .env* 35 | 36 | # vercel 37 | .vercel 38 | 39 | # typescript 40 | *.tsbuildinfo 41 | next-env.d.ts 42 | 43 | # content-collections 44 | .content-collections -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "typescript.tsdk": "node_modules/typescript/lib", 3 | "editor.defaultFormatter": "biomejs.biome", 4 | "editor.formatOnSave": true, 5 | "editor.formatOnPaste": true, 6 | "emmet.showExpandedAbbreviation": "never", 7 | "editor.codeActionsOnSave": { 8 | "source.fixAll.biome": "explicit", 9 | "source.organizeImports.biome": "explicit" 10 | }, 11 | "[typescript]": { 12 | "editor.defaultFormatter": "biomejs.biome" 13 | }, 14 | "[json]": { 15 | "editor.defaultFormatter": "biomejs.biome" 16 | }, 17 | "[javascript]": { 18 | "editor.defaultFormatter": "biomejs.biome" 19 | }, 20 | "[jsonc]": { 21 | "editor.defaultFormatter": "biomejs.biome" 22 | }, 23 | "[typescriptreact]": { 24 | "editor.defaultFormatter": "biomejs.biome" 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # haydenbleasel.com 2 | 3 | Hello! I'm Hayden Bleasel, a software engineer and designer. This is my personal website - it's built with [Next.js](https://nextjs.org) and hosted on [Vercel](https://vercel.com). 4 | 5 | ## Contributing 6 | 7 | To contribute, first install the dependencies: 8 | 9 | ```bash 10 | pnpm install 11 | ``` 12 | 13 | Then, run the development server: 14 | 15 | ```bash 16 | pnpm dev 17 | ``` 18 | 19 | Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. 20 | 21 | You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file. 22 | 23 | ## Deploying 24 | 25 | This website is deployed automatically to Vercel. 26 | -------------------------------------------------------------------------------- /app/actions/contact.ts: -------------------------------------------------------------------------------- 1 | 'use server'; 2 | 3 | import { env } from '@/lib/env'; 4 | import { resend } from '@/lib/resend'; 5 | import { Ratelimit } from '@upstash/ratelimit'; 6 | import { Redis } from '@upstash/redis'; 7 | import { revalidatePath } from 'next/cache'; 8 | import { headers } from 'next/headers'; 9 | 10 | const verifyRecaptcha = async (token: string) => { 11 | const recaptchaUrl = new URL( 12 | 'https://www.google.com/recaptcha/api/siteverify' 13 | ); 14 | 15 | recaptchaUrl.searchParams.set('secret', env.RECAPTCHA_SECRET_KEY); 16 | recaptchaUrl.searchParams.set('response', token); 17 | 18 | const recaptchaResponse = await fetch(recaptchaUrl, { 19 | method: 'POST', 20 | }); 21 | 22 | const recaptchaJson = await recaptchaResponse.json(); 23 | 24 | if (!recaptchaJson.success) { 25 | return { 26 | error: 'Please verify that you are human.', 27 | message: '', 28 | }; 29 | } 30 | }; 31 | 32 | export const contact = async ( 33 | formData: FormData 34 | ): Promise<{ 35 | message: string; 36 | error: string; 37 | }> => { 38 | const head = await headers(); 39 | 40 | const ip = head.get('x-forwarded-for'); 41 | const redis = Redis.fromEnv(); 42 | const ratelimit = new Ratelimit({ 43 | redis, 44 | // rate limit to 1 request every day 45 | limiter: Ratelimit.slidingWindow(1, '1d'), 46 | }); 47 | 48 | const { success } = await ratelimit.limit(`ratelimit_${ip}`); 49 | 50 | if (!success) { 51 | return { 52 | error: 'You have reached your request limit. Please try again later.', 53 | message: '', 54 | }; 55 | } 56 | 57 | const { name, email, message, subject, token } = Object.fromEntries(formData); 58 | 59 | // This is a honeypot field - if it's filled, it's likely a bot. 60 | // Fuck you, bots. 61 | if (typeof subject === 'string' && subject.length) { 62 | return { 63 | message: 'Thanks! Your message has been sent.', 64 | error: '', 65 | }; 66 | } 67 | 68 | if ( 69 | typeof name !== 'string' || 70 | typeof email !== 'string' || 71 | typeof message !== 'string' 72 | ) { 73 | return { 74 | error: 'Please fill in all fields.', 75 | message: '', 76 | }; 77 | } 78 | 79 | if (typeof token !== 'string') { 80 | return { 81 | error: 'Please verify that you are human.', 82 | message: '', 83 | }; 84 | } 85 | 86 | await verifyRecaptcha(token); 87 | 88 | const response = await resend.emails.send({ 89 | from: env.RESEND_TO, 90 | to: env.RESEND_TO, 91 | subject: `New message from ${name}`, 92 | replyTo: email, 93 | text: message, 94 | }); 95 | 96 | if (response.error) { 97 | return { 98 | error: response.error.message, 99 | message: '', 100 | }; 101 | } 102 | 103 | revalidatePath('/contact'); 104 | 105 | return { 106 | message: 'Thanks! Your message has been sent.', 107 | error: '', 108 | }; 109 | }; 110 | -------------------------------------------------------------------------------- /app/actions/subscribe.ts: -------------------------------------------------------------------------------- 1 | 'use server'; 2 | 3 | import { env } from '@/lib/env'; 4 | import { resend } from '@/lib/resend'; 5 | 6 | const audienceId = env.RESEND_AUDIENCE_ID; 7 | 8 | if (!audienceId) { 9 | throw new Error('Missing RESEND_AUDIENCE_ID'); 10 | } 11 | 12 | export const subscribe = async ( 13 | prevState: unknown, 14 | formData: FormData 15 | ): Promise<{ 16 | message: string; 17 | error: string; 18 | }> => { 19 | const email = formData.get('email'); 20 | 21 | if (typeof email !== 'string') { 22 | return { message: '', error: 'Invalid email address' }; 23 | } 24 | 25 | const response = await resend.contacts.create({ 26 | email, 27 | unsubscribed: false, 28 | audienceId, 29 | }); 30 | 31 | if (response.error) { 32 | return { message: '', error: response.error.message }; 33 | } 34 | 35 | return { message: 'Subscribed!', error: '' }; 36 | }; 37 | -------------------------------------------------------------------------------- /app/apple-touch-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/haydenbleasel/website/cd812d0db668b7e99c786fdc20277b5c6344f8b5/app/apple-touch-icon.png -------------------------------------------------------------------------------- /app/blog/[slug]/page.tsx: -------------------------------------------------------------------------------- 1 | import { Link } from '@/components/link'; 2 | import { Mdx } from '@/components/mdx'; 3 | import { Section } from '@/components/section'; 4 | import { createMetadata } from '@/lib/metadata'; 5 | import { cn } from '@/lib/utils'; 6 | import { allPosts } from 'content-collections'; 7 | import { ArrowLeftToLineIcon } from 'lucide-react'; 8 | import type { Metadata } from 'next'; 9 | import Image from 'next/image'; 10 | import { notFound } from 'next/navigation'; 11 | import type { FC } from 'react'; 12 | 13 | type PageProperties = { 14 | readonly params: Promise<{ 15 | slug: string; 16 | }>; 17 | }; 18 | 19 | export const runtime = 'nodejs'; 20 | 21 | export const generateMetadata = async ({ 22 | params, 23 | }: PageProperties): Promise => { 24 | const { slug } = await params; 25 | const page = allPosts.find((page) => page._meta.path === slug); 26 | 27 | if (!page) { 28 | return {}; 29 | } 30 | 31 | return createMetadata({ 32 | title: page.title, 33 | description: page.description, 34 | image: `/og?title=${page.title}&description=${page.description}`, 35 | }); 36 | }; 37 | 38 | export const generateStaticParams = (): { slug: string }[] => 39 | allPosts.map((page) => ({ 40 | slug: page._meta.path, 41 | })); 42 | 43 | const Page: FC = async ({ params }) => { 44 | const { slug } = await params; 45 | const page = allPosts.find((page) => page._meta.path === slug); 46 | 47 | if (!page) { 48 | notFound(); 49 | } 50 | 51 | return ( 52 | <> 53 |
57 | 64 | 65 | Blog 66 | 67 |
68 |
69 |

{page.title}

70 |

{page.description}

71 |
72 | {page.image ? ( 73 |
74 | {page.title} 83 |
84 | ) : null} 85 |
86 |
87 | 88 |
89 |
90 |
94 |

95 | Published on{' '} 96 | {new Intl.DateTimeFormat('en-US', { dateStyle: 'long' }).format( 97 | page.date 98 | )} 99 |

100 |

{page.readingTime}

101 |
102 | 103 | ); 104 | }; 105 | 106 | export default Page; 107 | -------------------------------------------------------------------------------- /app/blog/page.tsx: -------------------------------------------------------------------------------- 1 | import { Post } from '@/components/post'; 2 | import { Section } from '@/components/section'; 3 | import { createMetadata } from '@/lib/metadata'; 4 | import { allPosts } from 'content-collections'; 5 | import type { Metadata } from 'next'; 6 | 7 | const postsByYear = allPosts 8 | .sort((a, b) => b.date.getTime() - a.date.getTime()) 9 | .reduce( 10 | (acc, post) => { 11 | const year = post.date.getFullYear(); 12 | if (!acc[year]) { 13 | acc[year] = []; 14 | } 15 | acc[year].push(post); 16 | return acc; 17 | }, 18 | {} as Record 19 | ); 20 | 21 | const title = 'Blog'; 22 | const description = 'Thoughts, stories and ideas.'; 23 | 24 | export const metadata: Metadata = createMetadata({ 25 | title, 26 | description, 27 | ogText: 'My blog — thoughts, stories and ideas.', 28 | }); 29 | 30 | const Posts = () => ( 31 | <> 32 |
33 |

{title}

34 |

{description}

35 |
36 | {Object.entries(postsByYear) 37 | .sort(([a], [b]) => Number(b) - Number(a)) 38 | .map(([year, posts], index) => ( 39 |
40 |

41 | {year} 42 |

43 |
    44 | {posts.map((post) => ( 45 |
  • 46 | 47 |
  • 48 | ))} 49 |
50 |
51 | ))} 52 | 53 | ); 54 | 55 | export default Posts; 56 | -------------------------------------------------------------------------------- /app/contact/components/form.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { contact } from '@/app/actions/contact'; 4 | import { Input } from '@/components/input'; 5 | import { Textarea } from '@/components/textarea'; 6 | import { cn } from '@/lib/utils'; 7 | import { ArrowRightIcon, Loader2Icon } from 'lucide-react'; 8 | import { useReCaptcha } from 'next-recaptcha-v3'; 9 | import { Form } from 'radix-ui'; 10 | import { type FormEventHandler, useState } from 'react'; 11 | import { toast } from 'sonner'; 12 | 13 | export const emailRegex = /.+@.+/u; 14 | 15 | export const ContactForm = () => { 16 | const { executeRecaptcha } = useReCaptcha(); 17 | const [loading, setLoading] = useState(false); 18 | 19 | const handleSubmit: FormEventHandler = async (event) => { 20 | event.preventDefault(); 21 | 22 | if (loading) { 23 | return; 24 | } 25 | 26 | setLoading(true); 27 | 28 | try { 29 | const token = await executeRecaptcha('form_submit'); 30 | const form = event.target; 31 | 32 | if (!(form instanceof HTMLFormElement)) { 33 | toast.error('Form is not an HTMLFormElement'); 34 | return; 35 | } 36 | 37 | const formData = new FormData(form); 38 | 39 | formData.append('token', token); 40 | 41 | const response = await contact(formData); 42 | 43 | if (response.error) { 44 | throw new Error(response.error); 45 | } 46 | 47 | toast.success(response.message); 48 | } catch (error) { 49 | const message = 50 | error instanceof Error 51 | ? error.message 52 | : 'An error occurred while sending the message'; 53 | 54 | toast.error(message); 55 | } finally { 56 | setLoading(false); 57 | } 58 | }; 59 | 60 | return ( 61 | 62 | 69 | 77 |