diff --git a/.eslintrc.json b/.eslintrc.json index 4505427..9490889 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -1,10 +1,53 @@ { "extends": ["next/core-web-vitals", "next/typescript"], + "plugins": ["check-file"], + "rules": { + "max-lines": ["warn", { "max": 300, "skipBlankLines": true, "skipComments": true }], + "check-file/filename-naming-convention": [ + "error", + { "**/*.{js,ts,jsx,tsx}": "KEBAB_CASE" }, + { "ignoreMiddleExtensions": true } + ], + "check-file/folder-naming-convention": [ + "error", + { "src/**/!(*\\[*\\]|*\\(*\\))": "KEBAB_CASE", "app/**/!(*\\[*\\]|*\\(*\\))": "KEBAB_CASE" } + ], + "@typescript-eslint/no-unused-vars": ["error", { "argsIgnorePattern": "^_" }], + "indent": ["error", 2, { "SwitchCase": 1 }], + "@typescript-eslint/no-explicit-any": "warn", + "@typescript-eslint/consistent-type-imports": ["error", { "prefer": "type-imports" }], + "react/self-closing-comp": ["error", { "component": true, "html": true }], + "react/jsx-boolean-value": ["error", "never"], + "react/jsx-curly-brace-presence": ["error", { "props": "never", "children": "never" }], + "react/hook-use-state": "error", + "react-hooks/exhaustive-deps": "warn", + "react/forbid-component-props": ["error", { + "forbid": [{ + "propName": "className", + "allowedFor": ["div", "span", "button", "input", "a", "img", "ul", "li", "ol", "p", "h1", "h2", "h3", "h4", "h5", "h6", "section", "article", "nav", "header", "footer", "main", "form", "label", "table", "thead", "tbody", "tr", "td", "th", "svg", "path", "textarea", "select", "option", "pre", "code", "blockquote", "hr", "br", "strong", "em", "small", "sup", "sub", "cite", "Link", "Image", "motion.div", "motion.span", "motion.section", "motion.article", "motion.ul", "motion.li", "motion.p", "motion.button", "motion.a", "motion.nav", "motion.header", "ArrowLeft", "Check", "CheckSquare", "Copy", "Search", "Square", "Sun", "Moon", "Database", "Skeleton"], + "message": "Use variant props instead of className on design system components" + }] + }] + }, "overrides": [ { "files": ["next-env.d.ts"], "rules": { - "@typescript-eslint/triple-slash-reference": "off" + "@typescript-eslint/triple-slash-reference": "off", + "check-file/filename-naming-convention": "off" + } + }, + { + "files": ["components/**/*.tsx"], + "rules": { + "react/forbid-component-props": "off" + } + }, + { + "files": ["lib/notion.ts"], + "rules": { + "max-lines": "off", + "@typescript-eslint/no-explicit-any": "off" } } ] diff --git a/.github/workflows/check-quotes.yml b/.github/workflows/check-quotes.yml deleted file mode 100644 index 2e98bd3..0000000 --- a/.github/workflows/check-quotes.yml +++ /dev/null @@ -1,72 +0,0 @@ -name: Check for Quote Updates - -on: - schedule: - - cron: '0 15-23,0-3 * * *' # Every hour from 8am to 8pm PDT - workflow_dispatch: - -permissions: - actions: write - contents: write - -jobs: - check-quotes: - runs-on: ubuntu-latest - environment: - name: github-pages - steps: - - name: Checkout - uses: actions/checkout@v4 - - - name: Setup Bun - uses: oven-sh/setup-bun@v2 - with: - bun-version: latest - - - name: Install dependencies - run: bun install - - - name: Restore quotes hash cache - uses: actions/cache@v4 - with: - path: quotes-hash.txt - key: quotes-hash-${{ github.sha }} - restore-keys: quotes-hash- - - - name: Check if quotes changed - id: check - env: - NOTION_TOKEN: ${{ secrets.NOTION_TOKEN }} - QUOTES_DATABASE_ID: ${{ secrets.QUOTES_DATABASE_ID }} - run: bun run check-quotes - continue-on-error: true # Don't fail the workflow on exit code 1 or 2 - - - name: Save updated hash - if: steps.check.outcome == 'success' - uses: actions/cache/save@v4 - with: - path: quotes-hash.txt - key: quotes-hash-${{ github.sha }}-${{ github.run_id }} - - - name: Trigger build workflow - if: steps.check.outcome == 'success' - uses: actions/github-script@v7 - with: - script: | - await github.rest.actions.createWorkflowDispatch({ - owner: context.repo.owner, - repo: context.repo.repo, - workflow_id: 'nextjs.yml', - ref: 'main' - }) - console.log('Build workflow (workflow_dispatch) triggered!') - - - name: Log result - run: | - if [ "${{ steps.check.outcome }}" = "success" ]; then - echo "Quotes changed - build triggered" - elif [ "${{ steps.check.outcome }}" = "failure" ] && [ "${{ steps.check.conclusion }}" = "success" ]; then - echo "No changes - no build needed" - else - echo "Error occurred - build may be needed" - fi diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..23cb445 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,28 @@ +name: CI + +on: + pull_request: + branches: [main, dev] + +jobs: + check: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - uses: oven-sh/setup-bun@v2 + with: + bun-version: latest + + - name: Install dependencies + run: bun install --frozen-lockfile + + - name: Lint + run: bun run lint + + - name: Type check + run: bun run tsc + + - name: Build + run: bun run build diff --git a/.github/workflows/nextjs.yml b/.github/workflows/nextjs.yml deleted file mode 100644 index 7375f75..0000000 --- a/.github/workflows/nextjs.yml +++ /dev/null @@ -1,86 +0,0 @@ -# Sample workflow for building and deploying a Next.js site to GitHub Pages -# -# To get started with Next.js see: https://nextjs.org/docs/getting-started -# -name: Deploy Next.js site to Pages - -on: - # Runs on pushes targeting the default branch - push: - branches: ["main"] - - # Triggered when quotes change - repository_dispatch: - types: [quotes-changed] - - # Allows you to run this workflow manually from the Actions tab - workflow_dispatch: - -# Sets permissions of the GITHUB_TOKEN to allow deployment to GitHub Pages -permissions: - contents: read - pages: write - id-token: write - -# Allow only one concurrent deployment, skipping runs queued between the run in-progress and latest queued. -# However, do NOT cancel in-progress runs as we want to allow these production deployments to complete. -concurrency: - group: "pages" - cancel-in-progress: false - -jobs: - # Build job - build: - runs-on: ubuntu-latest - environment: - name: github-pages - steps: - - name: Checkout - uses: actions/checkout@v4 - - name: Setup Bun - uses: oven-sh/setup-bun@v2 - with: - bun-version: latest - - name: Setup Pages - uses: actions/configure-pages@v5 - with: - # Automatically inject basePath in your Next.js configuration file and disable - # server side image optimization (https://nextjs.org/docs/api-reference/next/image#unoptimized). - # - # You may remove this line if you want to manage the configuration yourself. - static_site_generator: next - - name: Restore cache - uses: actions/cache@v4 - with: - path: | - .next/cache - ~/.bun/install/cache - # Generate a new cache whenever packages or source files change. - key: ${{ runner.os }}-nextjs-${{ hashFiles('**/bun.lockb') }}-${{ hashFiles('**.[jt]s', '**.[jt]sx') }} - # If source files changed but packages didn't, rebuild from a prior cache. - restore-keys: | - ${{ runner.os }}-nextjs-${{ hashFiles('**/bun.lockb') }}- - - name: Install dependencies - run: bun install - - name: Create .env.local - run: | - echo "NOTION_TOKEN=${{ secrets.NOTION_TOKEN }}" >> .env.local - echo "QUOTES_DATABASE_ID=${{ secrets.QUOTES_DATABASE_ID }}" >> .env.local - - name: Build with Next.js - run: bun run build - - name: Upload artifact - uses: actions/upload-pages-artifact@v3 - with: - path: ./out - - # Deployment job - deploy: - environment: - name: github-pages - url: ${{ steps.deployment.outputs.page_url }} - runs-on: ubuntu-latest - needs: build - steps: - - name: Deploy to GitHub Pages - id: deployment - uses: actions/deploy-pages@v4 diff --git a/.gitignore b/.gitignore index 35f4826..a82e909 100644 --- a/.gitignore +++ b/.gitignore @@ -6,9 +6,6 @@ .pnp.js .yarn/install-state.gz -# scripts generated files -quotes-hash.txt - # testing /coverage diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..9dd0801 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,55 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Commands + +```bash +bun run dev # Start development server +bun run build # Production build +bun run lint # Run ESLint with --fix +``` + +Always use Bun, not Node.js/npm/pnpm. Bun automatically loads `.env` files. + +## Architecture + +**Stack**: Next.js 16 (App Router) + TypeScript + Tailwind CSS + Shadcn UI + Framer Motion + +**Deployed on Vercel** with ISR (Incremental Static Regeneration). Notion content auto-refreshes hourly via `revalidate: 3600` in fetch calls. + +### Content Sources +- **Quotes**: Fetched from Notion API, cached 1 hour, client-side searchable +- **Blogs**: Markdown files in `/content/blogs/`, parsed with gray-matter +- **Timeline**: Static TypeScript data in `/content/timeline-data.ts` + +### Key Files +- `lib/notion.ts` - All Notion API integration (exempted from max-lines rule) +- `lib/quotes.ts` - Quote fetching entry point +- `lib/blogs.ts` - Markdown blog loading with search +- `app/layout.tsx` - Root layout with Vercel Analytics + +### Server vs Client Components +- Pages are server components by default +- `"use client"` used for interactive components (TopNav, search features) + +## Code Standards + +### Naming (enforced via ESLint) +- Files: `kebab-case` (e.g., `top-nav.tsx`) +- Folders: `kebab-case` (except `[slug]` and `(groups)` for Next.js routing) +- Max 300 lines per file (exceptions: `lib/notion.ts`) + +### TypeScript +- Use `type` imports: `import type { Foo } from './bar'` +- Unused vars must be prefixed with `_` + +### React/JSX +- Self-closing components: `` not `` +- No unnecessary boolean values: `disabled` not `disabled={true}` +- No unnecessary curly braces: `prop="value"` not `prop={"value"}` + +### Styling +- Use variant props on Shadcn UI components, not `className` +- `className` allowed only on HTML primitives, Link, Image, and motion.* components +- Component files in `/components/` are exempt from this rule diff --git a/README.md b/README.md index 91080f5..b2a19bd 100644 --- a/README.md +++ b/README.md @@ -1,109 +1,30 @@ # Personal Website -This is my personal website and blog built with a modern component-based architecture. This README explains the technical decisions and architecture for those curious about how it works. +Personal website and blog built with Next.js App Router, deployed on Vercel. ## Tech Stack -The project uses a modern tech stack focused on developer experience, performance, and best practices: +- **Runtime**: [Bun](https://bun.sh/) +- **Framework**: [Next.js](https://nextjs.org/) (App Router) +- **Language**: [TypeScript](https://www.typescriptlang.org/) +- **Styling**: [Tailwind CSS](https://tailwindcss.com/) +- **UI Components**: [Shadcn UI](https://ui.shadcn.com/) +- **Animations**: [Framer Motion](https://www.framer.com/motion/) +- **CMS**: [Notion API](https://developers.notion.com/) for quotes and blog content +- **Deployment**: [Vercel](https://vercel.com/) with ISR -- **Runtime**: [Bun](https://bun.sh/) - Fast JavaScript runtime and package manager -- **Framework**: [Next.js](https://nextjs.org/) (App Router) -- **Language**: [TypeScript](https://www.typescriptlang.org/) -- **Styling**: [Tailwind CSS](https://tailwindcss.com/) -- **UI Components**: [Shadcn UI](https://ui.shadcn.com/) -- **Animations**: [Framer Motion](https://www.framer.com/motion/) -- **Icons**: [Lucide React](https://lucide.dev/) -- **Theming**: [next-themes](https://github.com/pacocoursey/next-themes) for light/dark mode. -- **Markdown/MDX**: `react-markdown` with `remark` and `rehype` for rendering blog posts. -- **CMS**: [Notion API](https://developers.notion.com/) for managing quotes content. +## Development -## Project Structure - -The project follows a structure that separates concerns while taking advantage of Next.js App Router features: - -- `app/`: Contains all the routes and pages for the application. Each folder represents a URL segment. - - `app/layout.tsx`: The root layout of the application. - - `app/(home)/page.tsx`: The entry point for the homepage. - - `app/(routes)`: Subdirectories for each page (e.g., `blogs`, `projects`), containing their specific components and logic. -- `components/`: Contains reusable components shared across the application. - - `components/ui`: UI primitives from Shadcn UI. - - `components/layout`: Components that define the structure of the site, like the `Sidebar`. - - `components/providers`: Wrapper components that provide context to the application (e.g., `ThemeProvider`). -- `content/`: Stores static data and content, such as blog posts or timeline information. -- `lib/`: Utility functions and helper scripts. -- `public/`: Static assets like images and fonts. - -## How It Works - -The website is built using **Bun** as the runtime and package manager for faster development and builds. The development server runs with `bun run dev` and leverages Next.js's App Router for routing and server-side rendering. - -## Dynamic Quotes with Notion Integration - -The quotes page uses a custom Notion integration to dynamically fetch and display quotes from a personal Notion database. This creates a seamless CMS experience where I can add new quotes directly in Notion and they appear on the website. - -### Technical Implementation - -Instead of using the official Notion SDK, the integration uses direct REST API calls to `https://api.notion.com/v1/databases/{database_id}/query`. - - -### Smart Text Processing - -The system handles Notion's rich text formatting by: -- Converting literal `\n` and `\t` characters to actual newlines and tabs -- Preserving formatting while extracting plain text -- Creating markdown-style links when author links are provided - -### Static Site + Dynamic Content Challenge - -Since this site is deployed to **GitHub Pages** (static hosting), traditional server-side features like Next.js's `revalidate` don't work - there's no running server to refresh cached content. This creates a challenge: how do you get fresh content from Notion without manual deployments? - -### Clever GitHub Actions Solution - -I built a smart automation system that works around static hosting limitations: - -#### **Smart Change Detection** -- A scheduled GitHub Action runs hourly during active hours (8am to 8pm PDT) -- Fetches fresh quotes from Notion API -- Creates a SHA-256 hash of the content for comparison -- Only triggers a rebuild if quotes have actually changed - -#### **Smart Caching** -- Uses GitHub Actions cache to store content hashes between runs -- Prevents unnecessary builds when quotes haven't changed -- Saves ~95% of build time on unchanged content - -#### **Two-Workflow Architecture** -1. **Quote Checker** (`check-quotes.yml`): Lightweight check (~30 seconds) -2. **Main Build** (`nextjs.yml`): Full rebuild only when needed (~5 minutes) - -```yaml -# Runs hourly during active hours, only builds when quotes change -on: - schedule: - - cron: '0 15-23,0-3 * * *' # 8am to 8pm PDT +```bash +bun install +bun run dev ``` -#### **TypeScript Build Script** -- Custom `scripts/check-quotes.ts` handles the smart comparison -- Exit codes control workflow behavior: - - `0`: Changes detected → Trigger build - - `1`: No changes → Skip build - - `2`: Error → Fail safe and build anyway - -### Performance Benefits - -- **Automatic updates**: Fresh quotes appear within an hour of Notion changes -- **Resource efficient**: No unnecessary rebuilds when content is unchanged -- **Zero maintenance**: Runs completely automated once configured -- **Client-side optimization**: Quotes are shuffled and filtered locally for smooth interactions -- **Real-time search**: Filter through quotes and authors without additional API calls - -This approach gives you **dynamic content** on a **static site** - the best of both worlds! And, free! :) +## Notion Integration -### Markdown Support +Quotes and blogs are fetched from Notion databases via the REST API. Content is cached and auto-refreshes hourly using Next.js ISR (`revalidate: 3600`). -The frontend includes a custom markdown renderer that handles: -- **Bold** and *italic* text formatting -- Automatic URL linking -- Line breaks and paragraph formatting -- Clickable author links when available +The integration handles Notion's rich text formatting: +- Converts literal `\n` and `\t` to actual newlines/tabs +- Preserves formatting while extracting plain text +- Creates markdown-style links for authors diff --git a/app/(home)/components/AboutSection.tsx b/app/(home)/components/AboutSection.tsx deleted file mode 100644 index d48dca9..0000000 --- a/app/(home)/components/AboutSection.tsx +++ /dev/null @@ -1,81 +0,0 @@ -"use client"; - -import React from "react"; -import { motion } from "framer-motion"; -import { Card, CardContent } from "@/components/ui/card"; -import EmphasisText from "@/components/text/EmphasisText"; - -export default function AboutSection(): React.ReactNode { - return ( - - - -

- - About Me -

-
-

- {"I build AI systems that solve real problems –– be it saving people hours of work, "} - {"catching issues before they become expensive mistakes, or bringing in new revenue. "} - {"While my primary focus is on "} - AI or ML engineering - {" (depending on the project), I also emphasize "} - full-stack development - {" because I believe that the best AI is the kind that actually gets used –– whether or not people know it's AI."} -

-

Some keyword spam (sorry):

-
    -
  • - {"My tech stack is always evolving, but my current language stack is "} - Python, SQL, JavaScript, and TypeScript. -
  • - {/*
  • - {"I've been working on mastering some more low-level languages like "} - C++ and Rust. -
  • */} -
  • - {"My current go-to systems and frameworks for frontend are "} - React, Next.js - {", Tailwind CSS, and Shadcn UI."} -
  • -
  • {"I like to use FastAPI for my backend."}
  • -
  • {"I've worked with databases like PostgreSQL, Databricks, and MS SQL."}
  • -
  • {"I use Docker for containerization."}
  • -
  • {"I have experience with cloud platforms such as AWS, GCP, and Azure."}
  • -
  • {"I've worked with both GitHub and Bitbucket for version control and building CI/CD pipelines."}
  • -
  • {"As far as AI/ML APIs, I've worked with OpenAI, Anthropic, Google, and Hugging Face."}
  • -
  • - {"Model development is obviously in "} - PyTorch - {" :)"} -
  • -
-

- {"More on traditional "} - Machine Learning: - {" My foundations are rooted in my academic background. I've been working towards an AI graduate certificate at "} - Stanford - {" maintaining a "} - 4.0 GPA - {" through some of the most challenging courses in the field. I've also completed an undergraduate degree in math, CS, and business data analytics (and music lol), where I graduated as the "} - outstanding Mathematics graduate. - {" This background enables me to approach ML challenges with deep fundamentals understanding, leading to strong models that actually make an impact on the business."} -

-

- {"Outside my day job, my non-extracurricular time is spent either "} - consulting - {" local businesses for AI solutions, or as a "} - course facilitator - {" for Stanford's Machine Learning and AI Principles courses."} -

-
-
-
-
- ) -} \ No newline at end of file diff --git a/app/(home)/components/CtaSection.tsx b/app/(home)/components/CtaSection.tsx deleted file mode 100644 index 1033424..0000000 --- a/app/(home)/components/CtaSection.tsx +++ /dev/null @@ -1,55 +0,0 @@ -"use client"; - -import React from "react"; -import { motion } from "framer-motion"; -import { Card, CardContent } from "@/components/ui/card"; - -export default function CtaSection(): React.ReactNode { - return ( - - - -

- Let's Build Something Amazing -

-

- I'm always open to collaborating on creative and challenging projects. - Whether you're looking to build an AI-powered application or explore innovative solutions, let's connect. -

-
- - Connect on 𝕏 - - - - Connect on LinkedIn - - - - View GitHub - -
-
-
-
- ) -} \ No newline at end of file diff --git a/app/(home)/components/HeroSection.tsx b/app/(home)/components/HeroSection.tsx deleted file mode 100644 index f392598..0000000 --- a/app/(home)/components/HeroSection.tsx +++ /dev/null @@ -1,49 +0,0 @@ -"use client"; - -import React from "react"; -import { motion } from "framer-motion"; - -export default function HeroSection(): React.ReactNode { - return ( - -
- - - Always open to new opportunities :) - - - I'm Jake, a mathematician turned{" "} - ML Engineer - and - Full Stack Developer - with a keen eye for - design. - - - {/* Building cool stuff that both look good and solve real problems, from algorithms to web & iOS apps... and some totally unnecessary but fun side projects too! */} - {"Build fast, ship fast, and fix fast, but it has to look good throughout. Optimizing every element from the user's perspective, be it frontend interfaces, backends, or machine learning models, is key to an end product that's actually useful."} - -
-
- ); -} \ No newline at end of file diff --git a/app/(home)/components/InfoGrid.tsx b/app/(home)/components/InfoGrid.tsx deleted file mode 100644 index f8c38e3..0000000 --- a/app/(home)/components/InfoGrid.tsx +++ /dev/null @@ -1,76 +0,0 @@ -"use client"; - -import React from "react"; -import { motion } from "framer-motion"; -import { Card, CardContent } from "@/components/ui/card"; - -export default function InfoGrid(): React.ReactNode { - return ( -
- - - -

- - What I'm Building -

-
    -
  • - - Autonomous agents that actually save businesses time and money -
  • -
  • - - Embedded machine learning models for quick, impactful wins -
  • -
  • - - Teaching Stanford professional students how to train and devleop ML models -
  • -
-
-
-
- - - - -

- Beyond Code -

-
    -
  • - - Serving at Agape Christian Church -
  • -
  • - - Soccer enthusiast (Força Barça!) -
  • -
  • - - Music composition & performance -
  • -
  • - - Nature and beach lover -
  • -
  • - - Music composition & performance -
  • -
-
-
-
-
- ) -} \ No newline at end of file diff --git a/app/(home)/page.tsx b/app/(home)/page.tsx index aca2acf..db84b63 100644 --- a/app/(home)/page.tsx +++ b/app/(home)/page.tsx @@ -1,23 +1,71 @@ -"use client"; +"use client" -import React from "react"; -import HeroSection from "./components/HeroSection"; -import AboutSection from "./components/AboutSection"; -import CtaSection from "./components/CtaSection"; +import Link from "next/link" +import { PageTitle } from "@/components/layout/page-title" -export default function HomePage(): React.ReactNode { +function TypographyParagraph({ children }: { children: React.ReactNode }) { return ( -
-
- + <> +

+ {children} +

+
+ + ) +} - {/* Main Content Grid */} -
- - {/* */} - -
+export default function HomePage() { + return ( +
+
+ jake bodea + + + resumes are boring, so i made this website to showcase myself. i have a{" "} + + timeline + {" "} + for more details, but i prefer to{" "} + + show + {" "} + my accomplishments + + + + i'm an all-around engineer with a math background. i majored in + math with minors in cs, business data analytics, and music. currently + studying ai at stanford. i love working on product and i like making + music + + + my tech stack is pretty much "i'll learn whatever you need me to learn", + but i have strong experience with TypeScript, Python, and SQL. also + dabbling in Rust :) + + + in my free time, you'll find me volunteering at church, working on + side projects (technical or musical), or exploring new adventures + with my wife + + +

+ thanks for checking out my website, and please please please {" "} + + say hi + + {" "}! +

- ); -} \ No newline at end of file + ) +} diff --git a/app/blogs/[slug]/page.tsx b/app/blogs/[slug]/page.tsx index f58c933..4e378b6 100644 --- a/app/blogs/[slug]/page.tsx +++ b/app/blogs/[slug]/page.tsx @@ -10,7 +10,8 @@ import remarkGfm from 'remark-gfm' import remarkMath from 'remark-math' import rehypeMathjax from 'rehype-mathjax' import { CodeBlock } from '@/components/ui/code-block' -import { CopyMarkdownButton } from '@/app/blogs/components/CopyMarkdownButton' +import { CopyMarkdownButton } from '@/components/common/copy-markdown-button' +import { BlogPostTitle } from '@/components/layout/blog-post-title' interface PageProps { slug: string @@ -51,9 +52,7 @@ export default async function BlogPostPage({ params }: { params: Promise
-

- {title} -

+ {title}

{new Date(date + 'T00:00:00').toLocaleDateString(undefined, { weekday: 'long', diff --git a/app/blogs/BlogsClient.tsx b/app/blogs/blogs-client.tsx similarity index 61% rename from app/blogs/BlogsClient.tsx rename to app/blogs/blogs-client.tsx index ecb47ba..6be75bf 100644 --- a/app/blogs/BlogsClient.tsx +++ b/app/blogs/blogs-client.tsx @@ -1,9 +1,10 @@ 'use client' -import { BlogData } from '@/lib/blogs' +import type { BlogData } from '@/lib/blogs' import { useState, useEffect } from 'react' -import PostList from './components/PostList' -import SearchBar from './components/SearchBar' +import { PostList } from '@/components/common/post-list' +import { SearchInput } from '@/components/common/search-input' +import { PageWrapper } from '@/components/layout/page-wrapper' interface BlogsPageProps { initialPosts: BlogData[] @@ -28,14 +29,9 @@ export default function BlogsPageClient({ initialPosts }: BlogsPageProps) { }, [searchQuery, initialPosts]) return ( -

-
-

Blogs

- - - - -
-
+ + + + ) } \ No newline at end of file diff --git a/app/blogs/components/PostList.tsx b/app/blogs/components/PostList.tsx deleted file mode 100644 index e32366c..0000000 --- a/app/blogs/components/PostList.tsx +++ /dev/null @@ -1,45 +0,0 @@ -'use client' - -import Link from 'next/link' -import { BlogData } from '@/lib/blogs' - -interface PostListProps { - posts: BlogData[]; - searchQuery: string; -} - -export default function PostList({ posts, searchQuery }: PostListProps) { - return ( -
    - {posts.length > 0 ? ( - posts.map((post) => ( -
  • - -
    -

    - {post.title} -

    -

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

    -
    -

    - {post.description} -

    - -
  • - )) - ) : ( -
  • -

    - No blog posts found matching “{searchQuery}” -

    -
  • - )} -
- ) -} \ No newline at end of file diff --git a/app/blogs/components/SearchBar.tsx b/app/blogs/components/SearchBar.tsx deleted file mode 100644 index 563ed63..0000000 --- a/app/blogs/components/SearchBar.tsx +++ /dev/null @@ -1,24 +0,0 @@ -'use client' - -import { Input } from '@/components/ui/input' -import { Search } from 'lucide-react' - -interface SearchBarProps { - searchQuery: string; - setSearchQuery: (query: string) => void; -} - -export default function SearchBar({ searchQuery, setSearchQuery }: SearchBarProps) { - return ( -
- - setSearchQuery(e.target.value)} - className="pl-10" - /> -
- ) -} \ No newline at end of file diff --git a/app/blogs/page.tsx b/app/blogs/page.tsx index 7a5f495..baee6a0 100644 --- a/app/blogs/page.tsx +++ b/app/blogs/page.tsx @@ -1,5 +1,5 @@ import { getAllBlogs } from '@/lib/blogs' -import BlogsPageClient from './BlogsClient' +import BlogsPageClient from './blogs-client' export default async function BlogsPage() { const posts = getAllBlogs() diff --git a/app/contact/contact-link.css b/app/contact/contact-link.css new file mode 100644 index 0000000..cbf0865 --- /dev/null +++ b/app/contact/contact-link.css @@ -0,0 +1,17 @@ +@keyframes blink { + 0%, 49% { + opacity: 1; + } + 50%, 100% { + opacity: 0; + } +} + +.cursor-block { + display: inline-block; + width: 0.6em; + height: 0.9em; + margin-left: 0.1em; + background-color: currentColor; + animation: blink 1s infinite; +} diff --git a/app/contact/contact-link.tsx b/app/contact/contact-link.tsx new file mode 100644 index 0000000..10151fb --- /dev/null +++ b/app/contact/contact-link.tsx @@ -0,0 +1,130 @@ +"use client" + +import { useState, useEffect, useRef } from "react" +import Link from "next/link" +import { motion, AnimatePresence } from "framer-motion" +import "./contact-link.css" + +function generateRandomKeyframes(): string { + const numSteps = Math.floor(Math.random() * 15) + 15 + const increments = Array.from({ length: numSteps }, () => Math.random() * 4 + 1) + const total = increments.reduce((a, b) => a + b, 0) + const normalized = increments.map(inc => (inc / total) * 100) + + let keyframes = "0% { width: 0; }" + let currentWidth = 0 + normalized.forEach((increment, index) => { + currentWidth += increment + const keyframePercent = ((index + 1) / numSteps) * 100 + keyframes += ` ${keyframePercent.toFixed(1)}% { width: ${currentWidth.toFixed(1)}%; }` + }) + + return keyframes +} + +interface ContactLinkProps { + url: string + display: string + easterEgg?: string +} + +export function ContactLink({ url, display, easterEgg }: ContactLinkProps) { + const [isHovered, setIsHovered] = useState(false) + const styleRef = useRef(null) + const animationIdRef = useRef("") + + useEffect(() => { + if (isHovered && easterEgg) { + const animationId = `typing-${Date.now()}-${Math.random().toString(36).substr(2, 9)}` + animationIdRef.current = animationId + + const keyframes = generateRandomKeyframes() + const css = `@keyframes ${animationId} { ${keyframes} }` + + if (styleRef.current) { + styleRef.current.remove() + } + + styleRef.current = document.createElement("style") + styleRef.current.textContent = css + document.head.appendChild(styleRef.current) + } + + return () => { + if (styleRef.current) { + styleRef.current.remove() + styleRef.current = null + } + } + }, [isHovered, easterEgg]) + + return ( +
setIsHovered(true)} + onMouseLeave={() => setIsHovered(false)} + className="min-h-[3rem]" + > +
+ $ + + open + {display} + +
+
+ {easterEgg && ( + + {isHovered && ( + + + # + + + {easterEgg} + + + + + + )} + + )} +
+
+ ) +} diff --git a/app/contact/page.tsx b/app/contact/page.tsx new file mode 100644 index 0000000..9abe199 --- /dev/null +++ b/app/contact/page.tsx @@ -0,0 +1,54 @@ +"use client" + +import { PageTitle } from "@/components/layout/page-title" +import { ContactLink } from "./contact-link" + +const contactLinks = [ + { + url: "mailto:jakebodea@gmail.com", + display: "jakebodea@gmail.com", + easterEgg: "pls no spam thanks" + }, + { + url: "https://x.com/jakebodea", + display: "x.com/jakebodea" + }, + { + url: "https://www.linkedin.com/in/jakebodea/", + display: "linkedin.com/in/jakebodea", + easterEgg: "unfortunately" + }, + { + url: "https://github.com/jakebodea", + display: "github.com/jakebodea", + easterEgg: "check out my commit history map" + } +] + +export default function ContactPage() { + + return ( +
+
+ contact + +

+ i'm always interested in connecting. here's where you can find me: +

+ +
+
+ {contactLinks.map((link) => ( + + ))} +
+
+
+
+ ) +} diff --git a/app/globals.css b/app/globals.css index 87e7ae9..b2e8f53 100644 --- a/app/globals.css +++ b/app/globals.css @@ -70,15 +70,15 @@ } [data-sonner-toast][data-type="success"] { - background: #E4E3D4 !important; - color: #2C2C2C !important; - border: 2px solid #E4E3D4 !important; + background: hsl(var(--card)) !important; + color: hsl(var(--foreground)) !important; + border: 1px solid hsl(var(--border)) !important; } [data-sonner-toast][data-type="error"] { background: hsl(var(--destructive) / 0.95) !important; color: hsl(var(--destructive-foreground)) !important; - border: 2px solid hsl(var(--destructive)) !important; + border: 1px solid hsl(var(--destructive)) !important; } [data-sonner-toast] [data-description] { @@ -89,16 +89,13 @@ /* Dark mode specific enhancements */ .dark [data-sonner-toast][data-type="success"] { - background: rgba(228, 227, 212, 0.18) !important; - color: #E4E3D4 !important; - border: 2px solid rgba(228, 227, 212, 0.4) !important; - backdrop-filter: blur(12px) !important; - box-shadow: 0 0 20px rgba(228, 227, 212, 0.3) !important; + background: hsl(var(--card-02)) !important; + color: hsl(var(--foreground)) !important; + border: 1px solid hsl(var(--border)) !important; } .dark [data-sonner-toast][data-type="error"] { background: hsl(var(--destructive) / 0.9) !important; - box-shadow: 0 0 20px hsl(var(--destructive) / 0.3) !important; } /* Override Sonner close button positioning */ @@ -204,129 +201,131 @@ @layer base { :root { - /* Perplexity core palette */ - /* Base neutrals */ - --background: 52 19% 88%; /* Light Green (#E4E3D4) */ - --foreground: 180 45% 6%; /* Offblack (#091717) */ - - /* Core brand turquoise */ - --primary: 188 63% 34%; /* True Turquoise (#20808D) */ - --primary-foreground: 0 0% 100%; /* white */ - - /* Supporting blues */ - --secondary: 193 41% 17%; /* Inky Blue (#19343B) */ - --secondary-foreground: 48 28% 97%; /* Paper White */ - - /* Surface colors */ - --card: var(--background); + /* Warm light theme */ + --background: 60 10% 97%; /* #F7F7F4 */ + --foreground: 47 15% 13%; /* #26251E */ + --foreground-secondary: 46 8% 22%; /* #3B3A33 */ + + /* Card hierarchy */ + --card: 48 11% 93%; /* #F0EFEA */ + --card-02: 48 10% 91%; /* #EBEAE5 */ + --card-03: 48 9% 89%; /* #E6E5E0 */ + --card-04: 48 8% 87%; /* #E1E0DB */ --card-foreground: var(--foreground); - --popover: var(--background); + --popover: var(--card); --popover-foreground: var(--foreground); - /* Accent cyan (Plex Blue) */ - --accent: 188 80% 45%; /* Plex Blue (#1FB8CD) */ + /* Accent - orange-red */ + --accent: 19 100% 48%; /* #F54E00 */ --accent-foreground: 0 0% 100%; - /* Muted state (light Peacock tint) */ - --muted: 30 12% 75%; /* Warm gray (#7B756F) */ - --muted-foreground: 193 25% 30%; + /* Primary uses accent */ + --primary: var(--accent); + --primary-foreground: 0 0% 100%; - /* Light contrast colors from palette - more contrast */ - --contrast-light: 180 19% 86%; /* Sky (#BADEDD) */ - --contrast-lighter: 194 14% 84%; /* Peacock 20 (#D5DDDF) */ - --contrast-lightest: 200 17% 93%; /* Paper White (#FBFAF4) */ + /* Secondary */ + --secondary: var(--card-02); + --secondary-foreground: var(--foreground); + + /* Muted */ + --muted: 45 5% 85%; + --muted-foreground: 45 6% 13%; /* #23221E */ - /* Destructive (Terra Cotta) */ - --destructive: 13 60% 43%; /* Terra Cotta (#A84B2F) */ - --destructive-foreground: 0 0% 98%; + /* Destructive */ + --destructive: 0 84% 60%; + --destructive-foreground: 0 0% 100%; /* Borders & inputs */ - --border: 188 25% 88%; - --input: 188 25% 88%; - --ring: var(--primary); + --border: 45 8% 88%; + --input: 45 8% 88%; + --ring: var(--accent); - --radius: 0.75rem; + --radius: 0.5rem; /* Charts */ - --chart-1: var(--primary); - --chart-2: 193 41% 17%; /* Inky Blue */ - --chart-3: 188 80% 45%; /* Plex Blue */ - --chart-4: 48 28% 97%; /* Paper White */ - --chart-5: 13 60% 43%; /* Terra Cotta */ - - /* Sidebar */ - --sidebar-background: var(--background); /* same as global background */ - --sidebar-foreground: var(--foreground); - --sidebar-primary: var(--primary); - --sidebar-primary-foreground: var(--background); - --sidebar-accent: var(--accent); - --sidebar-accent-foreground: var(--background); - --sidebar-border: var(--border); - --sidebar-ring: var(--primary); - - /* Custom sidebar variables */ - --sidebar-border: hsl(var(--border)); - --sidebar: hsl(var(--card)); - - /* Vintage white for light mode only */ - --vintage-white: #F3F3EE; + --chart-1: var(--accent); + --chart-2: 47 15% 13%; + --chart-3: 19 80% 55%; + --chart-4: 48 11% 93%; + --chart-5: 0 84% 60%; } .dark { - /* Dark mode */ - --background: 180 45% 6%; /* Offblack (#091717) */ - --foreground: 210 20% 90%; /* near-white for text */ - - --card: 193 41% 17%; /* Inky Blue (#19343B) - pops against offblack */ + /* Warm dark theme */ + --background: 40 27% 6%; /* #14120B */ + --foreground: 0 2% 93%; /* #EDECEC */ + --foreground-secondary: 0 1% 84%; /* #D7D6D5 */ + + /* Card hierarchy */ + --card: 40 14% 9%; /* #1B1913 */ + --card-02: 40 12% 11%; /* #201E18 */ + --card-03: 40 11% 13%; /* #26241E */ + --card-04: 40 10% 15%; /* #2B2923 */ --card-foreground: var(--foreground); - --popover: 193 41% 17%; /* Inky Blue */ + --popover: var(--card); --popover-foreground: var(--foreground); - --primary: 188 63% 48%; /* vivid turquoise stands out on dark */ - --primary-foreground: 180 45% 6%; /* Offblack for contrast */ + /* Accent - same orange-red */ + --accent: 19 100% 48%; /* #F54E00 */ + --accent-foreground: 0 0% 100%; + + /* Primary uses accent */ + --primary: var(--accent); + --primary-foreground: 0 0% 100%; - --secondary: 193 41% 17%; /* Inky Blue (#19343B) */ + /* Secondary */ + --secondary: var(--card-02); --secondary-foreground: var(--foreground); - --muted: 200 6% 22%; - --muted-foreground: 210 15% 70%; + /* Muted */ + --muted: 40 8% 20%; + --muted-foreground: 40 2% 58%; /* #969592 */ - --accent: 188 80% 50%; - --accent-foreground: var(--foreground); + /* Destructive */ + --destructive: 0 72% 51%; + --destructive-foreground: 0 0% 100%; - /* Dark mode contrast colors - more contrast against offblack */ - --contrast-light: 194 41% 22%; /* Peacock (#2E666E) */ - --contrast-lighter: 193 41% 17%; /* Inky Blue (#19343B) base */ - --contrast-lightest: 195 48% 15%; /* Dark Teal (#13343B) */ + /* Borders & inputs */ + --border: 40 8% 18%; + --input: 40 8% 18%; + --ring: var(--accent); - --destructive: 13 60% 55%; - --destructive-foreground: var(--foreground); + /* Charts */ + --chart-1: var(--accent); + --chart-2: 0 2% 93%; + --chart-3: 19 80% 55%; + --chart-4: 40 14% 9%; + --chart-5: 0 72% 51%; + } +} - --border: 200 6% 22%; - --input: 200 6% 22%; - --ring: var(--primary); +/* Theme transition - circular reveal from toggle button */ +@property --reveal-size { + syntax: ""; + initial-value: 0px; + inherits: false; +} - --chart-1: var(--primary); - --chart-2: 188 80% 50%; - --chart-3: 193 41% 17%; - --chart-4: 48 28% 97%; - --chart-5: 13 60% 55%; +::view-transition-old(root), +::view-transition-new(root) { + animation: none; + mix-blend-mode: normal; +} - --sidebar-background: var(--background); /* same as global background */ - --sidebar-foreground: var(--foreground); - --sidebar-primary: var(--primary); - --sidebar-primary-foreground: 180 45% 6%; /* Offblack */ - --sidebar-accent: var(--accent); - --sidebar-accent-foreground: 180 45% 6%; /* Offblack */ - --sidebar-border: var(--border); - --sidebar-ring: var(--primary); +::view-transition-old(root) { + z-index: 1; +} - /* Custom sidebar variables */ - --sidebar-border: hsl(var(--border)); - --sidebar: hsl(var(--card)); - } +::view-transition-new(root) { + z-index: 9999; + mask: radial-gradient( + circle at var(--reveal-x, 50%) var(--reveal-y, 50%), + black 0, + black calc(var(--reveal-size) - 160px), + transparent var(--reveal-size) + ); } @layer base { @@ -334,9 +333,38 @@ @apply border-border; } html { - scroll-behavior: smooth; /* Added smooth scrolling */ + scroll-behavior: smooth; + scrollbar-gutter: stable; } body { @apply bg-background text-foreground; } -} \ No newline at end of file + ::selection { + background: hsl(var(--primary) / 0.2); + } +} + +/* react-tweet theme overrides */ +:root .react-tweet-theme { + --tweet-bg-color: #F0EFEA; + --tweet-bg-color-hover: #EBEAE5; + --tweet-quoted-bg-color-hover: rgba(0, 0, 0, 0.03); + --tweet-border: 1px solid hsl(45 8% 80%); + --tweet-font-color: #26251E; + --tweet-font-color-secondary: #3B3A33; + --tweet-color-blue-primary: #F54E00; + --tweet-color-blue-primary-hover: #dc4600; + --tweet-font-family: inherit; +} + +.dark .react-tweet-theme { + --tweet-bg-color: #1B1913; + --tweet-bg-color-hover: #201E18; + --tweet-quoted-bg-color-hover: rgba(255, 255, 255, 0.03); + --tweet-border: 1px solid hsl(40 8% 25%); + --tweet-font-color: #EDECEC; + --tweet-font-color-secondary: #969592; + --tweet-color-blue-primary: #F54E00; + --tweet-color-blue-primary-hover: #dc4600; + --tweet-font-family: inherit; +} diff --git a/app/layout.tsx b/app/layout.tsx index 8ebc00b..9b75497 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -1,47 +1,47 @@ -import { SidebarProvider } from "@/components/ui/sidebar" import "./globals.css" -import { ThemeProvider } from "@/components/providers/ThemeProvider" +import { ThemeProvider } from "@/components/providers/theme-provider" +import { NavigationProvider } from "@/components/providers/navigation-provider" +import { StickyTitleProvider } from "@/components/providers/sticky-title-provider" import { Toaster } from "@/components/ui/sonner" -import { GPTSlopToast } from "@/components/GPTSlopToast" -import Script from 'next/script' -import { Montserrat, Instrument_Serif } from 'next/font/google' -import { Sidebar } from "@/components/layout/Sidebar" -import { CanvasTrigger } from "../components/layout/CanvasTrigger" -import { MobileFadeOverlay } from "../components/layout/MobileFadeOverlay" +import { Montserrat, Instrument_Serif } from "next/font/google" +import { TopNav } from "@/components/layout/top-nav" +import { PageTransition } from "@/components/layout/page-transition" +import { GPTSlopToast } from "@/components/gpt-slop-toast" +import { Analytics } from "@vercel/analytics/next" const montserrat = Montserrat({ - subsets: ['latin'], - variable: '--font-montserrat', - display: 'swap', + subsets: ["latin"], + variable: "--font-montserrat", + display: "swap", }) const instrumentSerif = Instrument_Serif({ - subsets: ['latin'], - variable: '--font-instrument-serif', - weight: ['400'], - style: ['normal', 'italic'], - display: 'swap', + subsets: ["latin"], + variable: "--font-instrument-serif", + weight: ["400"], + style: ["normal", "italic"], + display: "swap", }) export const metadata = { title: { - default: 'Jake Bodea', - template: '%s | Jake Bodea' + default: "jake bodea", + template: "%s | jake bodea", }, - description: 'Jake Bodea\'s personal website', + description: "jake bodea's personal website", openGraph: { - title: 'Jake Bodea', - description: 'Personal Website', - url: 'https://jakebodea.com', - siteName: 'Jake Bodea Personal Website', - locale: 'en_US', - type: 'website', + title: "jake bodea", + description: "personal website", + url: "https://jakebodea.com", + siteName: "jake bodea", + locale: "en_US", + type: "website", }, robots: { index: true, follow: true, }, - metadataBase: new URL('https://jakebodea.com'), + metadataBase: new URL("https://jakebodea.com"), } export default function RootLayout({ @@ -50,37 +50,55 @@ export default function RootLayout({ children: React.ReactNode }) { return ( - + - - - + + + - + - -
- -
-
- - -
{children}
+ + + {process.env.VERCEL_GIT_COMMIT_REF === "dev" && ( +
+ dev
-
-
- - - + )} + +
+ {children} +
+ + + + + -