Skip to content
1 change: 1 addition & 0 deletions public/brands/google.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
1 change: 1 addition & 0 deletions public/brands/perplexity.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
9 changes: 0 additions & 9 deletions src/app/(blog)/layout.tsx

This file was deleted.

66 changes: 38 additions & 28 deletions src/app/(blog)/writing/[slug]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,9 @@ import { BlogPostActions } from "@/components/blog/blog-post-actions";
import { JsonLd } from "@/components/json-ld";
import MDXImage from "@/components/mdx/MDXImage";
import { SiteMain } from "@/components/site-page";
import { SiteShell } from "@/components/site-shell";
import { Separator } from "@/components/ui/separator";
import { buildAskAiPrompt } from "@/lib/ask-ai";
import {
getBlogPost,
getBlogPosts,
Expand Down Expand Up @@ -111,34 +113,42 @@ export default async function BlogPostPage({ params }: { params: PageParams }) {
} satisfies MDXComponents;

return (
<SiteMain className="relative">
<JsonLd data={jsonLd} />
<article className="flex flex-col gap-8">
<header className="flex flex-col">
<h1 className="section-title mb-4 font-serif text-2xl font-normal text-foreground text-balance">
{metadata.title}
</h1>
<div
className="flex items-center justify-between gap-3 border-y border-border whitespace-nowrap"
data-slot="blog-post-rail"
>
<div className="flex min-h-11 shrink-0 items-center gap-2 text-xs text-muted-foreground sm:gap-3">
<time dateTime={date.toISOString()}>{formatDate(date)}</time>
<Separator orientation="vertical" />
<span>{readingTime}</span>
<SiteShell
activeHref="/writing"
askAiContext={{
label: "This article",
title: metadata.title,
prompt: buildAskAiPrompt({
title: metadata.title,
sourceUrl: publicUrl(`/writing/${slug}.md`),
}),
}}
>
<SiteMain className="relative">
<JsonLd data={jsonLd} />
<article className="flex flex-col gap-8">
<header className="flex flex-col">
<h1 className="section-title mb-4 font-serif text-2xl font-normal text-foreground text-balance">
{metadata.title}
</h1>
<div
className="flex items-center justify-between gap-3 border-y border-border whitespace-nowrap"
data-slot="blog-post-rail"
>
<div className="flex min-h-11 shrink-0 items-center gap-2 text-xs text-muted-foreground sm:gap-3">
<time dateTime={date.toISOString()}>{formatDate(date)}</time>
<Separator orientation="vertical" />
<span>{readingTime}</span>
</div>
<BlogPostActions markdownHref={`/writing/${slug}.md`} />
</div>
<BlogPostActions
markdownHref={`/writing/${slug}.md`}
sourceUrl={publicUrl(`/writing/${slug}.md`)}
title={metadata.title}
/>
</div>
</header>
<ArticleAnalytics slug={slug} />
<ArticleProse>
<Content components={mdxComponents} />
</ArticleProse>
</article>
</SiteMain>
</header>
<ArticleAnalytics slug={slug} />
<ArticleProse>
<Content components={mdxComponents} />
</ArticleProse>
</article>
</SiteMain>
</SiteShell>
);
}
12 changes: 10 additions & 2 deletions src/app/(blog)/writing/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import type { Metadata } from "next";

import { JsonLd } from "@/components/json-ld";
import { SiteMain } from "@/components/site-page";
import { SiteShell } from "@/components/site-shell";
import { WritingList } from "@/components/writing-list";
import { getBlogPosts } from "@/lib/blog-utils";
import { publicUrl, siteConfig } from "@/lib/site";
Expand Down Expand Up @@ -39,12 +40,19 @@ export default async function BlogIndexPage() {
const posts = await getBlogPosts();

return (
<>
<SiteShell
activeHref="/writing"
askAiContext={{
label: "My writing",
title: "Sid’s writing",
prompt: `Read ${publicUrl("/llms.txt")} and ${publicUrl("/writing")}. Help me explore Sid Jain’s published articles. Recommend an article to start with, then answer my questions using the linked Markdown posts as sources. Cite the articles you use and say if you cannot access them.`,
}}
>
<JsonLd data={buildBlogCollectionJsonLd(posts)} />
<SiteMain>
<h1 className="sr-only">Writing</h1>
<WritingList posts={posts} />
</SiteMain>
</>
</SiteShell>
);
}
118 changes: 118 additions & 0 deletions src/components/ask-ai-face.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
"use client";

import { motion, useSpring } from "motion/react";
import { useEffect, useRef, useState, useSyncExternalStore } from "react";

const spring = { stiffness: 1000, damping: 45, mass: 0.5 };
const motionQuery = "(prefers-reduced-motion: reduce)";
const subscribe = (onChange: () => void) => {
const query = matchMedia(motionQuery);
query.addEventListener("change", onChange);
return () => {
query.removeEventListener("change", onChange);
};
};
const getReducedMotion = () => matchMedia(motionQuery).matches;
const getServerReducedMotion = () => true;

export function AskAiFace() {
const face = useRef<HTMLSpanElement>(null);
const reducedMotion = useSyncExternalStore(
subscribe,
getReducedMotion,
getServerReducedMotion
);
const [tracking, setTracking] = useState(false);
const x = useSpring(0, spring);
const y = useSpring(0, spring);

useEffect(() => {
let idleTimer = 0;
const rest = () => {
clearTimeout(idleTimer);
x.set(0);
y.set(0);
setTracking(false);
};
const follow = (event: PointerEvent) => {
if (event.pointerType !== "mouse" || !face.current) {
return;
}
const rect = face.current.getBoundingClientRect();
x.set(Math.tanh((event.clientX - rect.x - rect.width / 2) / 400) * 6);
y.set(Math.tanh((event.clientY - rect.y - rect.height / 2) / 300) * 5);
setTracking(true);
clearTimeout(idleTimer);
idleTimer = window.setTimeout(rest, 2500);
};
if (reducedMotion) {
rest();
} else {
window.addEventListener("pointermove", follow, { passive: true });
window.addEventListener("blur", rest);
document.addEventListener("pointerleave", rest);
document.addEventListener("visibilitychange", rest);
}
return () => {
clearTimeout(idleTimer);
x.set(0);
y.set(0);
window.removeEventListener("pointermove", follow);
window.removeEventListener("blur", rest);
document.removeEventListener("pointerleave", rest);
document.removeEventListener("visibilitychange", rest);
};
}, [reducedMotion, x, y]);

return (
<span
ref={face}
aria-hidden="true"
className="relative flex size-7 items-center justify-center rounded-full bg-primary text-primary-foreground"
>
<motion.span
style={{ x: reducedMotion ? 0 : x, y: reducedMotion ? 0 : y }}
>
<motion.span
className="block"
initial={false}
animate={{
x: reducedMotion || tracking ? 0 : [null, 0, -3, -3, 2, 2, 0],
y: reducedMotion || tracking ? 0 : [null, 0, -1, -1, 1, 1, 0],
}}
transition={
reducedMotion
? { duration: 0 }
: tracking
? { type: "spring", ...spring }
: {
duration: 6,
times: [0, 0.22, 0.24, 0.56, 0.58, 0.94, 1],
repeat: Infinity,
ease: "easeInOut",
}
}
>
<motion.span
className="flex items-center gap-1.25 [&>span]:h-2 [&>span]:w-0.75 [&>span]:rounded-full [&>span]:bg-current [&>span]:transition-[height] [&>span]:duration-180 [&>span]:ease-settle group-hover:[&>span]:h-1.5 group-focus-visible:[&>span]:h-1.5 group-data-popup-open:[&>span]:h-1.5 motion-reduce:[&>span]:transition-none"
animate={{
scaleY: reducedMotion
? 1
: [1, 1, 0.12, 1, 1, 0.12, 1, 1, 0.12, 1, 1],
}}
transition={{
duration: reducedMotion ? 0 : 6,
times: [
0, 0.4, 0.41, 0.43, 0.78, 0.79, 0.81, 0.85, 0.86, 0.88, 1,
],
repeat: reducedMotion ? 0 : Infinity,
}}
>
<span />
<span />
</motion.span>
</motion.span>
</motion.span>
</span>
);
}
138 changes: 138 additions & 0 deletions src/components/ask-ai-widget.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
"use client";

import { Popover } from "@base-ui/react/popover";
import { X } from "lucide-react";
import Image from "next/image";
import { useId, useState } from "react";

import { AskAiFace } from "@/components/ask-ai-face";
import { AnimatedCopyButton } from "@/components/ui/animated-copy-button";
import { Button } from "@/components/ui/button";
import { buildAssistantActions } from "@/lib/ask-ai";
import type { AskAiPageContext } from "@/lib/ask-ai";

export function AskAiWidget({
profilePrompt,
pageContext,
}: Readonly<{ profilePrompt: string; pageContext?: AskAiPageContext }>) {
const topicId = useId();
const [topic, setTopic] = useState(
pageContext === undefined ? "profile" : "page"
);
const context = topic === "page" ? pageContext : undefined;
const prompt = context?.prompt ?? profilePrompt;
const actions = buildAssistantActions(prompt);
const subject = context?.title ?? "Sid";
const [open, setOpen] = useState(false);

return (
<Popover.Root open={open} onOpenChange={setOpen}>
<Popover.Trigger
aria-label="Ask an AI"
className="group fixed right-4 bottom-[calc(1rem+env(safe-area-inset-bottom))] z-40 inline-flex h-11 items-center gap-2 rounded-full bg-muted py-2 pr-2 pl-3 text-sm font-medium text-popover-foreground shadow-site-floating ring-1 ring-border dark:ring-0 transition-colors duration-150 hover:bg-accent focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-ring motion-reduce:transition-none sm:right-6 sm:bottom-6 print:hidden"
openOnHover
delay={250}
closeDelay={300}
>
<span>Ask an AI</span>
<AskAiFace />
</Popover.Trigger>
<Popover.Portal>
<Popover.Positioner
align="end"
side="top"
sideOffset={12}
collisionPadding={16}
className="z-50 print:hidden"
>
<Popover.Popup className="w-80 max-w-[calc(100vw-2rem)] max-h-[min(70dvh,var(--available-height))] overflow-y-auto overscroll-contain rounded-2xl bg-popover px-4 pt-3 pb-4 text-popover-foreground shadow-site-floating ring-1 ring-border dark:ring-0 outline-none origin-bottom-right transition-[opacity,transform] duration-150 data-starting-style:translate-y-1 data-starting-style:opacity-0 data-ending-style:translate-y-1 data-ending-style:opacity-0 motion-reduce:transition-none">
<div className="flex items-start justify-between gap-3">
<Popover.Title className="text-sm leading-6 font-medium">
Ask an AI
</Popover.Title>
<Popover.Close
aria-label="Close AI picker"
render={
<Button
variant="ghost"
className="-mt-1 -mr-1 size-8 rounded-full"
/>
}
>
<X aria-hidden="true" className="size-4" />
</Popover.Close>
</div>
{pageContext === undefined ? null : (
<fieldset className="mt-2 grid grid-cols-2 gap-1 rounded-lg bg-muted p-1">
<legend className="sr-only">Question context</legend>
{[
{ value: "page", label: pageContext.label },
{ value: "profile", label: "About Sid" },
].map((option) => (
<label
key={option.value}
className="relative cursor-pointer text-center text-xs"
>
<input
className="peer sr-only"
type="radio"
name={topicId}
value={option.value}
checked={topic === option.value}
onChange={() => {
setTopic(option.value);
}}
/>
<span className="flex min-h-11 items-center justify-center rounded-md px-2 text-muted-foreground peer-checked:bg-background peer-checked:text-foreground peer-checked:shadow-sm peer-focus-visible:outline-2 peer-focus-visible:outline-ring">
{option.label}
</span>
</label>
))}
</fieldset>
)}
<ul
aria-label="AI assistants"
className="mt-2 grid grid-cols-2 gap-2"
>
{actions.map((action) => (
<li key={action.label}>
<a
aria-label={`Ask ${action.label} about ${subject} (opens in a new tab)`}
className="flex h-20 flex-col items-center justify-center gap-2 rounded-lg bg-muted px-2 text-xs font-medium transition-colors hover:bg-accent focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring"
data-analytics-placement="ai-widget"
href={action.href}
onClick={() => {
setOpen(false);
}}
rel="noopener noreferrer"
target="_blank"
>
<Image
alt=""
className={
action.label === "ChatGPT" ||
action.label === "Perplexity"
? "size-5 dark:invert"
: "size-5"
}
height={20}
width={20}
src={action.iconSrc}
/>
<span>{action.label}</span>
</a>
</li>
))}
</ul>
<AnimatedCopyButton
key={`${open}:${prompt}`}
value={prompt}
label="Copy prompt"
className="mt-3 min-h-11 w-full text-muted-foreground"
/>
</Popover.Popup>
</Popover.Positioner>
</Popover.Portal>
</Popover.Root>
);
}
Loading