diff --git a/public/brands/google.svg b/public/brands/google.svg new file mode 100644 index 00000000..088288fa --- /dev/null +++ b/public/brands/google.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/brands/perplexity.svg b/public/brands/perplexity.svg new file mode 100644 index 00000000..648b7c7a --- /dev/null +++ b/public/brands/perplexity.svg @@ -0,0 +1 @@ +Perplexity \ No newline at end of file diff --git a/src/app/(blog)/layout.tsx b/src/app/(blog)/layout.tsx deleted file mode 100644 index c7d87a14..00000000 --- a/src/app/(blog)/layout.tsx +++ /dev/null @@ -1,9 +0,0 @@ -import { SiteShell } from "@/components/site-shell"; - -export default function BlogLayout({ - children, -}: Readonly<{ - children: React.ReactNode; -}>) { - return {children}; -} diff --git a/src/app/(blog)/writing/[slug]/page.tsx b/src/app/(blog)/writing/[slug]/page.tsx index 34fec568..53d1c9e1 100644 --- a/src/app/(blog)/writing/[slug]/page.tsx +++ b/src/app/(blog)/writing/[slug]/page.tsx @@ -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, @@ -111,34 +113,42 @@ export default async function BlogPostPage({ params }: { params: PageParams }) { } satisfies MDXComponents; return ( - - -
-
-

- {metadata.title} -

-
-
- - - {readingTime} + + + +
+
+

+ {metadata.title} +

+
+
+ + + {readingTime} +
+
- -
-
- - - - -
-
+ + + + + + + + ); } diff --git a/src/app/(blog)/writing/page.tsx b/src/app/(blog)/writing/page.tsx index 961b1bc0..88006b31 100644 --- a/src/app/(blog)/writing/page.tsx +++ b/src/app/(blog)/writing/page.tsx @@ -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"; @@ -39,12 +40,19 @@ export default async function BlogIndexPage() { const posts = await getBlogPosts(); return ( - <> +

Writing

- +
); } diff --git a/src/components/ask-ai-face.tsx b/src/components/ask-ai-face.tsx new file mode 100644 index 00000000..fb2f8048 --- /dev/null +++ b/src/components/ask-ai-face.tsx @@ -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(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 ( + + ); +} diff --git a/src/components/ask-ai-widget.tsx b/src/components/ask-ai-widget.tsx new file mode 100644 index 00000000..45cb8e96 --- /dev/null +++ b/src/components/ask-ai-widget.tsx @@ -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 ( + + + Ask an AI + + + + + +
+ + Ask an AI + + + } + > + +
+ {pageContext === undefined ? null : ( +
+ Question context + {[ + { value: "page", label: pageContext.label }, + { value: "profile", label: "About Sid" }, + ].map((option) => ( + + ))} +
+ )} + + +
+
+
+
+ ); +} diff --git a/src/components/blog/blog-post-actions.tsx b/src/components/blog/blog-post-actions.tsx index 92da843d..82113cc2 100644 --- a/src/components/blog/blog-post-actions.tsx +++ b/src/components/blog/blog-post-actions.tsx @@ -1,134 +1,21 @@ -"use client"; - -import { FileTextIcon, SparklesIcon } from "lucide-react"; -import Image from "next/image"; - -import { DisclosureChevron } from "@/components/ui/collapsible"; -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuLinkItem, - DropdownMenuTrigger, -} from "@/components/ui/dropdown-menu"; -import { buildAskAiLinks, buildAskAiPrompt } from "@/lib/ask-ai"; - -interface BlogPostActionsProps { - markdownHref: string; - sourceUrl: string; - title: string; -} - -const externalLinkProps = { - rel: "noopener noreferrer", - target: "_blank", -} as const; +import { FileTextIcon } from "lucide-react"; export function BlogPostActions({ markdownHref, - sourceUrl, - title, -}: Readonly) { - const context = { sourceUrl, title }; - const links = buildAskAiLinks(context); - - const copyPromptForGemini = async () => { - if (navigator.clipboard === undefined) { - return; - } - - try { - await navigator.clipboard.writeText(buildAskAiPrompt(context)); - } catch { - // Gemini still opens when clipboard access is unavailable. - } - }; - +}: Readonly<{ markdownHref: string }>) { return ( -

- Ask - {actions.map((action, index) => ( - - {index === 0 ? null : or} - - - {action.label} - - - ))} -

); diff --git a/src/components/site-shell.tsx b/src/components/site-shell.tsx index b6d5e183..9fe3d4b2 100644 --- a/src/components/site-shell.tsx +++ b/src/components/site-shell.tsx @@ -1,20 +1,25 @@ import type { ReactNode } from "react"; +import { AskAiWidget } from "@/components/ask-ai-widget"; import { SiteFooter } from "@/components/site-footer"; import { SiteHeader } from "@/components/site-header"; import type { SiteHeaderProps } from "@/components/site-header"; +import type { AskAiPageContext } from "@/lib/ask-ai"; +import { buildAskAboutMePrompt } from "@/lib/resume"; interface SiteShellProps extends SiteHeaderProps { children: ReactNode; + askAiContext?: AskAiPageContext; } export function SiteShell({ activeHref, + askAiContext, children, currentPath = activeHref ?? "/", }: Readonly) { return ( -
+ ); } diff --git a/src/components/ui/animated-copy-button.tsx b/src/components/ui/animated-copy-button.tsx new file mode 100644 index 00000000..413269db --- /dev/null +++ b/src/components/ui/animated-copy-button.tsx @@ -0,0 +1,113 @@ +"use client"; + +import { Check, Copy } from "lucide-react"; +import { AnimatePresence, motion, useReducedMotion } from "motion/react"; +import { useEffect, useRef, useState } from "react"; + +import { Button } from "@/components/ui/button"; + +export function AnimatedCopyButton({ + value, + label = "Copy", + className, +}: Readonly<{ value: string; label?: string; className?: string }>) { + const [status, setStatus] = useState<"idle" | "copied" | "error">("idle"); + const request = useRef(0); + const timeout = useRef(0); + const reducedMotion = useReducedMotion() === true; + const copied = status === "copied"; + const Icon = copied ? Check : Copy; + + useEffect( + () => () => { + request.current += 1; + clearTimeout(timeout.current); + }, + [] + ); + + const copy = async () => { + const currentRequest = ++request.current; + clearTimeout(timeout.current); + try { + await navigator.clipboard.writeText(value); + if (currentRequest !== request.current) { + return; + } + setStatus("copied"); + timeout.current = window.setTimeout(() => { + setStatus("idle"); + }, 1500); + } catch { + if (currentRequest === request.current) { + setStatus("error"); + } + } + }; + + return ( + <> + +

+ {status === "error" + ? "Select and copy the text below." + : copied + ? "Copied" + : ""} +

+ {status === "error" ? ( +