mirror of
https://github.com/jakejarvis/jarv.is.git
synced 2026-04-17 10:28:46 -04:00
2026 Redesign (#2531)
This commit is contained in:
97
.github/copilot-instructions.md
vendored
97
.github/copilot-instructions.md
vendored
@@ -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
AGENTS.md
Normal file
161
AGENTS.md
Normal 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
CLAUDE.md
73
CLAUDE.md
@@ -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
|
||||
@@ -11,35 +11,39 @@ export const metadata = createMetadata({
|
||||
|
||||
const Page = () => {
|
||||
return (
|
||||
<div className="w-full md:mx-auto md:w-2/3">
|
||||
<>
|
||||
<PageTitle canonical="/contact">Contact</PageTitle>
|
||||
|
||||
<p className="my-5 text-[0.925rem] leading-relaxed md:text-base">
|
||||
Fill out this quick form and I’ll get back to you as soon as I can! You can also{" "}
|
||||
<Link href="mailto:jake@jarv.is">email me directly</Link> or send me a direct message on{" "}
|
||||
<Link href="https://bsky.app/profile/jarv.is" className="text-nowrap">
|
||||
🦋 Bluesky
|
||||
</Link>{" "}
|
||||
or{" "}
|
||||
<Link href="https://fediverse.jarv.is/@jake" className="text-nowrap">
|
||||
🦣 Mastodon
|
||||
</Link>
|
||||
.
|
||||
</p>
|
||||
<p className="my-5 text-[0.925rem] leading-relaxed md:text-base">
|
||||
You can grab my public key here:{" "}
|
||||
<Link
|
||||
href="https://jrvs.io/pgp"
|
||||
title="3BC6 E577 6BF3 79D3 6F67 1480 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]"
|
||||
>
|
||||
2B0C 9CF2 51E6 9A39
|
||||
</Link>
|
||||
.
|
||||
</p>
|
||||
<div className="w-full md:mx-auto md:w-2/3">
|
||||
<div className="prose prose-sm prose-neutral dark:prose-invert max-w-none">
|
||||
<p>
|
||||
Fill out this quick form and I’ll get back to you as soon as I can! You can also{" "}
|
||||
<Link href="mailto:jake@jarv.is">email me directly</Link> or send me a direct message on{" "}
|
||||
<Link href="https://bsky.app/profile/jarv.is" className="text-nowrap">
|
||||
Bluesky
|
||||
</Link>{" "}
|
||||
or{" "}
|
||||
<Link href="https://fediverse.jarv.is/@jake" className="text-nowrap">
|
||||
Mastodon
|
||||
</Link>
|
||||
.
|
||||
</p>
|
||||
<p>
|
||||
You can grab my public key here:{" "}
|
||||
<Link
|
||||
href="https://jrvs.io/pgp"
|
||||
title="3BC6 E577 6BF3 79D3 6F67 1480 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]"
|
||||
>
|
||||
2B0C 9CF2 51E6 9A39
|
||||
</Link>
|
||||
.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<ContactForm />
|
||||
</div>
|
||||
<ContactForm />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
314
app/globals.css
314
app/globals.css
@@ -1,66 +1,22 @@
|
||||
@import "tailwindcss";
|
||||
@import "tw-animate-css";
|
||||
@plugin "@tailwindcss/typography";
|
||||
@import "react-lite-youtube-embed/dist/LiteYouTubeEmbed.css";
|
||||
|
||||
@custom-variant dark (&:where([data-theme=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);
|
||||
}
|
||||
@custom-variant dark (&:where(.dark *));
|
||||
|
||||
@theme inline {
|
||||
--font-sans: var(--font-geist-sans);
|
||||
--font-sans--font-feature-settings: "rlig" 1, "calt" 0;
|
||||
--font-mono: var(--font-geist-mono);
|
||||
--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-foreground: var(--foreground);
|
||||
--color-card: var(--card);
|
||||
@@ -75,24 +31,123 @@
|
||||
--color-muted-foreground: var(--muted-foreground);
|
||||
--color-accent: var(--accent);
|
||||
--color-accent-foreground: var(--accent-foreground);
|
||||
--color-highlight: var(--highlight);
|
||||
--color-highlight-foreground: var(--highlight-foreground);
|
||||
--color-destructive: var(--destructive);
|
||||
--color-warning: var(--warning);
|
||||
--color-success: var(--success);
|
||||
--color-destructive-foreground: var(--destructive-foreground);
|
||||
--color-border: var(--border);
|
||||
--color-input: var(--input);
|
||||
--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);
|
||||
--radius-md: calc(var(--radius) - 2px);
|
||||
--radius-lg: var(--radius);
|
||||
--radius-xl: calc(var(--radius) + 4px);
|
||||
:root {
|
||||
--radius: 0.625rem;
|
||||
--background: oklch(1 0 0);
|
||||
--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 {
|
||||
--animate-wave: wave 5s ease 1s infinite;
|
||||
--animate-heartbeat: heartbeat 10s ease 7.5s infinite;
|
||||
--animate-marquee: marquee 30s linear infinite;
|
||||
|
||||
@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 {
|
||||
from {
|
||||
transform: translateX(0);
|
||||
@@ -147,9 +187,11 @@
|
||||
* {
|
||||
@apply border-border outline-ring/50;
|
||||
}
|
||||
|
||||
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 {
|
||||
@@ -169,10 +211,114 @@
|
||||
}
|
||||
|
||||
@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 */
|
||||
.generated {
|
||||
@apply text-[0.925rem] leading-relaxed first:mt-0 last:mb-0 md:text-base [&_p]:my-5;
|
||||
/* Override shiki inline styles */
|
||||
& pre {
|
||||
@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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { env } from "@/lib/env";
|
||||
import { JsonLd } from "react-schemaorg";
|
||||
import Providers from "@/components/providers";
|
||||
import { ThemeScript } from "@/components/theme/theme-script";
|
||||
import Header from "@/components/layout/header";
|
||||
import Footer from "@/components/layout/footer";
|
||||
import Toaster from "@/components/ui/sonner";
|
||||
@@ -24,8 +23,6 @@ const RootLayout = ({ children }: Readonly<{ children: React.ReactNode }>) => {
|
||||
suppressHydrationWarning
|
||||
>
|
||||
<head>
|
||||
<ThemeScript />
|
||||
|
||||
<JsonLd<Person>
|
||||
item={{
|
||||
"@context": "https://schema.org",
|
||||
@@ -65,13 +62,11 @@ const RootLayout = ({ children }: Readonly<{ children: React.ReactNode }>) => {
|
||||
|
||||
<body className="bg-background text-foreground font-sans antialiased">
|
||||
<Providers>
|
||||
<div className="mx-auto w-full max-w-4xl px-5 py-1">
|
||||
<Header className="mt-4 mb-6 w-full" />
|
||||
|
||||
<Header />
|
||||
<div className="mx-auto mt-4 w-full max-w-4xl px-5">
|
||||
<main>{children}</main>
|
||||
|
||||
<Footer className="my-6 w-full" />
|
||||
</div>
|
||||
<Footer />
|
||||
|
||||
<Toaster position="bottom-center" hotkey={[]} />
|
||||
<Analytics />
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import Button from "@/components/ui/button";
|
||||
import Video from "@/components/video";
|
||||
import Link from "@/components/link";
|
||||
import type { Metadata } from "next";
|
||||
@@ -23,9 +24,9 @@ const Page = () => {
|
||||
<div className="mt-6 text-center">
|
||||
<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>
|
||||
</p>
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -61,8 +61,10 @@ const getFormattedDates = async (date: string) => {
|
||||
|
||||
const Page = async ({ params }: { params: Promise<{ slug: string }> }) => {
|
||||
const { slug } = await params;
|
||||
const frontmatter = await getFrontMatter(slug);
|
||||
const commentCount = await getCommentCounts(`${POSTS_DIR}/${slug}`);
|
||||
const [frontmatter, commentCount] = await Promise.all([
|
||||
getFrontMatter(slug),
|
||||
getCommentCounts(`${POSTS_DIR}/${slug}`),
|
||||
]);
|
||||
const formattedDates = await getFormattedDates(frontmatter!.date);
|
||||
|
||||
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}`}
|
||||
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}>
|
||||
{formattedDates.dateDisplay}
|
||||
</time>
|
||||
@@ -107,7 +109,7 @@ const Page = async ({ params }: { params: Promise<{ slug: string }> }) => {
|
||||
|
||||
{frontmatter!.tags && (
|
||||
<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) => (
|
||||
<span
|
||||
key={tag}
|
||||
@@ -126,7 +128,7 @@ const Page = async ({ params }: { params: Promise<{ slug: string }> }) => {
|
||||
title={`Edit "${frontmatter!.title}" on GitHub`}
|
||||
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>
|
||||
</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"}`}
|
||||
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>
|
||||
</Link>
|
||||
|
||||
<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}`} />
|
||||
</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
|
||||
href={`/${POSTS_DIR}/${frontmatter!.slug}`}
|
||||
dangerouslySetInnerHTML={{ __html: frontmatter!.htmlTitle || frontmatter!.title }}
|
||||
|
||||
@@ -24,8 +24,8 @@ const PostStats = ({ views, comments, slug }: { views: number; comments: number;
|
||||
<>
|
||||
{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">
|
||||
<EyeIcon className="inline-block size-4 shrink-0" />
|
||||
<span className="inline-block leading-none">{numberFormatter.format(views)}</span>
|
||||
<EyeIcon className="inline-block size-4 shrink-0" aria-hidden="true" />
|
||||
<span className="inline-block leading-none tabular-nums">{numberFormatter.format(views)}</span>
|
||||
</span>
|
||||
)}
|
||||
|
||||
@@ -37,8 +37,8 @@ const PostStats = ({ views, comments, slug }: { views: number; comments: number;
|
||||
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">
|
||||
<MessagesSquareIcon className="inline-block size-3 shrink-0" />
|
||||
<span className="inline-block leading-none">{numberFormatter.format(comments)}</span>
|
||||
<MessagesSquareIcon className="inline-block size-3 shrink-0" aria-hidden="true" />
|
||||
<span className="inline-block leading-none tabular-nums">{numberFormatter.format(comments)}</span>
|
||||
</span>
|
||||
</Link>
|
||||
)}
|
||||
@@ -96,8 +96,8 @@ const PostsList = async () => {
|
||||
|
||||
Object.entries(postsByYear).forEach(([year, posts]) => {
|
||||
sections.push(
|
||||
<section className="my-8 first-of-type:mt-6 last-of-type:mb-6" key={year}>
|
||||
<h2 id={year} className="mt-0 mb-4 text-3xl font-bold md:text-4xl">
|
||||
<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-4xl font-semibold tracking-tight sm:text-3xl">
|
||||
{year}
|
||||
</h2>
|
||||
<ul className="space-y-4">
|
||||
@@ -114,6 +114,7 @@ const PostsList = async () => {
|
||||
href={`/${POSTS_DIR}/${slug}`}
|
||||
prefetch={false}
|
||||
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} />
|
||||
|
||||
199
app/page.tsx
199
app/page.tsx
@@ -3,109 +3,50 @@ import { LockIcon } from "lucide-react";
|
||||
|
||||
const Page = () => {
|
||||
return (
|
||||
<>
|
||||
<h1 className="mt-0 mb-2 text-3xl leading-relaxed font-medium">
|
||||
<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="text-2xl font-medium">
|
||||
Hi there! I’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>
|
||||
|
||||
<h2 className="my-2 text-xl leading-relaxed font-normal">
|
||||
<h2 className="font-normal">
|
||||
I’m a frontend web developer based in the{" "}
|
||||
<Link
|
||||
href="https://www.youtube-nocookie.com/embed/rLwbzGyC6t4?hl=en&fs=1&showinfo=1&rel=0&iv_load_policy=3"
|
||||
title='"Boston Accent Trailer - Late Night with Seth Meyers" on YouTube'
|
||||
className="[--primary:#fb4d42] dark:[--primary:#ff5146]"
|
||||
>
|
||||
Boston
|
||||
</Link>{" "}
|
||||
area.
|
||||
</h2>
|
||||
|
||||
<p className="my-3 text-base leading-relaxed md:text-[0.975rem]">
|
||||
I specialize in using{" "}
|
||||
<Link href="https://www.typescriptlang.org/" className="[--primary:#235a97] dark:[--primary:#59a8ff]">
|
||||
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>
|
||||
I specialize in using TypeScript, React, and Next.js to make lightweight frontends with dynamic and powerful
|
||||
backends.
|
||||
</p>
|
||||
|
||||
<p className="my-3 text-base leading-relaxed md:text-[0.975rem]">
|
||||
<p>
|
||||
Whenever possible, I also apply my experience in{" "}
|
||||
<Link
|
||||
href="https://bugcrowd.com/jakejarvis"
|
||||
title="Jake Jarvis on Bugcrowd"
|
||||
className="[--primary:#00b81a] dark:[--primary:#57f06d]"
|
||||
>
|
||||
<Link href="https://bugcrowd.com/jakejarvis" title="Jake Jarvis on Bugcrowd">
|
||||
information security
|
||||
</Link>
|
||||
,{" "}
|
||||
<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>{" "}
|
||||
and{" "}
|
||||
<Link
|
||||
href="https://github.com/jakejarvis?tab=repositories&q=github-actions&type=&language=&sort=stargazers"
|
||||
title='My repositories tagged with "github-actions" on GitHub'
|
||||
className="[--primary:#ff6200] dark:[--primary:#f46c16]"
|
||||
>
|
||||
automation
|
||||
devops
|
||||
</Link>
|
||||
.
|
||||
</p>
|
||||
|
||||
<p className="my-3 text-base leading-relaxed md:text-[0.975rem]">
|
||||
<p>
|
||||
I fell in love with{" "}
|
||||
<Link
|
||||
href="/previously"
|
||||
prefetch={false}
|
||||
title="My Terrible, Horrible, No Good, Very Bad First Websites"
|
||||
className="[--primary:#4169e1] dark:[--primary:#8ca9ff]"
|
||||
>
|
||||
<Link href="/previously" prefetch={false} title="My Terrible, Horrible, No Good, Very Bad First Websites">
|
||||
frontend web design
|
||||
</Link>{" "}
|
||||
and{" "}
|
||||
<Link
|
||||
href="/notes/my-first-code"
|
||||
prefetch={false}
|
||||
title="Jake's Bulletin Board, circa 2003"
|
||||
className="[--primary:#9932cc] dark:[--primary:#d588fb]"
|
||||
>
|
||||
<Link href="/notes/my-first-code" prefetch={false} title="Jake's Bulletin Board, circa 2003">
|
||||
backend coding
|
||||
</Link>{" "}
|
||||
when my only source of income was{" "}
|
||||
@@ -113,131 +54,61 @@ const Page = () => {
|
||||
href="/birthday"
|
||||
prefetch={false}
|
||||
title="🎉 Cranky Birthday Boy on VHS Tape 📼"
|
||||
className="[--primary:#e40088] dark:[--primary:#fd40b1]"
|
||||
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`,
|
||||
}}
|
||||
className="font-normal no-underline"
|
||||
>
|
||||
the Tooth Fairy
|
||||
</Link>
|
||||
. <span className="text-muted-foreground">I’ve improved a bit since then, I think?</span>
|
||||
. <span className="text-muted-foreground">(I’ve improved a bit since then, I think?)</span>
|
||||
</p>
|
||||
|
||||
<p className="my-3 text-base leading-relaxed md:text-[0.975rem]">
|
||||
<p>
|
||||
I’m currently building{" "}
|
||||
<Link
|
||||
href="https://domainstack.io"
|
||||
title="Domainstack: Domain intelligence made easy"
|
||||
className="font-medium [--primary:#a054d0] dark:[--primary:#dd9fff]"
|
||||
>
|
||||
<Link href="https://domainstack.io" title="Domainstack: Domain intelligence made easy" className="font-medium">
|
||||
Domainstack
|
||||
</Link>
|
||||
, a beautiful all-in-one domain name intelligence tool, and{" "}
|
||||
<Link
|
||||
href="https://snoozle.ai"
|
||||
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
|
||||
</Link>
|
||||
, an AI-powered bedtime story generator. Over the years, some of my other side projects{" "}
|
||||
<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's Son" on Advertising Age'
|
||||
className="[--primary:#973999] dark:[--primary:#db60dd]"
|
||||
>
|
||||
outlets
|
||||
</Link>
|
||||
.
|
||||
, an AI-powered bedtime story generator.
|
||||
</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{" "}
|
||||
<Link href="https://github.com/jakejarvis" rel="me" className="[--primary:#8d4eff] dark:[--primary:#a379f0]">
|
||||
<Link href="https://github.com/jakejarvis" rel="me">
|
||||
GitHub
|
||||
</Link>{" "}
|
||||
and{" "}
|
||||
<Link
|
||||
href="https://www.linkedin.com/in/jakejarvis/"
|
||||
rel="me"
|
||||
className="[--primary:#0073b1] dark:[--primary:#3b9dd2]"
|
||||
>
|
||||
<Link href="https://www.linkedin.com/in/jakejarvis/" rel="me">
|
||||
LinkedIn
|
||||
</Link>
|
||||
. I’m always available to connect over{" "}
|
||||
<Link
|
||||
href="/contact"
|
||||
prefetch={false}
|
||||
title="Send an email"
|
||||
className="[--primary:#de0c0c] dark:[--primary:#ff5050]"
|
||||
>
|
||||
<Link href="/contact" prefetch={false} title="Send an email">
|
||||
email
|
||||
</Link>{" "}
|
||||
<sup className="mr-0.5 text-[0.6rem]">
|
||||
<sup className="">
|
||||
<Link
|
||||
href="https://jrvs.io/pgp"
|
||||
rel="pgpkey"
|
||||
title="3BC6 E577 6BF3 79D3 6F67 1480 2B0C 9CF2 51E6 9A39"
|
||||
className="space-x-0.5 px-0.5 text-nowrap [--primary:var(--muted-foreground)] hover:no-underline"
|
||||
title="Download my PGP key"
|
||||
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" />{" "}
|
||||
<code className="tracking-wider text-wrap [word-spacing:-3px]">2B0C 9CF2 51E6 9A39</code>
|
||||
<LockIcon className="inline size-2.5" aria-hidden="true" />
|
||||
<code className="text-[9px] leading-none tracking-wider text-wrap [word-spacing:-3px]">
|
||||
2B0C 9CF2 51E6 9A39
|
||||
</code>
|
||||
</Link>
|
||||
</sup>
|
||||
,{" "}
|
||||
<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!
|
||||
</sup>{" "}
|
||||
as well.
|
||||
</p>
|
||||
</>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ import { GitHubIcon } from "@/components/icons";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { createMetadata } from "@/lib/metadata";
|
||||
import { getContributions, getRepos } from "./github";
|
||||
import Button from "@/components/ui/button";
|
||||
|
||||
export const metadata = createMetadata({
|
||||
title: "Projects",
|
||||
@@ -122,9 +123,14 @@ const Page = async () => {
|
||||
)}
|
||||
|
||||
<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">
|
||||
View more on <GitHubIcon className="fill-foreground/80 mx-0.5 inline size-5 align-text-top" /> GitHub.
|
||||
</Link>
|
||||
<Button variant="secondary" asChild>
|
||||
<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>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -1,79 +1,51 @@
|
||||
import { CodeIcon, TerminalIcon } from "lucide-react";
|
||||
import { codeToHtml } from "shiki";
|
||||
import { cacheLife } from "next/cache";
|
||||
import CopyButton from "@/components/copy-button";
|
||||
import { cn } from "@/lib/utils";
|
||||
import reactToText from "react-to-text";
|
||||
import { codeToHtml } from "shiki";
|
||||
|
||||
interface CodeBlockProps extends React.ComponentProps<"pre"> {
|
||||
showLineNumbers?: boolean;
|
||||
showCopyButton?: boolean;
|
||||
}
|
||||
|
||||
const renderHighlightedCode = async (codeString: string, lang: string) => {
|
||||
const renderCode = async (code: string, lang: string): Promise<string> => {
|
||||
"use cache";
|
||||
cacheLife("max");
|
||||
|
||||
const html = await codeToHtml(codeString, {
|
||||
return codeToHtml(code, {
|
||||
lang,
|
||||
themes: {
|
||||
light: "github-light",
|
||||
dark: "github-dark",
|
||||
},
|
||||
themes: { light: "github-light", dark: "github-dark" },
|
||||
});
|
||||
|
||||
return html;
|
||||
};
|
||||
|
||||
const CodeBlock = async (props: CodeBlockProps) => {
|
||||
const { showLineNumbers = false, showCopyButton = true, children, className } = props;
|
||||
|
||||
// escape hatch if this code wasn't meant to be highlighted
|
||||
const CodeBlock = async ({ children, className, showLineNumbers = true, ...props }: CodeBlockProps) => {
|
||||
// Escape hatch for non-code pre blocks
|
||||
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 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 lang = codeProps.className?.split("language-")[1] ?? "";
|
||||
|
||||
const html = await renderHighlightedCode(codeString, lang);
|
||||
const html = await renderCode(codeString, lang);
|
||||
|
||||
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
|
||||
className={cn(
|
||||
"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",
|
||||
"[&_.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-slot="code-block"
|
||||
data-lang={lang}
|
||||
data-line-numbers={showLineNumbers || undefined}
|
||||
className={className}
|
||||
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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,59 +1,65 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import * as React from "react";
|
||||
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";
|
||||
|
||||
const CopyButton = ({
|
||||
source,
|
||||
timeout = 2000,
|
||||
function CopyButton({
|
||||
value,
|
||||
className,
|
||||
...rest
|
||||
}: React.ComponentProps<"button"> & {
|
||||
source: string;
|
||||
timeout?: number;
|
||||
}) => {
|
||||
const [copied, setCopied] = useState(false);
|
||||
variant = "ghost",
|
||||
tooltip = "Copy to Clipboard",
|
||||
...props
|
||||
}: React.ComponentProps<typeof Button> & {
|
||||
value: string;
|
||||
tooltip?: string;
|
||||
}) {
|
||||
const [hasCopied, setHasCopied] = React.useState(false);
|
||||
const timeoutRef = React.useRef<NodeJS.Timeout | undefined>(undefined);
|
||||
|
||||
const handleCopy: React.MouseEventHandler<React.ComponentRef<"button">> = (e) => {
|
||||
// prevent unintentional double-clicks by unfocusing button
|
||||
e.currentTarget.blur();
|
||||
React.useEffect(() => {
|
||||
return () => {
|
||||
if (timeoutRef.current) {
|
||||
clearTimeout(timeoutRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
// send plaintext to the clipboard
|
||||
const didCopy = copy(source);
|
||||
const handleCopy = () => {
|
||||
copy(value);
|
||||
setHasCopied(true);
|
||||
|
||||
// indicate success
|
||||
setCopied(didCopy);
|
||||
if (timeoutRef.current) {
|
||||
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 (
|
||||
<button
|
||||
onClick={handleCopy}
|
||||
disabled={copied}
|
||||
className={cn("cursor-pointer disabled:cursor-default", className)}
|
||||
{...rest}
|
||||
>
|
||||
{copied ? <CheckIcon className="stroke-success" /> : <ClipboardIcon />}
|
||||
<span className="sr-only">{copied ? "Copied" : "Copy to clipboard"}</span>
|
||||
</button>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
data-slot="copy-button"
|
||||
data-copied={hasCopied}
|
||||
size="icon"
|
||||
variant={variant}
|
||||
className={cn(
|
||||
"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;
|
||||
|
||||
@@ -1,54 +1,26 @@
|
||||
import { env } from "@/lib/env";
|
||||
import { HeartIcon } from "lucide-react";
|
||||
import Link from "@/components/link";
|
||||
import { NextjsIcon } from "@/components/icons";
|
||||
import { cn } from "@/lib/utils";
|
||||
import siteConfig from "@/lib/config/site";
|
||||
|
||||
const Footer = ({ className, ...rest }: React.ComponentProps<"footer">) => {
|
||||
const Footer = () => {
|
||||
return (
|
||||
<footer
|
||||
className={cn("text-foreground/85 text-[0.8rem] leading-loose md:flex md:flex-row md:justify-between", className)}
|
||||
{...rest}
|
||||
>
|
||||
<div>
|
||||
Content{" "}
|
||||
<Link href="/license" prefetch={false} className="text-foreground/85 hover:no-underline">
|
||||
licensed under {siteConfig.license}
|
||||
</Link>
|
||||
,{" "}
|
||||
<Link
|
||||
href="/previously"
|
||||
prefetch={false}
|
||||
title="Previously on..."
|
||||
className="text-foreground/85 hover:no-underline"
|
||||
>
|
||||
{siteConfig.copyrightYearStart}
|
||||
</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 className="text-muted-foreground mt-8 w-full py-6 text-center text-[13px] leading-loose">
|
||||
Content{" "}
|
||||
<Link href="/license" prefetch={false}>
|
||||
licensed under {siteConfig.license}
|
||||
</Link>
|
||||
,{" "}
|
||||
<Link href="/previously" prefetch={false} title="Previously on...">
|
||||
{siteConfig.copyrightYearStart}
|
||||
</Link>{" "}
|
||||
– 2026.{" "}
|
||||
<Link
|
||||
href={`https://github.com/${env.NEXT_PUBLIC_GITHUB_REPO}`}
|
||||
title="View Source on GitHub"
|
||||
className="font-medium underline underline-offset-4"
|
||||
>
|
||||
View source.
|
||||
</Link>
|
||||
</footer>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,36 +1,90 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { useTheme } from "next-themes";
|
||||
import Image from "next/image";
|
||||
import Link from "@/components/link";
|
||||
import Button from "@/components/ui/button";
|
||||
import Separator from "@/components/ui/separator";
|
||||
import Menu from "@/components/layout/menu";
|
||||
import { GitHubIcon } from "@/components/icons";
|
||||
import { cn } from "@/lib/utils";
|
||||
import authorConfig from "@/lib/config/author";
|
||||
import siteConfig from "@/lib/config/site";
|
||||
import { MoonIcon, SunIcon } from "lucide-react";
|
||||
|
||||
import avatarImg from "@/app/avatar.jpg";
|
||||
|
||||
const Header = ({ className, ...rest }: React.ComponentProps<"header">) => {
|
||||
return (
|
||||
<header className={cn("flex items-center justify-between", className)} {...rest}>
|
||||
<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>
|
||||
const Header = ({ className }: { className?: string }) => {
|
||||
const [isScrolled, setIsScrolled] = useState(false);
|
||||
const { theme, setTheme } = useTheme();
|
||||
|
||||
<Menu className="w-full max-w-64 sm:max-w-96 md:max-w-none" />
|
||||
</header>
|
||||
useEffect(() => {
|
||||
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>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -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;
|
||||
@@ -1,58 +1,38 @@
|
||||
"use client";
|
||||
|
||||
import { useSelectedLayoutSegment } from "next/navigation";
|
||||
import MenuItem from "@/components/layout/menu-item";
|
||||
import ThemeToggle from "@/components/theme/theme-toggle";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { HomeIcon, PencilLineIcon, CodeXmlIcon, MailIcon } from "lucide-react";
|
||||
import Button from "@/components/ui/button";
|
||||
import Link from "@/components/link";
|
||||
|
||||
const menuItems: React.ComponentProps<typeof MenuItem>[] = [
|
||||
{
|
||||
text: "Home",
|
||||
href: "/",
|
||||
icon: <HomeIcon />,
|
||||
},
|
||||
const menuItems = [
|
||||
{
|
||||
text: "Notes",
|
||||
href: "/notes",
|
||||
icon: <PencilLineIcon />,
|
||||
},
|
||||
{
|
||||
text: "Projects",
|
||||
href: "/projects",
|
||||
icon: <CodeXmlIcon />,
|
||||
},
|
||||
{
|
||||
text: "Contact",
|
||||
href: "/contact",
|
||||
icon: <MailIcon />,
|
||||
},
|
||||
{
|
||||
icon: <ThemeToggle />,
|
||||
},
|
||||
];
|
||||
] as const;
|
||||
|
||||
const Menu = ({ className, ...rest }: React.ComponentProps<"div">) => {
|
||||
const Menu = () => {
|
||||
const segment = useSelectedLayoutSegment() || "";
|
||||
|
||||
return (
|
||||
<div
|
||||
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}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
{menuItems.map((item, index) => {
|
||||
const isCurrent = item.href?.split("/")[1] === segment;
|
||||
|
||||
return (
|
||||
<div
|
||||
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"
|
||||
key={index}
|
||||
>
|
||||
<MenuItem {...item} current={isCurrent} />
|
||||
</div>
|
||||
<Button key={index} variant="ghost" size="sm" asChild>
|
||||
<Link href={item.href} prefetch={false} aria-label={item.text} data-current={isCurrent}>
|
||||
{item.text}
|
||||
</Link>
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
@@ -10,7 +10,10 @@ const PageTitle = ({
|
||||
canonical: string;
|
||||
}) => {
|
||||
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
|
||||
href={canonical}
|
||||
className="before:text-muted-foreground before:-mr-0.5 before:tracking-widest before:content-['\002E\002F'] hover:no-underline"
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
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
|
||||
// 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]);
|
||||
@@ -10,10 +9,6 @@ const Link = ({ href, rel, target, className, ...rest }: React.ComponentProps<ty
|
||||
href,
|
||||
target: target || (isExternal ? "_blank" : 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,
|
||||
};
|
||||
|
||||
|
||||
@@ -1,19 +1,11 @@
|
||||
"use client";
|
||||
|
||||
import { ThemeProvider } from "@/components/theme/theme-context";
|
||||
import { ProgressProvider } from "@bprogress/next/app";
|
||||
import { ThemeProvider } from "next-themes";
|
||||
|
||||
const Providers = ({ children }: { children: React.ReactNode }) => {
|
||||
return (
|
||||
<ThemeProvider>
|
||||
<ProgressProvider
|
||||
height="calc(var(--spacing) * 1)"
|
||||
color="var(--primary)"
|
||||
options={{ showSpinner: false }}
|
||||
shallowRouting
|
||||
>
|
||||
{children}
|
||||
</ProgressProvider>
|
||||
<ThemeProvider attribute="class" defaultTheme="system" enableSystem disableTransitionOnChange>
|
||||
{children}
|
||||
</ThemeProvider>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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>;
|
||||
};
|
||||
@@ -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){}})()",
|
||||
}}
|
||||
/>
|
||||
);
|
||||
@@ -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;
|
||||
@@ -1,9 +1,8 @@
|
||||
import NextImage from "next/image";
|
||||
import Link from "@/components/link";
|
||||
import CodeBlock from "@/components/code-block";
|
||||
import HeadingAnchor from "@/components/heading-anchor";
|
||||
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 YouTube from "@/components/third-party/youtube";
|
||||
import Gist from "@/components/third-party/gist";
|
||||
@@ -15,106 +14,30 @@ export const useMDXComponents = (components: MDXComponents): MDXComponents => {
|
||||
return {
|
||||
...components,
|
||||
a: Link,
|
||||
pre: ({ className, ...rest }) => <CodeBlock className={cn("my-5 w-full font-mono text-sm", className)} {...rest} />,
|
||||
code: ({ className, ...rest }) => (
|
||||
// only applies to inline code, *not* highlighted code blocks!
|
||||
<code
|
||||
className={cn("bg-muted relative rounded-sm px-[0.3rem] py-[0.2rem] text-sm font-medium", className)}
|
||||
{...rest}
|
||||
/>
|
||||
),
|
||||
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} />
|
||||
),
|
||||
pre: CodeBlock,
|
||||
img: ({ src, className, ...rest }) => {
|
||||
const imageWidth = typeof src === "object" && "width" in src && src.width > 896 ? 896 : undefined;
|
||||
const imageHeight =
|
||||
imageWidth && typeof src === "object" && "width" in src && "height" in src
|
||||
? Math.round((src.height / src.width) * imageWidth)
|
||||
: undefined;
|
||||
|
||||
// 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,
|
||||
ImageDiff,
|
||||
Tweet,
|
||||
|
||||
@@ -32,6 +32,7 @@ const nextConfig = {
|
||||
},
|
||||
productionBrowserSourceMaps: true,
|
||||
experimental: {
|
||||
viewTransition: true,
|
||||
serverActions: {
|
||||
// fix CSRF errors from tor reverse proxy
|
||||
allowedOrigins: [
|
||||
@@ -156,7 +157,12 @@ const nextPlugins: Array<
|
||||
rehypePlugins: [
|
||||
"rehype-unwrap-images",
|
||||
"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-import-media",
|
||||
],
|
||||
|
||||
38
package.json
38
package.json
@@ -20,13 +20,12 @@
|
||||
"prepare": "test -d node_modules/husky && husky || echo \"skipping husky\""
|
||||
},
|
||||
"dependencies": {
|
||||
"@bprogress/next": "^3.2.12",
|
||||
"@date-fns/tz": "^1.4.1",
|
||||
"@date-fns/utc": "^2.1.1",
|
||||
"@mdx-js/loader": "^3.1.1",
|
||||
"@mdx-js/react": "^3.1.1",
|
||||
"@neondatabase/serverless": "^1.0.2",
|
||||
"@next/mdx": "16.1.0",
|
||||
"@next/mdx": "16.1.6",
|
||||
"@octokit/graphql": "^9.0.3",
|
||||
"@octokit/graphql-schema": "^15.26.1",
|
||||
"@radix-ui/react-alert-dialog": "^1.1.15",
|
||||
@@ -44,24 +43,25 @@
|
||||
"@t3-oss/env-nextjs": "^0.13.10",
|
||||
"@vercel/analytics": "^1.6.1",
|
||||
"@vercel/speed-insights": "^1.3.1",
|
||||
"better-auth": "^1.4.7",
|
||||
"better-auth": "^1.4.17",
|
||||
"botid": "^1.5.10",
|
||||
"cheerio": "^1.1.2",
|
||||
"cheerio": "^1.2.0",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"copy-to-clipboard": "^3.3.3",
|
||||
"date-fns": "^4.1.0",
|
||||
"drizzle-orm": "^0.45.1",
|
||||
"fast-glob": "^3.3.3",
|
||||
"feed": "^5.1.0",
|
||||
"feed": "^5.2.0",
|
||||
"geist": "^1.5.1",
|
||||
"html-entities": "^2.6.0",
|
||||
"lucide-react": "0.562.0",
|
||||
"next": "16.1.0",
|
||||
"react": "19.2.3",
|
||||
"react-activity-calendar": "^3.0.1",
|
||||
"lucide-react": "0.563.0",
|
||||
"next": "16.1.6",
|
||||
"next-themes": "^0.4.6",
|
||||
"react": "19.2.4",
|
||||
"react-activity-calendar": "^3.0.5",
|
||||
"react-countup": "^6.5.3",
|
||||
"react-dom": "19.2.3",
|
||||
"react-dom": "19.2.4",
|
||||
"react-lite-youtube-embed": "^3.3.3",
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-schemaorg": "^2.0.0",
|
||||
@@ -85,45 +85,47 @@
|
||||
"remark-rehype": "^11.1.2",
|
||||
"remark-smartypants": "^3.0.2",
|
||||
"remark-strip-mdx-imports-exports": "^1.0.1",
|
||||
"resend": "^6.6.0",
|
||||
"resend": "^6.9.1",
|
||||
"server-only": "0.0.1",
|
||||
"shiki": "^3.20.0",
|
||||
"shiki": "^3.21.0",
|
||||
"sonner": "^2.0.7",
|
||||
"tailwind-merge": "^3.4.0",
|
||||
"tailwindcss": "^4.1.18",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"unified": "^11.0.5",
|
||||
"zod": "^4.2.1"
|
||||
"zod": "^4.3.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/eslintrc": "^3.3.3",
|
||||
"@eslint/js": "^9.39.2",
|
||||
"@jakejarvis/eslint-config": "^4.0.7",
|
||||
"@tailwindcss/postcss": "^4.1.18",
|
||||
"@tailwindcss/typography": "^0.5.19",
|
||||
"@types/hast": "^3.0.4",
|
||||
"@types/mdx": "^2.0.13",
|
||||
"@types/node": "^25.0.3",
|
||||
"@types/react": "19.2.7",
|
||||
"@types/node": "^25.0.10",
|
||||
"@types/react": "19.2.10",
|
||||
"@types/react-dom": "19.2.3",
|
||||
"babel-plugin-react-compiler": "19.1.0-rc.3",
|
||||
"cross-env": "^10.1.0",
|
||||
"dotenv": "^17.2.3",
|
||||
"drizzle-kit": "^0.31.8",
|
||||
"eslint": "^9.39.2",
|
||||
"eslint-config-next": "16.1.0",
|
||||
"eslint-config-next": "16.1.6",
|
||||
"eslint-config-prettier": "^10.1.8",
|
||||
"eslint-plugin-css-modules": "^2.12.0",
|
||||
"eslint-plugin-drizzle": "^0.2.3",
|
||||
"eslint-plugin-import": "^2.32.0",
|
||||
"eslint-plugin-jsx-a11y": "^6.10.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-compiler": "19.1.0-rc.2",
|
||||
"eslint-plugin-react-hooks": "^7.0.1",
|
||||
"husky": "^9.1.7",
|
||||
"lint-staged": "^16.2.7",
|
||||
"postcss": "^8.5.6",
|
||||
"prettier": "^3.7.4",
|
||||
"prettier": "^3.8.1",
|
||||
"prettier-plugin-tailwindcss": "^0.7.2",
|
||||
"schema-dts": "^1.1.5",
|
||||
"typescript": "5.9.3"
|
||||
|
||||
2237
pnpm-lock.yaml
generated
2237
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user