├── .env.example ├── .eslintrc.json ├── .gitignore ├── .vscode └── settings.json ├── LICENSE ├── README.md ├── next.config.mjs ├── package-lock.json ├── package.json ├── postcss.config.js ├── postcss.config.mjs ├── prd_generator_prompt.txt ├── progress.txt ├── progress_tracker_prompt.txt ├── public ├── DigitalProfile_tiny.png ├── _redirects ├── ai-chapel.JPEG ├── alpaca.jpg ├── building-loop.png ├── building-steps.png ├── dark-header.png ├── fonts │ ├── CRC25.otf │ ├── CRC35.otf │ ├── CRC55.otf │ └── CRC65.otf ├── freshsesh.jpeg ├── github.svg ├── gmail.svg ├── googlescholar.svg ├── images │ └── blog-banners │ │ ├── banner.png │ │ ├── future-of-fabric.png │ │ └── how-to-build-with-ai.png ├── light-header.png ├── linkedin.svg ├── makedark.svg ├── makelight.svg ├── merino-sheep.jpg ├── ocr.mov ├── paper.png ├── peak-trough.png ├── pip-demo.mp4 ├── post3-asset1.png ├── post4-asset1.jpeg ├── post4-asset2.png ├── post4-asset3.png ├── post4-asset4.png ├── tencel.png ├── thats-me.png ├── toggle-mode.png ├── uv-demo.mp4 ├── website.png └── youtube.svg ├── scripts ├── analyze-blog-posts.js └── migrate-blog-posts.js ├── src ├── app │ ├── about │ │ └── page.tsx │ ├── api │ │ ├── subscribe │ │ │ └── route.ts │ │ └── unsubscribe │ │ │ └── route.ts │ ├── blog │ │ ├── [slug] │ │ │ ├── getStaticParams.ts │ │ │ └── page.tsx │ │ ├── archive │ │ │ ├── [year] │ │ │ │ ├── [month] │ │ │ │ │ └── page.tsx │ │ │ │ └── page.tsx │ │ │ └── page.tsx │ │ ├── layout.tsx │ │ ├── page.tsx │ │ ├── series │ │ │ └── page.tsx │ │ └── tag │ │ │ └── [tag] │ │ │ └── page.tsx │ ├── favicon.ico │ ├── globals.css │ ├── layout.tsx │ ├── page.tsx │ ├── projects │ │ └── page.tsx │ ├── robots.txt │ │ └── route.ts │ └── sitemap.ts ├── components │ ├── BlogPostHeader.tsx │ ├── Breadcrumbs.tsx │ ├── CopyButton.tsx │ ├── Iframe.tsx │ ├── JsonLd.tsx │ ├── LatexEquation.tsx │ ├── MDXComponents.tsx │ ├── MDXContent.tsx │ ├── MDXTable.tsx │ ├── MinimalHeader.tsx │ ├── PerplexityLink.tsx │ ├── RelatedPosts.tsx │ ├── SeriesNavigation.tsx │ ├── SocialIcons.tsx │ ├── SubscribeInput.tsx │ ├── SubscriptionForm.tsx │ ├── Table.tsx │ ├── TagCloud.tsx │ ├── ThemeProvider.tsx │ ├── UmamiAnalytics.tsx │ ├── Video.tsx │ └── emails │ │ ├── BlogUpdateEmail.tsx │ │ └── WelcomeEmail.tsx ├── content │ └── blog │ │ ├── 1969 │ │ └── dec │ │ │ ├── books.mdx │ │ │ ├── films.mdx │ │ │ ├── longevity.mdx │ │ │ ├── music.mdx │ │ │ ├── quotes.mdx │ │ │ └── tools.mdx │ │ ├── 2024 │ │ ├── apr │ │ │ └── fitness-wearables.mdx │ │ ├── aug │ │ │ └── longevity-the-new-compound-interest.mdx │ │ ├── jan │ │ │ └── objectivity-in-design.mdx │ │ ├── jul │ │ │ └── sparse-autoencoders-and-tmux.mdx │ │ └── oct │ │ │ └── i-am-becuase-we-are.mdx │ │ └── 2025 │ │ ├── apr │ │ ├── future-of-fabric.mdx │ │ └── how-to-build-with-ai.mdx │ │ ├── feb │ │ ├── chronotype-based-scheduling.mdx │ │ └── uv-add-greater-pip-install.mdx │ │ └── jan │ │ └── from-apis-to-model-training-a-deep-learning-guide.mdx ├── lib │ ├── blog.js │ └── blog.ts └── pages │ └── _document.tsx ├── tailwind.config.js ├── tailwind.config.ts ├── todo.md └── tsconfig.json /.env.example: -------------------------------------------------------------------------------- 1 | # Analytics 2 | NEXT_PUBLIC_UMAMI_SCRIPT_URL=https://analytics.yourdomain.com/script.js 3 | NEXT_PUBLIC_UMAMI_WEBSITE_ID=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "next/core-web-vitals", 3 | "overrides": [ 4 | { 5 | "files": ["src/components/emails/**/*.tsx"], 6 | "rules": { 7 | "@next/next/no-img-element": "off" 8 | } 9 | } 10 | ] 11 | } 12 | -------------------------------------------------------------------------------- /.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.js 7 | .yarn/install-state.gz 8 | 9 | # testing 10 | /coverage 11 | 12 | # next.js 13 | /.next/ 14 | /out/ 15 | 16 | # production 17 | /build 18 | 19 | # misc 20 | .DS_Store 21 | *.pem 22 | 23 | # debug 24 | npm-debug.log* 25 | yarn-debug.log* 26 | yarn-error.log* 27 | 28 | # local env files 29 | .env*.local 30 | 31 | # vercel 32 | .vercel 33 | 34 | # typescript 35 | *.tsbuildinfo 36 | next-env.d.ts 37 | 38 | .env 39 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "liveServer.settings.port": 5501, 3 | "css.validate": false, 4 | "tailwindCSS.experimental.classRegex": [], 5 | "tailwindCSS.includeLanguages": { 6 | "typescript": "javascript", 7 | "typescriptreact": "javascript" 8 | }, 9 | "editor.quickSuggestions": { 10 | "strings": true 11 | }, 12 | "files.watcherExclude": { 13 | "**/.git/objects/**": true, 14 | "**/.git/subtree-cache/**": true, 15 | "**/node_modules/**": true, 16 | "**/.next/**": true 17 | }, 18 | "files.exclude": { 19 | "**/.git": true, 20 | "**/.svn": true, 21 | "**/.hg": true, 22 | "**/CVS": true, 23 | "**/.DS_Store": true, 24 | "**/node_modules": true, 25 | "**/.next": true 26 | } 27 | } -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Max Forsey 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # m4c2 2 | Version 2.0 of maxforsey.com using tailwind.css, next.js, and mdx files 3 | 4 | This is a [Next.js](https://nextjs.org/) project bootstrapped with [`create-next-app`](https://github.com/vercel/next.js/tree/canary/packages/create-next-app). 5 | 6 | ## Getting Started 7 | 8 | First, run the development server: 9 | 10 | ```bash 11 | npm run dev 12 | # or 13 | yarn dev 14 | # or 15 | pnpm dev 16 | # or 17 | bun dev 18 | ``` 19 | 20 | Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. 21 | 22 | You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file. 23 | 24 | This project uses [`next/font`](https://nextjs.org/docs/basic-features/font-optimization) to automatically optimize and load Inter, a custom Google Font. 25 | 26 | ## Learn More 27 | 28 | To learn more about Next.js, take a look at the following resources: 29 | 30 | - [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API. 31 | - [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial. 32 | 33 | You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js/) - your feedback and contributions are welcome! 34 | 35 | ## Deploy on Vercel 36 | 37 | The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js. 38 | 39 | Check out our [Next.js deployment documentation](https://nextjs.org/docs/deployment) for more details. 40 | -------------------------------------------------------------------------------- /next.config.mjs: -------------------------------------------------------------------------------- 1 | import createMDX from '@next/mdx' 2 | 3 | const withMDX = createMDX({ 4 | extension: /\.mdx?$/, 5 | options: { 6 | remarkPlugins: [], 7 | rehypePlugins: [], 8 | }, 9 | }) 10 | 11 | /** @type {import('next').NextConfig} */ 12 | const nextConfig = { 13 | reactStrictMode: true, 14 | pageExtensions: ['ts', 'tsx', 'js', 'jsx', 'md', 'mdx'], 15 | experimental: { 16 | mdxRs: true, 17 | }, 18 | webpack: (config, { isServer }) => { 19 | if (!isServer) { 20 | config.resolve.fallback = { 21 | ...config.resolve.fallback, 22 | punycode: false, 23 | }; 24 | } 25 | config.cache = false; 26 | return config; 27 | }, 28 | } 29 | 30 | export default withMDX(nextConfig) 31 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "maxforsey.com-2.0", 3 | "version": "0.1.0", 4 | "private": true, 5 | "scripts": { 6 | "dev": "NEXT_WEBPACK_USEPOLLING=1 next dev", 7 | "build": "next build", 8 | "start": "next start", 9 | "lint": "next lint" 10 | }, 11 | "dependencies": { 12 | "@heroicons/react": "^2.2.0", 13 | "@mdx-js/loader": "^3.0.1", 14 | "@mdx-js/react": "^3.0.1", 15 | "@next/mdx": "^14.2.7", 16 | "@radix-ui/react-dropdown-menu": "^2.1.2", 17 | "@supabase/supabase-js": "^2.45.6", 18 | "@types/katex": "^0.16.7", 19 | "date-fns": "^3.6.0", 20 | "framer-motion": "^11.11.9", 21 | "gray-matter": "^4.0.3", 22 | "katex": "^0.16.11", 23 | "next": "^14.2.24", 24 | "next-mdx-remote": "^5.0.0", 25 | "next-themes": "^0.2.1", 26 | "react": "^18", 27 | "react-dom": "^18", 28 | "react-katex": "^3.0.1", 29 | "resend": "^4.1.2", 30 | "server-only": "^0.0.1", 31 | "slugify": "^1.6.6", 32 | "web-vitals": "^4.2.4", 33 | "zod": "^3.24.2" 34 | }, 35 | "devDependencies": { 36 | "@tailwindcss/typography": "^0.5.16", 37 | "@types/node": "^20", 38 | "@types/react": "^18", 39 | "@types/react-dom": "^18", 40 | "@types/react-katex": "^3.0.4", 41 | "autoprefixer": "^10.4.20", 42 | "eslint": "^8", 43 | "eslint-config-next": "14.2.4", 44 | "postcss": "^8.5.1", 45 | "tailwindcss": "^3.4.17", 46 | "typescript": "^5" 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | } 6 | } -------------------------------------------------------------------------------- /postcss.config.mjs: -------------------------------------------------------------------------------- 1 | /** @type {import('postcss-load-config').Config} */ 2 | const config = { 3 | plugins: { 4 | tailwindcss: {}, 5 | autoprefixer: {}, 6 | }, 7 | }; 8 | 9 | export default config; 10 | -------------------------------------------------------------------------------- /prd_generator_prompt.txt: -------------------------------------------------------------------------------- 1 | # PRD Generator Prompt 2 | 3 | Transform my idea/concept below into a comprehensive Product Requirements Document (PRD) for [PROJECT_NAME]. The PRD should provide clear direction for both product vision and technical implementation. 4 | 5 | Please create a structured PRD with the following sections: 6 | 7 | ## Project Overview 8 | - Provide a concise summary of the project/feature 9 | - Explain the problem it solves and why it's valuable 10 | - Define the scope of the project 11 | 12 | ## Goals & Objectives 13 | - List 3-5 specific, measurable goals for this project 14 | - Define what success looks like 15 | - Identify key metrics that will be used to measure success 16 | 17 | ## Target Audience 18 | - Describe the primary users/customers for this project 19 | - Explain their needs, pain points, and how this addresses them 20 | - Include any relevant user personas 21 | 22 | ## Feature Requirements 23 | [Break down the project into clear, specific features/components] 24 | 25 | **Feature 1: [Name]** 26 | - Detailed description of functionality 27 | - User flows and interactions 28 | - Edge cases and considerations 29 | 30 | **Feature 2: [Name]** 31 | - Detailed description of functionality 32 | - User flows and interactions 33 | - Edge cases and considerations 34 | 35 | [Add more features as needed] 36 | 37 | ## User Experience Requirements 38 | - Describe the overall user experience and journey 39 | - Define key user interactions and flows 40 | - Outline any specific UX considerations or constraints 41 | - List accessibility requirements 42 | 43 | ## Technical Requirements 44 | - Define the technical approach and architecture 45 | - List required technologies, APIs, or integrations 46 | - Outline any specific performance requirements 47 | - Note any technical constraints or considerations 48 | 49 | ## Design Specifications 50 | - Describe the visual design direction and requirements 51 | - List any brand guidelines or design constraints 52 | - Specify required design assets or components 53 | - Note any responsive design considerations 54 | 55 | ## Implementation Considerations 56 | - Outline any dependencies or prerequisites 57 | - Note any security, privacy, or compliance requirements 58 | - Identify potential technical challenges 59 | - List any third-party services or tools required 60 | 61 | ## Out of Scope 62 | - Clearly define what is NOT included in this project 63 | - List any features or considerations for future phases 64 | 65 | ## Timeline & Milestones 66 | - Provide a high-level implementation timeline 67 | - Define key milestones or phases 68 | - Note any critical deadlines 69 | 70 | Format the PRD in a clear, well-structured manner using markdown for readability. Ensure that the requirements are specific enough to guide implementation but not so prescriptive that they limit creative solutions. 71 | 72 | MY IDEA/CONCEPT: 73 | -------------------------------------------------------------------------------- /progress.txt: -------------------------------------------------------------------------------- 1 | # WEBSITE SEO & BLOG POST ORGANIZATION TO-DO LIST 2 | 3 | ## PHASE 1: CONTENT RESTRUCTURING ✅ 4 | - [x] Create `/src/content/blog/` directory structure 5 | - [x] Create `scripts/analyze-blog-posts.js` for migration planning 6 | - [x] Create `scripts/migrate-blog-posts.js` for content migration 7 | - [x] Use three-letter month names for directories (jan, feb, mar) 8 | - [x] Generate descriptive slugs from post titles 9 | - [x] Generate SEO descriptions from content when missing 10 | - [x] Suggest tags based on post content analysis 11 | - [x] Update frontmatter with enhanced metadata 12 | - [x] Generate `/public/_redirects` file for backward compatibility 13 | - [x] Migrate all blog posts to new structure 14 | 15 | ## PHASE 2: URL & ROUTING UPDATES ✅ 16 | - [x] Create TypeScript library `/src/lib/blog.ts` with proper types 17 | - [x] Update blog listing page to use new content structure 18 | - [x] Implement year/month filtering of blog posts 19 | - [x] Implement tag-based filtering pages 20 | - [x] Update dynamic blog post page to use new content structure 21 | - [x] Add breadcrumb navigation to blog posts 22 | - [x] Set up proper canonical URLs in headers 23 | 24 | ## PHASE 3: SEO ENHANCEMENTS ✅ 25 | - [x] Add dynamic sitemap.xml generation 26 | - [x] Add Open Graph metadata to blog posts 27 | - [x] Add Twitter card metadata to blog posts 28 | - [x] Set up proper meta descriptions for all content 29 | - [x] Add structured data (JSON-LD) to blog posts 30 | - [x] Add robots.txt configuration 31 | - [x] Add canonical URL meta tags 32 | 33 | ## PHASE 4: SEARCH & DISCOVERY ✅ 34 | - [x] Add tag navigation to blog page 35 | - [x] Create tag cloud visualization 36 | - [x] Implement year-based archive page 37 | - [x] Add month-based archive page 38 | - [ ] ~~Add search functionality for blog content~~ (removed) 39 | - [x] Add related posts section based on tags 40 | - [x] Create "series" functionality for post sequences 41 | 42 | ## PHASE 5: VISUAL & USABILITY IMPROVEMENTS ✅ 43 | - [x] Add image optimization with Next.js Image 44 | - [x] Implement responsive images in MDX content 45 | - [x] Fix date parsing bug in blog post pages 46 | - [x] Clean up breadcrumbs and related posts UI 47 | - [x] Fix mobile UI issues 48 | 49 | ## NEXT ACTIONS (PRIORITIZED) 50 | 1. [x] Fix any TypeScript errors in current implementation 51 | 2. [x] Test all routes to ensure correct functionality 52 | 3. [x] Implement breadcrumb navigation 53 | 4. [x] Add structured data (JSON-LD) to blog posts 54 | 5. [x] Set up canonical URLs to improve SEO 55 | 6. [x] Add year/month filtering capability 56 | 7. [x] Add robots.txt configuration 57 | 8. [x] Create tag cloud visualization 58 | 9. [ ] ~~Add search functionality for blog content~~ (removed) 59 | 10. [x] Add related posts section based on tags 60 | 11. [x] Create "series" functionality for post sequences 61 | 12. [x] Add image optimization with Next.js Image 62 | 13. [ ] Implement accessibility improvements 63 | -------------------------------------------------------------------------------- /progress_tracker_prompt.txt: -------------------------------------------------------------------------------- 1 | # Universal Project Progress Tracker Prompt 2 | 3 | Create a progress.txt file that tracks the implementation of the project described in the PRD I've pasted at the end of this prompt. The file structure should be adapted based on the project scope (full application vs. feature addition). 4 | 5 | The progress tracker should follow this structure: 6 | 7 | ``` 8 | # [PROJECT_NAME] - Progress Tracker 9 | 10 | ## Project Overview 11 | This file tracks the implementation progress of [PROJECT_NAME] for [WEBSITE/APPLICATION/SYSTEM]. 12 | 13 | **IMPORTANT: Update this file whenever a milestone is completed** 14 | 15 | ## Reference Documentation 16 | - [Include relevant documentation links for the technologies being used] 17 | - [Examples: Framework docs, API docs, integration docs, etc.] 18 | 19 | ## Implementation Milestones 20 | 21 | [Number each milestone sequentially to create a clear implementation path] 22 | 23 | ### Milestone 1: [Component/Feature Name] 24 | #### Implementation Tasks 25 | 1.1 [ ] [Task 1] 26 | 1.2 [ ] [Task 2] 27 | 1.3 [ ] [Task 3] 28 | 29 | #### Testing Tasks 30 | 1.4 [ ] Test [specific functionality] 31 | 1.5 [ ] Verify [expected behavior] 32 | 1.6 [ ] Validate [requirements] 33 | 34 | ### Milestone 2: [Next Component/Feature] 35 | #### Implementation Tasks 36 | 2.1 [ ] [Task 1] 37 | 2.2 [ ] [Task 2] 38 | 2.3 [ ] [Task 3] 39 | 40 | #### Testing Tasks 41 | 2.4 [ ] Test [specific functionality] 42 | 2.5 [ ] Verify [expected behavior] 43 | 2.6 [ ] Validate [requirements] 44 | 45 | [Repeat the pattern for each milestone. For full applications, include system-level milestones like:] 46 | 47 | ### Milestone 3: Architecture Setup 48 | #### Implementation Tasks 49 | 3.1 [ ] Set up project structure 50 | 3.2 [ ] Configure build system 51 | 3.3 [ ] Set up database/storage 52 | 3.4 [ ] Configure CI/CD pipeline 53 | 54 | ### Milestone 4: Authentication/Users (if applicable) 55 | #### Implementation Tasks 56 | 4.1 [ ] Implement user registration 57 | 4.2 [ ] Implement login/logout 58 | 4.3 [ ] Set up authorization rules 59 | 60 | ### Milestone 5: Deployment & Final Validation 61 | 5.1 [ ] Deploy to staging/test environment 62 | 5.2 [ ] Conduct end-to-end testing 63 | 5.3 [ ] Deploy to production 64 | 5.4 [ ] Monitor performance metrics 65 | 66 | ## Implementation Locations 67 | [For features being added to existing systems, list where they'll be implemented:] 68 | - [ ] [Location 1 where component will be placed/integrated] 69 | - [ ] [Location 2 where component will be placed/integrated] 70 | 71 | [For full applications, list key pages/sections:] 72 | - [ ] [Page/Section 1] 73 | - [ ] [Page/Section 2] 74 | 75 | ## Technical Considerations 76 | - [List any technical considerations or potential challenges] 77 | - [Include notes on architecture, scalability, security, etc.] 78 | - [Note any third-party service dependencies] 79 | 80 | ## Notes on Progress 81 | (Add notes here as you complete milestones) 82 | 83 | Last Updated: [DATE] 84 | 85 | PRD TO CONVERT: -------------------------------------------------------------------------------- /public/DigitalProfile_tiny.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/max4c/m4c2/23af3dea934378b47779c9087af3f5649db07c52/public/DigitalProfile_tiny.png -------------------------------------------------------------------------------- /public/_redirects: -------------------------------------------------------------------------------- 1 | # Redirects from old blog URLs to new structure 2 | 3 | /blog/books /blog/books 301 4 | /blog/films /blog/films 301 5 | /blog/longevity /blog/longevity 301 6 | /blog/music /blog/music 301 7 | /blog/post1 /blog/objectivity-in-design 301 8 | /blog/post2 /blog/fitness-wearables 301 9 | /blog/post3 /blog/sparse-autoencoders-and-tmux 301 10 | /blog/post4 /blog/longevity-the-new-compound-interest 301 11 | /blog/post5 /blog/i-am-becuase-we-are 301 12 | /blog/post6 /blog/from-apis-to-model-training-a-deep-learning-guide 301 13 | /blog/post7 /blog/uv-add-greater-pip-install 301 14 | /blog/post8 /blog/chronotype-based-scheduling 301 15 | /blog/quotes /blog/quotes 301 16 | /blog/tools /blog/tools 301 17 | -------------------------------------------------------------------------------- /public/ai-chapel.JPEG: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/max4c/m4c2/23af3dea934378b47779c9087af3f5649db07c52/public/ai-chapel.JPEG -------------------------------------------------------------------------------- /public/alpaca.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/max4c/m4c2/23af3dea934378b47779c9087af3f5649db07c52/public/alpaca.jpg -------------------------------------------------------------------------------- /public/building-loop.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/max4c/m4c2/23af3dea934378b47779c9087af3f5649db07c52/public/building-loop.png -------------------------------------------------------------------------------- /public/building-steps.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/max4c/m4c2/23af3dea934378b47779c9087af3f5649db07c52/public/building-steps.png -------------------------------------------------------------------------------- /public/dark-header.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/max4c/m4c2/23af3dea934378b47779c9087af3f5649db07c52/public/dark-header.png -------------------------------------------------------------------------------- /public/fonts/CRC25.otf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/max4c/m4c2/23af3dea934378b47779c9087af3f5649db07c52/public/fonts/CRC25.otf -------------------------------------------------------------------------------- /public/fonts/CRC35.otf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/max4c/m4c2/23af3dea934378b47779c9087af3f5649db07c52/public/fonts/CRC35.otf -------------------------------------------------------------------------------- /public/fonts/CRC55.otf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/max4c/m4c2/23af3dea934378b47779c9087af3f5649db07c52/public/fonts/CRC55.otf -------------------------------------------------------------------------------- /public/fonts/CRC65.otf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/max4c/m4c2/23af3dea934378b47779c9087af3f5649db07c52/public/fonts/CRC65.otf -------------------------------------------------------------------------------- /public/freshsesh.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/max4c/m4c2/23af3dea934378b47779c9087af3f5649db07c52/public/freshsesh.jpeg -------------------------------------------------------------------------------- /public/github.svg: -------------------------------------------------------------------------------- 1 | GitHub -------------------------------------------------------------------------------- /public/gmail.svg: -------------------------------------------------------------------------------- 1 | Gmail -------------------------------------------------------------------------------- /public/googlescholar.svg: -------------------------------------------------------------------------------- 1 | Google Scholar -------------------------------------------------------------------------------- /public/images/blog-banners/banner.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/max4c/m4c2/23af3dea934378b47779c9087af3f5649db07c52/public/images/blog-banners/banner.png -------------------------------------------------------------------------------- /public/images/blog-banners/future-of-fabric.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/max4c/m4c2/23af3dea934378b47779c9087af3f5649db07c52/public/images/blog-banners/future-of-fabric.png -------------------------------------------------------------------------------- /public/images/blog-banners/how-to-build-with-ai.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/max4c/m4c2/23af3dea934378b47779c9087af3f5649db07c52/public/images/blog-banners/how-to-build-with-ai.png -------------------------------------------------------------------------------- /public/light-header.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/max4c/m4c2/23af3dea934378b47779c9087af3f5649db07c52/public/light-header.png -------------------------------------------------------------------------------- /public/linkedin.svg: -------------------------------------------------------------------------------- 1 | LinkedIn -------------------------------------------------------------------------------- /public/makedark.svg: -------------------------------------------------------------------------------- 1 | 2 | 6 | 7 | -------------------------------------------------------------------------------- /public/makelight.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /public/merino-sheep.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/max4c/m4c2/23af3dea934378b47779c9087af3f5649db07c52/public/merino-sheep.jpg -------------------------------------------------------------------------------- /public/ocr.mov: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/max4c/m4c2/23af3dea934378b47779c9087af3f5649db07c52/public/ocr.mov -------------------------------------------------------------------------------- /public/paper.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/max4c/m4c2/23af3dea934378b47779c9087af3f5649db07c52/public/paper.png -------------------------------------------------------------------------------- /public/peak-trough.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/max4c/m4c2/23af3dea934378b47779c9087af3f5649db07c52/public/peak-trough.png -------------------------------------------------------------------------------- /public/pip-demo.mp4: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/max4c/m4c2/23af3dea934378b47779c9087af3f5649db07c52/public/pip-demo.mp4 -------------------------------------------------------------------------------- /public/post3-asset1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/max4c/m4c2/23af3dea934378b47779c9087af3f5649db07c52/public/post3-asset1.png -------------------------------------------------------------------------------- /public/post4-asset1.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/max4c/m4c2/23af3dea934378b47779c9087af3f5649db07c52/public/post4-asset1.jpeg -------------------------------------------------------------------------------- /public/post4-asset2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/max4c/m4c2/23af3dea934378b47779c9087af3f5649db07c52/public/post4-asset2.png -------------------------------------------------------------------------------- /public/post4-asset3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/max4c/m4c2/23af3dea934378b47779c9087af3f5649db07c52/public/post4-asset3.png -------------------------------------------------------------------------------- /public/post4-asset4.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/max4c/m4c2/23af3dea934378b47779c9087af3f5649db07c52/public/post4-asset4.png -------------------------------------------------------------------------------- /public/tencel.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/max4c/m4c2/23af3dea934378b47779c9087af3f5649db07c52/public/tencel.png -------------------------------------------------------------------------------- /public/thats-me.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/max4c/m4c2/23af3dea934378b47779c9087af3f5649db07c52/public/thats-me.png -------------------------------------------------------------------------------- /public/toggle-mode.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/max4c/m4c2/23af3dea934378b47779c9087af3f5649db07c52/public/toggle-mode.png -------------------------------------------------------------------------------- /public/uv-demo.mp4: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/max4c/m4c2/23af3dea934378b47779c9087af3f5649db07c52/public/uv-demo.mp4 -------------------------------------------------------------------------------- /public/website.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/max4c/m4c2/23af3dea934378b47779c9087af3f5649db07c52/public/website.png -------------------------------------------------------------------------------- /public/youtube.svg: -------------------------------------------------------------------------------- 1 | YouTube -------------------------------------------------------------------------------- /scripts/analyze-blog-posts.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Blog Post Analysis Script 3 | * 4 | * This script analyzes existing blog posts in src/app/blog/*.mdx 5 | * and creates a migration plan for moving them to the new structure. 6 | */ 7 | 8 | const fs = require('fs'); 9 | const path = require('path'); 10 | const matter = require('gray-matter'); 11 | const slugify = require('slugify'); 12 | 13 | // Configuration 14 | const sourceDir = path.join(process.cwd(), 'src/app/blog'); 15 | const targetDir = path.join(process.cwd(), 'src/content/blog'); 16 | 17 | // Helper function to create a slug from title 18 | function createSlug(title) { 19 | return slugify(title, { 20 | lower: true, 21 | strict: true, 22 | remove: /[*+~.()'"!:@]/g 23 | }); 24 | } 25 | 26 | // Helper function to format date for directory structure 27 | function formatDateForPath(date) { 28 | let d; 29 | 30 | if (date instanceof Date) { 31 | d = date; 32 | } else if (typeof date === 'string') { 33 | d = new Date(date); 34 | } else { 35 | console.error(`Invalid date type: ${typeof date}, value: ${date}`); 36 | return { year: '0000', month: '00' }; 37 | } 38 | 39 | if (isNaN(d.getTime())) { 40 | console.error(`Invalid date: ${date}`); 41 | return { year: '0000', month: '00' }; 42 | } 43 | 44 | const year = d.getFullYear().toString(); 45 | // Month is 0-indexed, add 1 and pad with leading zero if needed 46 | const month = String(d.getMonth() + 1).padStart(2, '0'); 47 | 48 | return { year, month }; 49 | } 50 | 51 | // Main function to analyze blog posts 52 | function analyzeBlogPosts() { 53 | console.log('Analyzing blog posts...\n'); 54 | 55 | // Get all MDX files 56 | const files = fs.readdirSync(sourceDir) 57 | .filter(file => file.endsWith('.mdx') && !file.startsWith('_')); 58 | 59 | console.log(`Found ${files.length} MDX files.\n`); 60 | 61 | // Migration plan 62 | const migrationPlan = []; 63 | 64 | // Analyze each file 65 | for (const file of files) { 66 | const filePath = path.join(sourceDir, file); 67 | const content = fs.readFileSync(filePath, 'utf8'); 68 | const { data } = matter(content); 69 | 70 | // Extract key metadata 71 | const title = data.title || 'Untitled'; 72 | const date = data.date || new Date(0); 73 | const type = data.type || 'unknown'; 74 | 75 | // Generate slug from title 76 | const slug = createSlug(title); 77 | 78 | // Format date for directory path 79 | const { year, month } = formatDateForPath(date); 80 | 81 | // Create target directory and file path 82 | const targetDirPath = path.join(targetDir, year, month); 83 | const targetFilePath = path.join(targetDirPath, `${slug}.mdx`); 84 | 85 | // Create migration plan entry 86 | migrationPlan.push({ 87 | originalFile: file, 88 | newSlug: slug, 89 | newPath: path.relative(process.cwd(), targetFilePath), 90 | title, 91 | date: typeof date === 'object' ? date.toISOString() : date, 92 | type, 93 | missingMetadata: [] 94 | }); 95 | 96 | // Check for missing metadata 97 | if (!data.description) migrationPlan[migrationPlan.length - 1].missingMetadata.push('description'); 98 | if (!data.tags || !Array.isArray(data.tags)) migrationPlan[migrationPlan.length - 1].missingMetadata.push('tags'); 99 | if (!data.keywords) migrationPlan[migrationPlan.length - 1].missingMetadata.push('keywords'); 100 | } 101 | 102 | // Output migration plan 103 | console.log('Migration Plan:'); 104 | console.log('==============\n'); 105 | 106 | for (const plan of migrationPlan) { 107 | console.log(`Original: ${plan.originalFile}`); 108 | console.log(`Title: ${plan.title}`); 109 | console.log(`New slug: ${plan.newSlug}`); 110 | console.log(`New path: ${plan.newPath}`); 111 | console.log(`Missing metadata: ${plan.missingMetadata.join(', ') || 'None'}`); 112 | console.log('---'); 113 | } 114 | 115 | // Output redirect map 116 | console.log('\nRedirect Map:'); 117 | console.log('============\n'); 118 | 119 | for (const plan of migrationPlan) { 120 | const originalSlug = plan.originalFile.replace('.mdx', ''); 121 | console.log(`/blog/${originalSlug} -> /blog/${plan.newSlug}`); 122 | } 123 | 124 | return migrationPlan; 125 | } 126 | 127 | // Execute the analysis 128 | analyzeBlogPosts(); -------------------------------------------------------------------------------- /src/app/about/page.tsx: -------------------------------------------------------------------------------- 1 | import MinimalHeader from '@/components/MinimalHeader'; 2 | import Link from 'next/link'; 3 | 4 | type HighlightLink = { 5 | url: string; 6 | text: string; 7 | }; 8 | 9 | type Highlight = { 10 | text: string; 11 | links?: HighlightLink[] | null; 12 | }; 13 | 14 | const highlights: Highlight[] = [ 15 | { text: "Head of DevRel at RunPod", links: [{ url: "https://www.runpod.io/", text: "RunPod" }] }, 16 | { text: "Currently in Provo", links: null }, 17 | { text: "Researched Mechanistic Interpretability in the PCC Lab", links: null }, 18 | { text: "Studied machine learning at BYU (ended up dropping out)", links: null }, 19 | { text: "Co-founder of justbuild, the builder community", links: [{ url: "https://justbuild.ing/", text: "justbuild" }] }, 20 | { text: "Venture Partner at Contrary", links: [{ url: "https://contrary.com", text: "Contrary" }] }, 21 | { text: "Cofounded Sameday AI going through AI Grant and YC", 22 | links: [ 23 | { url: "https://www.gosameday.com/", text: "Sameday AI" }, 24 | { url: "https://aigrant.com/", text: "AI Grant" }, 25 | { url: "https://www.ycombinator.com/", text: "YC" } 26 | ] 27 | }, 28 | { text: "Frequently in San Francisco", links: null }, 29 | { text: "Hiked the Grand Canyon rim to rim", links: null }, 30 | { text: "Type in Colemak", links: [{ url: "https://colemak.com/", text: "Colemak" }] }, 31 | { text: "Have fun experimenting with longevity", links: [{ url: "https://www.maxforsey.com/blog/longevity", text: "longevity" }] }, 32 | { text: "Use a split keyboard and vertical mouse", 33 | links: [ 34 | { url: "https://www.zsa.io/moonlander/", text: "split keyboard" }, 35 | { url: "https://www.logitech.com/en-us/products/mice/lift-vertical-ergonomic-mouse.html", text: "vertical mouse" } 36 | ] 37 | }, 38 | { text: "Daily driver is a flip phone", links: [{ url: "https://sunbeamwireless.com/", text: "flip phone" }] } 39 | ]; 40 | 41 | export default function AboutPage() { 42 | return ( 43 | <> 44 | 45 |
46 |
47 | {highlights.map((highlight, index) => ( 48 |

49 | {highlight.links ? ( 50 | // Split text by the linked terms and interleave with links 51 | <> 52 | {highlight.text.split(new RegExp(highlight.links.map(link => link.text).join('|'))).map((part, i) => ( 53 | 54 | {part} 55 | {highlight.links && highlight.links[i] && ( 56 | 60 | {highlight.links[i].text} 61 | 62 | )} 63 | 64 | ))} 65 | 66 | ) : ( 67 | highlight.text 68 | )} 69 |

70 | ))} 71 |
72 | 73 |

interests

74 |
75 |

books

76 |

films

77 |

music

78 |

longevity

79 |

tools

80 |

quotes

81 |
82 |
83 | 84 | ); 85 | } -------------------------------------------------------------------------------- /src/app/api/subscribe/route.ts: -------------------------------------------------------------------------------- 1 | import { NextResponse } from 'next/server'; 2 | import { Resend } from 'resend'; 3 | import { z } from 'zod'; 4 | // Still importing but won't use 5 | import { WelcomeEmail } from '../../../components/emails/WelcomeEmail'; 6 | 7 | // Initialize Resend with API key 8 | const resend = new Resend(process.env.RESEND_API_KEY); 9 | 10 | // Create a schema for input validation 11 | const subscribeSchema = z.object({ 12 | email: z.string().email('Invalid email address'), 13 | name: z.string().optional(), 14 | }); 15 | 16 | export async function POST(request: Request) { 17 | try { 18 | // Get request body 19 | const body = await request.json(); 20 | 21 | // Validate input 22 | const result = subscribeSchema.safeParse(body); 23 | if (!result.success) { 24 | return NextResponse.json( 25 | { message: 'Invalid input', errors: result.error.flatten().fieldErrors }, 26 | { status: 400 } 27 | ); 28 | } 29 | 30 | const { email, name } = result.data; 31 | 32 | // Add to Resend audience (Blog Subscribers) 33 | // This is using Resend's Audiences API 34 | const audienceResponse = await resend.contacts.create({ 35 | email, 36 | firstName: name || undefined, 37 | audienceId: process.env.RESEND_AUDIENCE_ID || '', // Make sure to set this in .env 38 | }); 39 | 40 | if (audienceResponse.error) { 41 | console.error('Resend audience error:', audienceResponse.error); 42 | 43 | // Check if it's a duplicate contact error (already subscribed) 44 | if (audienceResponse.error.message?.includes('already exists')) { 45 | return NextResponse.json( 46 | { message: 'You are already subscribed to this newsletter.' }, 47 | { status: 409 } 48 | ); 49 | } 50 | 51 | throw new Error(audienceResponse.error.message || 'Failed to add to audience'); 52 | } 53 | 54 | // Create an explicit unsubscribe URL - using exact format from docs 55 | const unsubscribeUrl = `${process.env.NEXT_PUBLIC_URL || 'https://www.maxforsey.com'}/api/unsubscribe?email=${encodeURIComponent(email)}`; 56 | console.log('Generated unsubscribe URL:', unsubscribeUrl); 57 | 58 | // LOGGING: Print API key info (masked) 59 | const apiKeyPreview = process.env.RESEND_API_KEY ? 60 | `${process.env.RESEND_API_KEY.substring(0, 5)}...${process.env.RESEND_API_KEY.substring(process.env.RESEND_API_KEY.length - 5)}` : 61 | 'not found'; 62 | console.log('Using Resend API key (masked):', apiKeyPreview); 63 | console.log('Audience ID:', process.env.RESEND_AUDIENCE_ID); 64 | 65 | // Send simplified welcome email 66 | const emailResponse = await resend.emails.send({ 67 | from: 'Max Forsey ', 68 | to: [email], 69 | subject: 'Welcome to The Signal!', 70 | headers: { 71 | 'List-Unsubscribe': `<${unsubscribeUrl}>`, 72 | 'List-Unsubscribe-Post': 'List-Unsubscribe=One-Click' 73 | }, 74 | // Simplified HTML content 75 | html: ` 76 | 77 | 78 | 79 | 80 | 81 | Welcome to The Signal 82 | 83 | 84 |
85 |

Welcome to The Signal

86 | 87 |

Hey${name ? ` ${name}` : ''}, it's Max!

88 | 89 |

Thanks for subscribing. I'll send occasional emails with new articles and insights on AI, software, and productivity.

90 | 91 |

Best,
Max Forsey

92 | 93 |
94 | 95 |

96 | Unsubscribe 97 |

98 |
99 | 100 | 101 | ` 102 | }); 103 | 104 | // Log response from Resend 105 | console.log('Resend email response:', emailResponse); 106 | 107 | return NextResponse.json( 108 | { message: 'Successfully subscribed!', unsubscribeUrl }, 109 | { status: 200 } 110 | ); 111 | 112 | } catch (error) { 113 | console.error('Subscription error:', error); 114 | return NextResponse.json( 115 | { message: 'Internal server error', error: String(error) }, 116 | { status: 500 } 117 | ); 118 | } 119 | } -------------------------------------------------------------------------------- /src/app/api/unsubscribe/route.ts: -------------------------------------------------------------------------------- 1 | import { NextResponse } from 'next/server'; 2 | import { Resend } from 'resend'; 3 | 4 | // Initialize Resend with API key 5 | const resend = new Resend(process.env.RESEND_API_KEY); 6 | 7 | // Handle both GET and POST requests for compatibility with email clients 8 | async function handleUnsubscribe(request: Request) { 9 | try { 10 | // Get the email from the query string 11 | const url = new URL(request.url); 12 | const email = url.searchParams.get('email'); 13 | 14 | if (!email) { 15 | return NextResponse.json( 16 | { message: 'Email is required' }, 17 | { status: 400 } 18 | ); 19 | } 20 | 21 | console.log(`Processing unsubscribe request for email: ${email}`); 22 | 23 | // Remove from Resend audience 24 | const audienceId = process.env.RESEND_AUDIENCE_ID || ''; 25 | 26 | try { 27 | const result = await resend.contacts.remove({ 28 | audienceId, 29 | email, 30 | }); 31 | 32 | console.log('Unsubscribe result:', result); 33 | } catch (err) { 34 | console.error('Error removing contact from Resend:', err); 35 | // Continue to show success page even if removal fails 36 | // This prevents users from seeing an error when they're already unsubscribed 37 | } 38 | 39 | // For POST requests (one-click unsubscribe), return a simple 200 status 40 | if (request.method === 'POST') { 41 | return new Response('Unsubscribed successfully', { status: 200 }); 42 | } 43 | 44 | // For GET requests, return a success page 45 | return new Response(` 46 | 47 | 48 | 49 | Unsubscribed 50 | 51 | 85 | 86 | 87 |

You've been unsubscribed

88 |

You will no longer receive emails from The Signal.

89 |

Return to site

90 | 91 | 92 | `, { 93 | headers: { 94 | 'Content-Type': 'text/html', 95 | }, 96 | status: 200, 97 | }); 98 | 99 | } catch (error) { 100 | console.error('Unsubscribe error:', error); 101 | return NextResponse.json( 102 | { message: 'Failed to unsubscribe', error: String(error) }, 103 | { status: 500 } 104 | ); 105 | } 106 | } 107 | 108 | // Handle GET requests 109 | export async function GET(request: Request) { 110 | return handleUnsubscribe(request); 111 | } 112 | 113 | // Handle POST requests (for one-click unsubscribe) 114 | export async function POST(request: Request) { 115 | return handleUnsubscribe(request); 116 | } -------------------------------------------------------------------------------- /src/app/blog/[slug]/getStaticParams.ts: -------------------------------------------------------------------------------- 1 | import fs from 'fs'; 2 | import path from 'path'; 3 | 4 | export async function generateStaticParams() { 5 | const blogDir = path.join(process.cwd(), 'src/app/blog'); 6 | const files = fs.readdirSync(blogDir).filter(file => file.endsWith('.mdx')); 7 | return files.map(file => ({ 8 | slug: file.replace('.mdx', ''), 9 | })); 10 | } -------------------------------------------------------------------------------- /src/app/blog/[slug]/page.tsx: -------------------------------------------------------------------------------- 1 | import { MDXRemote } from 'next-mdx-remote/rsc'; 2 | import type { Metadata } from 'next'; 3 | import MDXContent from '@/components/MDXContent'; 4 | import { components as MDXComponents } from '@/components/MDXComponents'; 5 | import { format } from 'date-fns'; 6 | import BlogPostHeader from '@/components/BlogPostHeader'; 7 | import Link from 'next/link'; 8 | import Image from 'next/image'; 9 | import { getPostBySlug, getAllPosts, getSeriesNavigation } from '@/lib/blog'; 10 | import { notFound } from 'next/navigation'; 11 | import JsonLd from '@/components/JsonLd'; 12 | import SeriesNavigation from '@/components/SeriesNavigation'; 13 | import SubscribeInput from '@/components/SubscribeInput'; 14 | import PerplexityLink from '@/components/PerplexityLink'; 15 | 16 | // Generate static params for static generation 17 | export async function generateStaticParams() { 18 | const posts = getAllPosts(); 19 | return posts.map((post) => ({ 20 | slug: post.slug, 21 | })); 22 | } 23 | 24 | // Ensure date is always a Date object 25 | function ensureDate(date: Date | string): Date { 26 | return date instanceof Date ? date : new Date(date); 27 | } 28 | 29 | // Generate metadata for the page 30 | export async function generateMetadata({ params }: { params: { slug: string } }): Promise { 31 | const post = getPostBySlug(params.slug); 32 | 33 | if (!post) { 34 | return { 35 | title: 'Post Not Found', 36 | }; 37 | } 38 | 39 | const websiteUrl = 'https://www.maxforsey.com'; 40 | const canonicalUrl = `${websiteUrl}/blog/${post.slug}`; 41 | const postDate = ensureDate(post.date); 42 | 43 | return { 44 | title: post.title, 45 | description: post.description, 46 | openGraph: { 47 | title: post.title, 48 | description: post.description, 49 | type: 'article', 50 | publishedTime: postDate.toISOString(), 51 | authors: ['Max Forsey'], 52 | url: canonicalUrl, 53 | }, 54 | twitter: { 55 | card: 'summary_large_image', 56 | title: post.title, 57 | description: post.description, 58 | creator: '@maxforsey', 59 | }, 60 | keywords: post.keywords, 61 | authors: [{ name: 'Max Forsey' }], 62 | // Add canonical URL metadata 63 | alternates: { 64 | canonical: canonicalUrl, 65 | }, 66 | }; 67 | } 68 | 69 | export default async function BlogPostPage({ params }: { params: { slug: string } }) { 70 | const { slug } = params; 71 | const post = getPostBySlug(slug); 72 | 73 | if (!post) { 74 | notFound(); 75 | } 76 | 77 | if (!post.content) { 78 | notFound(); 79 | } 80 | 81 | const isOngoing = post.type === 'ongoing'; 82 | const seriesInfo = post.series ? getSeriesNavigation(post) : null; 83 | 84 | const postDate = ensureDate(post.date); 85 | const formattedDate = format(postDate, 'MM/dd/yyyy'); 86 | 87 | // Prepare JSON-LD structured data 88 | const websiteUrl = 'https://www.maxforsey.com'; 89 | const articleUrl = `${websiteUrl}/blog/${post.slug}`; 90 | const structuredData = { 91 | '@context': 'https://schema.org', 92 | '@type': 'BlogPosting', 93 | headline: post.title, 94 | description: post.description, 95 | author: { 96 | '@type': 'Person', 97 | name: 'Max Forsey', 98 | url: websiteUrl, 99 | }, 100 | datePublished: postDate.toISOString(), 101 | dateModified: post.lastModified 102 | ? ensureDate(post.lastModified).toISOString() 103 | : postDate.toISOString(), 104 | mainEntityOfPage: { 105 | '@type': 'WebPage', 106 | '@id': articleUrl, 107 | }, 108 | keywords: post.keywords?.join(', ') || '', 109 | publisher: { 110 | '@type': 'Person', 111 | name: 'Max Forsey', 112 | url: websiteUrl, 113 | }, 114 | url: articleUrl, 115 | }; 116 | 117 | return ( 118 |
119 | 120 | 121 | 128 | 129 | {/* Use the PerplexityLink Client Component */} 130 |
131 | 132 |
133 | 134 | {seriesInfo && seriesInfo.series && ( 135 |
136 | 143 |
144 | )} 145 | 146 | 147 | 151 | 152 | 153 | {seriesInfo && seriesInfo.series && ( 154 |
155 | 162 |
163 | )} 164 | 165 |
166 | 167 | {/* Display Date */} 168 | {formattedDate && ( 169 |

170 | {formattedDate} 171 |

172 | )} 173 | 174 | {/* Back to blog link */} 175 | 179 | back to blog 180 | 181 | 182 | {/* Subscribe Input */} 183 |
184 | 185 |
186 | 187 |
188 |
189 | ); 190 | } 191 | -------------------------------------------------------------------------------- /src/app/blog/archive/[year]/[month]/page.tsx: -------------------------------------------------------------------------------- 1 | import { format } from 'date-fns'; 2 | import Link from 'next/link'; 3 | import MinimalHeader from '@/components/MinimalHeader'; 4 | import { getAllPosts, getPostsByYear } from '@/lib/blog'; 5 | import { Metadata } from 'next'; 6 | import { notFound } from 'next/navigation'; 7 | import Breadcrumbs from '@/components/Breadcrumbs'; 8 | 9 | // Helper function to get all available year-month combinations 10 | function getAllYearMonthCombinations() { 11 | const posts = getAllPosts(); 12 | const combinations = new Set(); 13 | 14 | posts.forEach(post => { 15 | const year = post.date.getFullYear().toString(); 16 | const month = format(post.date, 'MMM').toLowerCase(); 17 | combinations.add(`${year}-${month}`); 18 | }); 19 | 20 | return Array.from(combinations).map(combination => { 21 | const [year, month] = combination.split('-'); 22 | return { year, month }; 23 | }); 24 | } 25 | 26 | // Generate static params for static generation 27 | export async function generateStaticParams() { 28 | return getAllYearMonthCombinations(); 29 | } 30 | 31 | // Helper function to format month name 32 | function getMonthName(month: string): string { 33 | const monthNames: Record = { 34 | 'jan': 'January', 35 | 'feb': 'February', 36 | 'mar': 'March', 37 | 'apr': 'April', 38 | 'may': 'May', 39 | 'jun': 'June', 40 | 'jul': 'July', 41 | 'aug': 'August', 42 | 'sep': 'September', 43 | 'oct': 'October', 44 | 'nov': 'November', 45 | 'dec': 'December' 46 | }; 47 | return monthNames[month.toLowerCase()] || month.charAt(0).toUpperCase() + month.slice(1); 48 | } 49 | 50 | // Generate metadata for the page 51 | export async function generateMetadata({ params }: { params: { year: string, month: string } }): Promise { 52 | const { year, month } = params; 53 | const monthName = getMonthName(month); 54 | const websiteUrl = 'https://www.maxforsey.com'; 55 | const canonicalUrl = `${websiteUrl}/blog/archive/${year}/${month}`; 56 | 57 | return { 58 | title: `${monthName} ${year} Archives | Blog | Max Forsey`, 59 | description: `Blog posts from ${monthName} ${year} by Max Forsey.`, 60 | openGraph: { 61 | title: `${monthName} ${year} Archives | Blog | Max Forsey`, 62 | description: `Blog posts from ${monthName} ${year} by Max Forsey.`, 63 | type: 'website', 64 | url: canonicalUrl, 65 | }, 66 | twitter: { 67 | card: 'summary_large_image', 68 | title: `${monthName} ${year} Archives | Blog | Max Forsey`, 69 | description: `Blog posts from ${monthName} ${year} by Max Forsey.`, 70 | }, 71 | alternates: { 72 | canonical: canonicalUrl, 73 | }, 74 | }; 75 | } 76 | 77 | export default function MonthArchivePage({ params }: { params: { year: string, month: string } }) { 78 | const { year, month } = params; 79 | 80 | // Get posts for the year and filter by month 81 | const yearPosts = getPostsByYear(year); 82 | const posts = yearPosts.filter(post => 83 | format(post.date, 'MMM').toLowerCase() === month.toLowerCase() 84 | ); 85 | 86 | if (posts.length === 0) { 87 | notFound(); 88 | } 89 | 90 | // Sort posts by date (newest first) 91 | const sortedPosts = posts.sort((a, b) => b.date.getTime() - a.date.getTime()); 92 | 93 | // Capitalize the first letter of the month for display 94 | const formattedMonth = month.charAt(0).toUpperCase() + month.slice(1); 95 | 96 | return ( 97 | <> 98 | 99 |
100 |
101 | 108 | 109 |

110 | {formattedMonth} {year} Archives 111 |

112 | 113 | 117 | ← Back to {year} archives 118 | 119 |
120 | 121 |
122 | {sortedPosts.map((post) => ( 123 |
124 | 125 | {format(post.date, 'dd MMM')} 126 | 127 |
128 | 132 | {post.title} 133 | 134 |

135 | {post.description.length > 120 136 | ? post.description.substring(0, 120) + '...' 137 | : post.description} 138 |

139 |
140 |
141 | ))} 142 |
143 |
144 | 145 | ); 146 | } -------------------------------------------------------------------------------- /src/app/blog/archive/[year]/page.tsx: -------------------------------------------------------------------------------- 1 | import { format } from 'date-fns'; 2 | import Link from 'next/link'; 3 | import MinimalHeader from '@/components/MinimalHeader'; 4 | import { getAllYears, getPostsByYear } from '@/lib/blog'; 5 | import { Metadata } from 'next'; 6 | import { notFound } from 'next/navigation'; 7 | import Breadcrumbs from '@/components/Breadcrumbs'; 8 | 9 | // Generate static params for static generation 10 | export async function generateStaticParams() { 11 | const years = getAllYears(); 12 | return years.map((year) => ({ 13 | year, 14 | })); 15 | } 16 | 17 | // Generate metadata for the page 18 | export async function generateMetadata({ params }: { params: { year: string } }): Promise { 19 | const { year } = params; 20 | const websiteUrl = 'https://www.maxforsey.com'; 21 | const canonicalUrl = `${websiteUrl}/blog/archive/${year}`; 22 | 23 | return { 24 | title: `${year} Archives | Blog | Max Forsey`, 25 | description: `Blog posts from ${year} by Max Forsey.`, 26 | openGraph: { 27 | title: `${year} Archives | Blog | Max Forsey`, 28 | description: `Blog posts from ${year} by Max Forsey.`, 29 | type: 'website', 30 | url: canonicalUrl, 31 | }, 32 | twitter: { 33 | card: 'summary_large_image', 34 | title: `${year} Archives | Blog | Max Forsey`, 35 | description: `Blog posts from ${year} by Max Forsey.`, 36 | }, 37 | alternates: { 38 | canonical: canonicalUrl, 39 | }, 40 | }; 41 | } 42 | 43 | export default function YearArchivePage({ params }: { params: { year: string } }) { 44 | const { year } = params; 45 | const posts = getPostsByYear(year); 46 | 47 | if (posts.length === 0) { 48 | notFound(); 49 | } 50 | 51 | // Group posts by month 52 | const postsByMonth: Record = {}; 53 | 54 | posts.forEach(post => { 55 | const month = format(post.date, 'MMM').toLowerCase(); 56 | if (!postsByMonth[month]) { 57 | postsByMonth[month] = []; 58 | } 59 | postsByMonth[month].push(post); 60 | }); 61 | 62 | // Sort months in reverse order (most recent first) 63 | const months = Object.keys(postsByMonth).sort((a, b) => { 64 | const monthOrder = ['dec', 'nov', 'oct', 'sep', 'aug', 'jul', 'jun', 'may', 'apr', 'mar', 'feb', 'jan']; 65 | return monthOrder.indexOf(a) - monthOrder.indexOf(b); 66 | }); 67 | 68 | return ( 69 | <> 70 | 71 |
72 |
73 | 79 | 80 |

{year} Archives

81 | 82 | 86 | ← Back to all posts 87 | 88 |
89 | 90 |
91 | {months.map(month => ( 92 |
93 |

94 | 98 | {month} 99 | 100 |

101 | 102 |
103 | {postsByMonth[month].map((post) => ( 104 |
105 | 106 | {format(post.date, 'dd MMM')} 107 | 108 | 112 | {post.title} 113 | 114 |
115 | ))} 116 |
117 |
118 | ))} 119 |
120 |
121 | 122 | ); 123 | } -------------------------------------------------------------------------------- /src/app/blog/archive/page.tsx: -------------------------------------------------------------------------------- 1 | import Link from 'next/link'; 2 | import MinimalHeader from '@/components/MinimalHeader'; 3 | import { getAllPosts, getAllYears } from '@/lib/blog'; 4 | import { Metadata } from 'next'; 5 | import Breadcrumbs from '@/components/Breadcrumbs'; 6 | 7 | export const metadata: Metadata = { 8 | title: 'Archives | Blog | Max Forsey', 9 | description: 'Browse blog posts by year and month', 10 | }; 11 | 12 | export default function ArchivePage() { 13 | const years = getAllYears(); 14 | const posts = getAllPosts(); 15 | 16 | // Count posts per year 17 | const postCountByYear: Record = {}; 18 | years.forEach(year => { 19 | postCountByYear[year] = posts.filter(post => 20 | post.date.getFullYear().toString() === year 21 | ).length; 22 | }); 23 | 24 | return ( 25 | <> 26 | 27 |
28 |
29 | 35 | 36 |

Blog Archives

37 | 38 | 42 | ← Back to all posts 43 | 44 |
45 | 46 |
47 | {years.map(year => ( 48 |
49 | 50 |
51 |

52 | {year} 53 |

54 |
55 | {postCountByYear[year]} {postCountByYear[year] === 1 ? 'post' : 'posts'} 56 |
57 |
58 | 59 |
60 | ))} 61 |
62 |
63 | 64 | ); 65 | } -------------------------------------------------------------------------------- /src/app/blog/layout.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | export default function BlogLayout({ 4 | children, 5 | }: { 6 | children: React.ReactNode 7 | }) { 8 | return ( 9 |
10 | {children} 11 |
12 | ); 13 | } 14 | -------------------------------------------------------------------------------- /src/app/blog/page.tsx: -------------------------------------------------------------------------------- 1 | import { format } from 'date-fns'; 2 | import Link from 'next/link'; 3 | import MinimalHeader from '@/components/MinimalHeader'; 4 | import { getAllPosts } from '@/lib/blog'; 5 | import { Metadata } from 'next'; 6 | 7 | export const metadata: Metadata = { 8 | title: 'Blog | Max Forsey', 9 | description: 'Thoughts on software development, machine learning, and more.', 10 | openGraph: { 11 | title: 'Blog | Max Forsey', 12 | description: 'Thoughts on software development, machine learning, and more.', 13 | type: 'website', 14 | }, 15 | twitter: { 16 | card: 'summary_large_image', 17 | title: 'Blog | Max Forsey', 18 | description: 'Thoughts on software development, machine learning, and more.', 19 | }, 20 | alternates: { 21 | canonical: 'https://www.maxforsey.com/blog', 22 | }, 23 | }; 24 | 25 | // This is a server component, so it's safe to use the blog.ts module 26 | export default function BlogPage() { 27 | // Filter out posts with type "ongoing" 28 | const posts = getAllPosts().filter(post => post.type !== 'ongoing'); 29 | 30 | return ( 31 | <> 32 | 33 |
34 | {/* Post list - simple chronological list */} 35 |
36 | {posts.map((post) => ( 37 |
38 | 39 | {format(post.date, 'dd MMM yyyy')} 40 | 41 | 45 | {post.title} 46 | 47 |
48 | ))} 49 |
50 |
51 | 52 | ); 53 | } 54 | -------------------------------------------------------------------------------- /src/app/blog/series/page.tsx: -------------------------------------------------------------------------------- 1 | import { Metadata } from 'next'; 2 | import Link from 'next/link'; 3 | import MinimalHeader from '@/components/MinimalHeader'; 4 | import { getAllSeries, getSeriesPosts } from '@/lib/blog'; 5 | import { ClipboardDocumentListIcon } from '@heroicons/react/24/outline'; 6 | 7 | export const metadata: Metadata = { 8 | title: 'Blog Series | Max Forsey', 9 | description: 'Multi-part blog post series on various topics including software development, machine learning, and more.', 10 | openGraph: { 11 | title: 'Blog Series | Max Forsey', 12 | description: 'Multi-part blog post series on various topics including software development, machine learning, and more.', 13 | type: 'website', 14 | }, 15 | twitter: { 16 | card: 'summary_large_image', 17 | title: 'Blog Series | Max Forsey', 18 | description: 'Multi-part blog post series on various topics including software development, machine learning, and more.', 19 | }, 20 | alternates: { 21 | canonical: 'https://www.maxforsey.com/blog/series', 22 | }, 23 | }; 24 | 25 | export default function SeriesPage() { 26 | // Get all series from blog posts 27 | const allSeries = getAllSeries(); 28 | 29 | // For each series, get the posts 30 | const seriesWithPosts = allSeries.map(series => ({ 31 | ...series, 32 | posts: getSeriesPosts(series.name) 33 | })); 34 | 35 | return ( 36 | <> 37 | 38 |
39 |
40 |
41 | 45 | ← Back to blog 46 | 47 |
48 | 49 |

Blog Series

50 |

51 | Collections of related posts organized into series 52 |

53 | 54 | {seriesWithPosts.length === 0 ? ( 55 |

56 | No series found. Check back later! 57 |

58 | ) : ( 59 |
60 | {seriesWithPosts.map(series => ( 61 |
62 |
63 | 64 |

{series.name}

65 |
66 | 67 |

68 | {series.count} {series.count === 1 ? 'part' : 'parts'} 69 |

70 | 71 |
72 | {series.posts.map(post => ( 73 |
74 | 75 | Part {post.series!.part}: 76 | 77 | 81 | {post.title} 82 | 83 |
84 | ))} 85 |
86 |
87 | ))} 88 |
89 | )} 90 |
91 |
92 | 93 | ); 94 | } -------------------------------------------------------------------------------- /src/app/blog/tag/[tag]/page.tsx: -------------------------------------------------------------------------------- 1 | import { format } from 'date-fns'; 2 | import Link from 'next/link'; 3 | import MinimalHeader from '@/components/MinimalHeader'; 4 | import { getAllTags, getPostsByTag } from '@/lib/blog'; 5 | import { Metadata } from 'next'; 6 | import { notFound } from 'next/navigation'; 7 | 8 | // Generate static params for static generation 9 | export async function generateStaticParams() { 10 | const tags = getAllTags(); 11 | return tags.map((tag) => ({ 12 | tag, 13 | })); 14 | } 15 | 16 | // Generate metadata for the page 17 | export async function generateMetadata({ params }: { params: { tag: string } }): Promise { 18 | const tag = decodeURIComponent(params.tag); 19 | const websiteUrl = 'https://www.maxforsey.com'; 20 | const canonicalUrl = `${websiteUrl}/blog/tag/${params.tag}`; 21 | 22 | return { 23 | title: `${tag} | Blog | Max Forsey`, 24 | description: `Blog posts about ${tag.toLowerCase()} by Max Forsey.`, 25 | openGraph: { 26 | title: `${tag} | Blog | Max Forsey`, 27 | description: `Blog posts about ${tag.toLowerCase()} by Max Forsey.`, 28 | type: 'website', 29 | url: canonicalUrl, 30 | }, 31 | twitter: { 32 | card: 'summary_large_image', 33 | title: `${tag} | Blog | Max Forsey`, 34 | description: `Blog posts about ${tag.toLowerCase()} by Max Forsey.`, 35 | }, 36 | alternates: { 37 | canonical: canonicalUrl, 38 | }, 39 | }; 40 | } 41 | 42 | export default function TagPage({ params }: { params: { tag: string } }) { 43 | const { tag } = params; 44 | const formattedTag = tag.replace('-', ' '); 45 | const posts = getPostsByTag(tag); 46 | 47 | if (posts.length === 0) { 48 | notFound(); 49 | } 50 | 51 | return ( 52 | <> 53 | 54 |
55 |
56 |

Articles tagged: {formattedTag}

57 | 61 | ← Back to all posts 62 | 63 |
64 | 65 |
66 | {posts.map((post) => ( 67 |
68 | 69 | {format(post.date, 'dd MMM yyyy')} 70 | 71 | 75 | {post.title} 76 | 77 |
78 | ))} 79 |
80 |
81 | 82 | ); 83 | } -------------------------------------------------------------------------------- /src/app/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/max4c/m4c2/23af3dea934378b47779c9087af3f5649db07c52/src/app/favicon.ico -------------------------------------------------------------------------------- /src/app/layout.tsx: -------------------------------------------------------------------------------- 1 | import './globals.css' 2 | import { Inter } from 'next/font/google' 3 | import { ThemeProvider } from '@/components/ThemeProvider' 4 | import { Metadata } from 'next' 5 | import UmamiAnalytics from '@/components/UmamiAnalytics' 6 | 7 | const inter = Inter({ subsets: ['latin'] }) 8 | 9 | export const metadata: Metadata = { 10 | metadataBase: new URL('https://maxforsey.com'), 11 | title: 'Max Forsey', 12 | description: 'I like to research AI and build helpful tools', 13 | openGraph: { 14 | title: 'Max Forsey', 15 | description: 'I like to research AI and build helpful tools', 16 | url: 'https://maxforsey.com', 17 | siteName: 'Max Forsey', 18 | locale: 'en_US', 19 | type: 'website', 20 | }, 21 | twitter: { 22 | card: 'summary', 23 | title: 'Max Forsey', 24 | description: 'I like to research AI and build helpful tools', 25 | }, 26 | } 27 | 28 | export default function RootLayout({ 29 | children, 30 | }: { 31 | children: React.ReactNode 32 | }) { 33 | return ( 34 | 35 | 36 | 37 | 38 | 44 |
45 |
46 | {children} 47 |
48 |
49 |
50 | 51 | 52 | ) 53 | } 54 | -------------------------------------------------------------------------------- /src/app/page.tsx: -------------------------------------------------------------------------------- 1 | import MinimalHeader from '@/components/MinimalHeader'; 2 | import SubscribeInput from '@/components/SubscribeInput'; 3 | import Link from 'next/link'; 4 | 5 | export default function Home() { 6 | return ( 7 | <> 8 | 9 |
10 |

11 | I like to research AI and build helpful tools 12 |

13 | 14 |
15 |
16 | 17 | email 18 | 19 | 20 | git 21 | 22 | 23 | x 24 | 25 | 26 | in 27 | 28 |
29 | 30 |
31 | 32 |
33 |
34 | 35 |
36 | Too busy to check out the website? Give the entire website to an LLM{' '} 37 | 43 | here 44 | 45 | . 46 |
47 |
48 | 49 | ); 50 | } 51 | -------------------------------------------------------------------------------- /src/app/projects/page.tsx: -------------------------------------------------------------------------------- 1 | import MinimalHeader from '@/components/MinimalHeader'; 2 | import Link from 'next/link'; 3 | import Image from 'next/image'; 4 | 5 | export default function ProjectsPage() { 6 | return ( 7 | <> 8 | 9 |
10 |
11 | {/* JustBuild Community Project */} 12 |
13 |
14 |

JustBuild

15 | 16 | view community → 17 | 18 |
19 |

20 | We host selective hackathons with thousands in prizes, plus weekly coworking sessions where we build projects together. 21 |

22 |
23 |
24 | That's me at JustBuild community event 31 |
32 |
33 |
34 | 35 | {/* Paper Project */} 36 |
37 |
38 |

Gradient Sparse Autoencoders

39 | 40 | view paper → 41 | 42 |
43 |

44 | Introduces a novel approach to extracting neural network features by considering both activation values and their downstream effects. 45 |

46 |
47 |
48 | Gradient analysis visualization 55 |
56 |
57 |
58 | 59 | {/* FreshSesh Project */} 60 |
61 |
62 |

FreshSesh AI

63 | 64 | view project → 65 | 66 |
67 |

68 | A developer tool that uses a local LLM to provide personalized context summaries of your previous work sessions. 69 |

70 |
71 |
72 | FreshSesh AI interface 79 |
80 |
81 |
82 | 83 | {/* Website Project */} 84 |
85 |

maxforsey.com

86 |

87 | This website has become so much fun to work on and to experiment with design principles. 88 |

89 |
90 |
91 | Website screenshot 98 |
99 |
100 |
101 |
102 |
103 | 104 | ); 105 | } -------------------------------------------------------------------------------- /src/app/robots.txt/route.ts: -------------------------------------------------------------------------------- 1 | import { NextResponse } from 'next/server'; 2 | 3 | export async function GET() { 4 | const robotsTxt = ` 5 | # robots.txt for maxforsey.com 6 | User-agent: * 7 | Allow: / 8 | 9 | # Sitemaps 10 | Sitemap: https://www.maxforsey.com/sitemap.xml 11 | 12 | # Private areas 13 | Disallow: /api/ 14 | Disallow: /admin/ 15 | `.trim(); 16 | 17 | return new NextResponse(robotsTxt, { 18 | headers: { 19 | 'Content-Type': 'text/plain', 20 | }, 21 | }); 22 | } -------------------------------------------------------------------------------- /src/app/sitemap.ts: -------------------------------------------------------------------------------- 1 | import { MetadataRoute } from 'next'; 2 | import { getAllPosts } from '@/lib/blog'; 3 | 4 | export default function sitemap(): MetadataRoute.Sitemap { 5 | const baseUrl = 'https://www.maxforsey.com'; 6 | 7 | // Get all blog posts 8 | const posts = getAllPosts(); 9 | 10 | // Static routes 11 | const staticRoutes = [ 12 | { 13 | url: baseUrl, 14 | lastModified: new Date(), 15 | changeFrequency: 'monthly' as const, 16 | priority: 1.0, 17 | }, 18 | { 19 | url: `${baseUrl}/blog`, 20 | lastModified: new Date(), 21 | changeFrequency: 'weekly' as const, 22 | priority: 0.8, 23 | }, 24 | { 25 | url: `${baseUrl}/about`, 26 | lastModified: new Date(), 27 | changeFrequency: 'monthly' as const, 28 | priority: 0.5, 29 | }, 30 | { 31 | url: `${baseUrl}/projects`, 32 | lastModified: new Date(), 33 | changeFrequency: 'monthly' as const, 34 | priority: 0.5, 35 | }, 36 | ]; 37 | 38 | // Blog post routes 39 | const blogRoutes = posts.map((post) => ({ 40 | url: `${baseUrl}/blog/${post.slug}`, 41 | lastModified: post.lastModified ? new Date(post.lastModified) : new Date(post.date), 42 | changeFrequency: 'monthly' as const, 43 | priority: 0.7, 44 | })); 45 | 46 | return [...staticRoutes, ...blogRoutes]; 47 | } -------------------------------------------------------------------------------- /src/components/BlogPostHeader.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import Link from 'next/link'; 4 | import { useTheme } from 'next-themes'; 5 | import { useEffect, useState } from 'react'; 6 | import Image from 'next/image'; 7 | 8 | interface BlogPostHeaderProps { 9 | title: string; 10 | type: string; 11 | formattedDate?: string; 12 | location?: string; 13 | banner?: string; 14 | } 15 | 16 | export default function BlogPostHeader({ title, type, formattedDate, location, banner }: BlogPostHeaderProps) { 17 | const [mounted, setMounted] = useState(false); 18 | const pathname = '/blog'; 19 | 20 | useEffect(() => { 21 | setMounted(true); 22 | }, []); 23 | 24 | const bannerSrc = banner || "/images/blog-banners/banner.png"; 25 | 26 | return ( 27 | <> 28 |
29 |
30 |

31 | 32 | maxforsey.com 33 | 34 |

35 |
36 | 62 |
63 |
64 |
65 | 66 |
67 | {`${title} 75 |
76 | 77 |
78 |

{title}

79 |
80 | 81 | ); 82 | } 83 | -------------------------------------------------------------------------------- /src/components/Breadcrumbs.tsx: -------------------------------------------------------------------------------- 1 | import Link from 'next/link'; 2 | import { Fragment } from 'react'; 3 | import { ChevronRightIcon, HomeIcon } from '@heroicons/react/20/solid'; 4 | 5 | export interface BreadcrumbItem { 6 | label: string; 7 | href?: string; 8 | } 9 | 10 | interface BreadcrumbsProps { 11 | items: BreadcrumbItem[]; 12 | homeHref?: string; 13 | className?: string; 14 | } 15 | 16 | export default function Breadcrumbs({ 17 | items, 18 | homeHref = '/', 19 | className = '', 20 | }: BreadcrumbsProps) { 21 | return ( 22 | 58 | ); 59 | } -------------------------------------------------------------------------------- /src/components/CopyButton.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { useState } from 'react'; 4 | 5 | interface CopyButtonProps { 6 | code: string; 7 | label?: string; 8 | } 9 | 10 | export default function CopyButton({ code, label }: CopyButtonProps) { 11 | const [copied, setCopied] = useState(false); 12 | 13 | const copyToClipboard = async () => { 14 | try { 15 | await navigator.clipboard.writeText(code); 16 | setCopied(true); 17 | setTimeout(() => setCopied(false), 2000); 18 | } catch (err) { 19 | console.error('Failed to copy:', err); 20 | } 21 | }; 22 | 23 | return ( 24 | 46 | ); 47 | } -------------------------------------------------------------------------------- /src/components/Iframe.tsx: -------------------------------------------------------------------------------- 1 | const Iframe = (props: React.IframeHTMLAttributes) => { 2 | return