This commit is contained in:
@@ -1,41 +1,97 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { logVisit } from '@/lib/db';
|
||||
import {
|
||||
getClientAddress,
|
||||
getUserAgent,
|
||||
isSameOriginRequest,
|
||||
normalizeVisitPath,
|
||||
} from '@/lib/request';
|
||||
|
||||
const ANALYTICS_KEY = process.env.ANALYTICS_KEY || 'default-analytics-key';
|
||||
export const runtime = 'nodejs';
|
||||
|
||||
const MAX_BODY_BYTES = 4096;
|
||||
|
||||
function getAdminRelayUrl() {
|
||||
const adminUrl = process.env.ADMIN_DASH_URL;
|
||||
if (!adminUrl) return null;
|
||||
|
||||
try {
|
||||
const url = new URL(adminUrl);
|
||||
if (url.protocol !== 'https:' && url.protocol !== 'http:') return null;
|
||||
return url.toString();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function relayVisit(path: string, visitorId: string | null) {
|
||||
const adminUrl = getAdminRelayUrl();
|
||||
if (!adminUrl) return;
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
};
|
||||
|
||||
if (process.env.ADMIN_DASH_KEY) {
|
||||
headers.Authorization = `Bearer ${process.env.ADMIN_DASH_KEY}`;
|
||||
}
|
||||
|
||||
fetch(adminUrl, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ path, visitorId, timestamp: Date.now() }),
|
||||
headers,
|
||||
signal: AbortSignal.timeout(1500),
|
||||
}).catch((error) => {
|
||||
if (process.env.NODE_ENV !== 'production') {
|
||||
console.error('Analytics relay failed', error);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export async function POST(req: Request) {
|
||||
try {
|
||||
const analyticsKey = req.headers.get('X-Analytics-Key');
|
||||
if (analyticsKey !== ANALYTICS_KEY) {
|
||||
return NextResponse.json({ success: false, error: 'Unauthorized' }, { status: 401 });
|
||||
if (!isSameOriginRequest(req)) {
|
||||
return NextResponse.json({ success: false, error: 'Forbidden' }, { status: 403 });
|
||||
}
|
||||
|
||||
const body = await req.json();
|
||||
const contentType = req.headers.get('content-type') || '';
|
||||
if (!contentType.toLowerCase().includes('application/json')) {
|
||||
return NextResponse.json({ success: false, error: 'Unsupported media type' }, { status: 415 });
|
||||
}
|
||||
|
||||
if (typeof body.path !== 'string' || typeof body.timestamp !== 'number') {
|
||||
const contentLength = Number(req.headers.get('content-length') || 0);
|
||||
if (contentLength > MAX_BODY_BYTES) {
|
||||
return NextResponse.json({ success: false, error: 'Payload too large' }, { status: 413 });
|
||||
}
|
||||
|
||||
const rawBody = await req.text();
|
||||
if (Buffer.byteLength(rawBody, 'utf8') > MAX_BODY_BYTES) {
|
||||
return NextResponse.json({ success: false, error: 'Payload too large' }, { status: 413 });
|
||||
}
|
||||
|
||||
const body = JSON.parse(rawBody);
|
||||
if (!body || typeof body !== 'object' || Array.isArray(body)) {
|
||||
return NextResponse.json({ success: false, error: 'Invalid input' }, { status: 400 });
|
||||
}
|
||||
|
||||
const visitPath = normalizeVisitPath(body.path);
|
||||
|
||||
if (!visitPath) {
|
||||
return NextResponse.json({ success: false, error: 'Invalid input' }, { status: 400 });
|
||||
}
|
||||
|
||||
const headers = req.headers;
|
||||
const forwarded = headers.get('x-forwarded-for');
|
||||
const ip = forwarded ? forwarded.split(',')[0].trim() : 'unknown';
|
||||
const clientAddress = getClientAddress(headers);
|
||||
const userAgent = getUserAgent(headers);
|
||||
let visitorId: string | null = null;
|
||||
|
||||
try {
|
||||
logVisit(ip, body.path);
|
||||
visitorId = logVisit(clientAddress, userAgent, visitPath);
|
||||
} catch (e) {
|
||||
console.error('Failed to log visit to SQLite', e);
|
||||
}
|
||||
|
||||
const adminUrl = process.env.ADMIN_DASH_URL || 'http://admin_dash:3000/api/track';
|
||||
|
||||
fetch(adminUrl, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ path: body.path, timestamp: body.timestamp, ip }),
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Forwarded-For': ip,
|
||||
},
|
||||
}).catch(e => console.error('Relay failed', e));
|
||||
relayVisit(visitPath, visitorId);
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
} catch {
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
const lineWidths = ['78%', '92%', '85%', '88%', '74%', '96%', '82%', '69%'];
|
||||
const lineWidths = ['w-[78%]', 'w-[92%]', 'w-[85%]', 'w-[88%]', 'w-[74%]', 'w-[96%]', 'w-[82%]', 'w-[69%]'];
|
||||
|
||||
export default function BlogPostLoading() {
|
||||
return (
|
||||
<div className="page-frame py-20 sm:py-24">
|
||||
<div className="mx-auto max-w-[70rem] animate-pulse xl:grid xl:grid-cols-[11rem_minmax(0,44rem)] xl:gap-x-10">
|
||||
<div className="mx-auto max-w-[70rem] motion-safe:animate-pulse xl:grid xl:grid-cols-[11rem_minmax(0,44rem)] xl:gap-x-10">
|
||||
<div className="hidden xl:block" />
|
||||
<div>
|
||||
<div className="mb-8 space-y-4 border-b border-line pb-8">
|
||||
@@ -13,7 +13,7 @@ export default function BlogPostLoading() {
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
{lineWidths.map((width) => (
|
||||
<div key={width} className="h-4 rounded-full bg-paper-strong" style={{ width }} />
|
||||
<div key={width} className={`h-4 rounded-full bg-paper-strong ${width}`} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,38 +1,186 @@
|
||||
import { getPostBySlug, getPostSlugs } from '@/lib/mdx';
|
||||
import { MDXRemote } from 'next-mdx-remote/rsc';
|
||||
import fs from 'fs';
|
||||
import NextImage, { type ImageProps } from 'next/image';
|
||||
import Link from 'next/link';
|
||||
import path from 'path';
|
||||
import { notFound } from 'next/navigation';
|
||||
import { format } from 'date-fns';
|
||||
import rehypeAutolinkHeadings from 'rehype-autolink-headings';
|
||||
import { isValidElement } from 'react';
|
||||
import type { ComponentPropsWithoutRef, ReactNode } from 'react';
|
||||
import { TableOfContents } from '@/components/mdx/TableOfContents';
|
||||
import { SideNote } from '@/components/mdx/SideNote';
|
||||
import { Citation, Bibliography } from '@/components/mdx/Citation';
|
||||
import { MobileTableOfContents } from '@/components/mdx/MobileTableOfContents';
|
||||
import { formatPostDate } from '@/lib/format';
|
||||
|
||||
const slugify = (text: React.ReactNode): string => {
|
||||
if (!text) return '';
|
||||
const str = typeof text === 'string' ? text : String(text);
|
||||
return str
|
||||
const slugify = (text: string): string => {
|
||||
return text
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '');
|
||||
.replace(/^-+|-+$/g, '') || 'section';
|
||||
};
|
||||
|
||||
const components = {
|
||||
h1: (props: React.ComponentPropsWithoutRef<'h1'>) => <h1 {...props} id={slugify(props.children)} className="mt-12 scroll-mt-24 font-sans text-[2rem] font-medium leading-tight tracking-[-0.05em] text-ink sm:text-[2.5rem]" />,
|
||||
h2: (props: React.ComponentPropsWithoutRef<'h2'>) => <h2 {...props} id={slugify(props.children)} className="mt-14 scroll-mt-24 font-sans text-[1.65rem] font-medium leading-tight tracking-[-0.05em] text-ink sm:text-[2rem]" />,
|
||||
h3: (props: React.ComponentPropsWithoutRef<'h3'>) => <h3 {...props} id={slugify(props.children)} className="mt-10 scroll-mt-24 font-sans text-[1.22rem] font-medium tracking-[-0.03em] text-ink sm:text-[1.42rem]" />,
|
||||
p: (props: React.ComponentPropsWithoutRef<'p'>) => <p {...props} className="mb-6 text-[1.02rem] leading-[1.85] text-ink-soft" />,
|
||||
ul: (props: React.ComponentPropsWithoutRef<'ul'>) => <ul {...props} className="mb-6 list-disc space-y-2 pl-5 text-[0.98rem] leading-8 text-ink-soft marker:text-accent" />,
|
||||
ol: (props: React.ComponentPropsWithoutRef<'ol'>) => <ol {...props} className="mb-6 list-decimal space-y-2 pl-5 text-[0.98rem] leading-8 text-ink-soft marker:text-accent" />,
|
||||
blockquote: (props: React.ComponentPropsWithoutRef<'blockquote'>) => <blockquote {...props} className="my-10 border-l border-line-strong pl-5 text-[1.05rem] italic leading-8 text-ink-soft" />,
|
||||
code: (props: React.ComponentPropsWithoutRef<'code'>) => <code {...props} className="rounded bg-paper-strong px-1.5 py-0.5 font-mono text-[0.9em] text-ink" />,
|
||||
pre: (props: React.ComponentPropsWithoutRef<'pre'>) => <pre {...props} className="my-8 overflow-x-auto rounded-[1rem] border border-line bg-[#161412] p-5 text-sm text-paper shadow-[0_16px_40px_rgba(17,16,15,0.12)]" />,
|
||||
a: (props: React.ComponentPropsWithoutRef<'a'>) => <a {...props} className="font-medium text-ink underline decoration-line-strong underline-offset-4 transition-colors hover:text-accent hover:decoration-accent" />,
|
||||
SideNote,
|
||||
Citation,
|
||||
Bibliography,
|
||||
};
|
||||
const publicDirectory = path.join(process.cwd(), 'public');
|
||||
const imageDimensionCache = new Map<string, { width: number; height: number } | null>();
|
||||
const PNG_SIGNATURE = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
|
||||
const JPEG_START_OF_FRAME_MARKERS = new Set([
|
||||
0xc0, 0xc1, 0xc2, 0xc3, 0xc5, 0xc6, 0xc7, 0xc9, 0xca, 0xcb, 0xcd, 0xce, 0xcf,
|
||||
]);
|
||||
|
||||
function normalizeHeadingText(text: string) {
|
||||
return text
|
||||
.replace(/\[([^\]]+)\]\([^)]+\)/g, '$1')
|
||||
.replace(/[`*_~]/g, '')
|
||||
.replace(/<[^>]+>/g, '')
|
||||
.trim();
|
||||
}
|
||||
|
||||
function createSlugger() {
|
||||
const counts = new Map<string, number>();
|
||||
|
||||
return (text: string) => {
|
||||
const base = slugify(text);
|
||||
const count = counts.get(base) || 0;
|
||||
counts.set(base, count + 1);
|
||||
return count === 0 ? base : `${base}-${count}`;
|
||||
};
|
||||
}
|
||||
|
||||
function toPlainText(node: ReactNode): string {
|
||||
if (typeof node === 'string' || typeof node === 'number') return String(node);
|
||||
if (Array.isArray(node)) return node.map(toPlainText).join('');
|
||||
if (isValidElement<{ children?: ReactNode }>(node)) return toPlainText(node.props.children);
|
||||
return '';
|
||||
}
|
||||
|
||||
function extractHeadings(content: string) {
|
||||
const slugger = createSlugger();
|
||||
const headingLines = content.match(/^#{1,3}\s+(.+)$/gm) || [];
|
||||
|
||||
return headingLines.flatMap((line) => {
|
||||
const level = line.match(/^#+/)?.[0].length || 2;
|
||||
const text = normalizeHeadingText(line.replace(/^#+\s+/, ''));
|
||||
const id = slugger(text);
|
||||
return level > 1 ? [{ id, text, level }] : [];
|
||||
});
|
||||
}
|
||||
|
||||
function getPngDimensions(buffer: Buffer) {
|
||||
if (buffer.length < 24 || !buffer.subarray(0, 8).equals(PNG_SIGNATURE)) return null;
|
||||
return {
|
||||
width: buffer.readUInt32BE(16),
|
||||
height: buffer.readUInt32BE(20),
|
||||
};
|
||||
}
|
||||
|
||||
function getJpegDimensions(buffer: Buffer) {
|
||||
if (buffer.length < 4 || buffer[0] !== 0xff || buffer[1] !== 0xd8) return null;
|
||||
|
||||
let offset = 2;
|
||||
while (offset < buffer.length - 9) {
|
||||
if (buffer[offset] !== 0xff) {
|
||||
offset += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
const marker = buffer[offset + 1];
|
||||
offset += 2;
|
||||
|
||||
if (marker === 0xd8 || marker === 0xd9 || (marker >= 0xd0 && marker <= 0xd7)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const length = buffer.readUInt16BE(offset);
|
||||
if (length < 2 || offset + length > buffer.length) break;
|
||||
|
||||
if (JPEG_START_OF_FRAME_MARKERS.has(marker)) {
|
||||
return {
|
||||
height: buffer.readUInt16BE(offset + 3),
|
||||
width: buffer.readUInt16BE(offset + 5),
|
||||
};
|
||||
}
|
||||
|
||||
offset += length;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function getLocalImageDimensions(src: string) {
|
||||
const pathname = src.split(/[?#]/, 1)[0];
|
||||
if (!pathname.startsWith('/') || pathname.includes('\0') || pathname.includes('..')) return null;
|
||||
|
||||
const imagePath = path.join(publicDirectory, pathname.replace(/^\/+/, ''));
|
||||
if (!imagePath.startsWith(`${publicDirectory}${path.sep}`)) return null;
|
||||
|
||||
if (imageDimensionCache.has(imagePath)) {
|
||||
return imageDimensionCache.get(imagePath) ?? null;
|
||||
}
|
||||
|
||||
try {
|
||||
const buffer = fs.readFileSync(imagePath);
|
||||
const dimensions = getPngDimensions(buffer) ?? getJpegDimensions(buffer);
|
||||
imageDimensionCache.set(imagePath, dimensions);
|
||||
return dimensions;
|
||||
} catch {
|
||||
imageDimensionCache.set(imagePath, null);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function numericDimension(value: ImageProps['width'] | ImageProps['height']) {
|
||||
if (typeof value === 'number') return value;
|
||||
if (typeof value === 'string') {
|
||||
const parsed = Number(value);
|
||||
return Number.isFinite(parsed) ? parsed : undefined;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function MdxImage({ src, alt, width, height, sizes, ...props }: ImageProps) {
|
||||
if (typeof src !== 'string') {
|
||||
return <NextImage {...props} src={src} alt={alt} width={width} height={height} sizes={sizes} />;
|
||||
}
|
||||
|
||||
const localDimensions = getLocalImageDimensions(src);
|
||||
const resolvedWidth = numericDimension(width) ?? localDimensions?.width;
|
||||
const resolvedHeight = numericDimension(height) ?? localDimensions?.height;
|
||||
const displayWidth = Math.min(resolvedWidth ?? 704, 704);
|
||||
const responsiveSizes = sizes ?? `(max-width: 768px) 100vw, ${displayWidth}px`;
|
||||
|
||||
return (
|
||||
<NextImage
|
||||
{...props}
|
||||
src={src}
|
||||
alt={alt}
|
||||
width={resolvedWidth ?? 1200}
|
||||
height={resolvedHeight ?? 675}
|
||||
sizes={responsiveSizes}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function createMdxComponents() {
|
||||
const slugger = createSlugger();
|
||||
const getHeadingId = (children: ReactNode) => slugger(toPlainText(children));
|
||||
|
||||
return {
|
||||
h1: ({ children, ...props }: ComponentPropsWithoutRef<'h1'>) => <h1 {...props} id={getHeadingId(children)} className="mt-12 scroll-mt-24 font-sans text-[2rem] font-medium leading-tight text-ink sm:text-[2.5rem]">{children}</h1>,
|
||||
h2: ({ children, ...props }: ComponentPropsWithoutRef<'h2'>) => <h2 {...props} id={getHeadingId(children)} className="mt-14 scroll-mt-24 font-sans text-[1.65rem] font-medium leading-tight text-ink sm:text-[2rem]">{children}</h2>,
|
||||
h3: ({ children, ...props }: ComponentPropsWithoutRef<'h3'>) => <h3 {...props} id={getHeadingId(children)} className="mt-10 scroll-mt-24 font-sans text-[1.22rem] font-medium text-ink sm:text-[1.42rem]">{children}</h3>,
|
||||
p: (props: ComponentPropsWithoutRef<'p'>) => <p {...props} className="mb-6 text-[1.02rem] leading-[1.85] text-ink-soft" />,
|
||||
ul: (props: ComponentPropsWithoutRef<'ul'>) => <ul {...props} className="mb-6 list-disc space-y-2 pl-5 text-[0.98rem] leading-8 text-ink-soft marker:text-accent" />,
|
||||
ol: (props: ComponentPropsWithoutRef<'ol'>) => <ol {...props} className="mb-6 list-decimal space-y-2 pl-5 text-[0.98rem] leading-8 text-ink-soft marker:text-accent" />,
|
||||
blockquote: (props: ComponentPropsWithoutRef<'blockquote'>) => <blockquote {...props} className="my-10 border-l border-line-strong pl-5 text-[1.05rem] italic leading-8 text-ink-soft" />,
|
||||
code: (props: ComponentPropsWithoutRef<'code'>) => <code {...props} className="rounded bg-paper-strong px-1.5 py-0.5 font-mono text-[0.9em] text-ink" />,
|
||||
pre: (props: ComponentPropsWithoutRef<'pre'>) => <pre {...props} className="my-8 overflow-x-auto rounded-[1rem] border border-line bg-[#161412] p-5 text-sm text-paper shadow-[0_16px_40px_rgba(17,16,15,0.12)]" />,
|
||||
a: (props: ComponentPropsWithoutRef<'a'>) => <a {...props} className="font-medium text-ink underline decoration-line-strong underline-offset-4 transition-colors hover:text-accent hover:decoration-accent" />,
|
||||
Image: MdxImage,
|
||||
SideNote,
|
||||
Citation,
|
||||
Bibliography,
|
||||
};
|
||||
}
|
||||
|
||||
type Props = {
|
||||
params: Promise<{ slug: string }>;
|
||||
@@ -80,13 +228,8 @@ export default async function BlogPost({ params }: Props) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
const headingLines = post.content.match(/^#{2,3}\s+(.+)$/gm) || [];
|
||||
const headings = headingLines.map((line) => {
|
||||
const level = line.match(/^#+/)?.[0].length || 2;
|
||||
const text = line.replace(/^#+\s+/, '').trim();
|
||||
const id = slugify(text);
|
||||
return { id, text, level };
|
||||
});
|
||||
const headings = extractHeadings(post.content);
|
||||
const mdxComponents = createMdxComponents();
|
||||
const tags = Array.isArray(post.metadata.tags) ? post.metadata.tags : [];
|
||||
|
||||
return (
|
||||
@@ -100,16 +243,16 @@ export default async function BlogPost({ params }: Props) {
|
||||
|
||||
<article className="w-full">
|
||||
<header className="mb-8 space-y-4 border-b border-line pb-8">
|
||||
<div className="flex flex-wrap items-center gap-x-4 gap-y-2 text-[0.72rem] font-mono uppercase tracking-[0.14em] text-muted-strong">
|
||||
<div className="flex flex-wrap items-center gap-x-4 gap-y-2 text-[0.72rem] font-mono uppercase text-muted-strong">
|
||||
<time dateTime={post.metadata.date}>
|
||||
{format(new Date(post.metadata.date), 'MMMM d, yyyy')}
|
||||
{formatPostDate(post.metadata.date)}
|
||||
</time>
|
||||
{tags.map((tag: string) => (
|
||||
<span key={tag}>{tag}</span>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<h1 className="max-w-[38rem] text-balance font-sans text-[clamp(2.7rem,5.8vw,4.4rem)] font-medium leading-[0.94] tracking-[-0.08em] text-ink">
|
||||
<h1 className="max-w-[38rem] text-balance font-sans text-4xl font-medium leading-[0.98] text-ink sm:text-5xl lg:text-6xl">
|
||||
{post.metadata.title}
|
||||
</h1>
|
||||
|
||||
@@ -123,14 +266,7 @@ export default async function BlogPost({ params }: Props) {
|
||||
<div className="essay-prose relative mt-8 max-w-none xl:max-w-[44rem]">
|
||||
<MDXRemote
|
||||
source={post.content}
|
||||
components={components}
|
||||
options={{
|
||||
mdxOptions: {
|
||||
rehypePlugins: [
|
||||
[rehypeAutolinkHeadings, { behavior: 'wrap' }]
|
||||
]
|
||||
}
|
||||
}}
|
||||
components={mdxComponents}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -138,7 +274,7 @@ export default async function BlogPost({ params }: Props) {
|
||||
<Link href="/blog" className="transition-colors hover:text-ink">
|
||||
← Back to all posts
|
||||
</Link>
|
||||
<a href="#" className="transition-colors hover:text-ink">
|
||||
<a href="#main-content" className="transition-colors hover:text-ink">
|
||||
Scroll to top ↑
|
||||
</a>
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
export default function BlogLoading() {
|
||||
return (
|
||||
<div className="page-frame py-20 sm:py-24">
|
||||
<div className="mx-auto max-w-[72rem] animate-pulse space-y-10">
|
||||
<div className="mx-auto max-w-[72rem] space-y-10 motion-safe:animate-pulse">
|
||||
<div className="space-y-4 border-b border-line pb-10">
|
||||
<div className="h-3 w-20 rounded-full bg-accent-soft" />
|
||||
<div className="h-14 w-2/3 rounded-[1rem] bg-paper-strong" />
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { Metadata } from 'next';
|
||||
import Link from 'next/link';
|
||||
import { format } from 'date-fns';
|
||||
import { formatPostDate } from '@/lib/format';
|
||||
import { getAllPosts } from '@/lib/mdx';
|
||||
|
||||
export const metadata: Metadata = {
|
||||
@@ -21,7 +21,7 @@ export default function BlogIndex() {
|
||||
<header className="space-y-4 border-b border-line pb-10">
|
||||
<div className="space-y-4">
|
||||
<p className="eyebrow">Writing</p>
|
||||
<h1 className="max-w-[40rem] text-balance font-sans text-[clamp(3rem,6vw,5rem)] font-medium leading-[0.94] tracking-[-0.08em] text-ink">
|
||||
<h1 className="max-w-[40rem] text-balance font-sans text-5xl font-medium leading-[0.96] text-ink sm:text-6xl lg:text-7xl">
|
||||
Notes on software, deep learning, and research.
|
||||
</h1>
|
||||
<p className="max-w-[34rem] text-[1rem] leading-8 text-muted">
|
||||
@@ -34,14 +34,14 @@ export default function BlogIndex() {
|
||||
{posts.map((post) => (
|
||||
<article key={post.slug} className="grid gap-3 py-6 md:grid-cols-[8rem_minmax(0,1fr)] md:gap-6">
|
||||
<div className="pt-1">
|
||||
<time dateTime={post.date} className="block font-mono text-[0.72rem] uppercase tracking-[0.18em] text-muted-strong">
|
||||
{format(new Date(post.date), 'MMMM d, yyyy')}
|
||||
<time dateTime={post.date} className="block font-mono text-[0.72rem] uppercase text-muted-strong">
|
||||
{formatPostDate(post.date)}
|
||||
</time>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<Link href={`/blog/${post.slug}`} className="block">
|
||||
<h2 className="max-w-[38rem] font-sans text-[1.8rem] font-medium leading-tight tracking-[-0.05em] text-ink transition-colors hover:text-accent sm:text-[2.1rem]">
|
||||
<h2 className="max-w-[38rem] font-sans text-[1.8rem] font-medium leading-tight text-ink transition-colors hover:text-accent sm:text-[2.1rem]">
|
||||
{post.title}
|
||||
</h2>
|
||||
</Link>
|
||||
@@ -50,7 +50,7 @@ export default function BlogIndex() {
|
||||
{post.description}
|
||||
</p>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-x-4 gap-y-2 pt-1 text-[0.76rem] font-mono uppercase tracking-[0.14em] text-muted-strong">
|
||||
<div className="flex flex-wrap items-center gap-x-4 gap-y-2 pt-1 text-[0.76rem] font-mono uppercase text-muted-strong">
|
||||
{post.tags?.map((tag) => (
|
||||
<span key={tag}>
|
||||
{tag}
|
||||
|
||||
@@ -7,16 +7,16 @@ export default function Error({
|
||||
reset: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="min-h-[60vh] flex flex-col items-center justify-center px-6 text-center animate-fade-in">
|
||||
<h1 className="text-4xl font-bold tracking-tight text-zinc-900 dark:text-zinc-50 mb-4">
|
||||
<div className="page-frame flex min-h-[60vh] flex-col items-center justify-center px-6 text-center">
|
||||
<h1 className="mb-4 text-4xl font-medium text-ink">
|
||||
Something went wrong
|
||||
</h1>
|
||||
<p className="text-lg text-zinc-500 dark:text-zinc-400 mb-8">
|
||||
An unexpected error occurred.
|
||||
<p className="mb-8 text-lg text-muted">
|
||||
Refresh the page or try again.
|
||||
</p>
|
||||
<button
|
||||
onClick={reset}
|
||||
className="text-sm font-medium px-4 py-2 rounded bg-zinc-900 dark:bg-zinc-100 text-zinc-50 dark:text-zinc-900 hover:opacity-80 transition-opacity"
|
||||
className="rounded-md border border-line bg-paper-strong px-4 py-2 text-sm font-medium text-ink transition-colors hover:border-line-strong hover:text-accent"
|
||||
>
|
||||
Try again
|
||||
</button>
|
||||
|
||||
104
app/globals.css
104
app/globals.css
@@ -10,17 +10,17 @@
|
||||
--font-sans: var(--font-instrument-sans);
|
||||
--font-mono: var(--font-ibm-plex-mono);
|
||||
|
||||
--color-paper: #f4efe7;
|
||||
--color-paper-strong: #fbf7f0;
|
||||
--color-paper-overlay: rgba(244, 239, 231, 0.92);
|
||||
--color-ink: #171411;
|
||||
--color-ink-soft: #27221d;
|
||||
--color-muted: #5b544b;
|
||||
--color-muted-strong: #7a7166;
|
||||
--color-line: rgba(23, 20, 17, 0.12);
|
||||
--color-line-strong: rgba(23, 20, 17, 0.22);
|
||||
--color-accent: #8d6a42;
|
||||
--color-accent-soft: rgba(141, 106, 66, 0.12);
|
||||
--color-paper: #f6f7f3;
|
||||
--color-paper-strong: #fffefa;
|
||||
--color-paper-overlay: rgba(246, 247, 243, 0.94);
|
||||
--color-ink: #151718;
|
||||
--color-ink-soft: #28302f;
|
||||
--color-muted: #596461;
|
||||
--color-muted-strong: #6f7a76;
|
||||
--color-line: rgba(21, 23, 24, 0.12);
|
||||
--color-line-strong: rgba(21, 23, 24, 0.22);
|
||||
--color-accent: #176b5d;
|
||||
--color-accent-soft: rgba(23, 107, 93, 0.12);
|
||||
|
||||
--animate-fade-in: fade-in 0.7s cubic-bezier(0.16, 1, 0.3, 1);
|
||||
--animate-fade-up: fade-up 0.8s cubic-bezier(0.16, 1, 0.3, 1) both;
|
||||
@@ -54,27 +54,27 @@
|
||||
color-scheme: light;
|
||||
--background: var(--color-paper);
|
||||
--foreground: var(--color-ink);
|
||||
--page-wash: rgba(170, 142, 97, 0.09);
|
||||
--page-wash: rgba(23, 107, 93, 0.08);
|
||||
--page-shadow: rgba(255, 255, 255, 0.24);
|
||||
--grain-opacity: 0.03;
|
||||
}
|
||||
|
||||
:root[data-theme="dark"] {
|
||||
color-scheme: dark;
|
||||
--background: #11100f;
|
||||
--foreground: #f2ede4;
|
||||
--color-paper: #11100f;
|
||||
--color-paper-strong: #171614;
|
||||
--color-paper-overlay: rgba(17, 16, 15, 0.92);
|
||||
--color-ink: #f2ede4;
|
||||
--color-ink-soft: #dfd7cb;
|
||||
--color-muted: #b3a99d;
|
||||
--color-muted-strong: #d0c4b5;
|
||||
--color-line: rgba(242, 237, 228, 0.12);
|
||||
--color-line-strong: rgba(242, 237, 228, 0.22);
|
||||
--color-accent: #d0af80;
|
||||
--color-accent-soft: rgba(208, 175, 128, 0.14);
|
||||
--page-wash: rgba(208, 175, 128, 0.08);
|
||||
--background: #111315;
|
||||
--foreground: #eff4ef;
|
||||
--color-paper: #111315;
|
||||
--color-paper-strong: #181c1d;
|
||||
--color-paper-overlay: rgba(17, 19, 21, 0.94);
|
||||
--color-ink: #eff4ef;
|
||||
--color-ink-soft: #d9e2dc;
|
||||
--color-muted: #aab7b1;
|
||||
--color-muted-strong: #c2d0c9;
|
||||
--color-line: rgba(239, 244, 239, 0.12);
|
||||
--color-line-strong: rgba(239, 244, 239, 0.22);
|
||||
--color-accent: #7bc7b5;
|
||||
--color-accent-soft: rgba(123, 199, 181, 0.16);
|
||||
--page-wash: rgba(88, 161, 172, 0.1);
|
||||
--page-shadow: rgba(255, 255, 255, 0.02);
|
||||
--grain-opacity: 0.055;
|
||||
}
|
||||
@@ -82,20 +82,20 @@
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root:not([data-theme="light"]) {
|
||||
color-scheme: dark;
|
||||
--background: #11100f;
|
||||
--foreground: #f2ede4;
|
||||
--color-paper: #11100f;
|
||||
--color-paper-strong: #171614;
|
||||
--color-paper-overlay: rgba(17, 16, 15, 0.92);
|
||||
--color-ink: #f2ede4;
|
||||
--color-ink-soft: #dfd7cb;
|
||||
--color-muted: #b3a99d;
|
||||
--color-muted-strong: #d0c4b5;
|
||||
--color-line: rgba(242, 237, 228, 0.12);
|
||||
--color-line-strong: rgba(242, 237, 228, 0.22);
|
||||
--color-accent: #d0af80;
|
||||
--color-accent-soft: rgba(208, 175, 128, 0.14);
|
||||
--page-wash: rgba(208, 175, 128, 0.08);
|
||||
--background: #111315;
|
||||
--foreground: #eff4ef;
|
||||
--color-paper: #111315;
|
||||
--color-paper-strong: #181c1d;
|
||||
--color-paper-overlay: rgba(17, 19, 21, 0.94);
|
||||
--color-ink: #eff4ef;
|
||||
--color-ink-soft: #d9e2dc;
|
||||
--color-muted: #aab7b1;
|
||||
--color-muted-strong: #c2d0c9;
|
||||
--color-line: rgba(239, 244, 239, 0.12);
|
||||
--color-line-strong: rgba(239, 244, 239, 0.22);
|
||||
--color-accent: #7bc7b5;
|
||||
--color-accent-soft: rgba(123, 199, 181, 0.16);
|
||||
--page-wash: rgba(88, 161, 172, 0.1);
|
||||
--page-shadow: rgba(255, 255, 255, 0.02);
|
||||
--grain-opacity: 0.055;
|
||||
}
|
||||
@@ -114,11 +114,11 @@ body {
|
||||
position: relative;
|
||||
background-image: linear-gradient(180deg, var(--page-shadow), transparent 28%);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
letter-spacing: 0.012em;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
h1, h2, h3, h4, h5, h6 {
|
||||
letter-spacing: 0.005em;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
body::before,
|
||||
@@ -138,7 +138,7 @@ body::before {
|
||||
body::after {
|
||||
background:
|
||||
radial-gradient(60rem 24rem at 50% -8%, var(--page-wash), transparent 70%),
|
||||
radial-gradient(24rem 18rem at 0% 0%, rgba(0, 0, 0, 0.02), transparent 74%);
|
||||
radial-gradient(24rem 18rem at 0% 0%, rgba(63, 92, 120, 0.03), transparent 74%);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
@@ -171,7 +171,7 @@ a {
|
||||
color: var(--color-muted-strong);
|
||||
font-family: var(--font-mono), monospace;
|
||||
font-size: 0.68rem;
|
||||
letter-spacing: 0.16em;
|
||||
letter-spacing: 0;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
@@ -187,7 +187,7 @@ a {
|
||||
position: absolute;
|
||||
background-image:
|
||||
radial-gradient(68rem 28rem at 50% -10%, var(--page-wash), transparent 72%),
|
||||
radial-gradient(40rem 24rem at 100% 0%, rgba(141, 106, 66, 0.05), transparent 76%);
|
||||
radial-gradient(40rem 24rem at 100% 0%, rgba(63, 92, 120, 0.05), transparent 76%);
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
@@ -233,9 +233,13 @@ a {
|
||||
}
|
||||
|
||||
.essay-prose img {
|
||||
display: block;
|
||||
height: auto;
|
||||
margin-block: 2.25rem;
|
||||
max-width: 100%;
|
||||
border: 1px solid var(--color-line);
|
||||
border-radius: 1rem;
|
||||
border-radius: 0.75rem;
|
||||
background: var(--color-paper-strong);
|
||||
}
|
||||
|
||||
.essay-prose pre code,
|
||||
@@ -277,4 +281,12 @@ a {
|
||||
html {
|
||||
scroll-behavior: auto;
|
||||
}
|
||||
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
animation-duration: 1ms !important;
|
||||
animation-iteration-count: 1 !important;
|
||||
transition-duration: 1ms !important;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
Instrument_Sans,
|
||||
} from "next/font/google";
|
||||
import Script from "next/script";
|
||||
import { Suspense } from "react";
|
||||
import "./globals.css";
|
||||
import { Analytics } from "@/components/Analytics";
|
||||
import { AmbientCanvas } from "@/components/layout/AmbientCanvas";
|
||||
@@ -53,17 +54,7 @@ export default function RootLayout({
|
||||
<body
|
||||
className={`${instrumentSans.variable} ${ibmPlexMono.variable} min-h-screen bg-paper font-sans text-ink antialiased`}
|
||||
>
|
||||
<Script id="theme-init" strategy="beforeInteractive">
|
||||
{`
|
||||
try {
|
||||
const storedTheme = localStorage.getItem("theme-preference");
|
||||
const theme = storedTheme === "light" || storedTheme === "dark"
|
||||
? storedTheme
|
||||
: (window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light");
|
||||
document.documentElement.dataset.theme = theme;
|
||||
} catch {}
|
||||
`}
|
||||
</Script>
|
||||
<Script src="/theme-init.js" strategy="beforeInteractive" />
|
||||
<a
|
||||
href="#main-content"
|
||||
className="sr-only focus:not-sr-only focus:absolute focus:left-4 focus:top-4 focus:z-[100] focus:rounded-full focus:bg-paper-strong focus:px-4 focus:py-2 focus:text-sm focus:font-medium focus:text-ink focus:shadow-[0_12px_30px_rgba(23,28,24,0.08)]"
|
||||
@@ -77,7 +68,9 @@ export default function RootLayout({
|
||||
{children}
|
||||
</main>
|
||||
<Footer />
|
||||
<Analytics />
|
||||
<Suspense fallback={null}>
|
||||
<Analytics />
|
||||
</Suspense>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -2,14 +2,14 @@ import Link from 'next/link';
|
||||
|
||||
export default function NotFound() {
|
||||
return (
|
||||
<div className="min-h-[60vh] flex flex-col items-center justify-center px-6 text-center animate-fade-in">
|
||||
<h1 className="text-6xl font-bold tracking-tight text-zinc-900 dark:text-zinc-50 mb-4">404</h1>
|
||||
<p className="text-lg text-zinc-500 dark:text-zinc-400 mb-8">
|
||||
<div className="page-frame flex min-h-[60vh] flex-col items-center justify-center px-6 text-center">
|
||||
<h1 className="mb-4 text-6xl font-medium text-ink">404</h1>
|
||||
<p className="mb-8 text-lg text-muted">
|
||||
This page doesn't exist.
|
||||
</p>
|
||||
<Link
|
||||
href="/"
|
||||
className="text-sm font-medium text-zinc-900 dark:text-zinc-100 underline decoration-zinc-300 dark:decoration-zinc-700 underline-offset-4 hover:decoration-zinc-900 dark:hover:decoration-zinc-100 transition-all"
|
||||
className="text-sm font-medium text-ink underline decoration-line-strong underline-offset-4 transition-colors hover:text-accent hover:decoration-accent"
|
||||
>
|
||||
← Back to home
|
||||
</Link>
|
||||
|
||||
@@ -15,7 +15,7 @@ export default function Home() {
|
||||
<p className="eyebrow">Akshay Kolli / Research + Writing</p>
|
||||
|
||||
<div className="space-y-4">
|
||||
<h1 className="max-w-[34rem] text-balance font-sans text-[clamp(3.15rem,6vw,5.2rem)] font-medium leading-[0.94] tracking-[-0.085em] text-ink">
|
||||
<h1 className="max-w-[34rem] text-balance font-sans text-5xl font-medium leading-[0.96] text-ink sm:text-6xl lg:text-7xl">
|
||||
World models and reinforcement learning.
|
||||
</h1>
|
||||
|
||||
@@ -49,6 +49,7 @@ export default function Home() {
|
||||
alt="Akshay Kolli"
|
||||
fill
|
||||
priority
|
||||
sizes="(min-width: 1024px) 15rem, 12rem"
|
||||
className="object-cover object-center"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -71,12 +71,20 @@ export default function ResumePage() {
|
||||
<header className="space-y-4 border-b border-line pb-10">
|
||||
<div className="space-y-4">
|
||||
<p className="eyebrow">Resume</p>
|
||||
<h1 className="max-w-[40rem] text-balance font-sans text-[clamp(3rem,6vw,4.8rem)] font-medium leading-[0.94] tracking-[-0.08em] text-ink">
|
||||
<h1 className="max-w-[40rem] text-balance font-sans text-5xl font-medium leading-[0.96] text-ink sm:text-6xl lg:text-7xl">
|
||||
Experience, education, and technical depth.
|
||||
</h1>
|
||||
<p className="max-w-[34rem] text-[1rem] leading-8 text-muted">
|
||||
Research, engineering, and systems work across academia and industry.
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-x-5 gap-y-2 text-[0.92rem] text-ink">
|
||||
<a href="/files/Resume_latest.pdf" className="transition-colors hover:text-accent">
|
||||
Open PDF
|
||||
</a>
|
||||
<a href="/files/Resume_latest.pdf" download className="transition-colors hover:text-accent">
|
||||
Download PDF
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@@ -85,12 +93,12 @@ export default function ResumePage() {
|
||||
<div className="space-y-8">
|
||||
{experience.map((item) => (
|
||||
<article key={`${item.title}-${item.period}`} className="grid gap-2 md:grid-cols-[8rem_minmax(0,1fr)] md:gap-5">
|
||||
<p className="font-mono text-[0.72rem] uppercase tracking-[0.18em] text-muted-strong">
|
||||
<p className="font-mono text-[0.72rem] uppercase text-muted-strong">
|
||||
{item.period}
|
||||
</p>
|
||||
<div className="space-y-2">
|
||||
<div className="space-y-1">
|
||||
<h2 className="font-sans text-[1.35rem] font-medium leading-tight tracking-[-0.04em] text-ink sm:text-[1.55rem]">
|
||||
<h2 className="font-sans text-[1.35rem] font-medium leading-tight text-ink sm:text-[1.55rem]">
|
||||
{item.title}
|
||||
</h2>
|
||||
<p className="text-[0.96rem] leading-7 text-muted">
|
||||
@@ -115,11 +123,11 @@ export default function ResumePage() {
|
||||
<div className="space-y-6">
|
||||
{education.map((item) => (
|
||||
<article key={`${item.title}-${item.period}`} className="grid gap-2 md:grid-cols-[8rem_minmax(0,1fr)] md:gap-5">
|
||||
<p className="font-mono text-[0.72rem] uppercase tracking-[0.18em] text-muted-strong">
|
||||
<p className="font-mono text-[0.72rem] uppercase text-muted-strong">
|
||||
{item.period}
|
||||
</p>
|
||||
<div className="space-y-1">
|
||||
<h2 className="font-sans text-[1.35rem] font-medium leading-tight tracking-[-0.04em] text-ink sm:text-[1.55rem]">
|
||||
<h2 className="font-sans text-[1.35rem] font-medium leading-tight text-ink sm:text-[1.55rem]">
|
||||
{item.title}
|
||||
</h2>
|
||||
<p className="text-[0.96rem] leading-7 text-muted">
|
||||
@@ -136,7 +144,7 @@ export default function ResumePage() {
|
||||
<div className="space-y-4">
|
||||
{skills.map((group) => (
|
||||
<article key={group.label} className="grid gap-2 md:grid-cols-[9rem_minmax(0,1fr)] md:gap-5">
|
||||
<h2 className="font-sans text-[1rem] font-medium tracking-[-0.02em] text-ink">
|
||||
<h2 className="font-sans text-[1rem] font-medium text-ink">
|
||||
{group.label}
|
||||
</h2>
|
||||
<p className="text-[0.96rem] leading-7 text-muted">
|
||||
|
||||
@@ -3,6 +3,7 @@ import { getAllPosts } from '@/lib/mdx';
|
||||
|
||||
export default function sitemap(): MetadataRoute.Sitemap {
|
||||
const posts = getAllPosts();
|
||||
const latestPostDate = posts[0]?.date ? new Date(posts[0].date) : undefined;
|
||||
|
||||
const blogEntries: MetadataRoute.Sitemap = posts.map((post) => ({
|
||||
url: `https://akkolli.net/blog/${post.slug}`,
|
||||
@@ -10,9 +11,9 @@ export default function sitemap(): MetadataRoute.Sitemap {
|
||||
}));
|
||||
|
||||
return [
|
||||
{ url: 'https://akkolli.net', lastModified: new Date() },
|
||||
{ url: 'https://akkolli.net/blog', lastModified: new Date() },
|
||||
{ url: 'https://akkolli.net/resume', lastModified: new Date() },
|
||||
{ url: 'https://akkolli.net', lastModified: latestPostDate },
|
||||
{ url: 'https://akkolli.net/blog', lastModified: latestPostDate },
|
||||
{ url: 'https://akkolli.net/resume' },
|
||||
...blogEntries,
|
||||
];
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user