1
mirror of https://github.com/jakejarvis/jarv.is.git synced 2026-06-05 20:15:31 -04:00

refactor: overhaul view transitions with granular per-page animation components

- Replace single `<ViewTransition>` wrapper in layout with `FadeTransition` and `DirectionalTransition` components applied per page
- Add `components/page-transition.tsx` with reusable transition wrappers
- Expand view transition CSS with named classes: fade, slide, nav-forward/back, morph, text-morph, scale — all driven by CSS custom property durations
- Use React `<ViewTransition name=... share="text-morph">` for shared note title element between list and detail views
- Wrap comments suspense boundary with enter/exit slide transitions
- Add `persistent-nav` and `persistent-footer` view-transition-name groups to keep chrome static during navigation
- Fix reduced-motion override to target delay and duration instead of `animation: none`
- Add tracking-tight and letter-spacing tweaks to home page typography
This commit is contained in:
2026-04-25 10:50:31 -04:00
parent ad90539df4
commit b2416ff0db
16 changed files with 1091 additions and 671 deletions
+7 -5
View File
@@ -2,6 +2,8 @@ import { ArrowUpRight } from "lucide-react";
import Image, { type StaticImageData } from "next/image";
import Link from "next/link";
import { FadeTransition } from "@/components/page-transition";
import domainstackIcon from "./icons/domainstack.png";
import snoozleIcon from "./icons/snoozle.png";
import sofaIcon from "./icons/sofa.png";
@@ -49,8 +51,8 @@ const projects: readonly Project[] = [
] as const;
const Page = () => (
<>
<h1 className="text-lg font-medium">
<FadeTransition>
<h1 className="text-lg font-medium tracking-tight">
Hi there! I&rsquo;m Jake.{" "}
<span className="motion-safe:animate-wave ml-0.5 inline-block origin-[65%_80%] text-[1.2rem]">
👋
@@ -58,7 +60,7 @@ const Page = () => (
</h1>
<div className="markdown">
<p className="text-sm leading-normal">
<p className="text-sm leading-normal tracking-[-0.0125em]">
I&rsquo;m a developer based in the{" "}
<Link
href="https://www.youtube-nocookie.com/embed/rLwbzGyC6t4?hl=en&amp;fs=1&amp;showinfo=1&amp;rel=0&amp;iv_load_policy=3"
@@ -89,7 +91,7 @@ const Page = () => (
decoding="async"
className="ring-border size-6 shrink-0 rounded-[26%] ring-1"
/>
<span className="text-primary text-sm font-medium group-hover:underline group-hover:underline-offset-4">
<span className="text-primary text-sm font-medium tracking-[-0.0125em] group-hover:underline group-hover:underline-offset-4">
{project.name}
</span>
</div>
@@ -105,7 +107,7 @@ const Page = () => (
))}
</ul>
</section>
</>
</FadeTransition>
);
export default Page;
+125 -19
View File
@@ -62,6 +62,9 @@
:root {
--radius: 0.625rem;
--duration-exit: 150ms;
--duration-enter: 210ms;
--duration-move: 400ms;
--background: oklch(1 0 0);
--foreground: oklch(0.145 0 0);
--card: oklch(1 0 0);
@@ -104,6 +107,12 @@
--selection-foreground: oklch(1 0 0);
}
@keyframes via-blur {
30% {
filter: blur(3px);
}
}
.dark {
--background: oklch(0 0 0);
--foreground: oklch(0.985 0 0);
@@ -202,34 +211,131 @@
cursor: pointer;
}
/* View Transitions - uses tw-animate-css's `enter` and `exit` keyframes */
@media (prefers-reduced-motion: reduce) {
::view-transition-group(*),
::view-transition-old(*),
::view-transition-new(*) {
animation: none !important;
}
}
/* Disable the default cross-fade on root (header/footer/chrome stay static) */
::view-transition-old(root),
::view-transition-new(root) {
animation: none;
}
/* Main content: fade + slide */
main {
view-transition-name: main-content;
}
::view-transition-old(main-content) {
::view-transition-old(.fade-out) {
--tw-exit-blur: 3px;
--tw-exit-opacity: 0;
--tw-exit-translate-y: -8px;
animation: 150ms ease-in forwards exit;
animation: var(--duration-exit) ease-in forwards exit;
}
::view-transition-new(main-content) {
::view-transition-new(.fade-in) {
--tw-enter-blur: 3px;
--tw-enter-opacity: 0;
--tw-enter-translate-y: 12px;
animation: 200ms ease-out forwards enter;
animation: var(--duration-enter) ease-out var(--duration-exit) both enter;
}
::view-transition-old(.slide-down) {
--tw-exit-opacity: 0;
--tw-exit-translate-y: 10px;
animation: var(--duration-exit) ease-out both exit;
}
::view-transition-new(.slide-up) {
--tw-enter-opacity: 0;
--tw-enter-translate-y: 10px;
animation: var(--duration-enter) ease-in var(--duration-exit) both enter;
}
::view-transition-new(.slide-from-right) {
--tw-enter-opacity: 0;
--tw-enter-translate-x: 60px;
animation: var(--duration-move) ease-in-out both enter;
}
::view-transition-old(.slide-to-left) {
--tw-exit-opacity: 0;
--tw-exit-translate-x: -60px;
animation: var(--duration-exit) ease-in both exit;
}
::view-transition-new(.slide-from-left) {
--tw-enter-opacity: 0;
--tw-enter-translate-x: -60px;
animation: var(--duration-move) ease-in-out both enter;
}
::view-transition-old(.slide-to-right) {
--tw-exit-opacity: 0;
--tw-exit-translate-x: 60px;
animation: var(--duration-exit) ease-in both exit;
}
::view-transition-old(.nav-forward) {
--tw-exit-opacity: 0;
--tw-exit-translate-x: -60px;
animation: var(--duration-exit) ease-in both exit;
}
::view-transition-new(.nav-forward) {
--tw-enter-opacity: 0;
--tw-enter-translate-x: 60px;
animation: var(--duration-move) ease-in-out both enter;
}
::view-transition-old(.nav-back) {
--tw-exit-opacity: 0;
--tw-exit-translate-x: 60px;
animation: var(--duration-exit) ease-in both exit;
}
::view-transition-new(.nav-back) {
--tw-enter-opacity: 0;
--tw-enter-translate-x: -60px;
animation: var(--duration-move) ease-in-out both enter;
}
::view-transition-group(.morph) {
animation-duration: var(--duration-move);
}
::view-transition-image-pair(.morph) {
animation-name: via-blur;
}
::view-transition-group(.text-morph) {
animation-duration: var(--duration-move);
}
::view-transition-old(.text-morph) {
display: none;
}
::view-transition-new(.text-morph) {
animation: none;
object-fit: none;
object-position: left top;
}
::view-transition-old(.scale-out) {
--tw-exit-opacity: 0;
--tw-exit-scale: 85%;
animation: var(--duration-exit) ease-in exit;
}
::view-transition-new(.scale-in) {
--tw-enter-opacity: 0;
--tw-enter-scale: 85%;
animation: var(--duration-enter) ease-out var(--duration-exit) both enter;
}
::view-transition-group(persistent-nav),
::view-transition-group(persistent-footer) {
animation: none;
z-index: 100;
}
::view-transition-old(persistent-nav) {
display: none;
}
::view-transition-new(persistent-nav),
::view-transition-old(persistent-footer),
::view-transition-new(persistent-footer) {
animation: none;
}
@media (prefers-reduced-motion: reduce) {
::view-transition-old(*),
::view-transition-new(*),
::view-transition-group(*) {
animation-delay: 0s !important;
animation-duration: 0s !important;
}
}
}
+1 -4
View File
@@ -1,4 +1,3 @@
import { ViewTransition } from "react";
import { JsonLd } from "react-schemaorg";
import type { Person, WebSite } from "schema-dts";
@@ -65,9 +64,7 @@ const RootLayout = ({ children }: Readonly<{ children: React.ReactNode }>) => (
<Providers>
<div className="mx-auto w-full max-w-[720px] px-5">
<Header />
<main className="mt-4 w-full">
<ViewTransition>{children}</ViewTransition>
</main>
<main className="mt-4 w-full">{children}</main>
<Footer />
</div>
<Toaster position="bottom-center" hotkey={[]} />
+3 -2
View File
@@ -2,6 +2,7 @@ import { JsonLd } from "react-schemaorg";
import type { VideoObject } from "schema-dts";
import { PageTitle } from "@/components/layout/page-title";
import { FadeTransition } from "@/components/page-transition";
import { Video } from "@/components/video";
import { createMetadata } from "@/lib/metadata";
@@ -22,7 +23,7 @@ export const metadata = createMetadata({
});
const Page = () => (
<>
<FadeTransition>
<JsonLd<VideoObject>
item={{
"@context": "https://schema.org",
@@ -65,7 +66,7 @@ const Page = () => (
</a>
. &copy; 2007 G4 Media, Inc.
</p>
</>
</FadeTransition>
);
export default Page;
+3 -2
View File
@@ -1,6 +1,7 @@
import type { Metadata } from "next";
import Link from "next/link";
import { FadeTransition } from "@/components/page-transition";
import { Button } from "@/components/ui/button";
import { Video } from "@/components/video";
@@ -14,7 +15,7 @@ export const metadata: Metadata = {
};
const Page = () => (
<>
<FadeTransition>
<Video
src="https://ijyxfbpcm3itvdly.public.blob.vercel-storage.com/not-found-SAtLyNyc7gVhveYxr6o1ITd9CSXo5X.mp4"
autoPlay
@@ -33,7 +34,7 @@ const Page = () => (
Go home?
</Button>
</div>
</>
</FadeTransition>
);
export default Page;
+26 -18
View File
@@ -8,13 +8,14 @@ import {
import type { Metadata } from "next";
import Link from "next/link";
import { notFound } from "next/navigation";
import { Suspense } from "react";
import { Suspense, ViewTransition } from "react";
import { JsonLd } from "react-schemaorg";
import type { BlogPosting } from "schema-dts";
import { CommentCount } from "@/components/comment-count";
import { Comments } from "@/components/comments/comments";
import { CommentsSkeleton } from "@/components/comments/comments-skeleton";
import { DirectionalTransition } from "@/components/page-transition";
import { ViewCounter } from "@/components/view-counter";
import authorConfig from "@/lib/config/author";
import siteConfig from "@/lib/config/site";
@@ -83,7 +84,7 @@ const Page = async ({ params }: { params: Promise<{ slug: string }> }) => {
const { default: MDXContent } = await import(`../../../${POSTS_DIR}/${slug}/index.mdx`);
return (
<>
<DirectionalTransition>
<JsonLd<BlogPosting>
item={{
"@context": "https://schema.org",
@@ -109,7 +110,7 @@ const Page = async ({ params }: { params: Promise<{ slug: string }> }) => {
}}
/>
<div className="text-foreground/70 flex flex-wrap justify-items-start gap-4 text-[13px] tracking-wide">
<div className="text-foreground/70 flex flex-wrap justify-items-start space-y-2.5 space-x-4 text-[13px] tracking-wide">
<Link
href={`/${POSTS_DIR}/${frontmatter?.slug}`}
className={
@@ -166,18 +167,17 @@ const Page = async ({ params }: { params: Promise<{ slug: string }> }) => {
</div>
</div>
<h1
className="my-5 text-3xl font-medium tracking-tight"
style={{ viewTransitionName: `note-title-${frontmatter?.slug}` }}
>
<Link
href={`/${POSTS_DIR}/${frontmatter?.slug}`}
dangerouslySetInnerHTML={{
__html: frontmatter.htmlTitle || frontmatter.title,
}}
className="text-foreground hover:no-underline"
/>
</h1>
<ViewTransition name={`note-title-${frontmatter.slug}`} share="text-morph" default="none">
<h1 className="my-5 text-2xl font-medium tracking-tight">
<Link
href={`/${POSTS_DIR}/${frontmatter.slug}`}
dangerouslySetInnerHTML={{
__html: frontmatter.htmlTitle || frontmatter.title,
}}
className="text-foreground hover:no-underline"
/>
</h1>
</ViewTransition>
<article className="markdown">
<MDXContent />
@@ -190,13 +190,21 @@ const Page = async ({ params }: { params: Promise<{ slug: string }> }) => {
<p className="text-center text-lg font-medium">Comments are closed.</p>
</div>
) : (
<Suspense fallback={<CommentsSkeleton />}>
<Comments slug={`${POSTS_DIR}/${frontmatter?.slug}`} />
<Suspense
fallback={
<ViewTransition exit="slide-down">
<CommentsSkeleton />
</ViewTransition>
}
>
<ViewTransition enter="slide-up" default="none">
<Comments slug={`${POSTS_DIR}/${frontmatter.slug}`} />
</ViewTransition>
</Suspense>
)}
</div>
</section>
</>
</DirectionalTransition>
);
};
+14 -10
View File
@@ -1,6 +1,8 @@
import Link from "next/link";
import { ViewTransition } from "react";
import { PageTitle } from "@/components/layout/page-title";
import { DirectionalTransition } from "@/components/page-transition";
import { PostStats, PostStatsProvider } from "@/components/post-stats";
import authorConfig from "@/lib/config/author";
import { createMetadata } from "@/lib/metadata";
@@ -56,24 +58,26 @@ const PostsList = async () => {
Object.entries(postsByYear).forEach(([year, yearPosts]) => {
sections.push(
<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-2xl font-semibold tracking-tight">
<h2 id={year} className="mt-0 mb-4 text-xl font-semibold tracking-tight">
{year}
</h2>
<ul className="space-y-4">
{yearPosts.map(({ slug, dateISO, dateTitle, dateDisplay, title, htmlTitle }) => (
<li className="flex text-base leading-relaxed" key={slug}>
<li className="flex text-sm leading-relaxed" key={slug}>
<span className="text-muted-foreground w-18 shrink-0 md:w-22">
<time dateTime={dateISO} title={dateTitle} suppressHydrationWarning>
{dateDisplay}
</time>
</span>
<div className="space-x-2">
<Link
href={`/${POSTS_DIR}/${slug}`}
dangerouslySetInnerHTML={{ __html: htmlTitle || title }}
className="mr-2.5 underline-offset-4 hover:underline"
style={{ viewTransitionName: `note-title-${slug}` }}
/>
<ViewTransition name={`note-title-${slug}`} share="text-morph" default="none">
<Link
href={`/${POSTS_DIR}/${slug}`}
transitionTypes={["nav-forward"]}
dangerouslySetInnerHTML={{ __html: htmlTitle || title }}
className="mr-2.5 underline-offset-4 hover:underline"
/>
</ViewTransition>
<PostStats slug={`${POSTS_DIR}/${slug}`} />
</div>
@@ -89,12 +93,12 @@ const PostsList = async () => {
};
const Page = async () => (
<>
<DirectionalTransition>
<PageTitle canonical="/notes">Notes</PageTitle>
<PostStatsProvider>
<PostsList />
</PostStatsProvider>
</>
</DirectionalTransition>
);
export default Page;
+5 -4
View File
@@ -4,6 +4,7 @@ import { Suspense } from "react";
import { ActivityCalendar } from "@/components/activity-calendar";
import { PageTitle } from "@/components/layout/page-title";
import { FadeTransition } from "@/components/page-transition";
import { RelativeTime } from "@/components/relative-time";
import { Button } from "@/components/ui/button";
import { Skeleton } from "@/components/ui/skeleton";
@@ -30,10 +31,10 @@ const Page = async () => {
const [contributions, repos] = await Promise.all([getContributions(), getRepos()]);
return (
<>
<FadeTransition>
<PageTitle canonical="/projects">Projects</PageTitle>
<h2 className="my-3.5 text-xl font-medium">
<h2 className="my-3.5 text-lg font-medium">
<a
href={`https://github.com/${process.env.NEXT_PUBLIC_GITHUB_USERNAME}`}
target="_blank"
@@ -56,7 +57,7 @@ const Page = async () => {
)}
</Suspense>
<h2 className="my-3.5 text-xl font-medium">
<h2 className="my-3.5 text-lg font-medium">
<a
href={`https://github.com/${process.env.NEXT_PUBLIC_GITHUB_USERNAME}?tab=repositories&sort=stargazers`}
target="_blank"
@@ -171,7 +172,7 @@ const Page = async () => {
<ExternalLinkIcon className="inline-block size-3.5 shrink-0" aria-hidden="true" />
</Button>
</p>
</>
</FadeTransition>
);
};
+4 -1
View File
@@ -3,7 +3,10 @@ import Link from "next/link";
import siteConfig from "@/lib/config/site";
const Footer = () => (
<footer className="text-muted-foreground border-border mt-8 w-full border-t py-6 text-xs leading-loose">
<footer
style={{ viewTransitionName: "persistent-footer" }}
className="text-muted-foreground border-border mt-8 w-full border-t py-6 text-xs leading-loose"
>
All content is licensed under{" "}
<Link href="/license" className="underline underline-offset-4">
{siteConfig.license}
+269 -30
View File
@@ -1,23 +1,273 @@
"use client";
import { AtSignIcon, MoonIcon, SunIcon } from "lucide-react";
import { AtSignIcon, ExternalLinkIcon, MoonIcon, SunIcon } from "lucide-react";
import { useTheme } from "next-themes";
import Image from "next/image";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { useEffect, useState } from "react";
import avatarImg from "@/app/avatar.jpg";
import { GitHubIcon } from "@/components/icons";
import { Menu } from "@/components/layout/menu";
import { Button } from "@/components/ui/button";
import {
Popover,
PopoverContent,
PopoverDescription,
PopoverHeader,
PopoverTitle,
PopoverTrigger,
} from "@/components/ui/popover";
import { Separator } from "@/components/ui/separator";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import authorConfig from "@/lib/config/author";
import siteConfig from "@/lib/config/site";
import { cn } from "@/lib/utils";
const contactIconClassName = "text-muted-foreground size-4";
const contactLinks = [
{
label: "Email",
value: authorConfig.email,
href: `mailto:${authorConfig.email}`,
Icon: (props: React.SVGProps<SVGSVGElement>) => (
<svg
xmlns="http://www.w3.org/2000/svg"
width="1em"
height="1em"
viewBox="0 0 24 24"
{...props}
>
{/* Icon from Material Symbols by Google - https://github.com/google/material-design-icons/blob/master/LICENSE */}
<path
fill="currentColor"
d="M4 20q-.825 0-1.412-.587T2 18V6q0-.825.588-1.412T4 4h16q.825 0 1.413.588T22 6v12q0 .825-.587 1.413T20 20zm8-7l8-5V6l-8 5l-8-5v2z"
/>
</svg>
),
external: false,
},
{
label: "GitHub",
value: `@${authorConfig.social.github}`,
href: `https://github.com/${authorConfig.social.github}`,
Icon: (props: React.SVGProps<SVGSVGElement>) => (
<svg
xmlns="http://www.w3.org/2000/svg"
width="1em"
height="1em"
viewBox="0 0 24 24"
{...props}
>
{/* Icon from Simple Icons by Simple Icons Collaborators - https://github.com/simple-icons/simple-icons/blob/develop/LICENSE.md */}
<path
fill="currentColor"
d="M12 .297c-6.63 0-12 5.373-12 12c0 5.303 3.438 9.8 8.205 11.385c.6.113.82-.258.82-.577c0-.285-.01-1.04-.015-2.04c-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729c1.205.084 1.838 1.236 1.838 1.236c1.07 1.835 2.809 1.305 3.495.998c.108-.776.417-1.305.76-1.605c-2.665-.3-5.466-1.332-5.466-5.93c0-1.31.465-2.38 1.235-3.22c-.135-.303-.54-1.523.105-3.176c0 0 1.005-.322 3.3 1.23c.96-.267 1.98-.399 3-.405c1.02.006 2.04.138 3 .405c2.28-1.552 3.285-1.23 3.285-1.23c.645 1.653.24 2.873.12 3.176c.765.84 1.23 1.91 1.23 3.22c0 4.61-2.805 5.625-5.475 5.92c.42.36.81 1.096.81 2.22c0 1.606-.015 2.896-.015 3.286c0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12"
/>
</svg>
),
external: true,
},
{
label: "Bluesky",
value: `@${authorConfig.social.bluesky}`,
href: `https://bsky.app/profile/${authorConfig.social.bluesky}`,
Icon: (props: React.SVGProps<SVGSVGElement>) => (
<svg
xmlns="http://www.w3.org/2000/svg"
width="1em"
height="1em"
viewBox="0 0 24 24"
{...props}
>
{/* Icon from Simple Icons by Simple Icons Collaborators - https://github.com/simple-icons/simple-icons/blob/develop/LICENSE.md */}
<path
fill="currentColor"
d="M5.202 2.857C7.954 4.922 10.913 9.11 12 11.358c1.087-2.247 4.046-6.436 6.798-8.501C20.783 1.366 24 .213 24 3.883c0 .732-.42 6.156-.667 7.037c-.856 3.061-3.978 3.842-6.755 3.37c4.854.826 6.089 3.562 3.422 6.299c-5.065 5.196-7.28-1.304-7.847-2.97c-.104-.305-.152-.448-.153-.327c0-.121-.05.022-.153.327c-.568 1.666-2.782 8.166-7.847 2.97c-2.667-2.737-1.432-5.473 3.422-6.3c-2.777.473-5.899-.308-6.755-3.369C.42 10.04 0 4.615 0 3.883c0-3.67 3.217-2.517 5.202-1.026"
/>
</svg>
),
external: true,
},
{
label: "Mastodon",
value: authorConfig.social.mastodon,
href: `https://${authorConfig.social.mastodon}`,
Icon: (props: React.SVGProps<SVGSVGElement>) => (
<svg
xmlns="http://www.w3.org/2000/svg"
width="1em"
height="1em"
viewBox="0 0 24 24"
{...props}
>
{/* Icon from Simple Icons by Simple Icons Collaborators - https://github.com/simple-icons/simple-icons/blob/develop/LICENSE.md */}
<path
fill="currentColor"
d="M23.268 5.313c-.35-2.578-2.617-4.61-5.304-5.004C17.51.242 15.792 0 11.813 0h-.03c-3.98 0-4.835.242-5.288.309C3.882.692 1.496 2.518.917 5.127C.64 6.412.61 7.837.661 9.143c.074 1.874.088 3.745.26 5.611c.118 1.24.325 2.47.62 3.68c.55 2.237 2.777 4.098 4.96 4.857c2.336.792 4.849.923 7.256.38q.398-.092.786-.213c.585-.184 1.27-.39 1.774-.753a.06.06 0 0 0 .023-.043v-1.809a.05.05 0 0 0-.02-.041a.05.05 0 0 0-.046-.01a20.3 20.3 0 0 1-4.709.545c-2.73 0-3.463-1.284-3.674-1.818a5.6 5.6 0 0 1-.319-1.433a.053.053 0 0 1 .066-.054c1.517.363 3.072.546 4.632.546c.376 0 .75 0 1.125-.01c1.57-.044 3.224-.124 4.768-.422q.059-.011.11-.024c2.435-.464 4.753-1.92 4.989-5.604c.008-.145.03-1.52.03-1.67c.002-.512.167-3.63-.024-5.545m-3.748 9.195h-2.561V8.29c0-1.309-.55-1.976-1.67-1.976c-1.23 0-1.846.79-1.846 2.35v3.403h-2.546V8.663c0-1.56-.617-2.35-1.848-2.35c-1.112 0-1.668.668-1.67 1.977v6.218H4.822V8.102q0-1.965 1.011-3.12c.696-.77 1.608-1.164 2.74-1.164c1.311 0 2.302.5 2.962 1.498l.638 1.06l.638-1.06c.66-.999 1.65-1.498 2.96-1.498c1.13 0 2.043.395 2.74 1.164q1.012 1.155 1.012 3.12z"
/>
</svg>
),
external: true,
},
{
label: "Twitter",
value: `@${authorConfig.social.twitter}`,
href: `https://x.com/${authorConfig.social.twitter}`,
Icon: (props: React.SVGProps<SVGSVGElement>) => (
<svg
xmlns="http://www.w3.org/2000/svg"
width="1em"
height="1em"
viewBox="0 0 24 24"
{...props}
>
{/* Icon from Simple Icons by Simple Icons Collaborators - https://github.com/simple-icons/simple-icons/blob/develop/LICENSE.md */}
<path
fill="currentColor"
d="M14.234 10.162L22.977 0h-2.072l-7.591 8.824L7.251 0H.258l9.168 13.343L.258 24H2.33l8.016-9.318L16.749 24h6.993zm-2.837 3.299l-.929-1.329L3.076 1.56h3.182l5.965 8.532l.929 1.329l7.754 11.09h-3.182z"
/>
</svg>
),
external: true,
},
{
label: "Instagram",
value: `@${authorConfig.social.instagram}`,
href: `https://www.instagram.com/${authorConfig.social.instagram}/`,
Icon: (props: React.SVGProps<SVGSVGElement>) => (
<svg
xmlns="http://www.w3.org/2000/svg"
width="1em"
height="1em"
viewBox="0 0 24 24"
{...props}
>
{/* Icon from Simple Icons by Simple Icons Collaborators - https://github.com/simple-icons/simple-icons/blob/develop/LICENSE.md */}
<path
fill="currentColor"
d="M7.03.084c-1.277.06-2.149.264-2.91.563a5.9 5.9 0 0 0-2.124 1.388a5.9 5.9 0 0 0-1.38 2.127C.321 4.926.12 5.8.064 7.076s-.069 1.688-.063 4.947s.021 3.667.083 4.947c.061 1.277.264 2.149.563 2.911c.308.789.72 1.457 1.388 2.123a5.9 5.9 0 0 0 2.129 1.38c.763.295 1.636.496 2.913.552c1.278.056 1.689.069 4.947.063s3.668-.021 4.947-.082c1.28-.06 2.147-.265 2.91-.563a5.9 5.9 0 0 0 2.123-1.388a5.9 5.9 0 0 0 1.38-2.129c.295-.763.496-1.636.551-2.912c.056-1.28.07-1.69.063-4.948c-.006-3.258-.02-3.667-.081-4.947c-.06-1.28-.264-2.148-.564-2.911a5.9 5.9 0 0 0-1.387-2.123a5.9 5.9 0 0 0-2.128-1.38c-.764-.294-1.636-.496-2.914-.55C15.647.009 15.236-.006 11.977 0S8.31.021 7.03.084m.14 21.693c-1.17-.05-1.805-.245-2.228-.408a3.7 3.7 0 0 1-1.382-.895a3.7 3.7 0 0 1-.9-1.378c-.165-.423-.363-1.058-.417-2.228c-.06-1.264-.072-1.644-.08-4.848c-.006-3.204.006-3.583.061-4.848c.05-1.169.246-1.805.408-2.228c.216-.561.477-.96.895-1.382a3.7 3.7 0 0 1 1.379-.9c.423-.165 1.057-.361 2.227-.417c1.265-.06 1.644-.072 4.848-.08c3.203-.006 3.583.006 4.85.062c1.168.05 1.804.244 2.227.408c.56.216.96.475 1.382.895s.681.817.9 1.378c.165.422.362 1.056.417 2.227c.06 1.265.074 1.645.08 4.848c.005 3.203-.006 3.583-.061 4.848c-.051 1.17-.245 1.805-.408 2.23c-.216.56-.477.96-.896 1.38a3.7 3.7 0 0 1-1.378.9c-.422.165-1.058.362-2.226.418c-1.266.06-1.645.072-4.85.079s-3.582-.006-4.848-.06m9.783-16.192a1.44 1.44 0 1 0 1.437-1.442a1.44 1.44 0 0 0-1.437 1.442M5.839 12.012a6.161 6.161 0 1 0 12.323-.024a6.162 6.162 0 0 0-12.323.024M8 12.008A4 4 0 1 1 12.008 16A4 4 0 0 1 8 12.008"
/>
</svg>
),
external: true,
},
{
label: "LinkedIn",
value: `/in/${authorConfig.social.linkedin}`,
href: `https://www.linkedin.com/in/${authorConfig.social.linkedin}/`,
Icon: (props: React.SVGProps<SVGSVGElement>) => (
<svg
xmlns="http://www.w3.org/2000/svg"
width="1em"
height="1em"
viewBox="0 0 24 24"
{...props}
>
{/* Icon from Simple Icons by Simple Icons Collaborators - https://github.com/simple-icons/simple-icons/blob/develop/LICENSE.md */}
<path
fill="currentColor"
d="M20.447 20.452h-3.554v-5.569c0-1.328-.027-3.037-1.852-3.037c-1.853 0-2.136 1.445-2.136 2.939v5.667H9.351V9h3.414v1.561h.046c.477-.9 1.637-1.85 3.37-1.85c3.601 0 4.267 2.37 4.267 5.455v6.286zM5.337 7.433a2.06 2.06 0 0 1-2.063-2.065a2.064 2.064 0 1 1 2.063 2.065m1.782 13.019H3.555V9h3.564zM22.225 0H1.771C.792 0 0 .774 0 1.729v20.542C0 23.227.792 24 1.771 24h20.451C23.2 24 24 23.227 24 22.271V1.729C24 .774 23.2 0 22.222 0z"
/>
</svg>
),
external: true,
},
{
label: "Medium",
value: `@${authorConfig.social.medium}`,
href: `https://medium.com/@${authorConfig.social.medium}`,
Icon: (props: React.SVGProps<SVGSVGElement>) => (
<svg
xmlns="http://www.w3.org/2000/svg"
width="1em"
height="1em"
viewBox="0 0 24 24"
{...props}
>
{/* Icon from Simple Icons by Simple Icons Collaborators - https://github.com/simple-icons/simple-icons/blob/develop/LICENSE.md */}
<path
fill="currentColor"
d="M4.21 0A4.2 4.2 0 0 0 0 4.21v15.58A4.2 4.2 0 0 0 4.21 24h15.58A4.2 4.2 0 0 0 24 19.79v-1.093a5 5 0 0 1-.422.02c-2.577 0-4.027-2.146-4.09-4.832a8 8 0 0 1 .022-.708c.093-1.186.475-2.241 1.105-3.022a3.9 3.9 0 0 1 1.395-1.1c.468-.237 1.127-.367 1.664-.367h.023q.151 0 .303.01V4.211A4.2 4.2 0 0 0 19.79 0Zm.198 5.583h4.165l3.588 8.435l3.59-8.435h3.864v.146l-.019.004c-.705.16-1.063.397-1.063 1.254h-.003l.003 10.274c.06.676.424.885 1.063 1.03l.02.004v.145h-4.923v-.145l.019-.005c.639-.144.994-.353 1.054-1.03V7.267l-4.745 11.15h-.261L6.15 7.569v9.445c0 .857.358 1.094 1.063 1.253l.02.004v.147H4.405v-.147l.019-.004c.705-.16 1.065-.397 1.065-1.253V6.987c0-.857-.358-1.094-1.064-1.254l-.018-.004zm19.25 3.668c-1.086.023-1.733 1.323-1.813 3.124H24V9.298a1.4 1.4 0 0 0-.342-.047m-1.862 3.632c-.1 1.756.86 3.239 2.204 3.634v-3.634z"
/>
</svg>
),
external: true,
},
{
label: "Facebook",
value: authorConfig.social.facebook,
href: `https://www.facebook.com/${authorConfig.social.facebook}`,
Icon: (props: React.SVGProps<SVGSVGElement>) => (
<svg
xmlns="http://www.w3.org/2000/svg"
width="1em"
height="1em"
viewBox="0 0 24 24"
{...props}
>
{/* Icon from Simple Icons by Simple Icons Collaborators - https://github.com/simple-icons/simple-icons/blob/develop/LICENSE.md */}
<path
fill="currentColor"
d="M9.101 23.691v-7.98H6.627v-3.667h2.474v-1.58c0-4.085 1.848-5.978 5.858-5.978c.401 0 .955.042 1.468.103a9 9 0 0 1 1.141.195v3.325a9 9 0 0 0-.653-.036a27 27 0 0 0-.733-.009c-.707 0-1.259.096-1.675.309a1.7 1.7 0 0 0-.679.622c-.258.42-.374.995-.374 1.752v1.297h3.919l-.386 2.103l-.287 1.564h-3.246v8.245C19.396 23.238 24 18.179 24 12.044c0-6.627-5.373-12-12-12s-12 5.373-12 12c0 5.628 3.874 10.35 9.101 11.647"
/>
</svg>
),
external: true,
},
] as const;
const ContactPopover = () => (
<Popover
// fixes tooltip flakiness when a link is behind the popover window
modal="trap-focus"
>
<PopoverTrigger
openOnHover
delay={0}
render={<Button variant="ghost" size="icon" aria-label="Open contact links" />}
>
<AtSignIcon aria-hidden="true" />
</PopoverTrigger>
<PopoverContent align="end" className="max-h-(--available-height) overflow-y-auto">
<PopoverHeader className="mt-1 px-1">
<PopoverTitle>Get in touch:</PopoverTitle>
<PopoverDescription className="sr-only">Email and social links.</PopoverDescription>
</PopoverHeader>
<nav aria-label="Contact links" className="flex flex-col gap-1">
{contactLinks.map((link) => (
<a
key={link.href}
href={link.href}
target={link.external ? "_blank" : undefined}
rel={`me${link.external ? " noopener noreferrer" : ""}`}
className="hover:bg-muted focus-visible:border-ring focus-visible:ring-ring/50 flex items-center gap-2 rounded-md px-1.5 py-1.5 no-underline transition-colors outline-none focus-visible:ring-3"
>
<Tooltip disableHoverablePopup>
<TooltipTrigger>
<link.Icon className={contactIconClassName} aria-hidden="true" />
</TooltipTrigger>
<TooltipContent sideOffset={8}>{link.label}</TooltipContent>
</Tooltip>
<span className="block min-w-0 flex-1 truncate text-[13px] leading-normal">
{link.value}
</span>
{link.external ? (
<ExternalLinkIcon className="text-muted-foreground/70 size-3.5" aria-hidden="true" />
) : null}
</a>
))}
</nav>
</PopoverContent>
</Popover>
);
const Header = ({ className }: { className?: string }) => {
const [isScrolled, setIsScrolled] = useState(false);
const { theme, setTheme } = useTheme();
const pathname = usePathname();
useEffect(() => {
const handleScroll = () => {
@@ -34,11 +284,14 @@ const Header = ({ className }: { className?: string }) => {
return (
<div
data-scrolled={isScrolled}
style={{ viewTransitionName: "persistent-nav" }}
className={cn(
"sticky top-0 z-50 w-full",
"motion-safe:transition-[background-color,backdrop-filter,border-color] motion-safe:duration-200",
"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]:bg-background/80",
"data-[scrolled=true]:backdrop-blur-md",
"data-[scrolled=true]:border-border/70 data-[scrolled=true]:border-b",
className,
)}
@@ -48,8 +301,12 @@ const Header = ({ className }: { className?: string }) => {
<Link
href="/"
rel="author"
transitionTypes={pathname === "/" ? undefined : ["nav-lateral"]}
aria-label={siteConfig.name}
className="hover:text-foreground/85 flex shrink-0 items-center gap-2.5 pr-2 hover:no-underline"
className={cn(
"hover:text-foreground/85 flex shrink-0 items-center",
"gap-2.5 pr-2 hover:no-underline",
)}
>
<Image
src={avatarImg}
@@ -60,7 +317,12 @@ const Header = ({ className }: { className?: string }) => {
quality={75}
priority
/>
<span className="text-[17.5px] font-medium tracking-tight whitespace-nowrap max-md:sr-only">
<span
className={cn(
"text-[17.5px] font-medium tracking-[-0.0375em] whitespace-nowrap",
"max-md:sr-only",
)}
>
{siteConfig.name}
</span>
</Link>
@@ -69,30 +331,7 @@ const Header = ({ className }: { className?: string }) => {
</div>
<div className="flex items-center gap-2.5">
<Button
variant="ghost"
size="icon"
nativeButton={false}
aria-label="Email Me"
render={<a href={`mailto:${authorConfig.email}`} />}
>
<AtSignIcon />
</Button>
<Button
variant="ghost"
size="icon"
nativeButton={false}
aria-label="Open GitHub profile"
render={
<a
href={`https://github.com/${authorConfig.social.github}`}
target="_blank"
rel="noopener noreferrer"
/>
}
>
<GitHubIcon />
</Button>
<ContactPopover />
<Button
variant="ghost"
size="icon"
+9 -2
View File
@@ -1,7 +1,7 @@
"use client";
import Link from "next/link";
import { useSelectedLayoutSegment } from "next/navigation";
import { usePathname, useSelectedLayoutSegment } from "next/navigation";
import { Button } from "@/components/ui/button";
@@ -17,12 +17,19 @@ const menuItems = [
] as const;
const Menu = () => {
const pathname = usePathname();
const segment = useSelectedLayoutSegment() || "";
return (
<nav data-slot="navigation-menu" className="flex items-center gap-2">
{menuItems.map((item) => {
const isCurrent = item.href?.split("/")[1] === segment;
const transitionTypes =
item.href === "/notes" && pathname.startsWith("/notes/")
? ["nav-back"]
: pathname === item.href
? undefined
: ["nav-lateral"];
return (
<Button
@@ -33,7 +40,7 @@ const Menu = () => {
aria-label={item.text}
data-current={isCurrent || undefined}
className="data-current:bg-accent/60 data-current:text-accent-foreground text-sm leading-none"
render={<Link href={item.href} />}
render={<Link href={item.href} transitionTypes={transitionTypes} />}
>
{item.text}
</Button>
+1 -1
View File
@@ -12,7 +12,7 @@ const PageTitle = ({
}) => (
<h1
className={cn(
"not-prose mt-0 mb-6 text-left text-3xl font-medium tracking-tight lowercase",
"not-prose mt-0 mb-6 text-left text-2xl font-medium tracking-tight lowercase",
className,
)}
{...rest}
+29
View File
@@ -0,0 +1,29 @@
import { ViewTransition } from "react";
const directionalEnterClasses = {
"nav-forward": "nav-forward",
"nav-back": "nav-back",
"nav-lateral": "fade-in",
default: "none",
} as const;
const directionalExitClasses = {
"nav-forward": "nav-forward",
"nav-back": "nav-back",
"nav-lateral": "fade-out",
default: "none",
} as const;
const DirectionalTransition = ({ children }: { children: React.ReactNode }) => (
<ViewTransition enter={directionalEnterClasses} exit={directionalExitClasses} default="none">
{children}
</ViewTransition>
);
const FadeTransition = ({ children }: { children: React.ReactNode }) => (
<ViewTransition enter="fade-in" exit="fade-out" default="none">
{children}
</ViewTransition>
);
export { DirectionalTransition, FadeTransition };
+7 -4
View File
@@ -58,8 +58,8 @@ const PostStats = ({ slug }: { slug: string }) => {
if (!loaded) {
return (
<>
<Skeleton className="inline-block h-5 w-12 rounded-full align-text-top" />
<Skeleton className="inline-block h-5 w-8 rounded-full align-text-top" />
<Skeleton className="inline-block h-5 w-16 translate-y-[-2px] rounded-4xl border border-transparent align-middle" />
<Skeleton className="inline-block h-5 w-12 translate-y-[-2px] rounded-4xl border border-transparent align-middle" />
</>
);
}
@@ -70,7 +70,10 @@ const PostStats = ({ slug }: { slug: string }) => {
return (
<>
{viewCount > 0 && (
<Badge variant="secondary" className="text-foreground/80 gap-[5px] tabular-nums">
<Badge
variant="secondary"
className="text-foreground/80 gap-[5px] text-[11px] tabular-nums"
>
<EyeIcon className="text-foreground/65" aria-hidden="true" />
{numberFormatter.format(viewCount)}
</Badge>
@@ -79,7 +82,7 @@ const PostStats = ({ slug }: { slug: string }) => {
{commentCount > 0 && (
<Badge
variant="secondary"
className="text-foreground/80 gap-[5px] tabular-nums"
className="text-foreground/80 gap-[5px] text-[11px] tabular-nums"
render={
<Link
href={`/${slug}#comments`}
+16 -16
View File
@@ -22,27 +22,27 @@
"db:migrate": "drizzle-kit migrate"
},
"dependencies": {
"@base-ui/react": "^1.4.0",
"@base-ui/react": "^1.4.1",
"@fontsource/inter": "^5.2.8",
"@mdx-js/loader": "^3.1.1",
"@mdx-js/react": "^3.1.1",
"@next/mdx": "16.2.3",
"@next/mdx": "16.2.4",
"@octokit/graphql": "^9.0.3",
"@octokit/graphql-schema": "^15.26.1",
"@vercel/analytics": "^2.0.1",
"@vercel/functions": "^3.4.3",
"@vercel/functions": "^3.4.4",
"@vercel/speed-insights": "^2.0.0",
"better-auth": "^1.6.4",
"better-auth": "^1.6.9",
"cheerio": "^1.2.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"copy-to-clipboard": "^3.3.3",
"copy-to-clipboard": "^4.0.2",
"drizzle-orm": "^0.45.2",
"fast-glob": "^3.3.3",
"feed": "^5.2.0",
"feed": "^5.2.1",
"html-entities": "^2.6.0",
"lucide-react": "1.7.0",
"next": "16.2.3",
"lucide-react": "1.11.0",
"next": "16.2.4",
"next-themes": "^0.4.6",
"pg": "^8.20.0",
"react": "19.2.5",
@@ -72,7 +72,7 @@
"remark-smartypants": "^3.0.2",
"remark-strip-mdx-imports-exports": "^1.0.1",
"server-only": "0.0.1",
"shadcn": "^4.2.0",
"shadcn": "^4.5.0",
"shiki": "^4.0.2",
"sonner": "^2.0.7",
"tailwind-merge": "^3.5.0",
@@ -80,28 +80,28 @@
"zod": "^4.3.6"
},
"devDependencies": {
"@tailwindcss/postcss": "^4.2.2",
"@tailwindcss/postcss": "^4.2.4",
"@tailwindcss/typography": "^0.5.19",
"@types/mdx": "^2.0.13",
"@types/node": "^25.6.0",
"@types/pg": "^8.20.0",
"@types/react": "19.2.14",
"@types/react-dom": "19.2.3",
"babel-plugin-react-compiler": "19.1.0-rc.3",
"babel-plugin-react-compiler": "1.0.0",
"dotenv": "^17.4.2",
"drizzle-kit": "^0.31.10",
"oxfmt": "^0.44.0",
"oxlint": "^1.60.0",
"oxfmt": "^0.46.0",
"oxlint": "^1.61.0",
"postcss": "^8.5.10",
"schema-dts": "^2.0.0",
"tailwindcss": "^4.2.2",
"tailwindcss": "^4.2.4",
"tw-animate-css": "^1.4.0",
"typescript": "6.0.2"
"typescript": "6.0.3"
},
"engines": {
"node": ">=24.x"
},
"packageManager": "pnpm@10.33.0+sha512.10568bb4a6afb58c9eb3630da90cc9516417abebd3fabbe6739f0ae795728da1491e9db5a544c76ad8eb7570f5c4bb3d6c637b2cb41bfdcdb47fa823c8649319",
"packageManager": "pnpm@10.33.2+sha512.a90faf6feeab71ad6c6e57f94e0fe1a12f5dcc22cd754db40ae9593eb6a3e0b6b12e3540218bb37ae083404b1f2ce6db2a4121e979829b4aff94b99f49da1cf8",
"pnpm": {
"onlyBuiltDependencies": [
"esbuild",
+572 -553
View File
File diff suppressed because it is too large Load Diff