aboutsummaryrefslogtreecommitdiffstats
path: root/src/components
diff options
context:
space:
mode:
Diffstat (limited to 'src/components')
-rw-r--r--src/components/AuthorCard.astro62
-rw-r--r--src/components/BlogCard.astro105
-rw-r--r--src/components/Breadcrumbs.astro61
-rw-r--r--src/components/Callout.astro183
-rw-r--r--src/components/Favicons.astro6
-rw-r--r--src/components/Footer.astro26
-rw-r--r--src/components/Head.astro49
-rw-r--r--src/components/Header.astro36
-rw-r--r--src/components/Link.astro27
-rw-r--r--src/components/PageHead.astro36
-rw-r--r--src/components/PostHead.astro60
-rw-r--r--src/components/PostNavigation.astro94
-rw-r--r--src/components/ProjectCard.astro70
-rw-r--r--src/components/SocialIcons.astro37
-rw-r--r--src/components/SubpostsHeader.astro314
-rw-r--r--src/components/SubpostsSidebar.astro267
-rw-r--r--src/components/TOCHeader.astro398
-rw-r--r--src/components/TOCSidebar.astro365
-rw-r--r--src/components/ThemeToggle.astro72
-rw-r--r--src/components/ui/avatar.tsx74
-rw-r--r--src/components/ui/badge.tsx46
-rw-r--r--src/components/ui/breadcrumb.tsx108
-rw-r--r--src/components/ui/button.tsx56
-rw-r--r--src/components/ui/pagination.tsx195
-rw-r--r--src/components/ui/scroll-area.tsx56
-rw-r--r--src/components/ui/separator.tsx26
26 files changed, 2829 insertions, 0 deletions
diff --git a/src/components/AuthorCard.astro b/src/components/AuthorCard.astro
new file mode 100644
index 0000000..9f178fa
--- /dev/null
+++ b/src/components/AuthorCard.astro
@@ -0,0 +1,62 @@
+---
+import Link from '@/components/Link.astro'
+import AvatarComponent from '@/components/ui/avatar'
+import { cn } from '@/lib/utils'
+import type { SocialLink } from '@/types'
+import type { CollectionEntry } from 'astro:content'
+import SocialIcons from './SocialIcons.astro'
+
+interface Props {
+ author: CollectionEntry<'authors'>
+}
+
+const { author } = Astro.props
+const { name, avatar, bio, pronouns } = author.data
+const currentPath = Astro.url.pathname
+const isAuthorPage = currentPath === `/authors/${author.id}`
+
+const socialLinks: SocialLink[] = [
+ author.data.website && { href: author.data.website, label: 'Website' },
+ author.data.github && { href: author.data.github, label: 'GitHub' },
+ author.data.twitter && { href: author.data.twitter, label: 'Twitter' },
+ author.data.linkedin && { href: author.data.linkedin, label: 'LinkedIn' },
+ author.data.mail && { href: `mailto:${author.data.mail}`, label: 'Email' },
+].filter(Boolean) as SocialLink[]
+---
+
+<div
+ class="has-[a:hover]:bg-muted/50 overflow-hidden rounded-xl border p-4 transition-colors duration-300 ease-in-out"
+>
+ <div class="flex flex-wrap gap-4">
+ <Link
+ href={`/authors/${author.id}`}
+ class={cn('block', isAuthorPage && 'pointer-events-none')}
+ >
+ <AvatarComponent
+ client:load
+ src={avatar}
+ alt={`Avatar of ${name}`}
+ fallback={name[0]}
+ className={cn(
+ 'size-32 rounded-md [&>[data-slot="avatar-fallback"]]:rounded-md',
+ !isAuthorPage &&
+ 'hover:ring-primary transition-shadow duration-300 hover:cursor-pointer hover:ring-2',
+ )}
+ />
+ </Link>
+ <div class="flex grow flex-col justify-between gap-y-4">
+ <div>
+ <div class="flex flex-wrap items-center gap-x-2">
+ <h3 class="text-lg font-medium">{name}</h3>
+ {
+ pronouns && (
+ <span class="text-muted-foreground text-sm">({pronouns})</span>
+ )
+ }
+ </div>
+ <p class="text-muted-foreground text-sm">{bio}</p>
+ </div>
+ <SocialIcons links={socialLinks} />
+ </div>
+ </div>
+</div>
diff --git a/src/components/BlogCard.astro b/src/components/BlogCard.astro
new file mode 100644
index 0000000..1a74025
--- /dev/null
+++ b/src/components/BlogCard.astro
@@ -0,0 +1,105 @@
+---
+import AvatarComponent from '@/components/ui/avatar'
+import { Badge } from '@/components/ui/badge'
+import { Separator } from '@/components/ui/separator'
+import {
+ getCombinedReadingTime,
+ getSubpostCount,
+ isSubpost,
+ parseAuthors,
+} from '@/lib/data-utils'
+import { formatDate } from '@/lib/utils'
+import { Icon } from 'astro-icon/components'
+import { Image } from 'astro:assets'
+import type { CollectionEntry } from 'astro:content'
+import Link from './Link.astro'
+
+interface Props {
+ entry: CollectionEntry<'blog'>
+}
+
+const { entry } = Astro.props
+const formattedDate = formatDate(entry.data.date)
+const readTime = await getCombinedReadingTime(entry.id)
+const authors = await parseAuthors(entry.data.authors ?? [])
+const subpostCount = !isSubpost(entry.id) ? await getSubpostCount(entry.id) : 0
+---
+
+<div
+ class="hover:bg-muted/50 rounded-xl border p-4 transition-colors duration-300 ease-in-out"
+>
+ <Link
+ href={`/${entry.collection}/${entry.id}`}
+ class="flex flex-col gap-4 sm:flex-row"
+ >
+ {
+ entry.data.image && (
+ <div class="w-full sm:max-w-3xs sm:shrink-0">
+ <Image
+ src={entry.data.image}
+ alt={entry.data.title}
+ width={1200}
+ height={630}
+ class="object-cover"
+ />
+ </div>
+ )
+ }
+
+ <div class="grow">
+ <h3 class="mb-1 text-lg font-medium">{entry.data.title}</h3>
+ <p class="text-muted-foreground mb-2 text-sm">{entry.data.description}</p>
+
+ <div
+ class="text-muted-foreground mb-2 flex flex-wrap items-center gap-x-2 text-xs"
+ >
+ {
+ authors.length > 0 && (
+ <>
+ {authors.map((author) => (
+ <div class="flex items-center gap-x-1.5">
+ <AvatarComponent
+ client:load
+ src={author.avatar}
+ alt={author.name}
+ fallback={author.name[0]}
+ className="size-5 rounded-full"
+ />
+ <span>{author.name}</span>
+ </div>
+ ))}
+ <Separator orientation="vertical" className="h-4!" />
+ </>
+ )
+ }
+ <span>{formattedDate}</span>
+ <Separator orientation="vertical" className="h-4!" />
+ <span>{readTime}</span>
+ {
+ subpostCount > 0 && (
+ <>
+ <Separator orientation="vertical" className="h-4!" />
+ <span class="flex items-center gap-1">
+ <Icon name="lucide:file-text" class="size-3" />
+ {subpostCount} subpost{subpostCount === 1 ? '' : 's'}
+ </span>
+ </>
+ )
+ }
+ </div>
+
+ {
+ entry.data.tags && (
+ <div class="flex flex-wrap gap-2">
+ {entry.data.tags.map((tag) => (
+ <Badge variant="muted" className="flex items-center gap-x-1">
+ <Icon name="lucide:hash" class="size-3" />
+ {tag}
+ </Badge>
+ ))}
+ </div>
+ )
+ }
+ </div>
+ </Link>
+</div>
diff --git a/src/components/Breadcrumbs.astro b/src/components/Breadcrumbs.astro
new file mode 100644
index 0000000..7f5181f
--- /dev/null
+++ b/src/components/Breadcrumbs.astro
@@ -0,0 +1,61 @@
+---
+import {
+ Breadcrumb,
+ BreadcrumbItem,
+ BreadcrumbLink,
+ BreadcrumbList,
+ BreadcrumbPage,
+ BreadcrumbSeparator,
+} from '@/components/ui/breadcrumb'
+import { Icon } from 'astro-icon/components'
+
+export interface BreadcrumbItem {
+ href?: string
+ label: string
+ icon?: string
+}
+
+interface Props {
+ items: BreadcrumbItem[]
+}
+
+const { items } = Astro.props
+---
+
+<Breadcrumb>
+ <BreadcrumbList>
+ <BreadcrumbItem>
+ <BreadcrumbLink href="/">
+ <Icon name="lucide:home" class="size-4 shrink-0" />
+ </BreadcrumbLink>
+ </BreadcrumbItem>
+ {
+ items.map((item, index) => (
+ <>
+ <BreadcrumbSeparator />
+ <BreadcrumbItem>
+ {index === items.length - 1 ? (
+ <BreadcrumbPage>
+ <span class="flex items-center gap-x-2">
+ {item.icon && (
+ <Icon name={item.icon} class="size-4 shrink-0" />
+ )}
+ <span>{item.label}</span>
+ </span>
+ </BreadcrumbPage>
+ ) : (
+ <BreadcrumbLink href={item.href}>
+ <span class="flex items-center gap-x-2">
+ {item.icon && (
+ <Icon name={item.icon} class="size-4 shrink-0" />
+ )}
+ <span>{item.label}</span>
+ </span>
+ </BreadcrumbLink>
+ )}
+ </BreadcrumbItem>
+ </>
+ ))
+ }
+ </BreadcrumbList>
+</Breadcrumb>
diff --git a/src/components/Callout.astro b/src/components/Callout.astro
new file mode 100644
index 0000000..98ef07d
--- /dev/null
+++ b/src/components/Callout.astro
@@ -0,0 +1,183 @@
+---
+import { cn } from '@/lib/utils'
+import { Icon } from 'astro-icon/components'
+import { cva, type VariantProps } from 'class-variance-authority'
+
+const calloutConfig = {
+ note: {
+ style: 'border-blue-500 dark:bg-blue-950/5',
+ textColor: 'text-blue-700 dark:text-blue-300',
+ icon: 'lucide:info',
+ },
+ tip: {
+ style: 'border-green-500 dark:bg-green-950/5',
+ textColor: 'text-green-700 dark:text-green-300',
+ icon: 'lucide:lightbulb',
+ },
+ warning: {
+ style: 'border-amber-500 dark:bg-amber-950/5',
+ textColor: 'text-amber-700 dark:text-amber-300',
+ icon: 'lucide:alert-triangle',
+ },
+ danger: {
+ style: 'border-red-500 dark:bg-red-950/5',
+ textColor: 'text-red-700 dark:text-red-300',
+ icon: 'lucide:shield-alert',
+ },
+ important: {
+ style: 'border-purple-500 dark:bg-purple-950/5',
+ textColor: 'text-purple-700 dark:text-purple-300',
+ icon: 'lucide:message-square-warning',
+ },
+ definition: {
+ style: 'border-purple-500 dark:bg-purple-950/5',
+ textColor: 'text-purple-700 dark:text-purple-300',
+ icon: 'lucide:book-open',
+ },
+ theorem: {
+ style: 'border-teal-500 dark:bg-teal-950/5',
+ textColor: 'text-teal-700 dark:text-teal-300',
+ icon: 'lucide:check-circle',
+ },
+ lemma: {
+ style: 'border-sky-400 dark:bg-sky-950/5',
+ textColor: 'text-sky-700 dark:text-sky-300',
+ icon: 'lucide:puzzle',
+ },
+ proof: {
+ style: 'border-gray-500 dark:bg-gray-950/5',
+ textColor: 'text-gray-700 dark:text-gray-300',
+ icon: 'lucide:check-square',
+ },
+ corollary: {
+ style: 'border-cyan-500 dark:bg-cyan-950/5',
+ textColor: 'text-cyan-700 dark:text-cyan-300',
+ icon: 'lucide:git-branch',
+ },
+ proposition: {
+ style: 'border-slate-500 dark:bg-slate-950/5',
+ textColor: 'text-slate-700 dark:text-slate-300',
+ icon: 'lucide:file-text',
+ },
+ axiom: {
+ style: 'border-violet-600 dark:bg-violet-950/5',
+ textColor: 'text-violet-700 dark:text-violet-300',
+ icon: 'lucide:anchor',
+ },
+ conjecture: {
+ style: 'border-pink-500 dark:bg-pink-950/5',
+ textColor: 'text-pink-700 dark:text-pink-300',
+ icon: 'lucide:help-circle',
+ },
+ notation: {
+ style: 'border-slate-400 dark:bg-slate-950/5',
+ textColor: 'text-slate-700 dark:text-slate-300',
+ icon: 'lucide:pen-tool',
+ },
+ remark: {
+ style: 'border-gray-400 dark:bg-gray-950/5',
+ textColor: 'text-gray-700 dark:text-gray-300',
+ icon: 'lucide:message-circle',
+ },
+ intuition: {
+ style: 'border-yellow-500 dark:bg-yellow-950/5',
+ textColor: 'text-yellow-700 dark:text-yellow-300',
+ icon: 'lucide:lightbulb',
+ },
+ recall: {
+ style: 'border-blue-300 dark:bg-blue-950/5',
+ textColor: 'text-blue-600 dark:text-blue-300',
+ icon: 'lucide:rotate-ccw',
+ },
+ explanation: {
+ style: 'border-lime-500 dark:bg-lime-950/5',
+ textColor: 'text-lime-700 dark:text-lime-300',
+ icon: 'lucide:help-circle',
+ },
+ example: {
+ style: 'border-emerald-500 dark:bg-emerald-950/5',
+ textColor: 'text-emerald-700 dark:text-emerald-300',
+ icon: 'lucide:code',
+ },
+ exercise: {
+ style: 'border-indigo-500 dark:bg-indigo-950/5',
+ textColor: 'text-indigo-700 dark:text-indigo-300',
+ icon: 'lucide:dumbbell',
+ },
+ problem: {
+ style: 'border-orange-600 dark:bg-orange-950/5',
+ textColor: 'text-orange-700 dark:text-orange-300',
+ icon: 'lucide:alert-circle',
+ },
+ answer: {
+ style: 'border-teal-500 dark:bg-teal-950/5',
+ textColor: 'text-teal-700 dark:text-teal-300',
+ icon: 'lucide:check',
+ },
+ solution: {
+ style: 'border-emerald-600 dark:bg-emerald-950/5',
+ textColor: 'text-emerald-700 dark:text-emerald-300',
+ icon: 'lucide:check-circle-2',
+ },
+ summary: {
+ style: 'border-sky-500 dark:bg-sky-950/5',
+ textColor: 'text-sky-700 dark:text-sky-300',
+ icon: 'lucide:list',
+ },
+} as const
+
+const capitalize = (str: string) => str.charAt(0).toUpperCase() + str.slice(1)
+
+const calloutVariants = cva('relative px-4 py-3 my-6 border-l-4 text-sm', {
+ variants: {
+ variant: Object.fromEntries(
+ Object.entries(calloutConfig).map(([key, config]) => [key, config.style]),
+ ),
+ },
+ defaultVariants: {
+ variant: 'note',
+ },
+})
+
+interface Props extends VariantProps<typeof calloutVariants> {
+ title?: string
+ class?: string
+ variant?: keyof typeof calloutConfig
+ defaultOpen?: boolean
+}
+
+const { title, variant = 'note', defaultOpen = true, ...rest } = Astro.props
+---
+
+<details
+ class={cn(
+ calloutVariants({ variant }),
+ rest.class,
+ '[&[open]>summary_svg:last-child]:rotate-180 [&[open]>summary]:mb-3',
+ )}
+ {...rest}
+ open={defaultOpen}
+>
+ <summary
+ class="flex cursor-pointer items-center font-medium [&::-webkit-details-marker]:hidden"
+ >
+ <Icon
+ name={calloutConfig[variant].icon}
+ class={cn('mr-2 size-4 shrink-0', calloutConfig[variant].textColor)}
+ />
+ <span class={cn('font-medium mr-2', calloutConfig[variant].textColor)}>
+ {capitalize(variant)}
+ {title && <span class="font-normal opacity-70"> ({title})</span>}
+ </span>
+ <Icon
+ name="lucide:chevron-down"
+ class={cn(
+ 'ml-auto h-4 w-4 shrink-0 transition-transform duration-200',
+ calloutConfig[variant].textColor,
+ )}
+ />
+ </summary>
+ <div>
+ <slot />
+ </div>
+</details>
diff --git a/src/components/Favicons.astro b/src/components/Favicons.astro
new file mode 100644
index 0000000..5816730
--- /dev/null
+++ b/src/components/Favicons.astro
@@ -0,0 +1,6 @@
+<link rel="icon" type="image/png" href="/favicon-96x96.png" sizes="96x96" />
+<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
+<link rel="shortcut icon" href="/favicon.ico" />
+<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
+<meta name="apple-mobile-web-app-title" content="FOSS CET" />
+<link rel="manifest" href="/site.webmanifest" />
diff --git a/src/components/Footer.astro b/src/components/Footer.astro
new file mode 100644
index 0000000..7ff1e50
--- /dev/null
+++ b/src/components/Footer.astro
@@ -0,0 +1,26 @@
+---
+import { Separator } from '@/components/ui/separator'
+import { SOCIAL_LINKS } from '@/consts'
+import Link from './Link.astro'
+import SocialIcons from './SocialIcons.astro'
+---
+
+<footer
+ class="mx-auto mb-8 flex w-full max-w-3xl flex-col items-center justify-center gap-y-2 px-4 sm:mb-4 sm:flex-row sm:justify-between"
+>
+ <div class="flex flex-wrap items-center justify-center gap-x-2 text-center">
+ <span class="text-muted-foreground text-sm">
+ &copy; {new Date().getFullYear()} All rights reserved.
+ </span>
+ <Separator orientation="vertical" className="hidden h-4! sm:block" />
+ <p class="text-muted-foreground text-sm">
+ Made with 🤍 by <Link
+ href="https://github.com/jktrn"
+ class="text-foreground"
+ external
+ underline>enscribe</Link
+ >!
+ </p>
+ </div>
+ <SocialIcons links={SOCIAL_LINKS} />
+</footer>
diff --git a/src/components/Head.astro b/src/components/Head.astro
new file mode 100644
index 0000000..8ce42cb
--- /dev/null
+++ b/src/components/Head.astro
@@ -0,0 +1,49 @@
+---
+import { SITE } from '@/consts'
+import { ClientRouter } from 'astro:transitions'
+import Favicons from './Favicons.astro'
+---
+
+<head>
+ <meta charset="utf-8" />
+ <meta http-equiv="X-UA-Compatible" content="IE=edge" />
+ <meta
+ name="viewport"
+ content="width=device-width, initial-scale=1.0, user-scalable=yes"
+ />
+ <meta name="generator" content={Astro.generator} />
+
+ <meta name="HandheldFriendly" content="True" />
+ <meta name="mobile-web-app-capable" content="yes" />
+ <meta name="apple-mobile-web-app-capable" content="yes" />
+ <meta name="apple-mobile-web-app-status-bar-style" content="default" />
+ <meta
+ name="format-detection"
+ content="telephone=no,date=no,address=no,email=no,url=no"
+ />
+
+ <meta
+ name="theme-color"
+ content="#121212"
+ media="(prefers-color-scheme: dark)"
+ />
+ <meta
+ name="theme-color"
+ content="#121212"
+ media="(prefers-color-scheme: light)"
+ />
+
+ <link rel="sitemap" href="/sitemap-index.xml" />
+ <link rel="manifest" href="/site.webmanifest" />
+ <link
+ rel="alternate"
+ type="application/rss+xml"
+ title={SITE.title}
+ href={new URL('rss.xml', Astro.site)}
+ />
+
+ <Favicons />
+ <ClientRouter />
+
+ <slot />
+</head>
diff --git a/src/components/Header.astro b/src/components/Header.astro
new file mode 100644
index 0000000..ff7dafe
--- /dev/null
+++ b/src/components/Header.astro
@@ -0,0 +1,36 @@
+---
+import Link from '@/components/Link.astro'
+import ThemeToggle from '@/components/ThemeToggle.astro'
+import { NAV_LINKS, SITE } from '@/consts'
+import { Image } from 'astro:assets'
+import logo from '../../public/static/logo.svg'
+---
+
+<div>
+ <div
+ class="mx-auto flex max-w-3xl items-center justify-between gap-4 px-4 py-3"
+ >
+ <Link href="/" class="flex shrink-0 items-center justify-center gap-3">
+ <Image src={logo} alt="Logo" class="size-5 sm:size-6" />
+ <span
+ class="hidden h-full text-lg leading-none font-medium min-[400px]:block"
+ >{SITE.title}</span
+ >
+ </Link>
+ <div class="flex items-center gap-4">
+ <nav class="flex items-center gap-4 text-sm sm:gap-6">
+ {
+ NAV_LINKS.map((item) => (
+ <Link
+ href={item.href}
+ class="text-foreground/60 hover:text-foreground capitalize transition-colors"
+ >
+ {item.label}
+ </Link>
+ ))
+ }
+ </nav>
+ <ThemeToggle transition:persist />
+ </div>
+ </div>
+</div>
diff --git a/src/components/Link.astro b/src/components/Link.astro
new file mode 100644
index 0000000..6d434b1
--- /dev/null
+++ b/src/components/Link.astro
@@ -0,0 +1,27 @@
+---
+import { cn } from '@/lib/utils'
+
+interface Props {
+ href: string
+ external?: boolean
+ class?: string
+ underline?: boolean
+ [key: string]: any
+}
+
+const { href, external, class: className, underline, ...rest } = Astro.props
+---
+
+<a
+ href={href}
+ target={external ? '_blank' : '_self'}
+ class={cn(
+ 'inline-block transition-colors duration-300 ease-in-out',
+ underline &&
+ 'underline decoration-muted-foreground underline-offset-[3px] hover:decoration-foreground',
+ className,
+ )}
+ {...rest}
+>
+ <slot />
+</a>
diff --git a/src/components/PageHead.astro b/src/components/PageHead.astro
new file mode 100644
index 0000000..7983fde
--- /dev/null
+++ b/src/components/PageHead.astro
@@ -0,0 +1,36 @@
+---
+import { SITE } from '@/consts'
+
+interface Props {
+ title?: string
+ description?: string
+ noindex?: boolean
+}
+
+const {
+ title = SITE.title,
+ description = SITE.description,
+ noindex = false,
+} = Astro.props
+const image = new URL('/static/1200x630.png', Astro.site)
+---
+
+<title>{`${title} | ${SITE.title}`}</title>
+<meta name="description" content={description} />
+<link rel="canonical" href={SITE.href} />
+{noindex && <meta name="robots" content="noindex, nofollow" />}
+
+<meta property="og:title" content={title} />
+<meta property="og:description" content={description} />
+<meta property="og:image" content={image} />
+<meta property="og:image:alt" content={title} />
+<meta property="og:type" content="website" />
+<meta property="og:locale" content={SITE.locale} />
+<meta property="og:site_name" content={SITE.title} />
+<meta property="og:url" content={Astro.url} />
+
+<meta name="twitter:title" content={title} />
+<meta name="twitter:description" content={description} />
+<meta name="twitter:image" content={image} />
+<meta name="twitter:image:alt" content={title} />
+<meta name="twitter:card" content="summary_large_image" />
diff --git a/src/components/PostHead.astro b/src/components/PostHead.astro
new file mode 100644
index 0000000..911b195
--- /dev/null
+++ b/src/components/PostHead.astro
@@ -0,0 +1,60 @@
+---
+import { SITE } from '@/consts'
+import { isSubpost } from '@/lib/data-utils'
+import type { CollectionEntry } from 'astro:content'
+
+interface Props {
+ post: CollectionEntry<'blog'>
+}
+
+const { post } = Astro.props
+
+const title = post.data.title || SITE.title
+const description = post.data.description || SITE.description
+const image = new URL('/static/1200x630.png', Astro.site)
+const author =
+ post.data.authors && post.data.authors.length > 0
+ ? post.data.authors.join(', ')
+ : SITE.author
+---
+
+<title>{`${title} | ${SITE.title}`}</title>
+<meta name="title" content={`${title} | ${SITE.title}`} />
+<meta name="description" content={description} />
+<link rel="canonical" href={SITE.href} />
+
+{isSubpost(post.id) && <meta name="robots" content="noindex" />}
+
+<meta property="og:title" content={title} />
+<meta property="og:description" content={description} />
+<meta
+ property="og:image"
+ content={post?.data?.image?.src
+ ? `${SITE.href}${post.data.image.src}`
+ : image}
+/>
+<meta property="og:image:alt" content={title} />
+<meta property="og:type" content="website" />
+<meta property="og:locale" content={SITE.locale} />
+<meta property="og:site_name" content={SITE.title} />
+<meta property="og:url" content={Astro.url} />
+<meta property="og:author" content={author} />
+
+<meta name="twitter:title" content={title} />
+<meta name="twitter:description" content={description} />
+<meta
+ property="twitter:image"
+ content={post?.data?.image?.src
+ ? `${SITE.href}${post.data.image.src}`
+ : image}
+/>
+<meta name="twitter:image:alt" content={title} />
+<meta name="twitter:card" content="summary_large_image" />
+<meta name="twitter:creator" content={author} />
+
+{
+ post?.data.tags &&
+ post.data.tags.map((tag: string) => {
+ return <meta property="article:tag" content={tag} />
+ })
+}
diff --git a/src/components/PostNavigation.astro b/src/components/PostNavigation.astro
new file mode 100644
index 0000000..8dc2db5
--- /dev/null
+++ b/src/components/PostNavigation.astro
@@ -0,0 +1,94 @@
+---
+import Link from '@/components/Link.astro'
+import { buttonVariants } from '@/components/ui/button'
+import { cn } from '@/lib/utils'
+import { Icon } from 'astro-icon/components'
+
+const { newerPost, olderPost, parentPost } = Astro.props
+const isSubpost = !!parentPost
+---
+
+<nav
+ class={cn(
+ 'col-start-2 grid grid-cols-1 gap-4',
+ isSubpost ? 'sm:grid-cols-3' : 'sm:grid-cols-2',
+ )}
+>
+ <Link
+ href={olderPost ? `/blog/${olderPost.id}#post-title` : '#'}
+ class={cn(
+ buttonVariants({ variant: 'outline' }),
+ 'rounded-lg group flex items-center justify-start size-full',
+ !olderPost && 'pointer-events-none opacity-50 cursor-not-allowed',
+ )}
+ aria-disabled={!olderPost}
+ >
+ <Icon
+ name="lucide:arrow-left"
+ class="mr-2 size-4 transition-transform group-hover:-translate-x-1"
+ />
+ <div class="flex flex-col items-start overflow-hidden text-wrap">
+ <span class="text-muted-foreground text-left text-xs">
+ {isSubpost ? 'Previous Subpost' : 'Previous Post'}
+ </span>
+ <span class="w-full text-left text-sm text-balance text-ellipsis">
+ {
+ olderPost?.data.title ||
+ (isSubpost ? 'No older subpost' : "You're at the oldest post!")
+ }
+ </span>
+ </div>
+ </Link>
+
+ {
+ isSubpost && (
+ <Link
+ href={parentPost ? `/blog/${parentPost.id}#post-title` : '#'}
+ class={cn(
+ buttonVariants({ variant: 'outline' }),
+ 'group flex size-full items-center justify-center rounded-lg',
+ !parentPost && 'pointer-events-none cursor-not-allowed opacity-50',
+ )}
+ >
+ <Icon
+ name="lucide:corner-left-up"
+ class="mr-2 size-4 transition-transform group-hover:-translate-y-1"
+ />
+ <div class="flex flex-col items-center overflow-hidden text-wrap">
+ <span class="text-muted-foreground text-center text-xs">
+ Parent Post
+ </span>
+ <span class="w-full text-center text-sm text-balance text-ellipsis">
+ {parentPost?.data.title || 'No parent post'}
+ </span>
+ </div>
+ </Link>
+ )
+ }
+
+ <Link
+ href={newerPost ? `/blog/${newerPost.id}#post-title` : '#'}
+ class={cn(
+ buttonVariants({ variant: 'outline' }),
+ 'rounded-lg group flex items-center justify-end size-full',
+ !newerPost && 'pointer-events-none opacity-50 cursor-not-allowed',
+ )}
+ aria-disabled={!newerPost}
+ >
+ <div class="flex flex-col items-end overflow-hidden text-wrap">
+ <span class="text-muted-foreground text-right text-xs">
+ {isSubpost ? 'Next Subpost' : 'Next Post'}
+ </span>
+ <span class="w-full text-right text-sm text-balance text-ellipsis">
+ {
+ newerPost?.data.title ||
+ (isSubpost ? 'No newer subpost' : "You're at the newest post!")
+ }
+ </span>
+ </div>
+ <Icon
+ name="lucide:arrow-right"
+ class="ml-2 size-4 transition-transform group-hover:translate-x-1"
+ />
+ </Link>
+</nav>
diff --git a/src/components/ProjectCard.astro b/src/components/ProjectCard.astro
new file mode 100644
index 0000000..03449a7
--- /dev/null
+++ b/src/components/ProjectCard.astro
@@ -0,0 +1,70 @@
+---
+import Link from '@/components/Link.astro'
+import { Badge } from '@/components/ui/badge'
+import { formatDate } from '@/lib/utils'
+import { Icon } from 'astro-icon/components'
+import { Image } from 'astro:assets'
+import type { CollectionEntry } from 'astro:content'
+
+interface Props {
+ project: CollectionEntry<'projects'>
+}
+
+const { project } = Astro.props
+---
+
+<div
+ class="hover:bg-muted/50 rounded-xl border p-4 transition-colors duration-300 ease-in-out"
+>
+ <Link
+ href={project.data.link}
+ class="flex flex-col gap-4 sm:flex-row"
+ external
+ >
+ {
+ project.data.image && (
+ <div class="w-full sm:max-w-3xs sm:shrink-0">
+ <Image
+ src={project.data.image}
+ alt={project.data.name}
+ width={1200}
+ height={630}
+ class="object-cover"
+ />
+ </div>
+ )
+ }
+ <div class="grow">
+ <h3 class="mb-1 text-lg font-medium">
+ {project.data.name}
+ </h3>
+ <p class="text-muted-foreground mb-2 text-sm">
+ {project.data.description}
+ </p>
+ {
+ project.data.startDate && (
+ <p class="text-muted-foreground/70 mb-2 flex items-center gap-x-1.5 text-xs">
+ <span class="flex items-center gap-x-1.5">
+ <Icon name="lucide:calendar" class="size-3" />
+ <span>
+ {formatDate(project.data.startDate)}
+ {project.data.endDate
+ ? ` → ${formatDate(project.data.endDate)}`
+ : ' → Present'}
+ </span>
+ </span>
+ </p>
+ )
+ }
+ {
+ project.data.tags && (
+ <div class="flex flex-wrap gap-2">
+ {project.data.tags.map((tag: string) => (
+ <Badge variant="muted">{tag}</Badge>
+ ))}
+ </div>
+ )
+ }
+ </div>
+ </Link>
+</div>
diff --git a/src/components/SocialIcons.astro b/src/components/SocialIcons.astro
new file mode 100644
index 0000000..236bb6a
--- /dev/null
+++ b/src/components/SocialIcons.astro
@@ -0,0 +1,37 @@
+---
+import Link from '@/components/Link.astro'
+import { buttonVariants } from '@/components/ui/button'
+import { ICON_MAP } from '@/consts'
+import type { SocialLink } from '@/types'
+import { Icon } from 'astro-icon/components'
+
+interface Props {
+ links: SocialLink[]
+}
+
+const { links } = Astro.props
+---
+
+<ul class="flex flex-wrap gap-2" role="list">
+ {
+ links.map(({ href, label }) => (
+ <li>
+ <Link
+ href={href}
+ aria-label={label}
+ title={label}
+ class={buttonVariants({ variant: 'outline', size: 'icon' })}
+ external
+ >
+ <Icon
+ name={
+ ICON_MAP[label as keyof typeof ICON_MAP] ||
+ 'lucide:message-circle-question'
+ }
+ class="size-4"
+ />
+ </Link>
+ </li>
+ ))
+ }
+</ul>
diff --git a/src/components/SubpostsHeader.astro b/src/components/SubpostsHeader.astro
new file mode 100644
index 0000000..0f1c597
--- /dev/null
+++ b/src/components/SubpostsHeader.astro
@@ -0,0 +1,314 @@
+---
+import { ScrollArea } from '@/components/ui/scroll-area'
+import {
+ getCombinedReadingTime,
+ getParentId,
+ getParentPost,
+ getPostById,
+ getPostReadingTime,
+ getSubpostsForParent,
+ isSubpost,
+} from '@/lib/data-utils'
+import { Icon } from 'astro-icon/components'
+
+const { parentId } = Astro.props
+const currentPostId = Astro.params.id as string
+const isCurrentSubpost = isSubpost(currentPostId)
+const rootParentId = isCurrentSubpost ? getParentId(currentPostId) : parentId
+
+const currentPost = !isCurrentSubpost ? await getPostById(currentPostId) : null
+const subposts = await getSubpostsForParent(rootParentId)
+const parentPost = isCurrentSubpost ? await getParentPost(currentPostId) : null
+
+const activePost = parentPost || currentPost
+const isActivePost = activePost?.id === currentPostId
+
+const activePostReadingTime = activePost
+ ? await getPostReadingTime(activePost.id)
+ : null
+const activePostCombinedReadingTime =
+ activePost && subposts.length > 0
+ ? await getCombinedReadingTime(activePost.id)
+ : null
+const subpostsWithReadingTime = await Promise.all(
+ subposts.map(async (subpost) => ({
+ ...subpost,
+ readingTime: await getPostReadingTime(subpost.id),
+ })),
+)
+
+const currentSubpostDetails = isCurrentSubpost
+ ? subpostsWithReadingTime.find((subpost) => subpost.id === currentPostId)
+ : null
+---
+
+{
+ activePost && subposts.length > 0 && (
+ <div id="mobile-subposts-container" class="w-full xl:hidden">
+ <details class="group">
+ <summary class="flex w-full cursor-pointer items-center justify-between">
+ <div class="mx-auto flex w-full max-w-3xl items-center px-4 py-3">
+ <div class="relative mr-2 size-4">
+ <Icon
+ name={
+ currentSubpostDetails
+ ? 'lucide:file-text'
+ : isActivePost
+ ? 'lucide:book-open-text'
+ : 'lucide:book-open'
+ }
+ class="size-4 flex-shrink-0"
+ aria-hidden="true"
+ />
+ </div>
+ <div class="flex flex-grow flex-col truncate text-sm">
+ <span class="text-muted-foreground truncate">
+ {currentSubpostDetails
+ ? currentSubpostDetails.data.title
+ : activePost?.data.title}
+ </span>
+ </div>
+ <span class="text-muted-foreground ml-2">
+ <Icon
+ name="lucide:chevron-down"
+ class="h-4 w-4 transition-transform duration-200 group-open:rotate-180"
+ />
+ </span>
+ </div>
+ </summary>
+
+ <ScrollArea
+ client:load
+ className="mx-auto max-w-3xl"
+ data-subposts-header-scroll
+ >
+ <div class="max-h-[30vh]">
+ <ul class="flex list-none flex-col gap-y-1 px-4 pb-4">
+ {activePost && (
+ <li>
+ {isActivePost ? (
+ <div class="text-foreground bg-muted mobile-subposts-active-item flex items-center gap-2 rounded-md px-2 py-1.5 text-sm font-medium">
+ <Icon
+ name="lucide:book-open-text"
+ class="size-4 flex-shrink-0"
+ aria-hidden="true"
+ />
+ <div class="flex flex-col">
+ <span class="line-clamp-2">
+ {activePost.data.title}
+ </span>
+ {activePostReadingTime && (
+ <span class="text-muted-foreground/80 text-xs">
+ {activePostReadingTime}
+ {activePostCombinedReadingTime &&
+ activePostCombinedReadingTime !==
+ activePostReadingTime && (
+ <span>
+ {' '}
+ ({activePostCombinedReadingTime} total)
+ </span>
+ )}
+ </span>
+ )}
+ </div>
+ </div>
+ ) : (
+ <a
+ href={`/blog/${activePost.id}`}
+ class="mobile-subposts-link hover:text-foreground text-muted-foreground hover:bg-muted/50 flex items-center gap-2 rounded-md px-2 py-1.5 text-sm transition-colors"
+ >
+ <Icon
+ name="lucide:book-open"
+ class="size-4 flex-shrink-0"
+ aria-hidden="true"
+ />
+ <div class="flex flex-col">
+ <span class="line-clamp-2">
+ {activePost.data.title}
+ </span>
+ {activePostReadingTime && (
+ <span class="text-muted-foreground/80 hover:text-foreground/80 text-xs">
+ {activePostReadingTime}
+ {activePostCombinedReadingTime &&
+ activePostCombinedReadingTime !==
+ activePostReadingTime && (
+ <span>
+ {' '}
+ ({activePostCombinedReadingTime} total)
+ </span>
+ )}
+ </span>
+ )}
+ </div>
+ </a>
+ )}
+ </li>
+ )}
+
+ {subpostsWithReadingTime.length > 0 && (
+ <div class="ml-4 space-y-1">
+ {subpostsWithReadingTime.map((subpost) =>
+ currentPostId === subpost.id ? (
+ <div class="text-foreground bg-muted mobile-subposts-active-item flex items-center gap-2 rounded-md px-2 py-1.5 text-sm font-medium">
+ <Icon
+ name="lucide:file-text"
+ class="size-4 flex-shrink-0"
+ aria-hidden="true"
+ />
+ <div class="flex flex-col">
+ <span class="line-clamp-2">{subpost.data.title}</span>
+ <span class="text-muted-foreground/80 text-xs">
+ {subpost.readingTime}
+ </span>
+ </div>
+ </div>
+ ) : (
+ <a
+ href={`/blog/${subpost.id}`}
+ class="mobile-subposts-link hover:text-foreground text-muted-foreground hover:bg-muted/50 flex items-center gap-2 rounded-md px-2 py-1.5 text-sm transition-colors"
+ >
+ <Icon
+ name="lucide:file"
+ class="size-4 flex-shrink-0"
+ aria-hidden="true"
+ />
+ <div class="flex flex-col">
+ <span class="line-clamp-2">{subpost.data.title}</span>
+ <span class="text-muted-foreground/80 hover:text-foreground/80 text-xs">
+ {subpost.readingTime}
+ </span>
+ </div>
+ </a>
+ ),
+ )}
+ </div>
+ )}
+ </ul>
+ </div>
+ </ScrollArea>
+ </details>
+ </div>
+ )
+}
+
+<script>
+ class SubpostsState {
+ scrollArea: HTMLElement | null = null
+ detailsElement: HTMLDetailsElement | null = null
+ headerScrollArea: HTMLElement | null = null
+
+ reset() {
+ this.scrollArea = null
+ this.detailsElement = null
+ this.headerScrollArea = null
+ }
+ }
+
+ const state = new SubpostsState()
+
+ class SubpostsHeaderScrollMask {
+ static update() {
+ if (!state.scrollArea || !state.headerScrollArea) return
+
+ const { scrollTop, scrollHeight, clientHeight } = state.scrollArea
+ const threshold = 5
+ const isAtTop = scrollTop <= threshold
+ const isAtBottom = scrollTop >= scrollHeight - clientHeight - threshold
+
+ state.headerScrollArea.classList.toggle('mask-t-from-80%', !isAtTop)
+ state.headerScrollArea.classList.toggle('mask-b-from-80%', !isAtBottom)
+ }
+ }
+
+ class SubpostsScroll {
+ static toActive() {
+ if (!state.scrollArea) return
+
+ const activeItem = state.scrollArea.querySelector(
+ '.mobile-subposts-active-item',
+ )
+ if (!activeItem) return
+
+ const { top: areaTop, height: areaHeight } =
+ state.scrollArea.getBoundingClientRect()
+ const { top: itemTop, height: itemHeight } =
+ activeItem.getBoundingClientRect()
+
+ const currentItemTop = itemTop - areaTop + state.scrollArea.scrollTop
+ const targetScroll = Math.max(
+ 0,
+ Math.min(
+ currentItemTop - (areaHeight - itemHeight) / 2,
+ state.scrollArea.scrollHeight - state.scrollArea.clientHeight,
+ ),
+ )
+
+ state.scrollArea.scrollTop = targetScroll
+ }
+
+ static setupInteraction() {
+ const container = document.getElementById('mobile-subposts-container')
+ if (!container) return
+
+ state.scrollArea = container.querySelector(
+ '[data-radix-scroll-area-viewport]',
+ )
+ state.detailsElement = container.querySelector('details')
+ state.headerScrollArea = container.querySelector(
+ '[data-subposts-header-scroll]',
+ )
+ const links = container.querySelectorAll('.mobile-subposts-link')
+
+ if (state.scrollArea) {
+ state.scrollArea.addEventListener(
+ 'scroll',
+ SubpostsHeaderScrollMask.update,
+ { passive: true },
+ )
+ }
+
+ if (state.detailsElement) {
+ state.detailsElement.addEventListener('toggle', () => {
+ if (state.detailsElement?.open) {
+ requestAnimationFrame(() => {
+ SubpostsScroll.toActive()
+ setTimeout(SubpostsHeaderScrollMask.update, 100)
+ })
+ }
+ })
+ }
+
+ links.forEach((link) => {
+ link.addEventListener('click', () => {
+ if (state.detailsElement) state.detailsElement.open = false
+ })
+ })
+ }
+ }
+
+ class SubpostsController {
+ static init() {
+ state.reset()
+ SubpostsScroll.setupInteraction()
+ }
+
+ static cleanup() {
+ if (state.scrollArea) {
+ state.scrollArea.removeEventListener(
+ 'scroll',
+ SubpostsHeaderScrollMask.update,
+ )
+ }
+ state.reset()
+ }
+ }
+
+ document.addEventListener('astro:page-load', () => SubpostsController.init())
+ document.addEventListener('astro:after-swap', () => {
+ SubpostsController.cleanup()
+ SubpostsController.init()
+ })
+ document.addEventListener('astro:before-swap', () =>
+ SubpostsController.cleanup(),
+ )
+</script>
diff --git a/src/components/SubpostsSidebar.astro b/src/components/SubpostsSidebar.astro
new file mode 100644
index 0000000..3428583
--- /dev/null
+++ b/src/components/SubpostsSidebar.astro
@@ -0,0 +1,267 @@
+---
+import Link from '@/components/Link.astro'
+import { ScrollArea } from '@/components/ui/scroll-area'
+import {
+ getCombinedReadingTime,
+ getParentId,
+ getParentPost,
+ getPostById,
+ getPostReadingTime,
+ getSubpostsForParent,
+ isSubpost,
+} from '@/lib/data-utils'
+import { Icon } from 'astro-icon/components'
+
+const { parentId } = Astro.props
+const currentPostId = Astro.params.id as string
+const isCurrentSubpost = isSubpost(currentPostId)
+const rootParentId = isCurrentSubpost ? getParentId(currentPostId) : parentId
+
+const currentPost = !isCurrentSubpost ? await getPostById(currentPostId) : null
+const subposts = await getSubpostsForParent(rootParentId)
+const parentPost = isCurrentSubpost ? await getParentPost(currentPostId) : null
+
+const activePost = parentPost || currentPost
+const isActivePost = activePost?.id === currentPostId
+
+const activePostReadingTime = activePost
+ ? await getPostReadingTime(activePost.id)
+ : null
+const activePostCombinedReadingTime =
+ activePost && subposts.length > 0
+ ? await getCombinedReadingTime(activePost.id)
+ : null
+const subpostsWithReadingTime = await Promise.all(
+ subposts.map(async (subpost) => ({
+ ...subpost,
+ readingTime: await getPostReadingTime(subpost.id),
+ })),
+)
+---
+
+<div
+ id="subposts-sidebar-container"
+ class="sticky top-20 col-start-3 row-span-1 mr-auto ml-8 hidden h-[calc(100vh-5rem)] max-w-md xl:block"
+>
+ <ScrollArea
+ client:load
+ className="flex max-h-[calc(100vh-8rem)] flex-col overflow-y-auto"
+ data-subposts-sidebar-scroll
+ >
+ <div class="px-4">
+ <ul class="space-y-1">
+ {
+ activePost && (
+ <li>
+ {isActivePost ? (
+ <div class="text-foreground bg-muted subposts-sidebar-active-item flex items-center gap-2 rounded-md py-1.5 pr-3 pl-2 text-sm font-medium text-pretty">
+ <Icon
+ name="lucide:book-open-text"
+ class="size-4 flex-shrink-0"
+ aria-hidden="true"
+ />
+ <div class="flex flex-col">
+ <span class="line-clamp-2 text-pretty">
+ {activePost.data.title}
+ </span>
+ {activePostReadingTime && (
+ <span class="text-muted-foreground/80 text-xs">
+ {activePostReadingTime}
+ {activePostCombinedReadingTime &&
+ activePostCombinedReadingTime !==
+ activePostReadingTime && (
+ <span>
+ {' '}
+ ({activePostCombinedReadingTime} total)
+ </span>
+ )}
+ </span>
+ )}
+ </div>
+ </div>
+ ) : (
+ <Link
+ href={`/blog/${activePost.id}#post-title`}
+ class="hover:text-foreground text-muted-foreground hover:bg-muted/50 subposts-sidebar-link flex items-center gap-2 rounded-md px-2 py-1.5 text-sm text-pretty transition-colors"
+ >
+ <Icon
+ name="lucide:book-open"
+ class="size-4 flex-shrink-0"
+ aria-hidden="true"
+ />
+ <div class="flex flex-col">
+ <span class="line-clamp-2 text-pretty">
+ {activePost.data.title}
+ </span>
+ {activePostReadingTime && (
+ <span class="text-muted-foreground/80 text-xs">
+ {activePostReadingTime}
+ {activePostCombinedReadingTime &&
+ activePostCombinedReadingTime !==
+ activePostReadingTime && (
+ <span>
+ {' '}
+ ({activePostCombinedReadingTime} total)
+ </span>
+ )}
+ </span>
+ )}
+ </div>
+ </Link>
+ )}
+ </li>
+ )
+ }
+
+ {
+ subpostsWithReadingTime.length > 0 && (
+ <li class="ml-4 space-y-1">
+ {subpostsWithReadingTime.map((subpost) =>
+ currentPostId === subpost.id ? (
+ <div class="text-foreground bg-muted subposts-sidebar-active-item flex items-center gap-2 rounded-md px-2 py-1.5 text-sm font-medium text-pretty">
+ <Icon
+ name="lucide:file-text"
+ class="size-4 flex-shrink-0"
+ aria-hidden="true"
+ />
+ <div class="flex flex-col">
+ <span class="line-clamp-2 text-pretty">
+ {subpost.data.title}
+ </span>
+ <span class="text-muted-foreground/80 text-xs">
+ {subpost.readingTime}
+ </span>
+ </div>
+ </div>
+ ) : (
+ <Link
+ href={`/blog/${subpost.id}`}
+ class="hover:text-foreground text-muted-foreground hover:bg-muted/50 subposts-sidebar-link flex items-center gap-2 rounded-md px-2 py-1.5 text-sm text-pretty transition-colors"
+ >
+ <Icon
+ name="lucide:file"
+ class="size-4 flex-shrink-0"
+ aria-hidden="true"
+ />
+ <div class="flex flex-col">
+ <span class="line-clamp-2 text-pretty">
+ {subpost.data.title}
+ </span>
+ <span class="text-muted-foreground/80 text-xs">
+ {subpost.readingTime}
+ </span>
+ </div>
+ </Link>
+ ),
+ )}
+ </li>
+ )
+ }
+ </ul>
+ </div>
+ </ScrollArea>
+</div>
+
+<script>
+ class SidebarState {
+ scrollArea: HTMLElement | null = null
+ sidebarScrollArea: HTMLElement | null = null
+
+ reset() {
+ this.scrollArea = null
+ this.sidebarScrollArea = null
+ }
+ }
+
+ const state = new SidebarState()
+
+ class SidebarScrollMask {
+ static update() {
+ if (!state.scrollArea || !state.sidebarScrollArea) return
+
+ const { scrollTop, scrollHeight, clientHeight } = state.scrollArea
+ const threshold = 5
+ const isAtTop = scrollTop <= threshold
+ const isAtBottom = scrollTop >= scrollHeight - clientHeight - threshold
+
+ state.sidebarScrollArea.classList.toggle('mask-t-from-90%', !isAtTop)
+ state.sidebarScrollArea.classList.toggle('mask-b-from-90%', !isAtBottom)
+ }
+ }
+
+ class SidebarScroll {
+ static toActive() {
+ if (!state.scrollArea) return
+
+ const activeItem = state.scrollArea.querySelector(
+ '.subposts-sidebar-active-item',
+ )
+ if (!activeItem) {
+ return
+ }
+
+ const { top: areaTop, height: areaHeight } =
+ state.scrollArea.getBoundingClientRect()
+ const { top: itemTop, height: itemHeight } =
+ activeItem.getBoundingClientRect()
+
+ const currentItemTop = itemTop - areaTop + state.scrollArea.scrollTop
+ const targetScroll = Math.max(
+ 0,
+ Math.min(
+ currentItemTop - (areaHeight - itemHeight) / 2,
+ state.scrollArea.scrollHeight - state.scrollArea.clientHeight,
+ ),
+ )
+
+ state.scrollArea.scrollTop = targetScroll
+ }
+ }
+
+ class SidebarController {
+ static init() {
+ state.reset()
+
+ const sidebarContainer = document.getElementById(
+ 'subposts-sidebar-container',
+ )
+ if (sidebarContainer) {
+ state.scrollArea = sidebarContainer.querySelector(
+ '[data-radix-scroll-area-viewport]',
+ )
+ state.sidebarScrollArea = sidebarContainer.querySelector(
+ '[data-subposts-sidebar-scroll]',
+ )
+
+ if (state.scrollArea) {
+ state.scrollArea.addEventListener(
+ 'scroll',
+ SidebarScrollMask.update,
+ { passive: true },
+ )
+ }
+
+ requestAnimationFrame(() => {
+ SidebarScroll.toActive()
+ setTimeout(SidebarScrollMask.update, 100)
+ })
+ }
+ }
+
+ static cleanup() {
+ if (state.scrollArea) {
+ state.scrollArea.removeEventListener('scroll', SidebarScrollMask.update)
+ }
+ state.reset()
+ }
+ }
+
+ document.addEventListener('astro:page-load', () => SidebarController.init())
+ document.addEventListener('astro:after-swap', () => {
+ SidebarController.cleanup()
+ SidebarController.init()
+ })
+ document.addEventListener('astro:before-swap', () =>
+ SidebarController.cleanup(),
+ )
+</script>
diff --git a/src/components/TOCHeader.astro b/src/components/TOCHeader.astro
new file mode 100644
index 0000000..5500f17
--- /dev/null
+++ b/src/components/TOCHeader.astro
@@ -0,0 +1,398 @@
+---
+import { ScrollArea } from '@/components/ui/scroll-area'
+import { cn, getHeadingMargin } from '@/lib/utils'
+import type { MarkdownHeading } from 'astro'
+import { Icon } from 'astro-icon/components'
+
+type Props = {
+ headings: MarkdownHeading[]
+}
+
+const { headings } = Astro.props
+---
+
+{
+ headings && headings.length > 0 && (
+ <div id="mobile-toc-container" class="w-full xl:hidden">
+ <details class="group">
+ <summary class="flex w-full cursor-pointer items-center justify-between">
+ <div class="mx-auto flex w-full max-w-3xl items-center px-4 py-3">
+ <div class="relative mr-2 size-4">
+ <svg class="h-4 w-4" viewBox="0 0 24 24">
+ <circle
+ class="text-primary/20"
+ cx="12"
+ cy="12"
+ r="10"
+ fill="none"
+ stroke="currentColor"
+ stroke-width="2"
+ />
+ <circle
+ id="mobile-toc-progress-circle"
+ class="text-primary"
+ cx="12"
+ cy="12"
+ r="10"
+ fill="none"
+ stroke="currentColor"
+ stroke-width="2"
+ stroke-dasharray="62.83"
+ stroke-dashoffset="62.83"
+ transform="rotate(-90 12 12)"
+ />
+ </svg>
+ </div>
+ <span
+ id="mobile-toc-current-section"
+ class="text-muted-foreground flex-grow truncate text-sm"
+ >
+ Overview
+ </span>
+ <span class="text-muted-foreground ml-2">
+ <Icon
+ name="lucide:chevron-down"
+ class="h-4 w-4 transition-transform duration-200 group-open:rotate-180"
+ />
+ </span>
+ </div>
+ </summary>
+
+ <ScrollArea
+ client:load
+ className="mx-auto max-w-3xl"
+ data-toc-header-scroll
+ >
+ <div class="max-h-[30vh]">
+ <ul
+ class="flex list-none flex-col gap-y-2 px-4 pb-4"
+ id="mobile-table-of-contents"
+ >
+ {headings.map((heading) => (
+ <li
+ class={cn(
+ 'px-4 text-sm',
+ getHeadingMargin(heading.depth),
+ 'text-foreground/60',
+ )}
+ >
+ <a
+ href={`#${heading.slug}`}
+ class="mobile-toc-item underline decoration-transparent underline-offset-[3px] transition-colors duration-200 hover:decoration-inherit"
+ data-heading-id={heading.slug}
+ >
+ {heading.text}
+ </a>
+ </li>
+ ))}
+ </ul>
+ </div>
+ </ScrollArea>
+ </details>
+ </div>
+ )
+}
+
+<script>
+ const INITIAL_OVERVIEW_TEXT = 'Overview'
+ const HEADER_OFFSET = 102 + 36
+ const PROGRESS_CIRCLE_RADIUS = 10
+ const PROGRESS_CIRCLE_CIRCUMFERENCE = 2 * Math.PI * PROGRESS_CIRCLE_RADIUS
+
+ class MobileTOCState {
+ progressCircle: HTMLElement | null = null
+ currentSectionText: HTMLElement | null = null
+ detailsElement: HTMLDetailsElement | null = null
+ listElement: HTMLElement | null = null
+ scrollArea: HTMLElement | null = null
+ headings: HTMLElement[] = []
+ regions: { id: string; start: number; end: number }[] = []
+ activeIds: string[] = []
+
+ reset() {
+ this.progressCircle = document.getElementById(
+ 'mobile-toc-progress-circle',
+ )
+ this.currentSectionText = document.getElementById(
+ 'mobile-toc-current-section',
+ )
+ this.detailsElement = document.querySelector(
+ '#mobile-toc-container details',
+ )
+ this.listElement = document.getElementById('mobile-table-of-contents')
+ const tocContainer = document.getElementById('mobile-toc-container')
+ this.scrollArea =
+ tocContainer?.querySelector('[data-radix-scroll-area-viewport]') || null
+ this.headings = []
+ this.regions = []
+ this.activeIds = []
+
+ if (this.progressCircle) {
+ this.progressCircle.style.strokeDasharray =
+ PROGRESS_CIRCLE_CIRCUMFERENCE.toString()
+ this.progressCircle.style.strokeDashoffset =
+ PROGRESS_CIRCLE_CIRCUMFERENCE.toString()
+ }
+ }
+ }
+
+ const state = new MobileTOCState()
+
+ class HeadingRegions {
+ static build() {
+ state.headings = Array.from(
+ document.querySelectorAll<HTMLElement>(
+ '.prose h2, .prose h3, .prose h4, .prose h5, .prose h6',
+ ),
+ )
+
+ if (state.headings.length === 0) {
+ state.regions = []
+ return
+ }
+
+ state.regions = state.headings.map((heading, index) => {
+ const nextHeading = state.headings[index + 1]
+ return {
+ id: heading.id,
+ start: heading.offsetTop,
+ end: nextHeading ? nextHeading.offsetTop : document.body.scrollHeight,
+ }
+ })
+ }
+
+ static getVisibleIds(): string[] {
+ if (state.headings.length === 0) return []
+
+ const viewportTop = window.scrollY + HEADER_OFFSET
+ const viewportBottom = window.scrollY + window.innerHeight
+ const visibleIds = new Set<string>()
+
+ const isInViewport = (top: number, bottom: number) =>
+ (top >= viewportTop && top <= viewportBottom) ||
+ (bottom >= viewportTop && bottom <= viewportBottom) ||
+ (top <= viewportTop && bottom >= viewportBottom)
+
+ state.headings.forEach((heading) => {
+ const headingBottom = heading.offsetTop + heading.offsetHeight
+ if (isInViewport(heading.offsetTop, headingBottom)) {
+ visibleIds.add(heading.id)
+ }
+ })
+
+ state.regions.forEach((region) => {
+ if (region.start <= viewportBottom && region.end >= viewportTop) {
+ const heading = document.getElementById(region.id)
+ if (heading) {
+ const headingBottom = heading.offsetTop + heading.offsetHeight
+ if (
+ region.end > headingBottom &&
+ (headingBottom < viewportBottom || viewportTop < region.end)
+ ) {
+ visibleIds.add(region.id)
+ }
+ }
+ }
+ })
+
+ return Array.from(visibleIds)
+ }
+ }
+
+ class TOCHeaderScrollMask {
+ static update() {
+ if (!state.scrollArea) return
+
+ const { scrollTop, scrollHeight, clientHeight } = state.scrollArea
+ const threshold = 5
+ const isAtTop = scrollTop <= threshold
+ const isAtBottom = scrollTop >= scrollHeight - clientHeight - threshold
+
+ state.scrollArea.classList.toggle('mask-t-from-80%', !isAtTop)
+ state.scrollArea.classList.toggle('mask-b-from-80%', !isAtBottom)
+ }
+ }
+
+ class MobileTOCLinks {
+ static update(headingIds: string[]) {
+ if (!state.listElement || !state.currentSectionText) return
+
+ state.listElement.querySelectorAll('.mobile-toc-item').forEach((item) => {
+ const tocItem = item as HTMLElement
+ const headingId = tocItem.dataset.headingId
+ if (headingId && headingIds.includes(headingId)) {
+ tocItem.classList.add('text-foreground')
+ } else {
+ tocItem.classList.remove('text-foreground')
+ }
+ })
+
+ if (headingIds.length > 0) {
+ this.scrollToActive(headingIds[0])
+ }
+
+ this.updateCurrentSectionText(headingIds)
+ }
+
+ static updateCurrentSectionText(headingIds: string[]) {
+ if (!state.currentSectionText) return
+
+ let textToShow = INITIAL_OVERVIEW_TEXT
+
+ if (headingIds.length > 0) {
+ const activeTexts = state.headings
+ .filter(
+ (heading) => headingIds.includes(heading.id) && heading.textContent,
+ )
+ .map((heading) => heading.textContent!.trim())
+
+ if (activeTexts.length > 0) {
+ textToShow = activeTexts.join(', ')
+ }
+ }
+
+ state.currentSectionText.textContent = textToShow
+ }
+
+ static scrollToActive(activeHeadingId: string) {
+ if (!state.listElement) return
+
+ const activeItem = state.listElement.querySelector(
+ `[data-heading-id="${activeHeadingId}"]`,
+ )
+ if (!activeItem) return
+
+ const scrollContainer = state.scrollArea
+ if (!scrollContainer) return
+
+ const { top: containerTop, height: containerHeight } =
+ scrollContainer.getBoundingClientRect()
+ const { top: itemTop, height: itemHeight } =
+ activeItem.getBoundingClientRect()
+
+ const currentItemTop = itemTop - containerTop + scrollContainer.scrollTop
+ const targetScroll = Math.max(
+ 0,
+ Math.min(
+ currentItemTop - (containerHeight - itemHeight) / 2,
+ scrollContainer.scrollHeight - scrollContainer.clientHeight,
+ ),
+ )
+
+ if (Math.abs(targetScroll - scrollContainer.scrollTop) > 5) {
+ scrollContainer.scrollTop = targetScroll
+ }
+ }
+
+ static setupInteraction() {
+ if (!state.listElement) return
+
+ state.listElement.querySelectorAll('.mobile-toc-item').forEach((item) => {
+ item.addEventListener('click', () => {
+ if (state.detailsElement) state.detailsElement.open = false
+ })
+ })
+
+ if (state.scrollArea) {
+ state.scrollArea.addEventListener(
+ 'scroll',
+ () => TOCHeaderScrollMask.update(),
+ { passive: true },
+ )
+ }
+
+ if (state.detailsElement) {
+ state.detailsElement.addEventListener('toggle', () => {
+ if (state.detailsElement?.open) {
+ setTimeout(TOCHeaderScrollMask.update, 100)
+ }
+ })
+ }
+ }
+ }
+
+ class ProgressCircle {
+ static update() {
+ if (!state.progressCircle) return
+
+ const scrollableDistance =
+ document.documentElement.scrollHeight - window.innerHeight
+ const scrollProgress =
+ scrollableDistance > 0
+ ? Math.min(Math.max(window.scrollY / scrollableDistance, 0), 1)
+ : 0
+
+ state.progressCircle.style.strokeDashoffset = (
+ PROGRESS_CIRCLE_CIRCUMFERENCE *
+ (1 - scrollProgress)
+ ).toString()
+ }
+ }
+
+ class MobileTOCController {
+ static handleScroll() {
+ const newActiveIds = HeadingRegions.getVisibleIds()
+
+ if (JSON.stringify(newActiveIds) !== JSON.stringify(state.activeIds)) {
+ state.activeIds = newActiveIds
+ MobileTOCLinks.update(state.activeIds)
+ }
+
+ ProgressCircle.update()
+ }
+
+ static handleResize() {
+ HeadingRegions.build()
+
+ const newActiveIds = HeadingRegions.getVisibleIds()
+ if (JSON.stringify(newActiveIds) !== JSON.stringify(state.activeIds)) {
+ state.activeIds = newActiveIds
+ MobileTOCLinks.update(state.activeIds)
+ }
+
+ ProgressCircle.update()
+ }
+
+ static init() {
+ state.reset()
+ HeadingRegions.build()
+
+ if (!state.currentSectionText) return
+
+ if (state.headings.length === 0) {
+ state.currentSectionText.textContent = INITIAL_OVERVIEW_TEXT
+ window.addEventListener('scroll', ProgressCircle.update, {
+ passive: true,
+ })
+ ProgressCircle.update()
+ return
+ }
+
+ state.activeIds = HeadingRegions.getVisibleIds()
+ MobileTOCLinks.update(state.activeIds)
+ ProgressCircle.update()
+
+ MobileTOCLinks.setupInteraction()
+ window.addEventListener('scroll', this.handleScroll, { passive: true })
+ window.addEventListener('resize', this.handleResize, { passive: true })
+ }
+
+ static cleanup() {
+ window.removeEventListener('scroll', this.handleScroll)
+ window.removeEventListener('scroll', ProgressCircle.update)
+ window.removeEventListener('resize', this.handleResize)
+ state.activeIds = []
+ state.headings = []
+ state.regions = []
+ }
+ }
+
+ document.addEventListener('astro:page-load', () => MobileTOCController.init())
+ document.addEventListener('astro:after-swap', () => {
+ MobileTOCController.cleanup()
+ MobileTOCController.init()
+ })
+ document.addEventListener('astro:before-swap', () =>
+ MobileTOCController.cleanup(),
+ )
+</script>
diff --git a/src/components/TOCSidebar.astro b/src/components/TOCSidebar.astro
new file mode 100644
index 0000000..77be7c6
--- /dev/null
+++ b/src/components/TOCSidebar.astro
@@ -0,0 +1,365 @@
+---
+import { ScrollArea } from '@/components/ui/scroll-area'
+import type { TOCSection } from '@/lib/data-utils'
+import { getParentId, isSubpost } from '@/lib/data-utils'
+import { cn, getHeadingMargin } from '@/lib/utils'
+
+type Props = {
+ sections: TOCSection[]
+ currentPostId: string
+}
+
+const { sections, currentPostId } = Astro.props
+const isCurrentSubpost = isSubpost(currentPostId)
+const parentId = isCurrentSubpost ? getParentId(currentPostId) : currentPostId
+---
+
+{
+ sections.length > 0 && (
+ <div
+ id="toc-sidebar-container"
+ class="sticky top-20 col-start-1 row-span-1 mr-8 ml-auto hidden h-[calc(100vh-5rem)] max-w-md xl:block"
+ >
+ <ScrollArea
+ client:load
+ className="flex max-h-[calc(100vh-8rem)] flex-col overflow-y-auto"
+ type="hover"
+ data-toc-scroll-area
+ >
+ <div class="flex flex-col gap-2 px-4">
+ <span class="text-lg font-medium">Table of Contents</span>
+ {sections.map((section, index) => {
+ const isFirstSubpost =
+ section.type === 'subpost' &&
+ (index === 0 || sections[index - 1].type === 'parent')
+
+ return (
+ <>
+ {isFirstSubpost && (
+ <div class="mt-2 flex items-center gap-2">
+ <div class="bg-border h-px flex-1" />
+ <span class="text-muted-foreground text-xs font-medium">
+ Subposts
+ </span>
+ <div class="bg-border h-px flex-1" />
+ </div>
+ )}
+
+ {section.type === 'parent' ? (
+ <ul class="flex list-none flex-col gap-y-2">
+ {section.headings.map((heading) => (
+ <li
+ class={cn(
+ 'text-sm',
+ getHeadingMargin(heading.depth),
+ isCurrentSubpost
+ ? 'text-foreground/40'
+ : 'text-foreground/60',
+ )}
+ >
+ <a
+ href={
+ isCurrentSubpost
+ ? `/blog/${parentId}#${heading.slug}`
+ : `#${heading.slug}`
+ }
+ class="marker:text-foreground/30 list-none underline decoration-transparent underline-offset-[3px] transition-colors duration-200 hover:decoration-inherit"
+ data-heading-link={heading.slug}
+ >
+ {heading.text}
+ </a>
+ </li>
+ ))}
+ </ul>
+ ) : (
+ <div
+ class={cn(
+ 'rounded-md border p-2',
+ section.subpostId === currentPostId ? 'bg-muted/50' : '',
+ )}
+ >
+ <ul class="flex list-none flex-col gap-y-2">
+ <li
+ class={cn(
+ 'text-xs font-medium',
+ section.subpostId === currentPostId
+ ? 'text-foreground'
+ : 'text-foreground/60',
+ )}
+ >
+ <a
+ href={
+ section.subpostId === currentPostId
+ ? '#'
+ : `/blog/${section.subpostId}`
+ }
+ class="marker:text-foreground/30 list-none underline decoration-transparent underline-offset-[3px] transition-colors duration-200 hover:decoration-inherit"
+ data-heading-link={
+ section.subpostId === currentPostId
+ ? 'top'
+ : `${section.subpostId}-top`
+ }
+ >
+ {section.title}
+ </a>
+ </li>
+ {section.headings.map((heading) => (
+ <li
+ class={cn(
+ 'text-xs',
+ getHeadingMargin(heading.depth),
+ section.subpostId === currentPostId
+ ? 'text-foreground/60'
+ : 'text-foreground/30',
+ )}
+ >
+ <a
+ href={
+ section.subpostId === currentPostId
+ ? `#${heading.slug}`
+ : `/blog/${section.subpostId}#${heading.slug}`
+ }
+ class="marker:text-foreground/30 hover:text-foreground/60 list-none underline decoration-transparent underline-offset-[3px] transition-colors duration-200 hover:decoration-inherit"
+ data-heading-link={
+ section.subpostId === currentPostId
+ ? heading.slug
+ : `${section.subpostId}-${heading.slug}`
+ }
+ >
+ {heading.text}
+ </a>
+ </li>
+ ))}
+ </ul>
+ </div>
+ )}
+ </>
+ )
+ })}
+ </div>
+ </ScrollArea>
+ </div>
+ )
+}
+
+<script>
+ const HEADER_OFFSET = 150
+
+ class TOCState {
+ links: NodeListOf<Element> = document.querySelectorAll(
+ '[data-heading-link]',
+ )
+ activeIds: string[] = []
+ headings: HTMLElement[] = []
+ regions: { id: string; start: number; end: number }[] = []
+ scrollArea: HTMLElement | null = null
+ tocScrollArea: HTMLElement | null = null
+
+ reset() {
+ this.links = document.querySelectorAll(
+ '#toc-sidebar-container [data-heading-link]',
+ )
+ this.activeIds = []
+ this.headings = []
+ this.regions = []
+ const tocContainer = document.getElementById('toc-sidebar-container')
+ this.scrollArea =
+ tocContainer?.querySelector('[data-radix-scroll-area-viewport]') || null
+ this.tocScrollArea =
+ tocContainer?.querySelector('[data-toc-scroll-area]') || null
+ }
+ }
+
+ const state = new TOCState()
+
+ class HeadingRegions {
+ static build() {
+ state.headings = Array.from(
+ document.querySelectorAll<HTMLElement>(
+ '.prose h2, .prose h3, .prose h4, .prose h5, .prose h6',
+ ),
+ )
+
+ if (state.headings.length === 0) {
+ state.regions = []
+ return
+ }
+
+ state.regions = state.headings.map((heading, index) => {
+ const nextHeading = state.headings[index + 1]
+ return {
+ id: heading.id,
+ start: heading.offsetTop,
+ end: nextHeading ? nextHeading.offsetTop : document.body.scrollHeight,
+ }
+ })
+ }
+
+ static getVisibleIds(): string[] {
+ if (state.headings.length === 0) return []
+
+ const viewportTop = window.scrollY + HEADER_OFFSET
+ const viewportBottom = window.scrollY + window.innerHeight
+ const visibleIds = new Set<string>()
+
+ const isInViewport = (top: number, bottom: number) =>
+ (top >= viewportTop && top <= viewportBottom) ||
+ (bottom >= viewportTop && bottom <= viewportBottom) ||
+ (top <= viewportTop && bottom >= viewportBottom)
+
+ state.headings.forEach((heading) => {
+ const headingBottom = heading.offsetTop + heading.offsetHeight
+ if (isInViewport(heading.offsetTop, headingBottom)) {
+ visibleIds.add(heading.id)
+ }
+ })
+
+ state.regions.forEach((region) => {
+ if (region.start <= viewportBottom && region.end >= viewportTop) {
+ const heading = document.getElementById(region.id)
+ if (heading) {
+ const headingBottom = heading.offsetTop + heading.offsetHeight
+ if (
+ region.end > headingBottom &&
+ (headingBottom < viewportBottom || viewportTop < region.end)
+ ) {
+ visibleIds.add(region.id)
+ }
+ }
+ }
+ })
+
+ return Array.from(visibleIds)
+ }
+ }
+
+ class TOCScrollMask {
+ static update() {
+ if (!state.scrollArea || !state.tocScrollArea) return
+
+ const { scrollTop, scrollHeight, clientHeight } = state.scrollArea
+ const threshold = 5
+ const isAtTop = scrollTop <= threshold
+ const isAtBottom = scrollTop >= scrollHeight - clientHeight - threshold
+
+ state.tocScrollArea.classList.toggle('mask-t-from-90%', !isAtTop)
+ state.tocScrollArea.classList.toggle('mask-b-from-90%', !isAtBottom)
+ }
+ }
+
+ class TOCLinks {
+ static update(headingIds: string[]) {
+ state.links.forEach((link) => {
+ link.classList.remove('text-foreground')
+ })
+
+ headingIds.forEach((id) => {
+ if (id) {
+ const activeLink = document.querySelector(
+ `#toc-sidebar-container [data-heading-link="${id}"]`,
+ )
+ if (activeLink) {
+ activeLink.classList.add('text-foreground')
+ }
+ }
+ })
+
+ this.scrollToActive(headingIds)
+ }
+
+ static scrollToActive(headingIds: string[]) {
+ if (!state.scrollArea || !headingIds.length) return
+
+ const activeLink = document.querySelector(
+ `#toc-sidebar-container [data-heading-link="${headingIds[0]}"]`,
+ )
+ if (!activeLink) return
+
+ const { top: areaTop, height: areaHeight } =
+ state.scrollArea.getBoundingClientRect()
+ const { top: linkTop, height: linkHeight } =
+ activeLink.getBoundingClientRect()
+
+ const currentLinkTop = linkTop - areaTop + state.scrollArea.scrollTop
+ const targetScroll = Math.max(
+ 0,
+ Math.min(
+ currentLinkTop - (areaHeight - linkHeight) / 2,
+ state.scrollArea.scrollHeight - state.scrollArea.clientHeight,
+ ),
+ )
+
+ if (Math.abs(targetScroll - state.scrollArea.scrollTop) > 5) {
+ state.scrollArea.scrollTop = targetScroll
+ }
+ }
+ }
+
+ class TOCController {
+ static handleScroll() {
+ const newActiveIds = HeadingRegions.getVisibleIds()
+
+ if (JSON.stringify(newActiveIds) !== JSON.stringify(state.activeIds)) {
+ state.activeIds = newActiveIds
+ TOCLinks.update(state.activeIds)
+ }
+ }
+
+ static handleTOCScroll = () => TOCScrollMask.update()
+
+ static handleResize() {
+ HeadingRegions.build()
+ const newActiveIds = HeadingRegions.getVisibleIds()
+
+ if (JSON.stringify(newActiveIds) !== JSON.stringify(state.activeIds)) {
+ state.activeIds = newActiveIds
+ TOCLinks.update(state.activeIds)
+ }
+
+ TOCScrollMask.update()
+ }
+
+ static init() {
+ state.reset()
+ HeadingRegions.build()
+
+ if (state.headings.length === 0) {
+ TOCLinks.update([])
+ return
+ }
+
+ this.handleScroll()
+ setTimeout(TOCScrollMask.update, 100)
+
+ const options = { passive: true }
+ window.addEventListener('scroll', this.handleScroll, options)
+ window.addEventListener('resize', this.handleResize, options)
+ state.scrollArea?.addEventListener(
+ 'scroll',
+ this.handleTOCScroll,
+ options,
+ )
+ }
+
+ static cleanup() {
+ window.removeEventListener('scroll', this.handleScroll)
+ window.removeEventListener('resize', this.handleResize)
+ state.scrollArea?.removeEventListener('scroll', this.handleTOCScroll)
+
+ Object.assign(state, {
+ activeIds: [],
+ headings: [],
+ regions: [],
+ scrollArea: null,
+ tocScrollArea: null,
+ })
+ }
+ }
+
+ document.addEventListener('astro:page-load', () => TOCController.init())
+ document.addEventListener('astro:after-swap', () => {
+ TOCController.cleanup()
+ TOCController.init()
+ })
+ document.addEventListener('astro:before-swap', () => TOCController.cleanup())
+</script>
diff --git a/src/components/ThemeToggle.astro b/src/components/ThemeToggle.astro
new file mode 100644
index 0000000..f7b0926
--- /dev/null
+++ b/src/components/ThemeToggle.astro
@@ -0,0 +1,72 @@
+---
+import { Button } from '@/components/ui/button'
+import { Icon } from 'astro-icon/components'
+---
+
+<Button
+ id="theme-toggle"
+ variant="ghost"
+ size="icon"
+ title="Toggle theme"
+ className="-my-2 -me-2 size-8"
+>
+ <Icon name="lucide:sun" class="size-4 dark:hidden" />
+ <Icon name="lucide:moon" class="absolute hidden size-4 dark:block" />
+ <span class="sr-only">Toggle theme</span>
+</Button>
+
+<script is:inline data-astro-rerun>
+ ;(() => {
+ const theme = (() => {
+ const stored = localStorage?.getItem('theme') ?? ''
+ if (['dark', 'light'].includes(stored)) return stored
+ return window.matchMedia('(prefers-color-scheme: dark)').matches
+ ? 'dark'
+ : 'light'
+ })()
+
+ document.documentElement.setAttribute('data-theme', theme)
+ window.localStorage.setItem('theme', theme)
+ })()
+</script>
+
+<script>
+ function handleToggleClick() {
+ const element = document.documentElement
+ const currentTheme = element.getAttribute('data-theme')
+ const newTheme = currentTheme === 'dark' ? 'light' : 'dark'
+
+ element.classList.add('[&_*]:transition-none')
+ element.setAttribute('data-theme', newTheme)
+ window.getComputedStyle(element).getPropertyValue('opacity')
+
+ requestAnimationFrame(() => {
+ element.classList.remove('[&_*]:transition-none')
+ })
+
+ localStorage.setItem('theme', newTheme)
+ }
+
+ function initThemeToggle() {
+ document
+ .getElementById('theme-toggle')
+ ?.addEventListener('click', handleToggleClick)
+ }
+
+ initThemeToggle()
+
+ document.addEventListener('astro:after-swap', () => {
+ const storedTheme = localStorage.getItem('theme') || 'light'
+ const element = document.documentElement
+
+ element.classList.add('[&_*]:transition-none')
+ window.getComputedStyle(element).getPropertyValue('opacity')
+ element.setAttribute('data-theme', storedTheme)
+
+ requestAnimationFrame(() => {
+ element.classList.remove('[&_*]:transition-none')
+ })
+
+ initThemeToggle()
+ })
+</script>
diff --git a/src/components/ui/avatar.tsx b/src/components/ui/avatar.tsx
new file mode 100644
index 0000000..dc92ac0
--- /dev/null
+++ b/src/components/ui/avatar.tsx
@@ -0,0 +1,74 @@
+import * as React from 'react'
+import { Avatar as AvatarPrimitive } from 'radix-ui'
+
+import { cn } from '@/lib/utils'
+
+function Avatar({
+ className,
+ ...props
+}: React.ComponentProps<typeof AvatarPrimitive.Root>) {
+ return (
+ <AvatarPrimitive.Root
+ data-slot="avatar"
+ className={cn(
+ 'relative flex size-8 shrink-0 overflow-hidden rounded-full',
+ className,
+ )}
+ {...props}
+ />
+ )
+}
+
+function AvatarImage({
+ className,
+ ...props
+}: React.ComponentProps<typeof AvatarPrimitive.Image>) {
+ return (
+ <AvatarPrimitive.Image
+ data-slot="avatar-image"
+ className={cn('aspect-square size-full', className)}
+ {...props}
+ />
+ )
+}
+
+function AvatarFallback({
+ className,
+ ...props
+}: React.ComponentProps<typeof AvatarPrimitive.Fallback>) {
+ return (
+ <AvatarPrimitive.Fallback
+ data-slot="avatar-fallback"
+ className={cn(
+ 'bg-muted flex size-full items-center justify-center rounded-full',
+ className,
+ )}
+ {...props}
+ />
+ )
+}
+
+const AvatarComponent: React.FC<AvatarComponentProps> = ({
+ src,
+ alt,
+ fallback,
+ className,
+}) => {
+ return (
+ <Avatar className={className}>
+ <AvatarImage src={src} alt={alt} />
+ <AvatarFallback>{fallback}</AvatarFallback>
+ </Avatar>
+ )
+}
+
+export default AvatarComponent
+
+interface AvatarComponentProps {
+ src?: string
+ alt?: string
+ fallback: string
+ className?: string
+}
+
+export { Avatar, AvatarImage, AvatarFallback }
diff --git a/src/components/ui/badge.tsx b/src/components/ui/badge.tsx
new file mode 100644
index 0000000..84f36c6
--- /dev/null
+++ b/src/components/ui/badge.tsx
@@ -0,0 +1,46 @@
+import * as React from 'react'
+import { Slot as SlotPrimitive } from 'radix-ui'
+import { cva, type VariantProps } from 'class-variance-authority'
+
+import { cn } from '@/lib/utils'
+
+const badgeVariants = cva(
+ 'inline-flex items-center justify-center rounded-md border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden',
+ {
+ variants: {
+ variant: {
+ default:
+ 'border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90',
+ muted:
+ 'border-transparent bg-muted text-foreground [a&]:hover:bg-muted/90',
+ destructive:
+ 'border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/70',
+ outline:
+ 'text-foreground [a&]:hover:bg-muted [a&]:hover:text-foreground',
+ },
+ },
+ defaultVariants: {
+ variant: 'default',
+ },
+ },
+)
+
+function Badge({
+ className,
+ variant,
+ asChild = false,
+ ...props
+}: React.ComponentProps<'span'> &
+ VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
+ const Comp = asChild ? SlotPrimitive.Root : 'span'
+
+ return (
+ <Comp
+ data-slot="badge"
+ className={cn(badgeVariants({ variant }), className)}
+ {...props}
+ />
+ )
+}
+
+export { Badge, badgeVariants }
diff --git a/src/components/ui/breadcrumb.tsx b/src/components/ui/breadcrumb.tsx
new file mode 100644
index 0000000..a639b0c
--- /dev/null
+++ b/src/components/ui/breadcrumb.tsx
@@ -0,0 +1,108 @@
+import * as React from 'react'
+import { Slot as SlotPrimitive } from 'radix-ui'
+import { cn } from '@/lib/utils'
+import { ChevronRight, MoreHorizontal } from 'lucide-react'
+
+function Breadcrumb({ ...props }: React.ComponentProps<'nav'>) {
+ return <nav aria-label="breadcrumb" data-slot="breadcrumb" {...props} />
+}
+
+function BreadcrumbList({ className, ...props }: React.ComponentProps<'ol'>) {
+ return (
+ <ol
+ data-slot="breadcrumb-list"
+ className={cn(
+ 'text-muted-foreground flex flex-wrap items-center gap-1.5 text-sm break-words sm:gap-2.5',
+ className,
+ )}
+ {...props}
+ />
+ )
+}
+
+function BreadcrumbItem({ className, ...props }: React.ComponentProps<'li'>) {
+ return (
+ <li
+ data-slot="breadcrumb-item"
+ className={cn('inline-flex items-center gap-1.5', className)}
+ {...props}
+ />
+ )
+}
+
+function BreadcrumbLink({
+ asChild,
+ className,
+ ...props
+}: React.ComponentProps<'a'> & {
+ asChild?: boolean
+}) {
+ const Comp = asChild ? SlotPrimitive.Root : 'a'
+
+ return (
+ <Comp
+ data-slot="breadcrumb-link"
+ className={cn('hover:text-foreground transition-colors', className)}
+ {...props}
+ />
+ )
+}
+
+function BreadcrumbPage({ className, ...props }: React.ComponentProps<'span'>) {
+ return (
+ <span
+ data-slot="breadcrumb-page"
+ role="link"
+ aria-disabled="true"
+ aria-current="page"
+ className={cn('text-foreground font-normal', className)}
+ {...props}
+ />
+ )
+}
+
+function BreadcrumbSeparator({
+ children,
+ className,
+ ...props
+}: React.ComponentProps<'li'>) {
+ return (
+ <li
+ data-slot="breadcrumb-separator"
+ role="presentation"
+ aria-hidden="true"
+ className={cn('[&>svg]:size-3.5', className)}
+ {...props}
+ >
+ {children ?? <ChevronRight />}
+ </li>
+ )
+}
+
+function BreadcrumbEllipsis({
+ className,
+ ...props
+}: React.ComponentProps<'span'>) {
+ return (
+ <span
+ data-slot="breadcrumb-ellipsis"
+ role="presentation"
+ aria-hidden="true"
+ className={cn('flex size-9 items-center justify-center', className)}
+ {...props}
+ >
+ <MoreHorizontal className="size-4" />
+ <span className="sr-only">More</span>
+ </span>
+ )
+}
+
+export {
+ Breadcrumb,
+ BreadcrumbList,
+ BreadcrumbItem,
+ BreadcrumbLink,
+ BreadcrumbPage,
+ BreadcrumbSeparator,
+ BreadcrumbEllipsis,
+}
diff --git a/src/components/ui/button.tsx b/src/components/ui/button.tsx
new file mode 100644
index 0000000..00c0502
--- /dev/null
+++ b/src/components/ui/button.tsx
@@ -0,0 +1,56 @@
+import * as React from 'react'
+import { Slot as SlotPrimitive } from 'radix-ui'
+import { cva, type VariantProps } from 'class-variance-authority'
+
+import { cn } from '@/lib/utils'
+
+const buttonVariants = cva(
+ "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
+ {
+ variants: {
+ variant: {
+ default: 'bg-primary text-primary-foreground hover:bg-primary/90',
+ destructive:
+ 'bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60',
+ outline:
+ 'border bg-background hover:bg-muted hover:text-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50',
+ muted: 'bg-muted text-foreground hover:bg-muted/80',
+ ghost: 'hover:bg-muted hover:text-foreground dark:hover:bg-muted/50',
+ link: 'text-primary underline-offset-4 hover:underline',
+ },
+ size: {
+ default: 'h-9 px-4 py-2 has-[>svg]:px-3',
+ sm: 'h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5',
+ lg: 'h-10 rounded-md px-6 has-[>svg]:px-4',
+ icon: 'size-9',
+ },
+ },
+ defaultVariants: {
+ variant: 'default',
+ size: 'default',
+ },
+ },
+)
+
+function Button({
+ className,
+ variant,
+ size,
+ asChild = false,
+ ...props
+}: React.ComponentProps<'button'> &
+ VariantProps<typeof buttonVariants> & {
+ asChild?: boolean
+ }) {
+ const Comp = asChild ? SlotPrimitive.Root : 'button'
+
+ return (
+ <Comp
+ data-slot="button"
+ className={cn(buttonVariants({ variant, size, className }))}
+ {...props}
+ />
+ )
+}
+
+export { Button, buttonVariants }
diff --git a/src/components/ui/pagination.tsx b/src/components/ui/pagination.tsx
new file mode 100644
index 0000000..ad0d40c
--- /dev/null
+++ b/src/components/ui/pagination.tsx
@@ -0,0 +1,195 @@
+import * as React from 'react'
+import {
+ ChevronLeftIcon,
+ ChevronRightIcon,
+ MoreHorizontalIcon,
+} from 'lucide-react'
+
+import { cn } from '@/lib/utils'
+import { Button, buttonVariants } from '@/components/ui/button'
+
+function Pagination({ className, ...props }: React.ComponentProps<'nav'>) {
+ return (
+ <nav
+ role="navigation"
+ aria-label="pagination"
+ data-slot="pagination"
+ className={cn('mx-auto flex w-full justify-center', className)}
+ {...props}
+ />
+ )
+}
+
+function PaginationContent({
+ className,
+ ...props
+}: React.ComponentProps<'ul'>) {
+ return (
+ <ul
+ data-slot="pagination-content"
+ className={cn('flex flex-row items-center gap-1', className)}
+ {...props}
+ />
+ )
+}
+
+function PaginationItem({ ...props }: React.ComponentProps<'li'>) {
+ return <li data-slot="pagination-item" {...props} />
+}
+
+type PaginationLinkProps = {
+ isActive?: boolean
+ isDisabled?: boolean
+} & Pick<React.ComponentProps<typeof Button>, 'size'> &
+ React.ComponentProps<'a'>
+
+function PaginationLink({
+ className,
+ isActive,
+ isDisabled,
+ size = 'icon',
+ ...props
+}: PaginationLinkProps) {
+ return (
+ <a
+ aria-current={isActive ? 'page' : undefined}
+ data-slot="pagination-link"
+ data-active={isActive}
+ data-disabled={isDisabled}
+ className={cn(
+ buttonVariants({
+ variant: isActive ? 'outline' : 'ghost',
+ size,
+ }),
+ isDisabled && 'pointer-events-none opacity-50',
+ className,
+ )}
+ {...props}
+ />
+ )
+}
+
+function PaginationPrevious({
+ className,
+ isDisabled,
+ ...props
+}: React.ComponentProps<typeof PaginationLink>) {
+ return (
+ <PaginationLink
+ aria-label="Go to previous page"
+ size="default"
+ className={cn('gap-1 px-2.5 sm:pl-2.5', className)}
+ isDisabled={isDisabled}
+ {...props}
+ >
+ <ChevronLeftIcon />
+ <span className="hidden sm:block">Previous</span>
+ </PaginationLink>
+ )
+}
+
+function PaginationNext({
+ className,
+ isDisabled,
+ ...props
+}: React.ComponentProps<typeof PaginationLink>) {
+ return (
+ <PaginationLink
+ aria-label="Go to next page"
+ size="default"
+ className={cn('gap-1 px-2.5 sm:pr-2.5', className)}
+ isDisabled={isDisabled}
+ {...props}
+ >
+ <span className="hidden sm:block">Next</span>
+ <ChevronRightIcon />
+ </PaginationLink>
+ )
+}
+
+function PaginationEllipsis({
+ className,
+ ...props
+}: React.ComponentProps<'span'>) {
+ return (
+ <span
+ aria-hidden
+ data-slot="pagination-ellipsis"
+ className={cn('flex size-9 items-center justify-center', className)}
+ {...props}
+ >
+ <MoreHorizontalIcon className="size-4" />
+ <span className="sr-only">More pages</span>
+ </span>
+ )
+}
+
+const PaginationComponent: React.FC<PaginationProps> = ({
+ currentPage,
+ totalPages,
+ baseUrl,
+}) => {
+ const pages = Array.from({ length: totalPages }, (_, i) => i + 1)
+
+ const getPageUrl = (page: number) => {
+ if (page === 1) return baseUrl
+ return `${baseUrl}${page}`
+ }
+
+ return (
+ <Pagination>
+ <PaginationContent className="flex-wrap">
+ <PaginationItem>
+ <PaginationPrevious
+ href={currentPage > 1 ? getPageUrl(currentPage - 1) : undefined}
+ isDisabled={currentPage === 1}
+ />
+ </PaginationItem>
+
+ {pages.map((page) => (
+ <PaginationItem key={page}>
+ <PaginationLink
+ href={getPageUrl(page)}
+ isActive={page === currentPage}
+ >
+ {page}
+ </PaginationLink>
+ </PaginationItem>
+ ))}
+
+ {totalPages > 5 && (
+ <PaginationItem>
+ <PaginationEllipsis />
+ </PaginationItem>
+ )}
+
+ <PaginationItem>
+ <PaginationNext
+ href={
+ currentPage < totalPages ? getPageUrl(currentPage + 1) : undefined
+ }
+ isDisabled={currentPage === totalPages}
+ />
+ </PaginationItem>
+ </PaginationContent>
+ </Pagination>
+ )
+}
+
+interface PaginationProps {
+ currentPage: number
+ totalPages: number
+ baseUrl: string
+}
+
+export default PaginationComponent
+
+export {
+ Pagination,
+ PaginationContent,
+ PaginationLink,
+ PaginationItem,
+ PaginationPrevious,
+ PaginationNext,
+ PaginationEllipsis,
+}
diff --git a/src/components/ui/scroll-area.tsx b/src/components/ui/scroll-area.tsx
new file mode 100644
index 0000000..9e28547
--- /dev/null
+++ b/src/components/ui/scroll-area.tsx
@@ -0,0 +1,56 @@
+import * as React from 'react'
+import { ScrollArea as ScrollAreaPrimitive } from 'radix-ui'
+
+import { cn } from '@/lib/utils'
+
+function ScrollArea({
+ className,
+ children,
+ ...props
+}: React.ComponentProps<typeof ScrollAreaPrimitive.Root>) {
+ return (
+ <ScrollAreaPrimitive.Root
+ data-slot="scroll-area"
+ className={cn('relative', className)}
+ {...props}
+ >
+ <ScrollAreaPrimitive.Viewport
+ data-slot="scroll-area-viewport"
+ className="ring-ring/10 dark:ring-ring/20 dark:outline-ring/40 outline-ring/50 size-full rounded-[inherit] transition-[color,box-shadow] focus-visible:ring-4 focus-visible:outline-1"
+ >
+ {children}
+ </ScrollAreaPrimitive.Viewport>
+ <ScrollBar />
+ <ScrollAreaPrimitive.Corner />
+ </ScrollAreaPrimitive.Root>
+ )
+}
+
+function ScrollBar({
+ className,
+ orientation = 'vertical',
+ ...props
+}: React.ComponentProps<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>) {
+ return (
+ <ScrollAreaPrimitive.ScrollAreaScrollbar
+ data-slot="scroll-area-scrollbar"
+ orientation={orientation}
+ className={cn(
+ 'flex touch-none p-px transition-colors select-none',
+ orientation === 'vertical' &&
+ 'h-full w-2.5 border-l border-l-transparent',
+ orientation === 'horizontal' &&
+ 'h-2.5 flex-col border-t border-t-transparent',
+ className,
+ )}
+ {...props}
+ >
+ <ScrollAreaPrimitive.ScrollAreaThumb
+ data-slot="scroll-area-thumb"
+ className="bg-border relative flex-1 rounded-full"
+ />
+ </ScrollAreaPrimitive.ScrollAreaScrollbar>
+ )
+}
+
+export { ScrollArea, ScrollBar }
diff --git a/src/components/ui/separator.tsx b/src/components/ui/separator.tsx
new file mode 100644
index 0000000..8624369
--- /dev/null
+++ b/src/components/ui/separator.tsx
@@ -0,0 +1,26 @@
+import * as React from 'react'
+import { Separator as SeparatorPrimitive } from 'radix-ui'
+
+import { cn } from '@/lib/utils'
+
+function Separator({
+ className,
+ orientation = 'horizontal',
+ decorative = true,
+ ...props
+}: React.ComponentProps<typeof SeparatorPrimitive.Root>) {
+ return (
+ <SeparatorPrimitive.Root
+ data-slot="separator-root"
+ decorative={decorative}
+ orientation={orientation}
+ className={cn(
+ 'bg-border shrink-0 data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-px',
+ className,
+ )}
+ {...props}
+ />
+ )
+}
+
+export { Separator }