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:
@@ -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
|
|
||||||
@@ -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
|
||||||
@@ -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,22 +11,24 @@ 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">
|
||||||
|
<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{" "}
|
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="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">
|
<Link href="https://bsky.app/profile/jarv.is" className="text-nowrap">
|
||||||
🦋 Bluesky
|
Bluesky
|
||||||
</Link>{" "}
|
</Link>{" "}
|
||||||
or{" "}
|
or{" "}
|
||||||
<Link href="https://fediverse.jarv.is/@jake" className="text-nowrap">
|
<Link href="https://fediverse.jarv.is/@jake" className="text-nowrap">
|
||||||
🦣 Mastodon
|
Mastodon
|
||||||
</Link>
|
</Link>
|
||||||
.
|
.
|
||||||
</p>
|
</p>
|
||||||
<p className="my-5 text-[0.925rem] leading-relaxed md:text-base">
|
<p>
|
||||||
You can grab my public key here:{" "}
|
You can grab my public key here:{" "}
|
||||||
<Link
|
<Link
|
||||||
href="https://jrvs.io/pgp"
|
href="https://jrvs.io/pgp"
|
||||||
@@ -37,9 +39,11 @@ const Page = () => {
|
|||||||
</Link>
|
</Link>
|
||||||
.
|
.
|
||||||
</p>
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
<ContactForm />
|
<ContactForm />
|
||||||
</div>
|
</div>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
+230
-84
@@ -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
@@ -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
@@ -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>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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’m Jake.{" "}
|
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>
|
</h1>
|
||||||
|
|
||||||
<h2 className="my-2 text-xl leading-relaxed font-normal">
|
<h2 className="font-normal">
|
||||||
I’m a frontend web developer based in the{" "}
|
I’m a frontend web developer based in the{" "}
|
||||||
<Link
|
<Link
|
||||||
href="https://www.youtube-nocookie.com/embed/rLwbzGyC6t4?hl=en&fs=1&showinfo=1&rel=0&iv_load_policy=3"
|
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'
|
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’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>
|
||||||
|
|
||||||
<p className="my-3 text-base leading-relaxed md:text-[0.975rem]">
|
<p>
|
||||||
I’m currently building{" "}
|
I’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'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’m always available to connect over{" "}
|
. I’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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
href={`https://github.com/${env.NEXT_PUBLIC_GITHUB_USERNAME}?tab=repositories&type=source&sort=stargazers`}
|
||||||
|
>
|
||||||
|
<GitHubIcon />
|
||||||
|
<span className="leading-none">Show All…</span>
|
||||||
</Link>
|
</Link>
|
||||||
|
</Button>
|
||||||
</p>
|
</p>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
+21
-49
@@ -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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
+49
-43
@@ -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
|
|
||||||
e.currentTarget.blur();
|
|
||||||
|
|
||||||
// send plaintext to the clipboard
|
|
||||||
const didCopy = copy(source);
|
|
||||||
|
|
||||||
// indicate success
|
|
||||||
setCopied(didCopy);
|
|
||||||
};
|
|
||||||
|
|
||||||
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 () => {
|
return () => {
|
||||||
clearTimeout(reset);
|
if (timeoutRef.current) {
|
||||||
|
clearTimeout(timeoutRef.current);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleCopy = () => {
|
||||||
|
copy(value);
|
||||||
|
setHasCopied(true);
|
||||||
|
|
||||||
|
if (timeoutRef.current) {
|
||||||
|
clearTimeout(timeoutRef.current);
|
||||||
|
}
|
||||||
|
timeoutRef.current = setTimeout(() => setHasCopied(false), 2000);
|
||||||
};
|
};
|
||||||
}, [timeout, copied]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<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}
|
onClick={handleCopy}
|
||||||
disabled={copied}
|
aria-label={hasCopied ? "Copied" : tooltip}
|
||||||
className={cn("cursor-pointer disabled:cursor-default", className)}
|
{...props}
|
||||||
{...rest}
|
|
||||||
>
|
>
|
||||||
{copied ? <CheckIcon className="stroke-success" /> : <ClipboardIcon />}
|
{hasCopied ? <CheckIcon aria-hidden="true" /> : <CopyIcon aria-hidden="true" />}
|
||||||
<span className="sr-only">{copied ? "Copied" : "Copy to clipboard"}</span>
|
</Button>
|
||||||
</button>
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>{hasCopied ? "Copied" : tooltip}</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
);
|
);
|
||||||
};
|
}
|
||||||
|
|
||||||
export default CopyButton;
|
export default CopyButton;
|
||||||
|
|||||||
@@ -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)}
|
|
||||||
{...rest}
|
|
||||||
>
|
|
||||||
<div>
|
|
||||||
Content{" "}
|
Content{" "}
|
||||||
<Link href="/license" prefetch={false} className="text-foreground/85 hover:no-underline">
|
<Link href="/license" prefetch={false}>
|
||||||
licensed under {siteConfig.license}
|
licensed under {siteConfig.license}
|
||||||
</Link>
|
</Link>
|
||||||
,{" "}
|
,{" "}
|
||||||
<Link
|
<Link href="/previously" prefetch={false} title="Previously on...">
|
||||||
href="/previously"
|
|
||||||
prefetch={false}
|
|
||||||
title="Previously on..."
|
|
||||||
className="text-foreground/85 hover:no-underline"
|
|
||||||
>
|
|
||||||
{siteConfig.copyrightYearStart}
|
{siteConfig.copyrightYearStart}
|
||||||
</Link>{" "}
|
</Link>{" "}
|
||||||
– 2025.
|
– 2026.{" "}
|
||||||
</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
|
<Link
|
||||||
href={`https://github.com/${env.NEXT_PUBLIC_GITHUB_REPO}`}
|
href={`https://github.com/${env.NEXT_PUBLIC_GITHUB_REPO}`}
|
||||||
title="View Source on GitHub"
|
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"
|
className="font-medium underline underline-offset-4"
|
||||||
>
|
>
|
||||||
View source.
|
View source.
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
|
||||||
</footer>
|
</footer>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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 }) => {
|
||||||
|
const [isScrolled, setIsScrolled] = useState(false);
|
||||||
|
const { theme, setTheme } = useTheme();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleScroll = () => {
|
||||||
|
setIsScrolled(window.scrollY > 10);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Check initial scroll position
|
||||||
|
handleScroll();
|
||||||
|
|
||||||
|
window.addEventListener("scroll", handleScroll, { passive: true });
|
||||||
|
return () => window.removeEventListener("scroll", handleScroll);
|
||||||
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<header className={cn("flex items-center justify-between", className)} {...rest}>
|
<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
|
<Link
|
||||||
href="/"
|
href="/"
|
||||||
rel="author"
|
rel="author"
|
||||||
aria-label={siteConfig.name}
|
aria-label={siteConfig.name}
|
||||||
className="hover:text-primary text-foreground/85 flex shrink-0 items-center hover:no-underline"
|
className="hover:text-foreground/85 flex shrink-0 items-center gap-2.5 pr-2 hover:no-underline"
|
||||||
>
|
>
|
||||||
<Image
|
<Image
|
||||||
src={avatarImg}
|
src={avatarImg}
|
||||||
alt={`Photo of ${siteConfig.name}`}
|
alt={`Photo of ${siteConfig.name}`}
|
||||||
className="border-ring/40 size-[64px] rounded-full border-2 md:size-[48px] md:border-1"
|
className="border-ring/30 size-[40px] rounded-full border md:size-[32px]"
|
||||||
width={64}
|
width={40}
|
||||||
height={64}
|
height={40}
|
||||||
quality={50}
|
quality={75}
|
||||||
priority
|
priority
|
||||||
/>
|
/>
|
||||||
<span className="mx-3 text-xl leading-none font-medium tracking-[0.01rem] max-md:sr-only">
|
<span className="text-[17px] font-medium whitespace-nowrap max-md:sr-only">{siteConfig.name}</span>
|
||||||
{siteConfig.name}
|
|
||||||
</span>
|
|
||||||
</Link>
|
</Link>
|
||||||
|
<Separator orientation="vertical" className="!h-6" />
|
||||||
|
<Menu />
|
||||||
|
</div>
|
||||||
|
|
||||||
<Menu className="w-full max-w-64 sm:max-w-96 md:max-w-none" />
|
<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>
|
</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;
|
|
||||||
+11
-31
@@ -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>
|
||||||
|
|||||||
@@ -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
@@ -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,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
|
||||||
height="calc(var(--spacing) * 1)"
|
|
||||||
color="var(--primary)"
|
|
||||||
options={{ showSpinner: false }}
|
|
||||||
shallowRouting
|
|
||||||
>
|
|
||||||
{children}
|
{children}
|
||||||
</ProgressProvider>
|
|
||||||
</ThemeProvider>
|
</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;
|
|
||||||
+16
-93
@@ -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 }) => (
|
return (
|
||||||
<NextImage
|
<NextImage
|
||||||
src={src}
|
src={src}
|
||||||
width={typeof src === "object" && "width" in src && src.width > 896 ? 896 : undefined} // => var(--container-4xl)
|
width={imageWidth}
|
||||||
|
height={imageHeight}
|
||||||
className={cn(
|
className={cn(
|
||||||
"mx-auto my-8 block h-auto max-w-full rounded-md",
|
"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",
|
"[&+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
|
className
|
||||||
)}
|
)}
|
||||||
{...rest}
|
{...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:
|
// React components and embeds:
|
||||||
Video,
|
Video,
|
||||||
ImageDiff,
|
ImageDiff,
|
||||||
Tweet,
|
Tweet,
|
||||||
|
|||||||
+7
-1
@@ -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
@@ -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"
|
||||||
|
|||||||
Generated
+1161
-1076
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user