1
mirror of https://github.com/jakejarvis/jarv.is.git synced 2026-06-05 19:15:30 -04:00

2026 Redesign (#2531)

This commit is contained in:
2026-01-27 22:53:59 -05:00
committed by GitHub
parent d72e587401
commit 2ece5c79fa
27 changed files with 1887 additions and 2012 deletions
-97
View File
@@ -1,97 +0,0 @@
This file provides guidance to GitHub Copilot when working with code in this repository.
## Project Overview
This is a personal website (jarv.is) built with Next.js, TypeScript, and various modern web technologies. The site uses the Next.js App Router and is designed to be deployed on Vercel. It includes features like blog posts (notes), projects showcase, contact form, and hit counter API.
## Development Commands
```bash
# Start development server
pnpm dev
# Build for production
pnpm build
# Start production server
pnpm start
# Lint code
pnpm lint
# Type check
pnpm typecheck
# Generate database migrations (using Drizzle)
pnpm db:generate
# Apply database migrations (using Drizzle)
pnpm db:migrate
```
## Environment Setup
The project requires several environment variables to function properly. The environment variables are documented and type-checked in `lib/env.ts`. Use `.env.example` as a template if a `.env` or `.env.local` file does not already exist.
Required server environment variables:
- `AUTH_SECRET`: Random value for authentication encryption
- `AUTH_GITHUB_CLIENT_ID`: Client ID from GitHub OAuth App
- `AUTH_GITHUB_CLIENT_SECRET`: Client secret from GitHub OAuth App
- `DATABASE_URL`: PostgreSQL connection string (Neon)
- `GITHUB_TOKEN`: GitHub API token for projects page
- `RESEND_API_KEY`: Resend API key for contact form
- `RESEND_TO_EMAIL`: Destination email for contact form
Required client environment variables:
- `NEXT_PUBLIC_GITHUB_REPO`: Repository in format "username/repo"
- `NEXT_PUBLIC_GITHUB_USERNAME`: GitHub username
## Architecture
### Core Technologies
- **Next.js App Router**: Modern React framework with server components
- **TypeScript**: Type safety throughout the project
- **Tailwind CSS**: Styling with utility classes
- **Drizzle ORM**: Database interactions with type safety
- **Better Auth**: Authentication system
- **MDX**: For notes/blog content with enhanced features
- **Neon PostgreSQL**: Serverless database
### Database Schema
The Drizzle ORM database schema is defined in `lib/db/schema.ts`.
### Content Structure
- `/app`: Next.js App Router pages and layouts
- `/components`: React components organized by feature
- `/lib`: Utility functions and configurations
- `/notes`: MDX content for blog posts
- `/public`: Static assets
## Development Considerations
1. When using ANY library, always use `use context7` to lookup documentation from the context7 MCP server, which provides access to all project-specific configuration files and standards
2. Always prefer React Server Components (RSC) over client components
3. React components follow patterns from Tailwind v4 with shadcn/ui components
4. The project assumes deployment to Vercel and makes use of Vercel-specific features
5. When working with MDX content, note the custom plugins and transformations in `next.config.ts`
6. Database operations use Drizzle ORM with Neon's serverless PostgreSQL client
7. The repository uses strict linting and type checking through ESLint and TypeScript
## External Documentation Lookup
1. The Context7 MCP server is available to reference documentation for any library or dependency
2. Before installing any package, running commands, or creating/updating dependency files, you MUST use `use context7` to retrieve the most up-to-date and authoritative documentation for the relevant stack, library, or technology
3. Do NOT rely solely on model training data or general knowledge; Context7 must be consulted for all dependency and setup actions
+161
View File
@@ -0,0 +1,161 @@
# AGENTS.md
Guidelines for AI coding agents working in this repository.
## Build & Development Commands
```bash
pnpm dev # Development server (binds to 0.0.0.0)
pnpm build # Production build
pnpm typecheck # Type checking
pnpm lint # Lint entire codebase
pnpm lint path/to/file.tsx # Lint a single file
pnpm db:generate # Generate migration files
pnpm db:migrate # Apply migrations
```
No test suite exists. Validate changes via `pnpm typecheck` and `pnpm lint`.
## Code Style
### Formatting (Prettier)
- **Line width:** 120 characters
- **Indentation:** 2 spaces (no tabs)
- **Quotes:** Double quotes, no JSX single quotes
- **Trailing commas:** ES5 style
- **Semicolons:** Required
- **Tailwind:** Classes auto-sorted via `prettier-plugin-tailwindcss`
### Import Organization
```typescript
// 1. React/Next.js core
import { useState } from "react";
// 2. External libraries
import { eq, desc } from "drizzle-orm";
// 3. Internal modules using @/ alias
import { db } from "@/lib/db";
import Button from "@/components/ui/button";
```
- Always use `@/` path aliases (never relative `../../`)
- Use `type` keyword for type-only imports: `import type { Foo } from "bar"`
### TypeScript
- **Strict mode** enabled - no implicit any, strict null checks
- **Explicit return types** for API routes and server actions
- **Zod** for runtime validation - share schemas between client and server
- Avoid `any` - use `unknown` with type guards when necessary
- Use `as const` for immutable config objects
### Naming Conventions
| Element | Convention | Example |
| ------------------- | ------------------------ | ---------------------------- |
| Files | kebab-case | `contact-form.tsx` |
| Components | PascalCase | `ContactForm` |
| Functions/Variables | camelCase | `getComments`, `isPending` |
| Types/Interfaces | PascalCase | `CommentWithUser` |
| Constants | camelCase or UPPER_SNAKE | `siteConfig`, `DATABASE_URL` |
### Component Patterns
**Prefer Server Components** - Only add `"use client"` when necessary for:
- Event handlers (onClick, onChange, onSubmit)
- React hooks (useState, useEffect, useTransition)
- Browser-only APIs
**Component structure:**
- Arrow function components with default exports
- Props destructured in parameters
- Use `cn()` from `@/lib/utils` for className merging
- Follow shadcn/ui patterns for UI components
```typescript
const Button = ({
className,
variant,
...rest
}: React.ComponentProps<"button"> & VariantProps<typeof buttonVariants>) => {
return <button className={cn(buttonVariants({ variant, className }))} {...rest} />;
};
export default Button;
```
### Server Actions & Error Handling
```typescript
"use server";
export type ActionState = { success: boolean; message: string; errors?: Record<string, string[]> };
export const submitForm = async (state: ActionState, payload: FormData): Promise<ActionState> => {
try {
const data = Schema.safeParse(Object.fromEntries(payload));
if (!data.success) {
return { success: false, message: "Validation failed", errors: data.error.flatten().fieldErrors };
}
// Perform action...
return { success: true, message: "Success!" };
} catch (error) {
console.error("[server/action] error:", error);
return { success: false, message: "Internal server error" };
}
};
```
**Client-side:** Use `toast` from sonner for feedback, `useTransition` for pending states.
### Database (Drizzle ORM)
- Schema in `lib/db/schema.ts`
- Use `"use cache"` directive with `cacheTag()` for cached queries
- Use `revalidatePath()` and `revalidateTag()` after mutations
```typescript
export const getData = async (slug: string) => {
"use cache";
cacheTag("data", `data-${slug}`);
return db.select().from(schema.table).where(eq(schema.table.slug, slug));
};
```
### Environment Variables
Type-checked in `lib/env.ts` using `@t3-oss/env-nextjs`. Never access `process.env` directly:
```typescript
import { env } from "@/lib/env";
const apiKey = env.RESEND_API_KEY;
```
## Project Structure
```
app/ # Next.js App Router pages and API routes
components/ # React components organized by feature
ui/ # shadcn/ui base components
layout/ # Header, footer, page layout
comments/ # Comment system components
lib/ # Core utilities and configuration
db/ # Drizzle schema and database client
server/ # Server actions (contact, comments, views)
config/ # Site and author configuration
schemas/ # Shared Zod validation schemas
notes/ # MDX blog content files
```
## Key Dependencies
- **Next.js 15+** with App Router and React 19
- **Tailwind CSS v4** with shadcn/ui components
- **Drizzle ORM** with Neon PostgreSQL
- **Better Auth** for GitHub OAuth authentication
- **Zod** for schema validation
- **MDX** for blog content with remark/rehype plugins
-73
View File
@@ -1,73 +0,0 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Development Commands
```bash
# Start development server
pnpm dev
# Build for production
pnpm build
# Type check
pnpm typecheck
# Lint code
pnpm lint
# Generate database migrations
pnpm db:generate
# Apply database migrations
pnpm db:migrate
```
## Environment Setup
Environment variables are strictly type-checked in `lib/env.ts`. Copy `.env.example` to `.env` or `.env.local` and populate required values.
Key required variables:
- `DATABASE_URL`: PostgreSQL connection string (Neon)
- `AUTH_SECRET`: Random value for authentication encryption
- `AUTH_GITHUB_CLIENT_ID` / `AUTH_GITHUB_CLIENT_SECRET`: GitHub OAuth App credentials
- `RESEND_API_KEY` / `RESEND_TO_EMAIL`: For contact form emails
- `NEXT_PUBLIC_GITHUB_REPO` / `NEXT_PUBLIC_GITHUB_USERNAME`: For projects page
## Architecture
### Tech Stack
- **Next.js App Router**: Server components with TypeScript
- **Tailwind CSS v4**: Utility-first styling with shadcn/ui components
- **Drizzle ORM**: Type-safe database interactions with Neon PostgreSQL
- **Better Auth**: Authentication with GitHub OAuth
- **MDX**: Enhanced markdown for blog posts with custom plugins
### Key Structure
- `/app`: App Router pages and API routes
- `/components`: Feature-organized React components
- `/lib`: Core utilities, configs, and database schema
- `/notes`: MDX blog content files
- Database schema: `lib/db/schema.ts` with users, sessions, comments, and page views
### Content & Features
- Blog posts (`/notes`) are MDX files with frontmatter
- Comments system with nested threads
- Hit counter API (`/api/hits`)
- GitHub projects showcase
- Contact form with Vercel BotID protection
- RSS/Atom feeds
- Theme toggle (dark/light mode)
## Development Notes
- Always prefer React Server Components over client components
- Database operations use Drizzle ORM with Neon's serverless client
- MDX processing includes custom remark/rehype plugins configured in `next.config.ts`
- Deployment assumes Vercel with specific environment handling
- Strict ESLint and TypeScript configuration enforced
Symlink
+1
View File
@@ -0,0 +1 @@
AGENTS.md
+30 -26
View File
@@ -11,35 +11,39 @@ export const metadata = createMetadata({
const Page = () => { const Page = () => {
return ( return (
<div className="w-full md:mx-auto md:w-2/3"> <>
<PageTitle canonical="/contact">Contact</PageTitle> <PageTitle canonical="/contact">Contact</PageTitle>
<p className="my-5 text-[0.925rem] leading-relaxed md:text-base"> <div className="w-full md:mx-auto md:w-2/3">
Fill out this quick form and I&rsquo;ll get back to you as soon as I can! You can also{" "} <div className="prose prose-sm prose-neutral dark:prose-invert max-w-none">
<Link href="mailto:jake@jarv.is">email me directly</Link> or send me a direct message on{" "} <p>
<Link href="https://bsky.app/profile/jarv.is" className="text-nowrap"> Fill out this quick form and I&rsquo;ll get back to you as soon as I can! You can also{" "}
🦋 Bluesky <Link href="mailto:jake@jarv.is">email me directly</Link> or send me a direct message on{" "}
</Link>{" "} <Link href="https://bsky.app/profile/jarv.is" className="text-nowrap">
or{" "} Bluesky
<Link href="https://fediverse.jarv.is/@jake" className="text-nowrap"> </Link>{" "}
🦣 Mastodon or{" "}
</Link> <Link href="https://fediverse.jarv.is/@jake" className="text-nowrap">
. Mastodon
</p> </Link>
<p className="my-5 text-[0.925rem] leading-relaxed md:text-base"> .
You can grab my public key here:{" "} </p>
<Link <p>
href="https://jrvs.io/pgp" You can grab my public key here:{" "}
title="3BC6 E577 6BF3 79D3 6F67 1480 2B0C 9CF2 51E6 9A39" <Link
className="bg-muted relative rounded-sm px-[0.3rem] py-[0.2rem] font-mono text-sm font-medium tracking-wider [word-spacing:-0.25em]" href="https://jrvs.io/pgp"
> title="3BC6 E577 6BF3 79D3 6F67 1480 2B0C 9CF2 51E6 9A39"
2B0C 9CF2 51E6 9A39 className="bg-muted relative rounded-sm px-[0.3rem] py-[0.2rem] font-mono text-sm font-medium tracking-wider [word-spacing:-0.25em]"
</Link> >
. 2B0C 9CF2 51E6 9A39
</p> </Link>
.
</p>
</div>
<ContactForm /> <ContactForm />
</div> </div>
</>
); );
}; };
+230 -84
View File
@@ -1,66 +1,22 @@
@import "tailwindcss"; @import "tailwindcss";
@import "tw-animate-css"; @import "tw-animate-css";
@plugin "@tailwindcss/typography";
@import "react-lite-youtube-embed/dist/LiteYouTubeEmbed.css";
@custom-variant dark (&:where([data-theme=dark] *)); @custom-variant dark (&:where(.dark *));
:root {
--background: oklch(1.00 0 0);
--foreground: oklch(0.26 0 0);
--card: oklch(1.00 0 0);
--card-foreground: oklch(0.26 0 0);
--popover: oklch(1.00 0 0);
--popover-foreground: oklch(0.26 0 0);
--primary: oklch(0.50 0.13 245.46);
--primary-foreground: oklch(0.99 0 0);
--secondary: oklch(0.98 0 0);
--secondary-foreground: oklch(0.33 0 0);
--muted: oklch(0.97 0 0);
--muted-foreground: oklch(0.556 0 0);
--accent: oklch(0.98 0 0);
--accent-foreground: oklch(0.33 0 0);
--highlight: oklch(0.50 0.13 245.46);
--highlight-foreground: oklch(0.99 0 0);
--destructive: oklch(0.62 0.21 25.77);
--warning: oklch(0.67 0.179 58.318);
--success: oklch(0.63 0.194 149.214);
--border: oklch(0.922 0 0);
--input: oklch(0.922 0 0);
--ring: oklch(0.708 0 0);
--radius: 0.625rem;
}
[data-theme="dark"] {
--background: oklch(0.20 0 0);
--foreground: oklch(0.98 0 0);
--card: oklch(0.14 0.00 285.82);
--card-foreground: oklch(0.98 0 0);
--popover: oklch(0.14 0.00 285.82);
--popover-foreground: oklch(0.98 0 0);
--primary: oklch(0.81 0.10 251.81);
--primary-foreground: oklch(0.21 0.01 285.88);
--secondary: oklch(0.269 0 0);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.269 0 0);
--muted-foreground: oklch(0.708 0 0);
--accent: oklch(0.27 0.01 286.03);
--accent-foreground: oklch(0.98 0 0);
--highlight: oklch(0.81 0.10 251.81);
--highlight-foreground: oklch(0.21 0.01 285.88);
--destructive: oklch(0.70 0.19 22.23);
--warning: oklch(0.8 0.184 86.047);
--success: oklch(0.79 0.209 151.711);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.556 0 0);
}
@theme inline { @theme inline {
--font-sans: var(--font-geist-sans); --font-sans: var(--font-geist-sans);
--font-sans--font-feature-settings: "rlig" 1, "calt" 0; --font-sans--font-feature-settings: "rlig" 1, "calt" 0;
--font-mono: var(--font-geist-mono); --font-mono: var(--font-geist-mono);
--font-mono--font-feature-settings: "liga" 0; --font-mono--font-feature-settings: "liga" 0;
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
--radius-2xl: calc(var(--radius) + 8px);
--radius-3xl: calc(var(--radius) + 12px);
--radius-4xl: calc(var(--radius) + 16px);
--color-background: var(--background); --color-background: var(--background);
--color-foreground: var(--foreground); --color-foreground: var(--foreground);
--color-card: var(--card); --color-card: var(--card);
@@ -75,24 +31,123 @@
--color-muted-foreground: var(--muted-foreground); --color-muted-foreground: var(--muted-foreground);
--color-accent: var(--accent); --color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground); --color-accent-foreground: var(--accent-foreground);
--color-highlight: var(--highlight);
--color-highlight-foreground: var(--highlight-foreground);
--color-destructive: var(--destructive); --color-destructive: var(--destructive);
--color-warning: var(--warning); --color-destructive-foreground: var(--destructive-foreground);
--color-success: var(--success);
--color-border: var(--border); --color-border: var(--border);
--color-input: var(--input); --color-input: var(--input);
--color-ring: var(--ring); --color-ring: var(--ring);
--color-chart-1: var(--chart-1);
--color-chart-2: var(--chart-2);
--color-chart-3: var(--chart-3);
--color-chart-4: var(--chart-4);
--color-chart-5: var(--chart-5);
--color-sidebar: var(--sidebar);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-ring: var(--sidebar-ring);
--color-surface: var(--surface);
--color-surface-foreground: var(--surface-foreground);
--color-code: var(--code);
--color-code-foreground: var(--code-foreground);
--color-code-highlight: var(--code-highlight);
--color-code-number: var(--code-number);
--color-selection: var(--selection);
--color-selection-foreground: var(--selection-foreground);
}
--radius-sm: calc(var(--radius) - 4px); :root {
--radius-md: calc(var(--radius) - 2px); --radius: 0.625rem;
--radius-lg: var(--radius); --background: oklch(1 0 0);
--radius-xl: calc(var(--radius) + 4px); --foreground: oklch(0.145 0 0);
--card: oklch(1 0 0);
--card-foreground: oklch(0.145 0 0);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.145 0 0);
--primary: oklch(0.205 0 0);
--primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.97 0 0);
--secondary-foreground: oklch(0.205 0 0);
--muted: oklch(0.97 0 0);
--muted-foreground: oklch(0.556 0 0);
--accent: oklch(0.97 0 0);
--accent-foreground: oklch(0.205 0 0);
--destructive: oklch(0.577 0.245 27.325);
--destructive-foreground: oklch(0.97 0.01 17);
--border: oklch(0.922 0 0);
--input: oklch(0.922 0 0);
--ring: oklch(0.708 0 0);
--chart-1: var(--color-blue-300);
--chart-2: var(--color-blue-500);
--chart-3: var(--color-blue-600);
--chart-4: var(--color-blue-700);
--chart-5: var(--color-blue-800);
--sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.145 0 0);
--sidebar-primary: oklch(0.205 0 0);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.97 0 0);
--sidebar-accent-foreground: oklch(0.205 0 0);
--sidebar-border: oklch(0.922 0 0);
--sidebar-ring: oklch(0.708 0 0);
--surface: oklch(0.98 0 0);
--surface-foreground: var(--foreground);
--code: var(--surface);
--code-foreground: var(--surface-foreground);
--code-highlight: oklch(0.96 0 0);
--code-number: oklch(0.56 0 0);
--selection: oklch(0.145 0 0);
--selection-foreground: oklch(1 0 0);
}
.dark {
--background: oklch(0.145 0 0);
--foreground: oklch(0.985 0 0);
--card: oklch(0.205 0 0);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.269 0 0);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.922 0 0);
--primary-foreground: oklch(0.205 0 0);
--secondary: oklch(0.269 0 0);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.269 0 0);
--muted-foreground: oklch(0.708 0 0);
--accent: oklch(0.371 0 0);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.704 0.191 22.216);
--destructive-foreground: oklch(0.58 0.22 27);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.556 0 0);
--chart-1: var(--color-blue-300);
--chart-2: var(--color-blue-500);
--chart-3: var(--color-blue-600);
--chart-4: var(--color-blue-700);
--chart-5: var(--color-blue-800);
--sidebar: oklch(0.205 0 0);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.488 0.243 264.376);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.269 0 0);
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.439 0 0);
--surface: oklch(0.2 0 0);
--surface-foreground: oklch(0.708 0 0);
--code: var(--surface);
--code-foreground: var(--surface-foreground);
--code-highlight: oklch(0.27 0 0);
--code-number: oklch(0.72 0 0);
--selection: oklch(0.922 0 0);
--selection-foreground: oklch(0.205 0 0);
} }
@theme { @theme {
--animate-wave: wave 5s ease 1s infinite; --animate-wave: wave 5s ease 1s infinite;
--animate-heartbeat: heartbeat 10s ease 7.5s infinite;
--animate-marquee: marquee 30s linear infinite; --animate-marquee: marquee 30s linear infinite;
@keyframes wave { @keyframes wave {
@@ -118,21 +173,6 @@
} }
} }
@keyframes heartbeat {
0%,
4%,
8%,
100% {
transform: scale(1);
}
2% {
transform: scale(1.25);
}
6% {
transform: scale(1.2);
}
}
@keyframes marquee { @keyframes marquee {
from { from {
transform: translateX(0); transform: translateX(0);
@@ -147,9 +187,11 @@
* { * {
@apply border-border outline-ring/50; @apply border-border outline-ring/50;
} }
body { body {
@apply bg-background text-foreground selection:bg-highlight selection:text-highlight-foreground; @apply bg-background text-foreground;
}
::selection {
@apply bg-selection text-selection-foreground;
} }
::-webkit-scrollbar { ::-webkit-scrollbar {
@@ -169,10 +211,114 @@
} }
@layer components { @layer components {
@import "react-lite-youtube-embed/dist/LiteYouTubeEmbed.css"; [data-slot="code-block"] {
@apply bg-code text-code-foreground overflow-x-auto overflow-y-hidden rounded-xl text-sm outline-none;
/* styles for wrapper around generated markdown content */ /* Override shiki inline styles */
.generated { & pre {
@apply text-[0.925rem] leading-relaxed first:mt-0 last:mb-0 md:text-base [&_p]:my-5; @apply m-0 rounded-xl !bg-transparent;
}
/* Dark mode token colors - override shiki inline color styles */
& span[style*="color"] {
@apply dark:![color:var(--shiki-dark)];
}
& code {
display: grid;
min-width: 100%;
white-space: pre;
padding: 0.875rem 1rem;
counter-reset: line;
}
& .line {
display: inline-block;
min-height: 1lh;
width: 100%;
padding-block: 0.125rem;
}
/* Highlighted lines */
& .line.highlighted {
position: relative;
background-color: var(--color-code-highlight);
&:after {
position: absolute;
top: 0;
left: 0;
width: 2px;
height: 100%;
content: "";
background-color: color-mix(in oklab, var(--muted-foreground) 50%, transparent);
}
}
/* Highlighted words */
& .highlighted-word {
position: relative;
background-color: var(--color-code-highlight);
border-radius: var(--radius-sm);
padding-inline: 0.3rem;
padding-block: 0.1rem;
}
}
/* Line numbers - only when data-line-numbers is set */
[data-slot="code-block"][data-line-numbers] .line::before {
font-size: var(--text-sm);
counter-increment: line;
content: counter(line);
display: inline-block;
width: 2rem;
margin-right: 1.5rem;
text-align: right;
color: var(--color-code-number);
}
[data-slot="code-block"][data-line-numbers] .line.highlighted::before {
background-color: var(--color-code-highlight);
}
}
/* View Transitions */
@layer base {
@media (prefers-reduced-motion: reduce) {
::view-transition-group(*),
::view-transition-old(*),
::view-transition-new(*) {
animation: none !important;
}
}
main {
view-transition-name: main-content;
}
::view-transition-old(main-content) {
animation: fade-out 120ms ease-out;
}
::view-transition-new(main-content) {
animation: fade-in 120ms ease-in 40ms;
}
@keyframes fade-out {
from {
opacity: 1;
}
to {
opacity: 0;
}
}
@keyframes fade-in {
from {
opacity: 0;
}
to {
opacity: 1;
}
} }
} }
+3 -8
View File
@@ -1,7 +1,6 @@
import { env } from "@/lib/env"; import { env } from "@/lib/env";
import { JsonLd } from "react-schemaorg"; import { JsonLd } from "react-schemaorg";
import Providers from "@/components/providers"; import Providers from "@/components/providers";
import { ThemeScript } from "@/components/theme/theme-script";
import Header from "@/components/layout/header"; import Header from "@/components/layout/header";
import Footer from "@/components/layout/footer"; import Footer from "@/components/layout/footer";
import Toaster from "@/components/ui/sonner"; import Toaster from "@/components/ui/sonner";
@@ -24,8 +23,6 @@ const RootLayout = ({ children }: Readonly<{ children: React.ReactNode }>) => {
suppressHydrationWarning suppressHydrationWarning
> >
<head> <head>
<ThemeScript />
<JsonLd<Person> <JsonLd<Person>
item={{ item={{
"@context": "https://schema.org", "@context": "https://schema.org",
@@ -65,13 +62,11 @@ const RootLayout = ({ children }: Readonly<{ children: React.ReactNode }>) => {
<body className="bg-background text-foreground font-sans antialiased"> <body className="bg-background text-foreground font-sans antialiased">
<Providers> <Providers>
<div className="mx-auto w-full max-w-4xl px-5 py-1"> <Header />
<Header className="mt-4 mb-6 w-full" /> <div className="mx-auto mt-4 w-full max-w-4xl px-5">
<main>{children}</main> <main>{children}</main>
<Footer className="my-6 w-full" />
</div> </div>
<Footer />
<Toaster position="bottom-center" hotkey={[]} /> <Toaster position="bottom-center" hotkey={[]} />
<Analytics /> <Analytics />
+3 -2
View File
@@ -1,3 +1,4 @@
import Button from "@/components/ui/button";
import Video from "@/components/video"; import Video from "@/components/video";
import Link from "@/components/link"; import Link from "@/components/link";
import type { Metadata } from "next"; import type { Metadata } from "next";
@@ -23,9 +24,9 @@ const Page = () => {
<div className="mt-6 text-center"> <div className="mt-6 text-center">
<h1 className="my-2 text-2xl font-semibold md:text-3xl">Page Not Found</h1> <h1 className="my-2 text-2xl font-semibold md:text-3xl">Page Not Found</h1>
<p className="mt-4 mb-0 text-lg font-medium md:text-xl"> <Button className="mt-4 mb-0 text-[15px] leading-none" size="lg" asChild>
<Link href="/">Go home?</Link> <Link href="/">Go home?</Link>
</p> </Button>
</div> </div>
</> </>
); );
+10 -8
View File
@@ -61,8 +61,10 @@ const getFormattedDates = async (date: string) => {
const Page = async ({ params }: { params: Promise<{ slug: string }> }) => { const Page = async ({ params }: { params: Promise<{ slug: string }> }) => {
const { slug } = await params; const { slug } = await params;
const frontmatter = await getFrontMatter(slug); const [frontmatter, commentCount] = await Promise.all([
const commentCount = await getCommentCounts(`${POSTS_DIR}/${slug}`); getFrontMatter(slug),
getCommentCounts(`${POSTS_DIR}/${slug}`),
]);
const formattedDates = await getFormattedDates(frontmatter!.date); const formattedDates = await getFormattedDates(frontmatter!.date);
const { default: MDXContent } = await import(`../../../${POSTS_DIR}/${slug}/index.mdx`); const { default: MDXContent } = await import(`../../../${POSTS_DIR}/${slug}/index.mdx`);
@@ -99,7 +101,7 @@ const Page = async ({ params }: { params: Promise<{ slug: string }> }) => {
href={`/${POSTS_DIR}/${frontmatter!.slug}`} href={`/${POSTS_DIR}/${frontmatter!.slug}`}
className={"text-foreground/70 flex flex-nowrap items-center gap-x-2 whitespace-nowrap hover:no-underline"} className={"text-foreground/70 flex flex-nowrap items-center gap-x-2 whitespace-nowrap hover:no-underline"}
> >
<CalendarDaysIcon className="inline size-4 shrink-0" /> <CalendarDaysIcon className="inline size-4 shrink-0" aria-hidden="true" />
<time dateTime={formattedDates.dateISO} title={formattedDates.dateTitle}> <time dateTime={formattedDates.dateISO} title={formattedDates.dateTitle}>
{formattedDates.dateDisplay} {formattedDates.dateDisplay}
</time> </time>
@@ -107,7 +109,7 @@ const Page = async ({ params }: { params: Promise<{ slug: string }> }) => {
{frontmatter!.tags && ( {frontmatter!.tags && (
<div className="flex flex-wrap items-center gap-x-2 whitespace-nowrap"> <div className="flex flex-wrap items-center gap-x-2 whitespace-nowrap">
<TagIcon className="inline size-4 shrink-0" /> <TagIcon className="inline size-4 shrink-0" aria-hidden="true" />
{frontmatter!.tags.map((tag) => ( {frontmatter!.tags.map((tag) => (
<span <span
key={tag} key={tag}
@@ -126,7 +128,7 @@ const Page = async ({ params }: { params: Promise<{ slug: string }> }) => {
title={`Edit "${frontmatter!.title}" on GitHub`} title={`Edit "${frontmatter!.title}" on GitHub`}
className={"text-foreground/70 flex flex-nowrap items-center gap-x-2 whitespace-nowrap hover:no-underline"} className={"text-foreground/70 flex flex-nowrap items-center gap-x-2 whitespace-nowrap hover:no-underline"}
> >
<SquarePenIcon className="inline size-4 shrink-0" /> <SquarePenIcon className="inline size-4 shrink-0" aria-hidden="true" />
<span>Improve This Post</span> <span>Improve This Post</span>
</Link> </Link>
@@ -135,17 +137,17 @@ const Page = async ({ params }: { params: Promise<{ slug: string }> }) => {
title={`${Intl.NumberFormat(env.NEXT_PUBLIC_SITE_LOCALE).format(commentCount || 0)} ${commentCount === 1 ? "comment" : "comments"}`} title={`${Intl.NumberFormat(env.NEXT_PUBLIC_SITE_LOCALE).format(commentCount || 0)} ${commentCount === 1 ? "comment" : "comments"}`}
className="text-foreground/70 flex flex-nowrap items-center gap-x-2 whitespace-nowrap hover:no-underline" className="text-foreground/70 flex flex-nowrap items-center gap-x-2 whitespace-nowrap hover:no-underline"
> >
<MessagesSquareIcon className="inline size-4 shrink-0" /> <MessagesSquareIcon className="inline size-4 shrink-0" aria-hidden="true" />
<span>{Intl.NumberFormat(env.NEXT_PUBLIC_SITE_LOCALE).format(commentCount || 0)}</span> <span>{Intl.NumberFormat(env.NEXT_PUBLIC_SITE_LOCALE).format(commentCount || 0)}</span>
</Link> </Link>
<div className="flex min-w-14 flex-nowrap items-center gap-x-2 whitespace-nowrap"> <div className="flex min-w-14 flex-nowrap items-center gap-x-2 whitespace-nowrap">
<EyeIcon className="inline size-4 shrink-0" /> <EyeIcon className="inline size-4 shrink-0" aria-hidden="true" />
<ViewCounter slug={`${POSTS_DIR}/${frontmatter!.slug}`} /> <ViewCounter slug={`${POSTS_DIR}/${frontmatter!.slug}`} />
</div> </div>
</div> </div>
<h1 className="mt-2 mb-3 text-3xl/10 font-bold md:text-4xl/12"> <h1 className="mt-4 mb-5 text-4xl font-semibold tracking-tight sm:text-3xl">
<Link <Link
href={`/${POSTS_DIR}/${frontmatter!.slug}`} href={`/${POSTS_DIR}/${frontmatter!.slug}`}
dangerouslySetInnerHTML={{ __html: frontmatter!.htmlTitle || frontmatter!.title }} dangerouslySetInnerHTML={{ __html: frontmatter!.htmlTitle || frontmatter!.title }}
+7 -6
View File
@@ -24,8 +24,8 @@ const PostStats = ({ views, comments, slug }: { views: number; comments: number;
<> <>
{views > 0 && ( {views > 0 && (
<span className="bg-muted text-foreground/65 inline-flex h-5 flex-nowrap items-center gap-1 rounded-md px-1.5 align-text-top text-xs font-semibold text-nowrap shadow select-none"> <span className="bg-muted text-foreground/65 inline-flex h-5 flex-nowrap items-center gap-1 rounded-md px-1.5 align-text-top text-xs font-semibold text-nowrap shadow select-none">
<EyeIcon className="inline-block size-4 shrink-0" /> <EyeIcon className="inline-block size-4 shrink-0" aria-hidden="true" />
<span className="inline-block leading-none">{numberFormatter.format(views)}</span> <span className="inline-block leading-none tabular-nums">{numberFormatter.format(views)}</span>
</span> </span>
)} )}
@@ -37,8 +37,8 @@ const PostStats = ({ views, comments, slug }: { views: number; comments: number;
className="inline-flex hover:no-underline" className="inline-flex hover:no-underline"
> >
<span className="bg-muted text-foreground/65 inline-flex h-5 flex-nowrap items-center gap-1 rounded-md px-1.5 align-text-top text-xs font-semibold text-nowrap shadow select-none"> <span className="bg-muted text-foreground/65 inline-flex h-5 flex-nowrap items-center gap-1 rounded-md px-1.5 align-text-top text-xs font-semibold text-nowrap shadow select-none">
<MessagesSquareIcon className="inline-block size-3 shrink-0" /> <MessagesSquareIcon className="inline-block size-3 shrink-0" aria-hidden="true" />
<span className="inline-block leading-none">{numberFormatter.format(comments)}</span> <span className="inline-block leading-none tabular-nums">{numberFormatter.format(comments)}</span>
</span> </span>
</Link> </Link>
)} )}
@@ -96,8 +96,8 @@ const PostsList = async () => {
Object.entries(postsByYear).forEach(([year, posts]) => { Object.entries(postsByYear).forEach(([year, posts]) => {
sections.push( sections.push(
<section className="my-8 first-of-type:mt-6 last-of-type:mb-6" key={year}> <section className="my-8 first-of-type:mt-0 last-of-type:mb-0" key={year}>
<h2 id={year} className="mt-0 mb-4 text-3xl font-bold md:text-4xl"> <h2 id={year} className="mt-0 mb-4 text-4xl font-semibold tracking-tight sm:text-3xl">
{year} {year}
</h2> </h2>
<ul className="space-y-4"> <ul className="space-y-4">
@@ -114,6 +114,7 @@ const PostsList = async () => {
href={`/${POSTS_DIR}/${slug}`} href={`/${POSTS_DIR}/${slug}`}
prefetch={false} prefetch={false}
dangerouslySetInnerHTML={{ __html: htmlTitle || title }} dangerouslySetInnerHTML={{ __html: htmlTitle || title }}
className="inline-flex items-center gap-2 text-lg font-medium underline-offset-4 hover:underline md:text-base"
/> />
<PostStats slug={slug} views={views} comments={comments} /> <PostStats slug={slug} views={views} comments={comments} />
+35 -164
View File
@@ -3,109 +3,50 @@ import { LockIcon } from "lucide-react";
const Page = () => { const Page = () => {
return ( return (
<> <div className="prose prose-sm prose-neutral dark:prose-invert prose-headings:mt-0 prose-headings:mb-3 prose-p:my-3 prose-p:leading-[1.75] md:prose-p:leading-relaxed max-w-none">
<h1 className="mt-0 mb-2 text-3xl leading-relaxed font-medium"> <h1 className="text-2xl font-medium">
Hi there! I&rsquo;m Jake.{" "} Hi there! I&rsquo;m Jake.{" "}
<span className="motion-safe:animate-wave ml-0.5 inline-block origin-[65%_80%] text-3xl">👋</span> <span className="motion-safe:animate-wave ml-0.5 inline-block origin-[65%_80%] text-2xl">👋</span>
</h1> </h1>
<h2 className="my-2 text-xl leading-relaxed font-normal"> <h2 className="font-normal">
I&rsquo;m a frontend web developer based in the{" "} I&rsquo;m a frontend web developer based in the{" "}
<Link <Link
href="https://www.youtube-nocookie.com/embed/rLwbzGyC6t4?hl=en&amp;fs=1&amp;showinfo=1&amp;rel=0&amp;iv_load_policy=3" href="https://www.youtube-nocookie.com/embed/rLwbzGyC6t4?hl=en&amp;fs=1&amp;showinfo=1&amp;rel=0&amp;iv_load_policy=3"
title='"Boston Accent Trailer - Late Night with Seth Meyers" on YouTube' title='"Boston Accent Trailer - Late Night with Seth Meyers" on YouTube'
className="[--primary:#fb4d42] dark:[--primary:#ff5146]"
> >
Boston Boston
</Link>{" "} </Link>{" "}
area. area.
</h2> </h2>
<p className="my-3 text-base leading-relaxed md:text-[0.975rem]"> <p>
I specialize in using{" "} I specialize in using TypeScript, React, and Next.js to make lightweight frontends with dynamic and powerful
<Link href="https://www.typescriptlang.org/" className="[--primary:#235a97] dark:[--primary:#59a8ff]"> backends.
TypeScript
</Link>
,{" "}
<Link href="https://reactjs.org/" className="[--primary:#1091b3] dark:[--primary:#6fcbe3]">
React
</Link>
, and{" "}
<Link href="https://nextjs.org/" className="[--primary:#5e7693] dark:[--primary:#a8b9c0]">
Next.js
</Link>{" "}
to make lightweight{" "}
<Link href="https://jamstack.org/glossary/jamstack/" className="[--primary:#04a699] dark:[--primary:#08bbac]">
Jamstack sites
</Link>{" "}
with dynamic and powerful{" "}
<Link href="https://nodejs.org/en/" className="[--primary:#6fbc4e] dark:[--primary:#84d95f]">
Node
</Link>{" "}
backends. But I still know my way around{" "}
<Link
href="https://www.jetbrains.com/lp/php-25/"
title="25 Years of PHP History"
className="[--primary:#8892bf] dark:[--primary:#a4afe3]"
>
less buzzwordy
</Link>{" "}
stacks (and{" "}
<Link
href="https://timkadlec.com/remembers/2020-04-21-the-cost-of-javascript-frameworks/"
title='"The Cost of Javascript Frameworks" by Tim Kadlec'
className="[--primary:#f48024] dark:[--primary:#e18431]"
>
vanilla JavaScript
</Link>
), too.
</p> </p>
<p className="my-3 text-base leading-relaxed md:text-[0.975rem]"> <p>
Whenever possible, I also apply my experience in{" "} Whenever possible, I also apply my experience in{" "}
<Link <Link href="https://bugcrowd.com/jakejarvis" title="Jake Jarvis on Bugcrowd">
href="https://bugcrowd.com/jakejarvis"
title="Jake Jarvis on Bugcrowd"
className="[--primary:#00b81a] dark:[--primary:#57f06d]"
>
information security information security
</Link> </Link>{" "}
,{" "} and{" "}
<Link
href="https://www.cloudflare.com/learning/serverless/what-is-serverless/"
title='"What is serverless computing?" on Cloudflare'
className="[--primary:#0098ec] dark:[--primary:#43b9fb]"
>
serverless architecture
</Link>
, and{" "}
<Link <Link
href="https://github.com/jakejarvis?tab=repositories&q=github-actions&type=&language=&sort=stargazers" href="https://github.com/jakejarvis?tab=repositories&q=github-actions&type=&language=&sort=stargazers"
title='My repositories tagged with "github-actions" on GitHub' title='My repositories tagged with "github-actions" on GitHub'
className="[--primary:#ff6200] dark:[--primary:#f46c16]"
> >
automation devops
</Link> </Link>
. .
</p> </p>
<p className="my-3 text-base leading-relaxed md:text-[0.975rem]"> <p>
I fell in love with{" "} I fell in love with{" "}
<Link <Link href="/previously" prefetch={false} title="My Terrible, Horrible, No Good, Very Bad First Websites">
href="/previously"
prefetch={false}
title="My Terrible, Horrible, No Good, Very Bad First Websites"
className="[--primary:#4169e1] dark:[--primary:#8ca9ff]"
>
frontend web design frontend web design
</Link>{" "} </Link>{" "}
and{" "} and{" "}
<Link <Link href="/notes/my-first-code" prefetch={false} title="Jake's Bulletin Board, circa 2003">
href="/notes/my-first-code"
prefetch={false}
title="Jake's Bulletin Board, circa 2003"
className="[--primary:#9932cc] dark:[--primary:#d588fb]"
>
backend coding backend coding
</Link>{" "} </Link>{" "}
when my only source of income was{" "} when my only source of income was{" "}
@@ -113,131 +54,61 @@ const Page = () => {
href="/birthday" href="/birthday"
prefetch={false} prefetch={false}
title="🎉 Cranky Birthday Boy on VHS Tape 📼" title="🎉 Cranky Birthday Boy on VHS Tape 📼"
className="[--primary:#e40088] dark:[--primary:#fd40b1]"
style={{ style={{
cursor: `url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='24' height='30' style='font-size:24px'><text y='50%' transform='rotate(-70 0 0) translate(-20, 6)'>🪄</text></svg>") 5 5, auto`, cursor: `url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='24' height='30' style='font-size:24px'><text y='50%' transform='rotate(-70 0 0) translate(-20, 6)'>🪄</text></svg>") 5 5, auto`,
}} }}
className="font-normal no-underline"
> >
the Tooth Fairy the Tooth Fairy
</Link> </Link>
. <span className="text-muted-foreground">I&rsquo;ve improved a bit since then, I think?</span> . <span className="text-muted-foreground">(I&rsquo;ve improved a bit since then, I think?)</span>
</p> </p>
<p className="my-3 text-base leading-relaxed md:text-[0.975rem]"> <p>
I&rsquo;m currently building{" "} I&rsquo;m currently building{" "}
<Link <Link href="https://domainstack.io" title="Domainstack: Domain intelligence made easy" className="font-medium">
href="https://domainstack.io"
title="Domainstack: Domain intelligence made easy"
className="font-medium [--primary:#a054d0] dark:[--primary:#dd9fff]"
>
Domainstack Domainstack
</Link> </Link>
, a beautiful all-in-one domain name intelligence tool, and{" "} , a beautiful all-in-one domain name intelligence tool, and{" "}
<Link <Link
href="https://snoozle.ai" href="https://snoozle.ai"
title="Snoozle: AI-powered bedtime stories for children" title="Snoozle: AI-powered bedtime stories for children"
className="bg-gradient-to-r from-purple-500 to-pink-500 bg-clip-text font-medium text-transparent [--primary:#a855f7] dark:from-purple-400 dark:to-pink-400 dark:[--primary:#c084fc]" className="font-medium"
> >
Snoozle Snoozle
</Link> </Link>
, an AI-powered bedtime story generator. Over the years, some of my other side projects{" "} , an AI-powered bedtime story generator.
<Link
href="/leo"
prefetch={false}
title="Powncer segment on The Lab with Leo Laporte (G4techTV)"
className="[--primary:#ff1b1b] dark:[--primary:#f06060]"
>
have
</Link>{" "}
<Link
href="https://tuftsdaily.com/news/2012/04/06/student-designs-iphone-joeytracker-app/"
title='"Student designs iPhone JoeyTracker app" on The Tufts Daily'
className="[--primary:#f78200] dark:[--primary:#fd992a]"
>
been
</Link>{" "}
<Link
href="https://www.google.com/books/edition/The_Facebook_Effect/RRUkLhyGZVgC?hl=en&gbpv=1&dq=%22jake%20jarvis%22&pg=PA226&printsec=frontcover&bsq=%22jake%20jarvis%22"
title='"The Facebook Effect" by David Kirkpatrick (Google Books)'
className="[--primary:#f2b702] dark:[--primary:#ffcc2e]"
>
featured
</Link>{" "}
<Link
href="https://money.cnn.com/2007/06/01/technology/facebookplatform.fortune/index.htm"
title='"The new Facebook is on a roll" on CNN Money'
className="[--primary:#5ebd3e] dark:[--primary:#78df55]"
>
by
</Link>{" "}
<Link
href="https://www.wired.com/2007/04/our-web-servers/"
title='"Middio: A YouTube Scraper for Major Label Music Videos" on Wired'
className="[--primary:#009cdf] dark:[--primary:#29bfff]"
>
various
</Link>{" "}
<Link
href="https://gigaom.com/2009/10/06/fresh-faces-in-tech-10-kid-entrepreneurs-to-watch/6/"
title='"Fresh Faces in Tech: 10 Kid Entrepreneurs to Watch" on Gigaom'
className="[--primary:#3e49bb] dark:[--primary:#7b87ff]"
>
media
</Link>{" "}
<Link
href="https://adage.com/article/small-agency-diary/client-ceo-s-son/116723/"
title='"Your Next Client? The CEO&#39;s Son" on Advertising Age'
className="[--primary:#973999] dark:[--primary:#db60dd]"
>
outlets
</Link>
.
</p> </p>
<p className="mt-3 mb-0 text-base leading-relaxed md:text-[0.975rem]"> <p className="mt-2 mb-0 text-sm leading-normal">
You can find my work on{" "} You can find my work on{" "}
<Link href="https://github.com/jakejarvis" rel="me" className="[--primary:#8d4eff] dark:[--primary:#a379f0]"> <Link href="https://github.com/jakejarvis" rel="me">
GitHub GitHub
</Link>{" "} </Link>{" "}
and{" "} and{" "}
<Link <Link href="https://www.linkedin.com/in/jakejarvis/" rel="me">
href="https://www.linkedin.com/in/jakejarvis/"
rel="me"
className="[--primary:#0073b1] dark:[--primary:#3b9dd2]"
>
LinkedIn LinkedIn
</Link> </Link>
. I&rsquo;m always available to connect over{" "} . I&rsquo;m always available to connect over{" "}
<Link <Link href="/contact" prefetch={false} title="Send an email">
href="/contact"
prefetch={false}
title="Send an email"
className="[--primary:#de0c0c] dark:[--primary:#ff5050]"
>
email email
</Link>{" "} </Link>{" "}
<sup className="mr-0.5 text-[0.6rem]"> <sup className="">
<Link <Link
href="https://jrvs.io/pgp" href="https://jrvs.io/pgp"
rel="pgpkey" rel="pgpkey"
title="3BC6 E577 6BF3 79D3 6F67 1480 2B0C 9CF2 51E6 9A39" title="Download my PGP key"
className="space-x-0.5 px-0.5 text-nowrap [--primary:var(--muted-foreground)] hover:no-underline" className="not-prose text-muted-foreground hover:text-primary space-x-1 px-0.5 text-nowrap no-underline hover:no-underline"
> >
<LockIcon className="inline size-3 align-text-top" />{" "} <LockIcon className="inline size-2.5" aria-hidden="true" />
<code className="tracking-wider text-wrap [word-spacing:-3px]">2B0C 9CF2 51E6 9A39</code> <code className="text-[9px] leading-none tracking-wider text-wrap [word-spacing:-3px]">
2B0C 9CF2 51E6 9A39
</code>
</Link> </Link>
</sup> </sup>{" "}
,{" "} as well.
<Link href="https://bsky.app/profile/jarv.is" rel="me" className="[--primary:#0085ff] dark:[--primary:#208bfe]">
Bluesky
</Link>
, or{" "}
<Link href="https://fediverse.jarv.is/@jake" rel="me" className="[--primary:#6d6eff] dark:[--primary:#7b87ff]">
Mastodon
</Link>{" "}
as well!
</p> </p>
</> </div>
); );
}; };
+9 -3
View File
@@ -11,6 +11,7 @@ import { GitHubIcon } from "@/components/icons";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { createMetadata } from "@/lib/metadata"; import { createMetadata } from "@/lib/metadata";
import { getContributions, getRepos } from "./github"; import { getContributions, getRepos } from "./github";
import Button from "@/components/ui/button";
export const metadata = createMetadata({ export const metadata = createMetadata({
title: "Projects", title: "Projects",
@@ -122,9 +123,14 @@ const Page = async () => {
)} )}
<p className="mt-6 mb-0 text-center text-base font-medium"> <p className="mt-6 mb-0 text-center text-base font-medium">
<Link href={`https://github.com/${env.NEXT_PUBLIC_GITHUB_USERNAME}`} className="hover:no-underline"> <Button variant="secondary" asChild>
View more on <GitHubIcon className="fill-foreground/80 mx-0.5 inline size-5 align-text-top" /> GitHub. <Link
</Link> href={`https://github.com/${env.NEXT_PUBLIC_GITHUB_USERNAME}?tab=repositories&type=source&sort=stargazers`}
>
<GitHubIcon />
<span className="leading-none">Show All</span>
</Link>
</Button>
</p> </p>
</> </>
); );
+21 -49
View File
@@ -1,79 +1,51 @@
import { CodeIcon, TerminalIcon } from "lucide-react"; import { codeToHtml } from "shiki";
import { cacheLife } from "next/cache"; import { cacheLife } from "next/cache";
import CopyButton from "@/components/copy-button"; import CopyButton from "@/components/copy-button";
import { cn } from "@/lib/utils";
import reactToText from "react-to-text"; import reactToText from "react-to-text";
import { codeToHtml } from "shiki";
interface CodeBlockProps extends React.ComponentProps<"pre"> { interface CodeBlockProps extends React.ComponentProps<"pre"> {
showLineNumbers?: boolean; showLineNumbers?: boolean;
showCopyButton?: boolean;
} }
const renderHighlightedCode = async (codeString: string, lang: string) => { const renderCode = async (code: string, lang: string): Promise<string> => {
"use cache"; "use cache";
cacheLife("max"); cacheLife("max");
const html = await codeToHtml(codeString, { return codeToHtml(code, {
lang, lang,
themes: { themes: { light: "github-light", dark: "github-dark" },
light: "github-light",
dark: "github-dark",
},
}); });
return html;
}; };
const CodeBlock = async (props: CodeBlockProps) => { const CodeBlock = async ({ children, className, showLineNumbers = true, ...props }: CodeBlockProps) => {
const { showLineNumbers = false, showCopyButton = true, children, className } = props; // Escape hatch for non-code pre blocks
// escape hatch if this code wasn't meant to be highlighted
if (!children || typeof children !== "object" || !("props" in children)) { if (!children || typeof children !== "object" || !("props" in children)) {
return <pre {...props}>{children}</pre>; return (
<pre className={className} {...props}>
{children}
</pre>
);
} }
const codeProps = children.props as React.ComponentProps<"code">; const codeProps = children.props as React.ComponentProps<"code">;
const codeString = reactToText(codeProps.children).trim(); const codeString = reactToText(codeProps.children).trim();
const lang = codeProps.className?.split("language-")[1] ?? "text";
// the language set in the markdown is passed as a className const html = await renderCode(codeString, lang);
const lang = codeProps.className?.split("language-")[1] ?? "";
const html = await renderHighlightedCode(codeString, lang);
return ( return (
<div className={cn("bg-muted/35 relative isolate rounded-lg border-2 font-mono shadow", className)}> <div className="group not-prose relative">
<CopyButton
value={codeString}
className="absolute top-2 right-2 z-10 opacity-0 transition-opacity group-hover:opacity-100"
/>
<div <div
className={cn( data-slot="code-block"
"grid max-h-[500px] w-full overflow-x-auto overscroll-x-none p-4 **:bg-transparent! data-language:pt-10 md:max-h-[650px] dark:**:text-[var(--shiki-dark)]! [&_pre]:whitespace-normal", data-lang={lang}
"[&_.line]:inline-block [&_.line]:min-w-full [&_.line]:py-1 [&_.line]:leading-none [&_.line]:whitespace-pre [&_.line]:after:hidden",
"data-line-numbers:[&_.line]:before:text-muted-foreground data-line-numbers:[counter-reset:line] data-line-numbers:[&_.line]:[counter-increment:line] data-line-numbers:[&_.line]:before:mr-5 data-line-numbers:[&_.line]:before:inline-block data-line-numbers:[&_.line]:before:w-5 data-line-numbers:[&_.line]:before:text-right data-line-numbers:[&_.line]:before:content-[counter(line)]"
)}
data-language={lang || undefined}
data-line-numbers={showLineNumbers || undefined} data-line-numbers={showLineNumbers || undefined}
className={className}
dangerouslySetInnerHTML={{ __html: html }} dangerouslySetInnerHTML={{ __html: html }}
/> />
{lang && (
<span className="[&_svg]:stroke-primary/90 text-foreground/75 bg-muted/40 absolute top-0 left-0 z-10 flex items-center gap-[8px] rounded-tl-md rounded-br-lg border-r-2 border-b-2 px-[10px] py-[5px] font-mono text-xs font-medium tracking-wide uppercase backdrop-blur-sm select-none [&_svg]:size-[14px] [&_svg]:shrink-0">
{["sh", "bash", "zsh", "shell"].includes(lang) ? (
<>
<TerminalIcon />
<span>Shell</span>
</>
) : (
<>
<CodeIcon />
<span>{lang}</span>
</>
)}
</span>
)}
{showCopyButton && (
<CopyButton
source={codeString}
className="text-foreground/75 hover:text-primary bg-muted/40 absolute top-0 right-0 z-10 size-10 rounded-tr-md rounded-bl-lg border-b-2 border-l-2 p-0 backdrop-blur-sm select-none [&_svg]:my-auto [&_svg]:inline-block [&_svg]:size-4.5 [&_svg]:align-text-bottom"
/>
)}
</div> </div>
); );
}; };
+50 -44
View File
@@ -1,59 +1,65 @@
"use client"; "use client";
import { useState, useEffect } from "react"; import * as React from "react";
import copy from "copy-to-clipboard"; import copy from "copy-to-clipboard";
import { ClipboardIcon, CheckIcon } from "lucide-react"; import { CheckIcon, CopyIcon } from "lucide-react";
import Button from "@/components/ui/button";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
const CopyButton = ({ function CopyButton({
source, value,
timeout = 2000,
className, className,
...rest variant = "ghost",
}: React.ComponentProps<"button"> & { tooltip = "Copy to Clipboard",
source: string; ...props
timeout?: number; }: React.ComponentProps<typeof Button> & {
}) => { value: string;
const [copied, setCopied] = useState(false); tooltip?: string;
}) {
const [hasCopied, setHasCopied] = React.useState(false);
const timeoutRef = React.useRef<NodeJS.Timeout | undefined>(undefined);
const handleCopy: React.MouseEventHandler<React.ComponentRef<"button">> = (e) => { React.useEffect(() => {
// prevent unintentional double-clicks by unfocusing button return () => {
e.currentTarget.blur(); if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
};
}, []);
// send plaintext to the clipboard const handleCopy = () => {
const didCopy = copy(source); copy(value);
setHasCopied(true);
// indicate success if (timeoutRef.current) {
setCopied(didCopy); clearTimeout(timeoutRef.current);
}
timeoutRef.current = setTimeout(() => setHasCopied(false), 2000);
}; };
useEffect(() => {
if (!copied) {
return;
}
// reset to original icon after given ms (defaults to 2 seconds)
const reset = setTimeout(() => {
setCopied(false);
}, timeout);
// cancel timeout to avoid memory leaks if unmounted in the middle of this
return () => {
clearTimeout(reset);
};
}, [timeout, copied]);
return ( return (
<button <Tooltip>
onClick={handleCopy} <TooltipTrigger asChild>
disabled={copied} <Button
className={cn("cursor-pointer disabled:cursor-default", className)} data-slot="copy-button"
{...rest} data-copied={hasCopied}
> size="icon"
{copied ? <CheckIcon className="stroke-success" /> : <ClipboardIcon />} variant={variant}
<span className="sr-only">{copied ? "Copied" : "Copy to clipboard"}</span> className={cn(
</button> "bg-code absolute top-3 right-2 z-10 size-7 hover:opacity-100 focus-visible:opacity-100",
className
)}
onClick={handleCopy}
aria-label={hasCopied ? "Copied" : tooltip}
{...props}
>
{hasCopied ? <CheckIcon aria-hidden="true" /> : <CopyIcon aria-hidden="true" />}
</Button>
</TooltipTrigger>
<TooltipContent>{hasCopied ? "Copied" : tooltip}</TooltipContent>
</Tooltip>
); );
}; }
export default CopyButton; export default CopyButton;
+18 -46
View File
@@ -1,54 +1,26 @@
import { env } from "@/lib/env"; import { env } from "@/lib/env";
import { HeartIcon } from "lucide-react";
import Link from "@/components/link"; import Link from "@/components/link";
import { NextjsIcon } from "@/components/icons";
import { cn } from "@/lib/utils";
import siteConfig from "@/lib/config/site"; import siteConfig from "@/lib/config/site";
const Footer = ({ className, ...rest }: React.ComponentProps<"footer">) => { const Footer = () => {
return ( return (
<footer <footer className="text-muted-foreground mt-8 w-full py-6 text-center text-[13px] leading-loose">
className={cn("text-foreground/85 text-[0.8rem] leading-loose md:flex md:flex-row md:justify-between", className)} Content{" "}
{...rest} <Link href="/license" prefetch={false}>
> licensed under {siteConfig.license}
<div> </Link>
Content{" "} ,{" "}
<Link href="/license" prefetch={false} className="text-foreground/85 hover:no-underline"> <Link href="/previously" prefetch={false} title="Previously on...">
licensed under {siteConfig.license} {siteConfig.copyrightYearStart}
</Link> </Link>{" "}
,{" "} 2026.{" "}
<Link <Link
href="/previously" href={`https://github.com/${env.NEXT_PUBLIC_GITHUB_REPO}`}
prefetch={false} title="View Source on GitHub"
title="Previously on..." className="font-medium underline underline-offset-4"
className="text-foreground/85 hover:no-underline" >
> View source.
{siteConfig.copyrightYearStart} </Link>
</Link>{" "}
2025.
</div>
<div>
Made with{" "}
<HeartIcon className="animate-heartbeat stroke-destructive fill-destructive mx-px inline size-4 align-text-top" />{" "}
and{" "}
<Link
href="https://nextjs.org/"
title="Powered by Next.js"
aria-label="Next.js"
className="text-foreground/85 hover:text-foreground/60 hover:no-underline"
>
<NextjsIcon className="mx-px inline size-4 align-text-top" />
</Link>
.{" "}
<Link
href={`https://github.com/${env.NEXT_PUBLIC_GITHUB_REPO}`}
title="View Source on GitHub"
className="border-muted-foreground text-foreground/85 hover:border-muted-foreground/60 border-b-1 pb-0.5 hover:no-underline"
>
View source.
</Link>
</div>
</footer> </footer>
); );
}; };
+78 -24
View File
@@ -1,36 +1,90 @@
"use client";
import { useState, useEffect } from "react";
import { useTheme } from "next-themes";
import Image from "next/image"; import Image from "next/image";
import Link from "@/components/link"; import Link from "@/components/link";
import Button from "@/components/ui/button";
import Separator from "@/components/ui/separator";
import Menu from "@/components/layout/menu"; import Menu from "@/components/layout/menu";
import { GitHubIcon } from "@/components/icons";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import authorConfig from "@/lib/config/author";
import siteConfig from "@/lib/config/site"; import siteConfig from "@/lib/config/site";
import { MoonIcon, SunIcon } from "lucide-react";
import avatarImg from "@/app/avatar.jpg"; import avatarImg from "@/app/avatar.jpg";
const Header = ({ className, ...rest }: React.ComponentProps<"header">) => { const Header = ({ className }: { className?: string }) => {
return ( const [isScrolled, setIsScrolled] = useState(false);
<header className={cn("flex items-center justify-between", className)} {...rest}> const { theme, setTheme } = useTheme();
<Link
href="/"
rel="author"
aria-label={siteConfig.name}
className="hover:text-primary text-foreground/85 flex shrink-0 items-center hover:no-underline"
>
<Image
src={avatarImg}
alt={`Photo of ${siteConfig.name}`}
className="border-ring/40 size-[64px] rounded-full border-2 md:size-[48px] md:border-1"
width={64}
height={64}
quality={50}
priority
/>
<span className="mx-3 text-xl leading-none font-medium tracking-[0.01rem] max-md:sr-only">
{siteConfig.name}
</span>
</Link>
<Menu className="w-full max-w-64 sm:max-w-96 md:max-w-none" /> useEffect(() => {
</header> const handleScroll = () => {
setIsScrolled(window.scrollY > 10);
};
// Check initial scroll position
handleScroll();
window.addEventListener("scroll", handleScroll, { passive: true });
return () => window.removeEventListener("scroll", handleScroll);
}, []);
return (
<div
data-scrolled={isScrolled}
className={cn(
"sticky top-0 z-50 w-full",
"motion-safe:transition-[background-color,backdrop-filter,border-color] motion-safe:duration-200",
"bg-background/0 backdrop-blur-none",
"data-[scrolled=true]:bg-background/80 data-[scrolled=true]:backdrop-blur-md",
"data-[scrolled=true]:border-border/50 data-[scrolled=true]:border-b",
className
)}
>
<header className="mx-auto flex w-full max-w-4xl items-center justify-between px-5 py-4">
<div className="flex items-center gap-3">
<Link
href="/"
rel="author"
aria-label={siteConfig.name}
className="hover:text-foreground/85 flex shrink-0 items-center gap-2.5 pr-2 hover:no-underline"
>
<Image
src={avatarImg}
alt={`Photo of ${siteConfig.name}`}
className="border-ring/30 size-[40px] rounded-full border md:size-[32px]"
width={40}
height={40}
quality={75}
priority
/>
<span className="text-[17px] font-medium whitespace-nowrap max-md:sr-only">{siteConfig.name}</span>
</Link>
<Separator orientation="vertical" className="!h-6" />
<Menu />
</div>
<div className="flex items-center gap-2">
<Button variant="ghost" size="sm" aria-label="Open GitHub profile" asChild>
<Link href={`https://github.com/${authorConfig.social.github}`}>
<GitHubIcon />
</Link>
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => setTheme(theme === "light" ? "dark" : "light")}
aria-label="Toggle theme"
className="group"
>
<SunIcon className="group-hover:stroke-orange-600 dark:hidden" aria-hidden="true" />
<MoonIcon className="not-dark:hidden group-hover:stroke-yellow-400" aria-hidden="true" />
</Button>
</div>
</header>
</div>
); );
}; };
-48
View File
@@ -1,48 +0,0 @@
import Link from "@/components/link";
import { cn } from "@/lib/utils";
const MenuItem = ({
text,
href,
icon,
current,
className,
...rest
}: React.ComponentProps<"div"> & {
text?: string;
href?: `/${string}`;
icon: React.ReactNode;
current?: boolean;
}) => {
const item = (
<div
className={cn(
"[&_svg]:stroke-foreground/85 inline-flex items-center [&_svg]:size-7 [&_svg]:md:size-5",
className
)}
{...rest}
>
{icon}
{text && <span className="ml-3 text-sm leading-none font-medium tracking-wide max-md:sr-only">{text}</span>}
</div>
);
// allow both navigational links and/or other interactive react components (e.g. the theme toggle)
if (href) {
return (
<Link
href={href}
prefetch={false}
aria-label={text}
data-current={current || undefined}
className="text-foreground/85 hover:border-b-ring/80 data-current:border-b-primary/60 inline-flex items-center hover:no-underline"
>
{item}
</Link>
);
}
return item;
};
export default MenuItem;
+11 -31
View File
@@ -1,58 +1,38 @@
"use client"; "use client";
import { useSelectedLayoutSegment } from "next/navigation"; import { useSelectedLayoutSegment } from "next/navigation";
import MenuItem from "@/components/layout/menu-item"; import Button from "@/components/ui/button";
import ThemeToggle from "@/components/theme/theme-toggle"; import Link from "@/components/link";
import { cn } from "@/lib/utils";
import { HomeIcon, PencilLineIcon, CodeXmlIcon, MailIcon } from "lucide-react";
const menuItems: React.ComponentProps<typeof MenuItem>[] = [ const menuItems = [
{
text: "Home",
href: "/",
icon: <HomeIcon />,
},
{ {
text: "Notes", text: "Notes",
href: "/notes", href: "/notes",
icon: <PencilLineIcon />,
}, },
{ {
text: "Projects", text: "Projects",
href: "/projects", href: "/projects",
icon: <CodeXmlIcon />,
}, },
{ {
text: "Contact", text: "Contact",
href: "/contact", href: "/contact",
icon: <MailIcon />,
}, },
{ ] as const;
icon: <ThemeToggle />,
},
];
const Menu = ({ className, ...rest }: React.ComponentProps<"div">) => { const Menu = () => {
const segment = useSelectedLayoutSegment() || ""; const segment = useSelectedLayoutSegment() || "";
return ( return (
<div <div className="flex items-center gap-2">
className={cn(
"flex max-w-2/3 flex-row items-center justify-between md:max-w-none md:justify-end md:gap-4",
className
)}
{...rest}
>
{menuItems.map((item, index) => { {menuItems.map((item, index) => {
const isCurrent = item.href?.split("/")[1] === segment; const isCurrent = item.href?.split("/")[1] === segment;
return ( return (
<div <Button key={index} variant="ghost" size="sm" asChild>
className="inline-flex items-center last:-mr-2.5 max-sm:first:hidden **:[a,button]:border-y-3 **:[a,button]:border-y-transparent **:[a,button]:p-2.5" <Link href={item.href} prefetch={false} aria-label={item.text} data-current={isCurrent}>
key={index} {item.text}
> </Link>
<MenuItem {...item} current={isCurrent} /> </Button>
</div>
); );
})} })}
</div> </div>
+4 -1
View File
@@ -10,7 +10,10 @@ const PageTitle = ({
canonical: string; canonical: string;
}) => { }) => {
return ( return (
<h1 className={cn("mt-0 mb-6 text-left text-3xl font-medium tracking-[-0.015em] lowercase", className)} {...rest}> <h1
className={cn("mt-0 mb-6 text-left text-4xl font-semibold tracking-tight lowercase sm:text-3xl", className)}
{...rest}
>
<Link <Link
href={canonical} href={canonical}
className="before:text-muted-foreground before:-mr-0.5 before:tracking-widest before:content-['\002E\002F'] hover:no-underline" className="before:text-muted-foreground before:-mr-0.5 before:tracking-widest before:content-['\002E\002F'] hover:no-underline"
+1 -6
View File
@@ -1,7 +1,6 @@
import NextLink from "next/link"; import NextLink from "next/link";
import { cn } from "@/lib/utils";
const Link = ({ href, rel, target, className, ...rest }: React.ComponentProps<typeof NextLink>) => { const Link = ({ href, rel, target, ...rest }: React.ComponentProps<typeof NextLink>) => {
// This component auto-detects whether or not this link should open in the same window (the default for internal // This component auto-detects whether or not this link should open in the same window (the default for internal
// links) or a new tab (the default for external links). Defaults can be overridden with `target="_blank"`. // links) or a new tab (the default for external links). Defaults can be overridden with `target="_blank"`.
const isExternal = typeof href === "string" && !["/", "#"].includes(href[0]); const isExternal = typeof href === "string" && !["/", "#"].includes(href[0]);
@@ -10,10 +9,6 @@ const Link = ({ href, rel, target, className, ...rest }: React.ComponentProps<ty
href, href,
target: target || (isExternal ? "_blank" : undefined), target: target || (isExternal ? "_blank" : undefined),
rel: `${rel ? `${rel} ` : ""}${target === "_blank" || isExternal ? "noopener noreferrer" : ""}`.trim() || undefined, rel: `${rel ? `${rel} ` : ""}${target === "_blank" || isExternal ? "noopener noreferrer" : ""}`.trim() || undefined,
className: cn(
"text-primary decoration-primary/40 no-underline decoration-2 underline-offset-4 hover:underline",
className
),
...rest, ...rest,
}; };
+3 -11
View File
@@ -1,19 +1,11 @@
"use client"; "use client";
import { ThemeProvider } from "@/components/theme/theme-context"; import { ThemeProvider } from "next-themes";
import { ProgressProvider } from "@bprogress/next/app";
const Providers = ({ children }: { children: React.ReactNode }) => { const Providers = ({ children }: { children: React.ReactNode }) => {
return ( return (
<ThemeProvider> <ThemeProvider attribute="class" defaultTheme="system" enableSystem disableTransitionOnChange>
<ProgressProvider {children}
height="calc(var(--spacing) * 1)"
color="var(--primary)"
options={{ showSpinner: false }}
shallowRouting
>
{children}
</ProgressProvider>
</ThemeProvider> </ThemeProvider>
); );
}; };
-48
View File
@@ -1,48 +0,0 @@
"use client";
import { createContext, useEffect } from "react";
import { useLocalStorage, useMedia } from "react-use";
export const ThemeContext = createContext<{
/**
* If the user's theme preference is unset, this returns whether the system preference resolved to "light" or "dark".
* If the user's theme preference is set, the preference is returned instead, regardless of their system's theme.
*/
theme: string;
/** Update the theme manually and save to local storage. */
setTheme: (theme: string) => void;
}>({
theme: "",
// eslint-disable-next-line @typescript-eslint/no-unused-vars
setTheme: (_) => {},
});
// provider used once in _app.tsx to wrap entire app
export const ThemeProvider = ({ children }: React.PropsWithChildren) => {
// keep track of if/when the user has set their theme *on this site*
const [preferredTheme, setPreferredTheme] = useLocalStorage<string>("theme", undefined, { raw: true });
// hook into system `prefers-color-scheme` setting
// https://web.dev/prefers-color-scheme/#the-prefers-color-scheme-media-query
const isSystemDark = useMedia("(prefers-color-scheme: dark)", false);
// Derive system theme directly from media query to avoid setState in effect
const systemTheme = isSystemDark ? "dark" : "light";
// actual DOM updates must be done in useEffect
useEffect(() => {
// only "light" and "dark" are valid themes
const resolvedTheme = preferredTheme && ["light", "dark"].includes(preferredTheme) ? preferredTheme : systemTheme;
// this is what actually changes the CSS variables
document.documentElement.dataset.theme = resolvedTheme;
// less important, but tells the browser how to render built-in elements like forms, scrollbars, etc.
document.documentElement.style?.setProperty("color-scheme", resolvedTheme);
}, [preferredTheme, systemTheme]);
const providerValues = {
theme: preferredTheme ?? systemTheme,
setTheme: setPreferredTheme,
};
return <ThemeContext.Provider value={providerValues}>{children}</ThemeContext.Provider>;
};
-12
View File
@@ -1,12 +0,0 @@
// loaded in <head> by layout.tsx to avoid blinding flash of unstyled content (FOUC). irrelevant after the first render
// when <ThemeProvider /> takes over.
// unminified JS: https://gist.github.com/jakejarvis/79b0ec8506bc843023546d0d29861bf0
export const ThemeScript = () => (
<script
id="restore-theme"
dangerouslySetInnerHTML={{
__html:
"(()=>{try{const e=document.documentElement,t='undefined'!=typeof Storage?window.localStorage.getItem('theme'):null,a=(t&&'dark'===t)??window.matchMedia('(prefers-color-scheme: dark)').matches?'dark':'light';e.dataset.theme=a,e.style.colorScheme=a}catch(e){}})()",
}}
/>
);
-25
View File
@@ -1,25 +0,0 @@
"use client";
import { useContext } from "react";
import { MoonIcon, SunIcon } from "lucide-react";
import { ThemeContext } from "@/components/theme/theme-context";
import { cn } from "@/lib/utils";
const ThemeToggle = ({ className, ...rest }: React.ComponentProps<"button">) => {
const { theme, setTheme } = useContext(ThemeContext);
return (
<button
onClick={() => setTheme(theme === "light" ? "dark" : "light")}
aria-label="Toggle theme"
className={cn("hover:[&_svg]:stroke-warning block cursor-pointer bg-transparent", className)}
{...rest}
>
<SunIcon className="dark:hidden" />
<MoonIcon className="not-dark:hidden" />
<span className="sr-only">Toggle theme</span>
</button>
);
};
export default ThemeToggle;
+24 -101
View File
@@ -1,9 +1,8 @@
import NextImage from "next/image"; import NextImage from "next/image";
import Link from "@/components/link"; import Link from "@/components/link";
import CodeBlock from "@/components/code-block"; import CodeBlock from "@/components/code-block";
import HeadingAnchor from "@/components/heading-anchor";
import Video from "@/components/video"; import Video from "@/components/video";
import ImageDiff from "./components/image-diff"; import ImageDiff from "@/components/image-diff";
import Tweet from "@/components/third-party/tweet"; import Tweet from "@/components/third-party/tweet";
import YouTube from "@/components/third-party/youtube"; import YouTube from "@/components/third-party/youtube";
import Gist from "@/components/third-party/gist"; import Gist from "@/components/third-party/gist";
@@ -15,106 +14,30 @@ export const useMDXComponents = (components: MDXComponents): MDXComponents => {
return { return {
...components, ...components,
a: Link, a: Link,
pre: ({ className, ...rest }) => <CodeBlock className={cn("my-5 w-full font-mono text-sm", className)} {...rest} />, pre: CodeBlock,
code: ({ className, ...rest }) => ( img: ({ src, className, ...rest }) => {
// only applies to inline code, *not* highlighted code blocks! const imageWidth = typeof src === "object" && "width" in src && src.width > 896 ? 896 : undefined;
<code const imageHeight =
className={cn("bg-muted relative rounded-sm px-[0.3rem] py-[0.2rem] text-sm font-medium", className)} imageWidth && typeof src === "object" && "width" in src && "height" in src
{...rest} ? Math.round((src.height / src.width) * imageWidth)
/> : undefined;
),
img: ({ src, className, ...rest }) => (
<NextImage
src={src}
width={typeof src === "object" && "width" in src && src.width > 896 ? 896 : undefined} // => var(--container-4xl)
className={cn(
"mx-auto my-8 block h-auto max-w-full rounded-md",
"[&+em]:text-muted-foreground [&+em]:-mt-4 [&+em]:block [&+em]:text-center [&+em]:text-[0.875em] [&+em]:leading-normal [&+em]:font-medium [&+em]:not-italic",
className
)}
{...rest}
/>
),
figure: ({ className, ...rest }) => <figure className={cn("my-8 *:my-0", className)} {...rest} />,
figcaption: ({ className, ...rest }) => (
<figcaption className={cn("text-muted-foreground mt-3.5 text-[0.875em] leading-snug", className)} {...rest} />
),
blockquote: ({ className, ...rest }) => (
<blockquote className={cn("text-muted-foreground mt-6 border-l-4 pl-4", className)} {...rest} />
),
h1: ({ className, id, children, ...rest }) => (
<h1
className={cn(
"group mt-6 mb-4 scroll-mt-4 text-3xl leading-snug font-extrabold md:text-4xl [&_strong]:font-black [&+*]:mt-0",
className
)}
id={id}
tabIndex={-1}
{...rest}
>
{children}
{id && <HeadingAnchor id={id} title={children} className="opacity-0 group-hover:opacity-100 max-md:hidden" />}
</h1>
),
h2: ({ className, id, children, ...rest }) => (
<h2
className={cn(
"group mt-6 mb-4 scroll-mt-4 text-xl leading-snug font-bold first:mt-0 md:text-2xl [&_code]:text-[0.875em] [&_strong]:font-extrabold [&+*]:mt-0",
className
)}
id={id}
tabIndex={-1}
{...rest}
>
{children}
{id && <HeadingAnchor id={id} title={children} className="opacity-0 group-hover:opacity-100 max-md:hidden" />}
</h2>
),
h3: ({ className, id, children, ...rest }) => (
<h3
className={cn(
"group mt-6 mb-4 scroll-mt-4 text-lg leading-relaxed font-semibold md:text-xl [&_code]:text-[0.9em] [&_strong]:font-bold [&+*]:mt-0",
className
)}
id={id}
tabIndex={-1}
{...rest}
>
{children}
{id && <HeadingAnchor id={id} title={children} className="opacity-0 group-hover:opacity-100 max-md:hidden" />}
</h3>
),
h4: ({ className, ...rest }) => (
<h4
className={cn(
"mt-6 mb-2 scroll-mt-4 text-base leading-normal font-semibold [&_strong]:font-bold [&+*]:mt-0",
className
)}
{...rest}
/>
),
h5: ({ className, ...rest }) => (
<h5 className={cn("mt-6 mb-2 scroll-mt-4 text-base leading-normal font-medium", className)} {...rest} />
),
h6: ({ className, ...rest }) => (
<h6 className={cn("mt-6 mb-2 scroll-mt-4 text-sm leading-normal font-normal", className)} {...rest} />
),
ul: ({ className, ...rest }) => <ul className={cn("my-5 list-disc pl-7 [&>li]:pl-1.5", className)} {...rest} />,
ol: ({ className, ...rest }) => <ol className={cn("my-5 list-decimal pl-7 [&>li]:pl-1.5", className)} {...rest} />,
li: ({ className, ...rest }) => (
<li
className={cn(
"[&::marker]:text-muted-foreground my-0.5 [&::marker]:font-normal [&>ol]:my-1 [&>ul]:my-1",
className
)}
{...rest}
/>
),
hr: ({ className, ...rest }) => (
<hr className={cn("mx-auto my-6 w-11/12 border-t-2 [&+*]:mt-0", className)} {...rest} />
),
// react components and embeds: return (
<NextImage
src={src}
width={imageWidth}
height={imageHeight}
className={cn(
"mx-auto my-8 block h-auto max-w-full rounded-sm",
"[&+em]:text-muted-foreground [&+em]:-mt-4 [&+em]:block [&+em]:text-center [&+em]:text-[0.875em] [&+em]:leading-normal [&+em]:font-medium [&+em]:not-italic",
className
)}
{...rest}
/>
);
},
// React components and embeds:
Video, Video,
ImageDiff, ImageDiff,
Tweet, Tweet,
+7 -1
View File
@@ -32,6 +32,7 @@ const nextConfig = {
}, },
productionBrowserSourceMaps: true, productionBrowserSourceMaps: true,
experimental: { experimental: {
viewTransition: true,
serverActions: { serverActions: {
// fix CSRF errors from tor reverse proxy // fix CSRF errors from tor reverse proxy
allowedOrigins: [ allowedOrigins: [
@@ -156,7 +157,12 @@ const nextPlugins: Array<
rehypePlugins: [ rehypePlugins: [
"rehype-unwrap-images", "rehype-unwrap-images",
"rehype-slug", "rehype-slug",
["rehype-wrapper", { className: "generated" }], [
"rehype-wrapper",
{
className: "prose prose-sm prose-neutral dark:prose-invert max-w-none",
},
],
"rehype-mdx-code-props", "rehype-mdx-code-props",
"rehype-mdx-import-media", "rehype-mdx-import-media",
], ],
+20 -18
View File
@@ -20,13 +20,12 @@
"prepare": "test -d node_modules/husky && husky || echo \"skipping husky\"" "prepare": "test -d node_modules/husky && husky || echo \"skipping husky\""
}, },
"dependencies": { "dependencies": {
"@bprogress/next": "^3.2.12",
"@date-fns/tz": "^1.4.1", "@date-fns/tz": "^1.4.1",
"@date-fns/utc": "^2.1.1", "@date-fns/utc": "^2.1.1",
"@mdx-js/loader": "^3.1.1", "@mdx-js/loader": "^3.1.1",
"@mdx-js/react": "^3.1.1", "@mdx-js/react": "^3.1.1",
"@neondatabase/serverless": "^1.0.2", "@neondatabase/serverless": "^1.0.2",
"@next/mdx": "16.1.0", "@next/mdx": "16.1.6",
"@octokit/graphql": "^9.0.3", "@octokit/graphql": "^9.0.3",
"@octokit/graphql-schema": "^15.26.1", "@octokit/graphql-schema": "^15.26.1",
"@radix-ui/react-alert-dialog": "^1.1.15", "@radix-ui/react-alert-dialog": "^1.1.15",
@@ -44,24 +43,25 @@
"@t3-oss/env-nextjs": "^0.13.10", "@t3-oss/env-nextjs": "^0.13.10",
"@vercel/analytics": "^1.6.1", "@vercel/analytics": "^1.6.1",
"@vercel/speed-insights": "^1.3.1", "@vercel/speed-insights": "^1.3.1",
"better-auth": "^1.4.7", "better-auth": "^1.4.17",
"botid": "^1.5.10", "botid": "^1.5.10",
"cheerio": "^1.1.2", "cheerio": "^1.2.0",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"copy-to-clipboard": "^3.3.3", "copy-to-clipboard": "^3.3.3",
"date-fns": "^4.1.0", "date-fns": "^4.1.0",
"drizzle-orm": "^0.45.1", "drizzle-orm": "^0.45.1",
"fast-glob": "^3.3.3", "fast-glob": "^3.3.3",
"feed": "^5.1.0", "feed": "^5.2.0",
"geist": "^1.5.1", "geist": "^1.5.1",
"html-entities": "^2.6.0", "html-entities": "^2.6.0",
"lucide-react": "0.562.0", "lucide-react": "0.563.0",
"next": "16.1.0", "next": "16.1.6",
"react": "19.2.3", "next-themes": "^0.4.6",
"react-activity-calendar": "^3.0.1", "react": "19.2.4",
"react-activity-calendar": "^3.0.5",
"react-countup": "^6.5.3", "react-countup": "^6.5.3",
"react-dom": "19.2.3", "react-dom": "19.2.4",
"react-lite-youtube-embed": "^3.3.3", "react-lite-youtube-embed": "^3.3.3",
"react-markdown": "^10.1.0", "react-markdown": "^10.1.0",
"react-schemaorg": "^2.0.0", "react-schemaorg": "^2.0.0",
@@ -85,45 +85,47 @@
"remark-rehype": "^11.1.2", "remark-rehype": "^11.1.2",
"remark-smartypants": "^3.0.2", "remark-smartypants": "^3.0.2",
"remark-strip-mdx-imports-exports": "^1.0.1", "remark-strip-mdx-imports-exports": "^1.0.1",
"resend": "^6.6.0", "resend": "^6.9.1",
"server-only": "0.0.1", "server-only": "0.0.1",
"shiki": "^3.20.0", "shiki": "^3.21.0",
"sonner": "^2.0.7", "sonner": "^2.0.7",
"tailwind-merge": "^3.4.0", "tailwind-merge": "^3.4.0",
"tailwindcss": "^4.1.18", "tailwindcss": "^4.1.18",
"tw-animate-css": "^1.4.0", "tw-animate-css": "^1.4.0",
"unified": "^11.0.5", "unified": "^11.0.5",
"zod": "^4.2.1" "zod": "^4.3.6"
}, },
"devDependencies": { "devDependencies": {
"@eslint/eslintrc": "^3.3.3", "@eslint/eslintrc": "^3.3.3",
"@eslint/js": "^9.39.2", "@eslint/js": "^9.39.2",
"@jakejarvis/eslint-config": "^4.0.7", "@jakejarvis/eslint-config": "^4.0.7",
"@tailwindcss/postcss": "^4.1.18", "@tailwindcss/postcss": "^4.1.18",
"@tailwindcss/typography": "^0.5.19",
"@types/hast": "^3.0.4",
"@types/mdx": "^2.0.13", "@types/mdx": "^2.0.13",
"@types/node": "^25.0.3", "@types/node": "^25.0.10",
"@types/react": "19.2.7", "@types/react": "19.2.10",
"@types/react-dom": "19.2.3", "@types/react-dom": "19.2.3",
"babel-plugin-react-compiler": "19.1.0-rc.3", "babel-plugin-react-compiler": "19.1.0-rc.3",
"cross-env": "^10.1.0", "cross-env": "^10.1.0",
"dotenv": "^17.2.3", "dotenv": "^17.2.3",
"drizzle-kit": "^0.31.8", "drizzle-kit": "^0.31.8",
"eslint": "^9.39.2", "eslint": "^9.39.2",
"eslint-config-next": "16.1.0", "eslint-config-next": "16.1.6",
"eslint-config-prettier": "^10.1.8", "eslint-config-prettier": "^10.1.8",
"eslint-plugin-css-modules": "^2.12.0", "eslint-plugin-css-modules": "^2.12.0",
"eslint-plugin-drizzle": "^0.2.3", "eslint-plugin-drizzle": "^0.2.3",
"eslint-plugin-import": "^2.32.0", "eslint-plugin-import": "^2.32.0",
"eslint-plugin-jsx-a11y": "^6.10.2", "eslint-plugin-jsx-a11y": "^6.10.2",
"eslint-plugin-mdx": "^3.6.2", "eslint-plugin-mdx": "^3.6.2",
"eslint-plugin-prettier": "^5.5.4", "eslint-plugin-prettier": "^5.5.5",
"eslint-plugin-react": "^7.37.5", "eslint-plugin-react": "^7.37.5",
"eslint-plugin-react-compiler": "19.1.0-rc.2", "eslint-plugin-react-compiler": "19.1.0-rc.2",
"eslint-plugin-react-hooks": "^7.0.1", "eslint-plugin-react-hooks": "^7.0.1",
"husky": "^9.1.7", "husky": "^9.1.7",
"lint-staged": "^16.2.7", "lint-staged": "^16.2.7",
"postcss": "^8.5.6", "postcss": "^8.5.6",
"prettier": "^3.7.4", "prettier": "^3.8.1",
"prettier-plugin-tailwindcss": "^0.7.2", "prettier-plugin-tailwindcss": "^0.7.2",
"schema-dts": "^1.1.5", "schema-dts": "^1.1.5",
"typescript": "5.9.3" "typescript": "5.9.3"
+1161 -1076
View File
File diff suppressed because it is too large Load Diff