mirror of
https://github.com/prdlk/website.git
synced 2026-08-02 17:31:41 +00:00
fix(ui): remove deleted components
This commit is contained in:
@@ -1,70 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { useContext } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
|
||||
import { AppContext } from '@/app/providers'
|
||||
import { Container } from '@/components/Container'
|
||||
import { Prose } from '@/components/Prose'
|
||||
import { type ArticleWithSlug } from '@/lib/articles'
|
||||
import { formatDate } from '@/lib/formatDate'
|
||||
|
||||
function ArrowLeftIcon(props: React.ComponentPropsWithoutRef<'svg'>) {
|
||||
return (
|
||||
<svg viewBox="0 0 16 16" fill="none" aria-hidden="true" {...props}>
|
||||
<path
|
||||
d="M7.25 11.25 3.75 8m0 0 3.5-3.25M3.75 8h8.5"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function ArticleLayout({
|
||||
article,
|
||||
children,
|
||||
}: {
|
||||
article: ArticleWithSlug
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
let router = useRouter()
|
||||
let { previousPathname } = useContext(AppContext)
|
||||
|
||||
return (
|
||||
<Container className="mt-16 lg:mt-32">
|
||||
<div className="xl:relative">
|
||||
<div className="mx-auto max-w-2xl">
|
||||
{previousPathname && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => router.back()}
|
||||
aria-label="Go back to articles"
|
||||
className="group mb-8 flex h-10 w-10 items-center justify-center rounded-full bg-white shadow-md shadow-zinc-800/5 ring-1 ring-zinc-900/5 transition dark:border dark:border-zinc-700/50 dark:bg-zinc-800 dark:ring-0 dark:ring-white/10 dark:hover:border-zinc-700 dark:hover:ring-white/20 lg:absolute lg:-left-5 lg:-mt-2 lg:mb-0 xl:-top-1.5 xl:left-0 xl:mt-0"
|
||||
>
|
||||
<ArrowLeftIcon className="h-4 w-4 stroke-zinc-500 transition group-hover:stroke-zinc-700 dark:stroke-zinc-500 dark:group-hover:stroke-zinc-400" />
|
||||
</button>
|
||||
)}
|
||||
<article>
|
||||
<header className="flex flex-col">
|
||||
<h1 className="mt-6 text-4xl font-bold tracking-tight text-zinc-800 dark:text-zinc-100 sm:text-5xl">
|
||||
{article.title}
|
||||
</h1>
|
||||
<time
|
||||
dateTime={article.date}
|
||||
className="order-first flex items-center text-base text-zinc-400 dark:text-zinc-500"
|
||||
>
|
||||
<span className="h-4 w-0.5 rounded-full bg-zinc-200 dark:bg-zinc-500" />
|
||||
<span className="ml-3">{formatDate(article.date)}</span>
|
||||
</time>
|
||||
</header>
|
||||
<Prose className="mt-8" data-mdx-content>
|
||||
{children}
|
||||
</Prose>
|
||||
</article>
|
||||
</div>
|
||||
</div>
|
||||
</Container>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
---
|
||||
import { WEBMENTION_PINGBACK, WEBMENTION_URL } from "astro:env/client";
|
||||
import { siteConfig } from "@/site.config";
|
||||
import type { SiteMeta } from "@/types";
|
||||
import "@/styles/global.css";
|
||||
|
||||
type Props = SiteMeta;
|
||||
|
||||
const { articleDate, description, ogImage, title } = Astro.props;
|
||||
|
||||
const titleSeparator = "•";
|
||||
const siteTitle = `${title} ${titleSeparator} ${siteConfig.title}`;
|
||||
const canonicalURL = new URL(Astro.url.pathname, Astro.site);
|
||||
const socialImageURL = new URL(ogImage ? ogImage : "/social-card.png", Astro.url).href;
|
||||
---
|
||||
|
||||
<meta charset="utf-8" />
|
||||
<meta content="width=device-width, initial-scale=1.0" name="viewport" />
|
||||
<title>{siteTitle}</title>
|
||||
|
||||
{/* Icons */}
|
||||
<link href="/icon.svg" rel="icon" type="image/svg+xml" />
|
||||
{
|
||||
import.meta.env.PROD && (
|
||||
<>
|
||||
{/* Favicon & Apple Icon, Generated at build */}
|
||||
<link rel="icon" href="/favicon-32x32.png" type="image/png" />
|
||||
<link href="/icons/apple-touch-icon.png" rel="apple-touch-icon" />
|
||||
{/* Manifest */}
|
||||
<link href="/manifest.webmanifest" rel="manifest" />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
{/* Canonical URL */}
|
||||
<link href={canonicalURL} rel="canonical" />
|
||||
|
||||
{/* Primary Meta Tags */}
|
||||
<meta content={siteTitle} name="title" />
|
||||
<meta content={description} name="description" />
|
||||
<meta content={siteConfig.author} name="author" />
|
||||
|
||||
{/* Open Graph / Facebook */}
|
||||
<meta content={articleDate ? "article" : "website"} property="og:type" />
|
||||
<meta content={title} property="og:title" />
|
||||
<meta content={description} property="og:description" />
|
||||
<meta content={canonicalURL} property="og:url" />
|
||||
<meta content={siteConfig.title} property="og:site_name" />
|
||||
<meta content={siteConfig.ogLocale} property="og:locale" />
|
||||
<meta content={socialImageURL} property="og:image" />
|
||||
<meta content="1200" property="og:image:width" />
|
||||
<meta content="630" property="og:image:height" />
|
||||
{
|
||||
articleDate && (
|
||||
<>
|
||||
<meta content={siteConfig.author} property="article:author" />
|
||||
<meta content={articleDate} property="article:published_time" />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
{/* Twitter */}
|
||||
<meta content="summary_large_image" property="twitter:card" />
|
||||
<meta content={canonicalURL} property="twitter:url" />
|
||||
<meta content={title} property="twitter:title" />
|
||||
<meta content={description} property="twitter:description" />
|
||||
<meta content={socialImageURL} property="twitter:image" />
|
||||
|
||||
{/* Sitemap */}
|
||||
<link href="/sitemap-index.xml" rel="sitemap" />
|
||||
|
||||
{/* RSS auto-discovery */}
|
||||
<link href="/rss.xml" title="Blog" rel="alternate" type="application/rss+xml" />
|
||||
<link href="/notes/rss.xml" title="Notes" rel="alternate" type="application/rss+xml" />
|
||||
|
||||
{/* Webmentions */}
|
||||
{
|
||||
WEBMENTION_URL && (
|
||||
<>
|
||||
<link href={WEBMENTION_URL} rel="webmention" />
|
||||
{WEBMENTION_PINGBACK && <link href={WEBMENTION_PINGBACK} rel="pingback" />}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
<meta content={Astro.generator} name="generator" />
|
||||
@@ -1,34 +0,0 @@
|
||||
import Link from 'next/link'
|
||||
import clsx from 'clsx'
|
||||
|
||||
const variantStyles = {
|
||||
primary:
|
||||
'bg-zinc-800 font-semibold text-zinc-100 hover:bg-zinc-700 active:bg-zinc-800 active:text-zinc-100/70 dark:bg-zinc-700 dark:hover:bg-zinc-600 dark:active:bg-zinc-700 dark:active:text-zinc-100/70',
|
||||
secondary:
|
||||
'bg-zinc-50 font-medium text-zinc-900 hover:bg-zinc-100 active:bg-zinc-100 active:text-zinc-900/60 dark:bg-zinc-800/50 dark:text-zinc-300 dark:hover:bg-zinc-800 dark:hover:text-zinc-50 dark:active:bg-zinc-800/50 dark:active:text-zinc-50/70',
|
||||
}
|
||||
|
||||
type ButtonProps = {
|
||||
variant?: keyof typeof variantStyles
|
||||
} & (
|
||||
| (React.ComponentPropsWithoutRef<'button'> & { href?: undefined })
|
||||
| React.ComponentPropsWithoutRef<typeof Link>
|
||||
)
|
||||
|
||||
export function Button({
|
||||
variant = 'primary',
|
||||
className,
|
||||
...props
|
||||
}: ButtonProps) {
|
||||
className = clsx(
|
||||
'inline-flex items-center gap-2 justify-center rounded-md py-2 px-3 text-sm outline-offset-2 transition active:transition-none',
|
||||
variantStyles[variant],
|
||||
className,
|
||||
)
|
||||
|
||||
return typeof props.href === 'undefined' ? (
|
||||
<button className={className} {...props} />
|
||||
) : (
|
||||
<Link className={className} {...props} />
|
||||
)
|
||||
}
|
||||
@@ -1,124 +0,0 @@
|
||||
import Link from 'next/link'
|
||||
import clsx from 'clsx'
|
||||
|
||||
function ChevronRightIcon(props: React.ComponentPropsWithoutRef<'svg'>) {
|
||||
return (
|
||||
<svg viewBox="0 0 16 16" fill="none" aria-hidden="true" {...props}>
|
||||
<path
|
||||
d="M6.75 5.75 9.25 8l-2.5 2.25"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function Card<T extends React.ElementType = 'div'>({
|
||||
as,
|
||||
className,
|
||||
children,
|
||||
}: Omit<React.ComponentPropsWithoutRef<T>, 'as' | 'className'> & {
|
||||
as?: T
|
||||
className?: string
|
||||
}) {
|
||||
let Component = as ?? 'div'
|
||||
|
||||
return (
|
||||
<Component
|
||||
className={clsx(className, 'group relative flex flex-col items-start')}
|
||||
>
|
||||
{children}
|
||||
</Component>
|
||||
)
|
||||
}
|
||||
|
||||
Card.Link = function CardLink({
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentPropsWithoutRef<typeof Link>) {
|
||||
return (
|
||||
<>
|
||||
<div className="absolute -inset-x-4 -inset-y-6 z-0 scale-95 bg-zinc-50 opacity-0 transition group-hover:scale-100 group-hover:opacity-100 dark:bg-zinc-800/50 sm:-inset-x-6 sm:rounded-2xl" />
|
||||
<Link {...props}>
|
||||
<span className="absolute -inset-x-4 -inset-y-6 z-20 sm:-inset-x-6 sm:rounded-2xl" />
|
||||
<span className="relative z-10">{children}</span>
|
||||
</Link>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
Card.Title = function CardTitle<T extends React.ElementType = 'h2'>({
|
||||
as,
|
||||
href,
|
||||
children,
|
||||
}: Omit<React.ComponentPropsWithoutRef<T>, 'as' | 'href'> & {
|
||||
as?: T
|
||||
href?: string
|
||||
}) {
|
||||
let Component = as ?? 'h2'
|
||||
|
||||
return (
|
||||
<Component className="text-base font-semibold tracking-tight text-zinc-800 dark:text-zinc-100">
|
||||
{href ? <Card.Link href={href}>{children}</Card.Link> : children}
|
||||
</Component>
|
||||
)
|
||||
}
|
||||
|
||||
Card.Description = function CardDescription({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
return (
|
||||
<p className="relative z-10 mt-2 text-sm text-zinc-600 dark:text-zinc-400">
|
||||
{children}
|
||||
</p>
|
||||
)
|
||||
}
|
||||
|
||||
Card.Cta = function CardCta({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="relative z-10 mt-4 flex items-center text-sm font-medium text-teal-500"
|
||||
>
|
||||
{children}
|
||||
<ChevronRightIcon className="ml-1 h-4 w-4 stroke-current" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
Card.Eyebrow = function CardEyebrow<T extends React.ElementType = 'p'>({
|
||||
as,
|
||||
decorate = false,
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: Omit<React.ComponentPropsWithoutRef<T>, 'as' | 'decorate'> & {
|
||||
as?: T
|
||||
decorate?: boolean
|
||||
}) {
|
||||
let Component = as ?? 'p'
|
||||
|
||||
return (
|
||||
<Component
|
||||
className={clsx(
|
||||
className,
|
||||
'relative z-10 order-first mb-3 flex items-center text-sm text-zinc-400 dark:text-zinc-500',
|
||||
decorate && 'pl-3.5',
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{decorate && (
|
||||
<span
|
||||
className="absolute inset-y-0 left-0 flex items-center"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<span className="h-4 w-0.5 rounded-full bg-zinc-200 dark:bg-zinc-500" />
|
||||
</span>
|
||||
)}
|
||||
{children}
|
||||
</Component>
|
||||
)
|
||||
}
|
||||
@@ -1,39 +0,0 @@
|
||||
import { forwardRef } from 'react'
|
||||
import clsx from 'clsx'
|
||||
|
||||
export const ContainerOuter = forwardRef<
|
||||
React.ElementRef<'div'>,
|
||||
React.ComponentPropsWithoutRef<'div'>
|
||||
>(function OuterContainer({ className, children, ...props }, ref) {
|
||||
return (
|
||||
<div ref={ref} className={clsx('sm:px-8', className)} {...props}>
|
||||
<div className="mx-auto w-full max-w-7xl lg:px-8">{children}</div>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
export const ContainerInner = forwardRef<
|
||||
React.ElementRef<'div'>,
|
||||
React.ComponentPropsWithoutRef<'div'>
|
||||
>(function InnerContainer({ className, children, ...props }, ref) {
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={clsx('relative px-4 sm:px-8 lg:px-12', className)}
|
||||
{...props}
|
||||
>
|
||||
<div className="mx-auto max-w-2xl lg:max-w-5xl">{children}</div>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
export const Container = forwardRef<
|
||||
React.ElementRef<typeof ContainerOuter>,
|
||||
React.ComponentPropsWithoutRef<typeof ContainerOuter>
|
||||
>(function Container({ children, ...props }, ref) {
|
||||
return (
|
||||
<ContainerOuter ref={ref} {...props}>
|
||||
<ContainerInner>{children}</ContainerInner>
|
||||
</ContainerOuter>
|
||||
)
|
||||
})
|
||||
@@ -1,45 +0,0 @@
|
||||
import Link from 'next/link'
|
||||
|
||||
import { ContainerInner, ContainerOuter } from '@/components/Container'
|
||||
|
||||
function NavLink({
|
||||
href,
|
||||
children,
|
||||
}: {
|
||||
href: string
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
return (
|
||||
<Link
|
||||
href={href}
|
||||
className="transition hover:text-teal-500 dark:hover:text-teal-400"
|
||||
>
|
||||
{children}
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
|
||||
export function Footer() {
|
||||
return (
|
||||
<footer className="mt-32 flex-none">
|
||||
<ContainerOuter>
|
||||
<div className="border-t border-zinc-100 pb-16 pt-10 dark:border-zinc-700/40">
|
||||
<ContainerInner>
|
||||
<div className="flex flex-col items-center justify-between gap-6 sm:flex-row">
|
||||
<div className="flex flex-wrap justify-center gap-x-6 gap-y-1 text-sm font-medium text-zinc-800 dark:text-zinc-200">
|
||||
<NavLink href="/about">About</NavLink>
|
||||
<NavLink href="/projects">Projects</NavLink>
|
||||
<NavLink href="/speaking">Speaking</NavLink>
|
||||
<NavLink href="/uses">Uses</NavLink>
|
||||
</div>
|
||||
<p className="text-sm text-zinc-400 dark:text-zinc-500">
|
||||
© {new Date().getFullYear()} Prad Nukala. All rights
|
||||
reserved.
|
||||
</p>
|
||||
</div>
|
||||
</ContainerInner>
|
||||
</div>
|
||||
</ContainerOuter>
|
||||
</footer>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
---
|
||||
import type { HTMLAttributes } from "astro/types";
|
||||
import { getFormattedDate } from "@/utils/date";
|
||||
|
||||
type Props = HTMLAttributes<"time"> & {
|
||||
date: Date;
|
||||
dateTimeOptions?: Intl.DateTimeFormatOptions;
|
||||
};
|
||||
|
||||
const { date, dateTimeOptions, ...attrs } = Astro.props;
|
||||
|
||||
const postDate = getFormattedDate(date, dateTimeOptions);
|
||||
const ISO = date.toISOString();
|
||||
---
|
||||
|
||||
<time datetime={ISO} title={ISO} {...attrs}>{postDate}</time>
|
||||
@@ -1,461 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { Fragment, useEffect, useRef, useState } from 'react'
|
||||
import Image from 'next/image'
|
||||
import Link from 'next/link'
|
||||
import { usePathname } from 'next/navigation'
|
||||
import { useTheme } from 'next-themes'
|
||||
import { Popover, Transition } from '@headlessui/react'
|
||||
import clsx from 'clsx'
|
||||
|
||||
import { Container } from '@/components/Container'
|
||||
import avatarImage from '@/images/avatar.jpg'
|
||||
|
||||
function CloseIcon(props: React.ComponentPropsWithoutRef<'svg'>) {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true" {...props}>
|
||||
<path
|
||||
d="m17.25 6.75-10.5 10.5M6.75 6.75l10.5 10.5"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
function ChevronDownIcon(props: React.ComponentPropsWithoutRef<'svg'>) {
|
||||
return (
|
||||
<svg viewBox="0 0 8 6" aria-hidden="true" {...props}>
|
||||
<path
|
||||
d="M1.75 1.75 4 4.25l2.25-2.5"
|
||||
fill="none"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
function SunIcon(props: React.ComponentPropsWithoutRef<'svg'>) {
|
||||
return (
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
aria-hidden="true"
|
||||
{...props}
|
||||
>
|
||||
<path d="M8 12.25A4.25 4.25 0 0 1 12.25 8v0a4.25 4.25 0 0 1 4.25 4.25v0a4.25 4.25 0 0 1-4.25 4.25v0A4.25 4.25 0 0 1 8 12.25v0Z" />
|
||||
<path
|
||||
d="M12.25 3v1.5M21.5 12.25H20M18.791 18.791l-1.06-1.06M18.791 5.709l-1.06 1.06M12.25 20v1.5M4.5 12.25H3M6.77 6.77 5.709 5.709M6.77 17.73l-1.061 1.061"
|
||||
fill="none"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
function MoonIcon(props: React.ComponentPropsWithoutRef<'svg'>) {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true" {...props}>
|
||||
<path
|
||||
d="M17.25 16.22a6.937 6.937 0 0 1-9.47-9.47 7.451 7.451 0 1 0 9.47 9.47ZM12.75 7C17 7 17 2.75 17 2.75S17 7 21.25 7C17 7 17 11.25 17 11.25S17 7 12.75 7Z"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
function MobileNavItem({
|
||||
href,
|
||||
children,
|
||||
}: {
|
||||
href: string
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
return (
|
||||
<li>
|
||||
<Popover.Button as={Link} href={href} className="block py-2">
|
||||
{children}
|
||||
</Popover.Button>
|
||||
</li>
|
||||
)
|
||||
}
|
||||
|
||||
function MobileNavigation(
|
||||
props: React.ComponentPropsWithoutRef<typeof Popover>,
|
||||
) {
|
||||
return (
|
||||
<Popover {...props}>
|
||||
<Popover.Button className="group flex items-center rounded-full bg-white/90 px-4 py-2 text-sm font-medium text-zinc-800 shadow-lg shadow-zinc-800/5 ring-1 ring-zinc-900/5 backdrop-blur dark:bg-zinc-800/90 dark:text-zinc-200 dark:ring-white/10 dark:hover:ring-white/20">
|
||||
Menu
|
||||
<ChevronDownIcon className="ml-3 h-auto w-2 stroke-zinc-500 group-hover:stroke-zinc-700 dark:group-hover:stroke-zinc-400" />
|
||||
</Popover.Button>
|
||||
<Transition.Root>
|
||||
<Transition.Child
|
||||
as={Fragment}
|
||||
enter="duration-150 ease-out"
|
||||
enterFrom="opacity-0"
|
||||
enterTo="opacity-100"
|
||||
leave="duration-150 ease-in"
|
||||
leaveFrom="opacity-100"
|
||||
leaveTo="opacity-0"
|
||||
>
|
||||
<Popover.Overlay className="fixed inset-0 z-50 bg-zinc-800/40 backdrop-blur-sm dark:bg-black/80" />
|
||||
</Transition.Child>
|
||||
<Transition.Child
|
||||
as={Fragment}
|
||||
enter="duration-150 ease-out"
|
||||
enterFrom="opacity-0 scale-95"
|
||||
enterTo="opacity-100 scale-100"
|
||||
leave="duration-150 ease-in"
|
||||
leaveFrom="opacity-100 scale-100"
|
||||
leaveTo="opacity-0 scale-95"
|
||||
>
|
||||
<Popover.Panel
|
||||
focus
|
||||
className="fixed inset-x-4 top-8 z-50 origin-top rounded-3xl bg-white p-8 ring-1 ring-zinc-900/5 dark:bg-zinc-900 dark:ring-zinc-800"
|
||||
>
|
||||
<div className="flex flex-row-reverse items-center justify-between">
|
||||
<Popover.Button aria-label="Close menu" className="-m-1 p-1">
|
||||
<CloseIcon className="h-6 w-6 text-zinc-500 dark:text-zinc-400" />
|
||||
</Popover.Button>
|
||||
<h2 className="text-sm font-medium text-zinc-600 dark:text-zinc-400">
|
||||
Navigation
|
||||
</h2>
|
||||
</div>
|
||||
<nav className="mt-6">
|
||||
<ul className="-my-2 divide-y divide-zinc-100 text-base text-zinc-800 dark:divide-zinc-100/5 dark:text-zinc-300">
|
||||
<MobileNavItem href="/about">About</MobileNavItem>
|
||||
<MobileNavItem href="/articles">Writing</MobileNavItem>
|
||||
<MobileNavItem href="/projects">Projects</MobileNavItem>
|
||||
<MobileNavItem href="/speaking">Speaking</MobileNavItem>
|
||||
<MobileNavItem href="/uses">Uses</MobileNavItem>
|
||||
</ul>
|
||||
</nav>
|
||||
</Popover.Panel>
|
||||
</Transition.Child>
|
||||
</Transition.Root>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
|
||||
function NavItem({
|
||||
href,
|
||||
children,
|
||||
}: {
|
||||
href: string
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
let isActive = usePathname() === href
|
||||
|
||||
return (
|
||||
<li>
|
||||
<Link
|
||||
href={href}
|
||||
className={clsx(
|
||||
'relative block px-3 py-2 transition',
|
||||
isActive
|
||||
? 'text-teal-500 dark:text-teal-400'
|
||||
: 'hover:text-teal-500 dark:hover:text-teal-400',
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
{isActive && (
|
||||
<span className="absolute inset-x-1 -bottom-px h-px bg-gradient-to-r from-teal-500/0 via-teal-500/40 to-teal-500/0 dark:from-teal-400/0 dark:via-teal-400/40 dark:to-teal-400/0" />
|
||||
)}
|
||||
</Link>
|
||||
</li>
|
||||
)
|
||||
}
|
||||
|
||||
function DesktopNavigation(props: React.ComponentPropsWithoutRef<'nav'>) {
|
||||
return (
|
||||
<nav {...props}>
|
||||
<ul className="flex rounded-full bg-white/90 px-3 text-sm font-medium text-zinc-800 shadow-lg shadow-zinc-800/5 ring-1 ring-zinc-900/5 backdrop-blur dark:bg-zinc-800/90 dark:text-zinc-200 dark:ring-white/10">
|
||||
<NavItem href="/about">About</NavItem>
|
||||
<NavItem href="/articles">Writing</NavItem>
|
||||
<NavItem href="/projects">Projects</NavItem>
|
||||
<NavItem href="/speaking">Speaking</NavItem>
|
||||
<NavItem href="/uses">Uses</NavItem>
|
||||
</ul>
|
||||
</nav>
|
||||
)
|
||||
}
|
||||
|
||||
function ThemeToggle() {
|
||||
let { resolvedTheme, setTheme } = useTheme()
|
||||
let otherTheme = resolvedTheme === 'dark' ? 'light' : 'dark'
|
||||
let [mounted, setMounted] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true)
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={mounted ? `Switch to ${otherTheme} theme` : 'Toggle theme'}
|
||||
className="group rounded-full bg-white/90 px-3 py-2 shadow-lg shadow-zinc-800/5 ring-1 ring-zinc-900/5 backdrop-blur transition dark:bg-zinc-800/90 dark:ring-white/10 dark:hover:ring-white/20"
|
||||
onClick={() => setTheme(otherTheme)}
|
||||
>
|
||||
<SunIcon className="h-6 w-6 fill-zinc-100 stroke-zinc-500 transition group-hover:fill-zinc-200 group-hover:stroke-zinc-700 dark:hidden [@media(prefers-color-scheme:dark)]:fill-teal-50 [@media(prefers-color-scheme:dark)]:stroke-teal-500 [@media(prefers-color-scheme:dark)]:group-hover:fill-teal-50 [@media(prefers-color-scheme:dark)]:group-hover:stroke-teal-600" />
|
||||
<MoonIcon className="hidden h-6 w-6 fill-zinc-700 stroke-zinc-500 transition dark:block [@media(prefers-color-scheme:dark)]:group-hover:stroke-zinc-400 [@media_not_(prefers-color-scheme:dark)]:fill-teal-400/10 [@media_not_(prefers-color-scheme:dark)]:stroke-teal-500" />
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
function clamp(number: number, a: number, b: number) {
|
||||
let min = Math.min(a, b)
|
||||
let max = Math.max(a, b)
|
||||
return Math.min(Math.max(number, min), max)
|
||||
}
|
||||
|
||||
function AvatarContainer({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentPropsWithoutRef<'div'>) {
|
||||
return (
|
||||
<div
|
||||
className={clsx(
|
||||
className,
|
||||
'h-10 w-10 rounded-full bg-white/90 p-0.5 shadow-lg shadow-zinc-800/5 ring-1 ring-zinc-900/5 backdrop-blur dark:bg-zinc-800/90 dark:ring-white/10',
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function Avatar({
|
||||
large = false,
|
||||
className,
|
||||
...props
|
||||
}: Omit<React.ComponentPropsWithoutRef<typeof Link>, 'href'> & {
|
||||
large?: boolean
|
||||
}) {
|
||||
return (
|
||||
<Link
|
||||
href="/"
|
||||
aria-label="Home"
|
||||
className={clsx(className, 'pointer-events-auto')}
|
||||
{...props}
|
||||
>
|
||||
<Image
|
||||
src={avatarImage}
|
||||
alt=""
|
||||
sizes={large ? '4rem' : '2.25rem'}
|
||||
className={clsx(
|
||||
'rounded-full bg-zinc-100 object-cover dark:bg-zinc-800',
|
||||
large ? 'h-16 w-16' : 'h-9 w-9',
|
||||
)}
|
||||
priority
|
||||
/>
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
|
||||
export function Header() {
|
||||
let isHomePage = usePathname() === '/'
|
||||
|
||||
let headerRef = useRef<React.ElementRef<'div'>>(null)
|
||||
let avatarRef = useRef<React.ElementRef<'div'>>(null)
|
||||
let isInitial = useRef(true)
|
||||
|
||||
useEffect(() => {
|
||||
let downDelay = avatarRef.current?.offsetTop ?? 0
|
||||
let upDelay = 64
|
||||
|
||||
function setProperty(property: string, value: string) {
|
||||
document.documentElement.style.setProperty(property, value)
|
||||
}
|
||||
|
||||
function removeProperty(property: string) {
|
||||
document.documentElement.style.removeProperty(property)
|
||||
}
|
||||
|
||||
function updateHeaderStyles() {
|
||||
if (!headerRef.current) {
|
||||
return
|
||||
}
|
||||
|
||||
let { top, height } = headerRef.current.getBoundingClientRect()
|
||||
let scrollY = clamp(
|
||||
window.scrollY,
|
||||
0,
|
||||
document.body.scrollHeight - window.innerHeight,
|
||||
)
|
||||
|
||||
if (isInitial.current) {
|
||||
setProperty('--header-position', 'sticky')
|
||||
}
|
||||
|
||||
setProperty('--content-offset', `${downDelay}px`)
|
||||
|
||||
if (isInitial.current || scrollY < downDelay) {
|
||||
setProperty('--header-height', `${downDelay + height}px`)
|
||||
setProperty('--header-mb', `${-downDelay}px`)
|
||||
} else if (top + height < -upDelay) {
|
||||
let offset = Math.max(height, scrollY - upDelay)
|
||||
setProperty('--header-height', `${offset}px`)
|
||||
setProperty('--header-mb', `${height - offset}px`)
|
||||
} else if (top === 0) {
|
||||
setProperty('--header-height', `${scrollY + height}px`)
|
||||
setProperty('--header-mb', `${-scrollY}px`)
|
||||
}
|
||||
|
||||
if (top === 0 && scrollY > 0 && scrollY >= downDelay) {
|
||||
setProperty('--header-inner-position', 'fixed')
|
||||
removeProperty('--header-top')
|
||||
removeProperty('--avatar-top')
|
||||
} else {
|
||||
removeProperty('--header-inner-position')
|
||||
setProperty('--header-top', '0px')
|
||||
setProperty('--avatar-top', '0px')
|
||||
}
|
||||
}
|
||||
|
||||
function updateAvatarStyles() {
|
||||
if (!isHomePage) {
|
||||
return
|
||||
}
|
||||
|
||||
let fromScale = 1
|
||||
let toScale = 36 / 64
|
||||
let fromX = 0
|
||||
let toX = 2 / 16
|
||||
|
||||
let scrollY = downDelay - window.scrollY
|
||||
|
||||
let scale = (scrollY * (fromScale - toScale)) / downDelay + toScale
|
||||
scale = clamp(scale, fromScale, toScale)
|
||||
|
||||
let x = (scrollY * (fromX - toX)) / downDelay + toX
|
||||
x = clamp(x, fromX, toX)
|
||||
|
||||
setProperty(
|
||||
'--avatar-image-transform',
|
||||
`translate3d(${x}rem, 0, 0) scale(${scale})`,
|
||||
)
|
||||
|
||||
let borderScale = 1 / (toScale / scale)
|
||||
let borderX = (-toX + x) * borderScale
|
||||
let borderTransform = `translate3d(${borderX}rem, 0, 0) scale(${borderScale})`
|
||||
|
||||
setProperty('--avatar-border-transform', borderTransform)
|
||||
setProperty('--avatar-border-opacity', scale === toScale ? '1' : '0')
|
||||
}
|
||||
|
||||
function updateStyles() {
|
||||
updateHeaderStyles()
|
||||
updateAvatarStyles()
|
||||
isInitial.current = false
|
||||
}
|
||||
|
||||
updateStyles()
|
||||
window.addEventListener('scroll', updateStyles, { passive: true })
|
||||
window.addEventListener('resize', updateStyles)
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('scroll', updateStyles)
|
||||
window.removeEventListener('resize', updateStyles)
|
||||
}
|
||||
}, [isHomePage])
|
||||
|
||||
return (
|
||||
<>
|
||||
<header
|
||||
className="pointer-events-none relative z-50 flex flex-none flex-col"
|
||||
style={{
|
||||
height: 'var(--header-height)',
|
||||
marginBottom: 'var(--header-mb)',
|
||||
}}
|
||||
>
|
||||
{isHomePage && (
|
||||
<>
|
||||
<div
|
||||
ref={avatarRef}
|
||||
className="order-last mt-[calc(theme(spacing.16)-theme(spacing.3))]"
|
||||
/>
|
||||
<Container
|
||||
className="top-0 order-last -mb-3 pt-3"
|
||||
style={{
|
||||
position:
|
||||
'var(--header-position)' as React.CSSProperties['position'],
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="top-[var(--avatar-top,theme(spacing.3))] w-full"
|
||||
style={{
|
||||
position:
|
||||
'var(--header-inner-position)' as React.CSSProperties['position'],
|
||||
}}
|
||||
>
|
||||
<div className="relative">
|
||||
<AvatarContainer
|
||||
className="absolute left-0 top-3 origin-left transition-opacity"
|
||||
style={{
|
||||
opacity: 'var(--avatar-border-opacity, 0)',
|
||||
transform: 'var(--avatar-border-transform)',
|
||||
}}
|
||||
/>
|
||||
<Avatar
|
||||
large
|
||||
className="block h-16 w-16 origin-left"
|
||||
style={{ transform: 'var(--avatar-image-transform)' }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Container>
|
||||
</>
|
||||
)}
|
||||
<div
|
||||
ref={headerRef}
|
||||
className="top-0 z-10 h-16 pt-6"
|
||||
style={{
|
||||
position:
|
||||
'var(--header-position)' as React.CSSProperties['position'],
|
||||
}}
|
||||
>
|
||||
<Container
|
||||
className="top-[var(--header-top,theme(spacing.6))] w-full"
|
||||
style={{
|
||||
position:
|
||||
'var(--header-inner-position)' as React.CSSProperties['position'],
|
||||
}}
|
||||
>
|
||||
<div className="relative flex gap-4">
|
||||
<div className="flex flex-1">
|
||||
{!isHomePage && (
|
||||
<AvatarContainer>
|
||||
<Avatar />
|
||||
</AvatarContainer>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-1 justify-end md:justify-center">
|
||||
<MobileNavigation className="pointer-events-auto md:hidden" />
|
||||
<DesktopNavigation className="pointer-events-auto hidden md:block" />
|
||||
</div>
|
||||
<div className="flex justify-end md:flex-1">
|
||||
<div className="pointer-events-auto">
|
||||
<ThemeToggle />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Container>
|
||||
</div>
|
||||
</header>
|
||||
{isHomePage && (
|
||||
<div
|
||||
className="flex-none"
|
||||
style={{ height: 'var(--content-offset)' }}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
import { Footer } from '@/components/Footer'
|
||||
import { Header } from '@/components/Header'
|
||||
|
||||
export function Layout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<>
|
||||
<div className="fixed inset-0 flex justify-center sm:px-8">
|
||||
<div className="flex w-full max-w-7xl lg:px-8">
|
||||
<div className="w-full bg-white ring-1 ring-zinc-100 dark:bg-zinc-900 dark:ring-zinc-300/20" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="relative flex w-full flex-col">
|
||||
<Header />
|
||||
<main className="flex-auto">{children}</main>
|
||||
<Footer />
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
---
|
||||
import type { PaginationLink } from "@/types";
|
||||
|
||||
interface Props {
|
||||
nextUrl?: PaginationLink;
|
||||
prevUrl?: PaginationLink;
|
||||
}
|
||||
|
||||
const { nextUrl, prevUrl } = Astro.props;
|
||||
---
|
||||
|
||||
{
|
||||
(prevUrl || nextUrl) && (
|
||||
<nav class="mt-8 flex items-center gap-x-4">
|
||||
{prevUrl && (
|
||||
<a class="hover:text-accent me-auto py-2" href={prevUrl.url}>
|
||||
{prevUrl.srLabel && <span class="sr-only">{prevUrl.srLabel}</span>}
|
||||
{prevUrl.text ? prevUrl.text : "Previous"}
|
||||
</a>
|
||||
)}
|
||||
{nextUrl && (
|
||||
<a class="hover:text-accent ms-auto py-2" href={nextUrl.url}>
|
||||
{nextUrl.srLabel && <span class="sr-only">{nextUrl.srLabel}</span>}
|
||||
{nextUrl.text ? nextUrl.text : "Next"}
|
||||
</a>
|
||||
)}
|
||||
</nav>
|
||||
)
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
import clsx from 'clsx'
|
||||
|
||||
export function Prose({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentPropsWithoutRef<'div'>) {
|
||||
return (
|
||||
<div className={clsx(className, 'prose dark:prose-invert')} {...props} />
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
---
|
||||
import "@/styles/blocks/search.css";
|
||||
---
|
||||
|
||||
<script>
|
||||
(() => {
|
||||
if (import.meta.env.DEV) return;
|
||||
const onIdle = window.requestIdleCallback || ((cb) => setTimeout(cb, 1));
|
||||
onIdle(async () => {
|
||||
await import("@pagefind/component-ui");
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
|
||||
<pagefind-config
|
||||
baseUrl={`${import.meta.env.BASE_URL}`}
|
||||
bundle-path={`${import.meta.env.BASE_URL.replace(/\/$/, "")}/pagefind/`}></pagefind-config>
|
||||
<pagefind-modal-trigger id="search" class="ms-auto" hide-shortcut compact></pagefind-modal-trigger>
|
||||
<pagefind-modal></pagefind-modal>
|
||||
@@ -1,28 +0,0 @@
|
||||
import { useId } from 'react'
|
||||
|
||||
export function Section({
|
||||
title,
|
||||
children,
|
||||
}: {
|
||||
title: string
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
let id = useId()
|
||||
|
||||
return (
|
||||
<section
|
||||
aria-labelledby={id}
|
||||
className="md:border-l md:border-zinc-100 md:pl-6 md:dark:border-zinc-700/40"
|
||||
>
|
||||
<div className="grid max-w-3xl grid-cols-1 items-baseline gap-y-8 md:grid-cols-4">
|
||||
<h2
|
||||
id={id}
|
||||
className="text-sm font-semibold text-zinc-800 dark:text-zinc-100"
|
||||
>
|
||||
{title}
|
||||
</h2>
|
||||
<div className="md:col-span-3">{children}</div>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
import { Container } from '@/components/Container'
|
||||
|
||||
export function SimpleLayout({
|
||||
title,
|
||||
intro,
|
||||
children,
|
||||
}: {
|
||||
title: string
|
||||
intro: string
|
||||
children?: React.ReactNode
|
||||
}) {
|
||||
return (
|
||||
<Container className="mt-16 sm:mt-32">
|
||||
<header className="max-w-2xl">
|
||||
<h1 className="text-4xl font-bold tracking-tight text-zinc-800 dark:text-zinc-100 sm:text-5xl">
|
||||
{title}
|
||||
</h1>
|
||||
<p className="mt-6 text-base text-zinc-600 dark:text-zinc-400">
|
||||
{intro}
|
||||
</p>
|
||||
</header>
|
||||
{children && <div className="mt-16 sm:mt-20">{children}</div>}
|
||||
</Container>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
<a class="sr-only focus:not-sr-only focus:fixed focus:start-1 focus:top-1.5" href="#main"
|
||||
>skip to content
|
||||
</a>
|
||||
@@ -1,44 +0,0 @@
|
||||
export function TwitterIcon(props: React.ComponentPropsWithoutRef<'svg'>) {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true" {...props}>
|
||||
<path d="M20.055 7.983c.011.174.011.347.011.523 0 5.338-3.92 11.494-11.09 11.494v-.003A10.755 10.755 0 0 1 3 18.186c.308.038.618.057.928.058a7.655 7.655 0 0 0 4.841-1.733c-1.668-.032-3.13-1.16-3.642-2.805a3.753 3.753 0 0 0 1.76-.07C5.07 13.256 3.76 11.6 3.76 9.676v-.05a3.77 3.77 0 0 0 1.77.505C3.816 8.945 3.288 6.583 4.322 4.737c1.98 2.524 4.9 4.058 8.034 4.22a4.137 4.137 0 0 1 1.128-3.86A3.807 3.807 0 0 1 19 5.274a7.657 7.657 0 0 0 2.475-.98c-.29.934-.9 1.729-1.713 2.233A7.54 7.54 0 0 0 22 5.89a8.084 8.084 0 0 1-1.945 2.093Z" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function MediumIcon(props: React.ComponentPropsWithoutRef<'svg'>) {
|
||||
return (
|
||||
<svg viewBox="0 0 64 64" aria-hidden="true" {...props}>
|
||||
<path d="M 18.998047 15 A 17.002 17.002 0 0 0 18.998047 49.003906 A 17.002 17.002 0 0 0 18.998047 15 z M 45.498047 16 A 8.502 16.002 0 0 0 45.498047 48.003906 A 8.502 16.002 0 0 0 45.498047 16 z M 58.5 17 A 3.5 15.002 0 1 0 58.5 47.003906 A 3.5 15.002 0 1 0 58.5 17 z"></path>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function InstagramIcon(props: React.ComponentPropsWithoutRef<'svg'>) {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true" {...props}>
|
||||
<path d="M12 3c-2.444 0-2.75.01-3.71.054-.959.044-1.613.196-2.185.418A4.412 4.412 0 0 0 4.51 4.511c-.5.5-.809 1.002-1.039 1.594-.222.572-.374 1.226-.418 2.184C3.01 9.25 3 9.556 3 12s.01 2.75.054 3.71c.044.959.196 1.613.418 2.185.23.592.538 1.094 1.039 1.595.5.5 1.002.808 1.594 1.038.572.222 1.226.374 2.184.418C9.25 20.99 9.556 21 12 21s2.75-.01 3.71-.054c.959-.044 1.613-.196 2.185-.419a4.412 4.412 0 0 0 1.595-1.038c.5-.5.808-1.002 1.038-1.594.222-.572.374-1.226.418-2.184.044-.96.054-1.267.054-3.711s-.01-2.75-.054-3.71c-.044-.959-.196-1.613-.419-2.185A4.412 4.412 0 0 0 19.49 4.51c-.5-.5-1.002-.809-1.594-1.039-.572-.222-1.226-.374-2.184-.418C14.75 3.01 14.444 3 12 3Zm0 1.622c2.403 0 2.688.009 3.637.052.877.04 1.354.187 1.67.31.421.163.72.358 1.036.673.315.315.51.615.673 1.035.123.317.27.794.31 1.671.043.95.052 1.234.052 3.637s-.009 2.688-.052 3.637c-.04.877-.187 1.354-.31 1.67-.163.421-.358.72-.673 1.036a2.79 2.79 0 0 1-1.035.673c-.317.123-.794.27-1.671.31-.95.043-1.234.052-3.637.052s-2.688-.009-3.637-.052c-.877-.04-1.354-.187-1.67-.31a2.789 2.789 0 0 1-1.036-.673 2.79 2.79 0 0 1-.673-1.035c-.123-.317-.27-.794-.31-1.671-.043-.95-.052-1.234-.052-3.637s.009-2.688.052-3.637c.04-.877.187-1.354.31-1.67.163-.421.358-.72.673-1.036.315-.315.615-.51 1.035-.673.317-.123.794-.27 1.671-.31.95-.043 1.234-.052 3.637-.052Z" />
|
||||
<path d="M12 15a3 3 0 1 1 0-6 3 3 0 0 1 0 6Zm0-7.622a4.622 4.622 0 1 0 0 9.244 4.622 4.622 0 0 0 0-9.244Zm5.884-.182a1.08 1.08 0 1 1-2.16 0 1.08 1.08 0 0 1 2.16 0Z" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function GitHubIcon(props: React.ComponentPropsWithoutRef<'svg'>) {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true" {...props}>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M12 2C6.475 2 2 6.588 2 12.253c0 4.537 2.862 8.369 6.838 9.727.5.09.687-.218.687-.487 0-.243-.013-1.05-.013-1.91C7 20.059 6.35 18.957 6.15 18.38c-.113-.295-.6-1.205-1.025-1.448-.35-.192-.85-.667-.013-.68.788-.012 1.35.744 1.538 1.051.9 1.551 2.338 1.116 2.912.846.088-.666.35-1.115.638-1.371-2.225-.256-4.55-1.14-4.55-5.062 0-1.115.387-2.038 1.025-2.756-.1-.256-.45-1.307.1-2.717 0 0 .837-.269 2.75 1.051.8-.23 1.65-.346 2.5-.346.85 0 1.7.115 2.5.346 1.912-1.333 2.75-1.05 2.75-1.05.55 1.409.2 2.46.1 2.716.637.718 1.025 1.628 1.025 2.756 0 3.934-2.337 4.806-4.562 5.062.362.32.675.936.675 1.897 0 1.371-.013 2.473-.013 2.82 0 .268.188.589.688.486a10.039 10.039 0 0 0 4.932-3.74A10.447 10.447 0 0 0 22 12.253C22 6.588 17.525 2 12 2Z"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function LinkedInIcon(props: React.ComponentPropsWithoutRef<'svg'>) {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true" {...props}>
|
||||
<path d="M18.335 18.339H15.67v-4.177c0-.996-.02-2.278-1.39-2.278-1.389 0-1.601 1.084-1.601 2.205v4.25h-2.666V9.75h2.56v1.17h.035c.358-.674 1.228-1.387 2.528-1.387 2.7 0 3.2 1.778 3.2 4.091v4.715zM7.003 8.575a1.546 1.546 0 01-1.548-1.549 1.548 1.548 0 111.547 1.549zm1.336 9.764H5.666V9.75H8.34v8.589zM19.67 3H4.329C3.593 3 3 3.58 3 4.297v15.406C3 20.42 3.594 21 4.328 21h15.338C20.4 21 21 20.42 21 19.703V4.297C21 3.58 20.4 3 19.666 3h.003z" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
---
|
||||
import { Icon } from "astro-icon/components";
|
||||
|
||||
/**
|
||||
Uses https://www.astroicon.dev/getting-started/
|
||||
Find icons via guide: https://www.astroicon.dev/guides/customization/#open-source-icon-sets
|
||||
Only installed pack is: @iconify-json/mdi
|
||||
*/
|
||||
const socialLinks: {
|
||||
friendlyName: string;
|
||||
isWebmention?: boolean;
|
||||
link: string;
|
||||
name: string;
|
||||
}[] = [
|
||||
{
|
||||
friendlyName: "Github",
|
||||
link: "https://github.com/chrismwilliams/astro-cactus",
|
||||
name: "mdi:github",
|
||||
},
|
||||
];
|
||||
---
|
||||
|
||||
<div class="flex flex-wrap items-end gap-x-2">
|
||||
<p>Find me on</p>
|
||||
<ul class="flex flex-1 items-center gap-x-2 sm:flex-initial">
|
||||
{
|
||||
socialLinks.map(({ friendlyName, isWebmention, link, name }) => (
|
||||
<li class="flex">
|
||||
<a
|
||||
class="hover:text-link inline-block"
|
||||
href={link}
|
||||
rel={`noreferrer ${isWebmention ? "me authn" : ""}`}
|
||||
target="_blank"
|
||||
>
|
||||
<Icon aria-hidden="true" class="h-8 w-8" focusable="false" name={name} />
|
||||
<span class="sr-only">{friendlyName}</span>
|
||||
</a>
|
||||
</li>
|
||||
))
|
||||
}
|
||||
</ul>
|
||||
</div>
|
||||
@@ -0,0 +1,48 @@
|
||||
{/* Inlined to avoid FOUC. This is a parser blocking script. */}
|
||||
<script is:inline>
|
||||
const lightModePref = window.matchMedia("(prefers-color-scheme: light)");
|
||||
|
||||
function getUserPref() {
|
||||
const storedTheme = typeof localStorage !== "undefined" && localStorage.getItem("theme");
|
||||
return storedTheme || (lightModePref.matches ? "light" : "dark");
|
||||
}
|
||||
|
||||
function setTheme(newTheme) {
|
||||
if (newTheme !== "light" && newTheme !== "dark") {
|
||||
return console.warn(
|
||||
`Invalid theme value '${newTheme}' received. Expected 'light' or 'dark'.`,
|
||||
);
|
||||
}
|
||||
|
||||
const root = document.documentElement;
|
||||
|
||||
// root already set to newTheme, exit early
|
||||
if (newTheme === root.getAttribute("data-theme")) {
|
||||
return;
|
||||
}
|
||||
|
||||
root.setAttribute("data-theme", newTheme);
|
||||
|
||||
if (typeof localStorage !== "undefined") {
|
||||
localStorage.setItem("theme", newTheme);
|
||||
}
|
||||
}
|
||||
|
||||
// initial setup
|
||||
setTheme(getUserPref());
|
||||
|
||||
// listen for pageshow event, browser restored page from bfcache
|
||||
window.addEventListener("pageshow", (e) => {
|
||||
if (e.persisted) {
|
||||
setTheme(getUserPref());
|
||||
}
|
||||
});
|
||||
|
||||
// listen for theme-change custom event, fired in src/components/ThemeToggle.astro
|
||||
document.addEventListener("theme-change", (e) => {
|
||||
setTheme(e.detail.theme);
|
||||
});
|
||||
|
||||
// listen for prefers-color-scheme change.
|
||||
lightModePref.addEventListener("change", (e) => setTheme(e.matches ? "light" : "dark"));
|
||||
</script>
|
||||
@@ -0,0 +1,90 @@
|
||||
<theme-toggle class="ms-2 sm:ms-4">
|
||||
<button class="hover:text-accent relative h-9 w-9 cursor-pointer p-2" type="button">
|
||||
<span class="sr-only">Dark Theme</span>
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
class="absolute inset-s-1/2 top-1/2 h-7 w-7 -translate-x-1/2 -translate-y-1/2 scale-100 opacity-100 transition-all dark:scale-0 dark:opacity-0"
|
||||
fill="none"
|
||||
focusable="false"
|
||||
id="sun-svg"
|
||||
stroke-width="1.5"
|
||||
viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M12 18C15.3137 18 18 15.3137 18 12C18 8.68629 15.3137 6 12 6C8.68629 6 6 8.68629 6 12C6 15.3137 8.68629 18 12 18Z"
|
||||
stroke="currentColor"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"></path>
|
||||
<path d="M22 12L23 12" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"
|
||||
></path>
|
||||
<path d="M12 2V1" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"></path>
|
||||
<path d="M12 23V22" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"
|
||||
></path>
|
||||
<path d="M20 20L19 19" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"
|
||||
></path>
|
||||
<path d="M20 4L19 5" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"
|
||||
></path>
|
||||
<path d="M4 20L5 19" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"
|
||||
></path>
|
||||
<path d="M4 4L5 5" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"
|
||||
></path>
|
||||
<path d="M1 12L2 12" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"
|
||||
></path>
|
||||
</svg>
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
class="absolute inset-s-1/2 top-1/2 h-7 w-7 -translate-x-1/2 -translate-y-1/2 scale-0 opacity-0 transition-all dark:scale-100 dark:opacity-100"
|
||||
fill="none"
|
||||
focusable="false"
|
||||
id="moon-svg"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path d="M0 0h24v24H0z" fill="none" stroke="none"></path>
|
||||
<path d="M12 3c.132 0 .263 0 .393 0a7.5 7.5 0 0 0 7.92 12.446a9 9 0 1 1 -8.313 -12.454z"
|
||||
></path>
|
||||
<path d="M17 4a2 2 0 0 0 2 2a2 2 0 0 0 -2 2a2 2 0 0 0 -2 -2a2 2 0 0 0 2 -2"></path>
|
||||
<path d="M19 11h2m-1 -1v2"></path>
|
||||
</svg>
|
||||
</button>
|
||||
</theme-toggle>
|
||||
|
||||
<script>
|
||||
// Note that if you fire the theme-change event outside of this component, it will not be reflected in the button's aria-checked attribute. You will need to add an event listener if you want that.
|
||||
import { rootInDarkMode } from "@/utils/domElement";
|
||||
|
||||
class ThemeToggle extends HTMLElement {
|
||||
constructor() {
|
||||
super();
|
||||
const button = this.querySelector<HTMLButtonElement>("button");
|
||||
|
||||
if (button) {
|
||||
// set aria role value
|
||||
button.setAttribute("role", "switch");
|
||||
button.setAttribute("aria-checked", String(rootInDarkMode()));
|
||||
|
||||
// button event
|
||||
button.addEventListener("click", () => {
|
||||
// invert theme
|
||||
let themeChangeEvent = new CustomEvent("theme-change", {
|
||||
detail: {
|
||||
theme: rootInDarkMode() ? "light" : "dark",
|
||||
},
|
||||
});
|
||||
// dispatch event -> ThemeProvider.astro
|
||||
document.dispatchEvent(themeChangeEvent);
|
||||
|
||||
// set the aria-checked attribute
|
||||
button.setAttribute("aria-checked", String(rootInDarkMode()));
|
||||
});
|
||||
} else {
|
||||
console.warn("Theme Toggle: No button found");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define("theme-toggle", ThemeToggle);
|
||||
</script>
|
||||
@@ -0,0 +1,84 @@
|
||||
---
|
||||
import { Image } from "astro:assets";
|
||||
import type { CollectionEntry } from "astro:content";
|
||||
import FormattedDate from "@/components/FormattedDate.astro";
|
||||
|
||||
interface Props {
|
||||
content: CollectionEntry<"post">;
|
||||
readingTime: string;
|
||||
}
|
||||
|
||||
const {
|
||||
content: { data },
|
||||
readingTime,
|
||||
} = Astro.props;
|
||||
|
||||
const dateTimeOptions: Intl.DateTimeFormatOptions = {
|
||||
month: "long",
|
||||
};
|
||||
---
|
||||
|
||||
{
|
||||
data.coverImage && (
|
||||
<div class="mb-6 aspect-video">
|
||||
<Image
|
||||
alt={data.coverImage.alt}
|
||||
layout="constrained"
|
||||
width={748}
|
||||
height={420}
|
||||
priority
|
||||
src={data.coverImage.src}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
{data.draft ? <span class="text-base text-red-500">(Draft)</span> : null}
|
||||
<h1 class="title">
|
||||
{data.title}
|
||||
</h1>
|
||||
<div class="flex flex-wrap items-center gap-x-3 gap-y-2">
|
||||
<p class="font-semibold">
|
||||
<FormattedDate date={data.publishDate} dateTimeOptions={dateTimeOptions} /> /{" "}
|
||||
{readingTime}
|
||||
</p>
|
||||
{
|
||||
data.updatedDate && (
|
||||
<span class="bg-quote/5 text-quote rounded-lg px-2 py-1">
|
||||
Updated:
|
||||
<FormattedDate class="ms-1" date={data.updatedDate} dateTimeOptions={dateTimeOptions} />
|
||||
</span>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
{
|
||||
!!data.tags?.length && (
|
||||
<div class="mt-2">
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
class="inline-block h-6 w-6"
|
||||
fill="none"
|
||||
focusable="false"
|
||||
stroke="var(--color-muted)"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="1.5"
|
||||
viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path d="M0 0h24v24H0z" fill="none" stroke="none" />
|
||||
<path d="M7.859 6h-2.834a2.025 2.025 0 0 0 -2.025 2.025v2.834c0 .537 .213 1.052 .593 1.432l6.116 6.116a2.025 2.025 0 0 0 2.864 0l2.834 -2.834a2.025 2.025 0 0 0 0 -2.864l-6.117 -6.116a2.025 2.025 0 0 0 -1.431 -.593z" />
|
||||
<path d="M17.573 18.407l2.834 -2.834a2.025 2.025 0 0 0 0 -2.864l-7.117 -7.116" />
|
||||
<path d="M6 9h-.01" />
|
||||
</svg>
|
||||
{data.tags.map((tag, i) => (
|
||||
<>
|
||||
{/* prettier-ignore */}
|
||||
<span class="contents">
|
||||
<a class="cactus-link inline-block before:content-['#']" data-pagefind-filter={`tag:${tag}`} href={`/tags/${tag}/`}><span class="sr-only">View more blogs with the tag </span>{tag}
|
||||
</a>{i < data.tags.length - 1 && ", "}
|
||||
</span>
|
||||
</>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
---
|
||||
import type { CollectionEntry } from "astro:content";
|
||||
import type { HTMLTag, Polymorphic } from "astro/types";
|
||||
import FormattedDate from "@/components/FormattedDate.astro";
|
||||
|
||||
type Props<Tag extends HTMLTag> = Polymorphic<{ as: Tag }> & {
|
||||
post: CollectionEntry<"post">;
|
||||
withDesc?: boolean;
|
||||
};
|
||||
|
||||
const { as: Tag = "div", post, withDesc = false } = Astro.props;
|
||||
---
|
||||
|
||||
<FormattedDate class="text-muted min-w-30 font-semibold" date={post.data.publishDate} />
|
||||
<Tag>
|
||||
{post.data.draft && <span class="text-red-500">(Draft) </span>}
|
||||
<a class="cactus-link" href={`/posts/${post.id}/`}>
|
||||
{post.data.title}
|
||||
</a>
|
||||
</Tag>
|
||||
{withDesc && <q class="line-clamp-3 italic">{post.data.description}</q>}
|
||||
@@ -0,0 +1,22 @@
|
||||
---
|
||||
import type { MarkdownHeading } from "astro";
|
||||
import { generateToc } from "@/utils/generateToc";
|
||||
import TOCHeading from "./TOCHeading.astro";
|
||||
|
||||
interface Props {
|
||||
headings: MarkdownHeading[];
|
||||
}
|
||||
|
||||
const { headings } = Astro.props;
|
||||
|
||||
const toc = generateToc(headings);
|
||||
---
|
||||
|
||||
<details open class="lg:sticky lg:top-12 lg:order-2 lg:-me-32 lg:basis-64">
|
||||
<summary class="title hover:marker:text-accent cursor-pointer text-lg">Table of Contents</summary>
|
||||
<nav class="ms-4 lg:w-full">
|
||||
<ol class="mt-4">
|
||||
{toc.map((heading) => <TOCHeading heading={heading} />)}
|
||||
</ol>
|
||||
</nav>
|
||||
</details>
|
||||
@@ -0,0 +1,27 @@
|
||||
---
|
||||
import type { TocItem } from "@/utils/generateToc";
|
||||
|
||||
interface Props {
|
||||
heading: TocItem;
|
||||
}
|
||||
|
||||
const {
|
||||
heading: { children, depth, slug, text },
|
||||
} = Astro.props;
|
||||
---
|
||||
|
||||
<li class={`${depth > 2 ? "ms-2" : ""}`}>
|
||||
<a
|
||||
class={`line-clamp-2 hover:text-accent ${depth <= 2 ? "mt-3" : "mt-2 text-xs"}`}
|
||||
href={`#${slug}`}><span aria-hidden="true" class="me-0.5">#</span>{text}</a
|
||||
>
|
||||
{
|
||||
!!children.length && (
|
||||
<ol>
|
||||
{children.map((subheading) => (
|
||||
<Astro.self heading={subheading} />
|
||||
))}
|
||||
</ol>
|
||||
)
|
||||
}
|
||||
</li>
|
||||
@@ -0,0 +1,87 @@
|
||||
---
|
||||
import { Image } from "astro:assets";
|
||||
import { Icon } from "astro-icon/components";
|
||||
import type { WebmentionsChildren } from "@/types";
|
||||
|
||||
interface Props {
|
||||
mentions: WebmentionsChildren[];
|
||||
}
|
||||
|
||||
const { mentions } = Astro.props;
|
||||
|
||||
const validComments = ["mention-of", "in-reply-to"];
|
||||
|
||||
const comments = mentions.filter(
|
||||
(mention) => validComments.includes(mention["wm-property"]) && mention.content?.text,
|
||||
);
|
||||
---
|
||||
|
||||
{
|
||||
!!comments.length && (
|
||||
<div>
|
||||
<p class="text-accent-2 mb-0">
|
||||
<strong>{comments.length}</strong> Mention{comments.length > 1 ? "s" : ""}
|
||||
</p>
|
||||
<ul class="divide-global-text/20 mt-0 divide-y ps-0" role="list">
|
||||
{comments.map((mention) => (
|
||||
<li class="p-comment h-cite my-0 flex items-start gap-x-5 py-5">
|
||||
{mention.author?.photo && mention.author.photo !== "" ? (
|
||||
mention.author.url && mention.author.url !== "" ? (
|
||||
<a
|
||||
class="u-author not-prose ring-global-text hover:ring-link focus-visible:ring-link shrink-0 overflow-hidden rounded-full ring-2 hover:ring-4 focus-visible:ring-4"
|
||||
href={mention.author.url}
|
||||
rel="noreferrer"
|
||||
target="_blank"
|
||||
title={mention.author.name}
|
||||
>
|
||||
<Image
|
||||
alt={mention.author?.name}
|
||||
class="u-photo my-0 h-12 w-12"
|
||||
height={48}
|
||||
src={mention.author?.photo}
|
||||
width={48}
|
||||
/>
|
||||
</a>
|
||||
) : (
|
||||
<Image
|
||||
alt={mention.author?.name}
|
||||
class="u-photo my-0 h-12 w-12 rounded-full"
|
||||
height={48}
|
||||
src={mention.author?.photo}
|
||||
width={48}
|
||||
/>
|
||||
)
|
||||
) : null}
|
||||
<div class="flex-auto">
|
||||
<div class="p-author h-card flex items-center justify-between gap-x-2">
|
||||
<p class="p-name text-accent-2 my-0 line-clamp-1 font-semibold">
|
||||
{mention.author?.name}
|
||||
</p>
|
||||
<a
|
||||
aria-labelledby="cmt-source"
|
||||
class="u-url not-prose hover:text-link"
|
||||
href={mention.url}
|
||||
rel="noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
<span class="hidden" id="cmt-source">
|
||||
Visit the source of this webmention
|
||||
</span>
|
||||
<Icon
|
||||
aria-hidden="true"
|
||||
class="h-5 w-5"
|
||||
focusable="false"
|
||||
name="mdi:open-in-new"
|
||||
/>
|
||||
</a>
|
||||
</div>
|
||||
<p class="comment-content mt-1 mb-0 [word-break:break-word] whitespace-pre-wrap">
|
||||
{mention.content?.text}
|
||||
</p>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
---
|
||||
import { Image } from "astro:assets";
|
||||
import type { WebmentionsChildren } from "@/types";
|
||||
|
||||
interface Props {
|
||||
mentions: WebmentionsChildren[];
|
||||
}
|
||||
|
||||
const { mentions } = Astro.props;
|
||||
const MAX_LIKES = 10;
|
||||
|
||||
const likes = mentions.filter((mention) => mention["wm-property"] === "like-of");
|
||||
const likesToShow = likes
|
||||
.filter((like) => like.author?.photo && like.author.photo !== "")
|
||||
.slice(0, MAX_LIKES);
|
||||
---
|
||||
|
||||
{
|
||||
!!likes.length && (
|
||||
<div>
|
||||
<p class="text-accent-2 mb-0">
|
||||
<strong>{likes.length}</strong>
|
||||
{likes.length > 1 ? " People" : " Person"} liked this
|
||||
</p>
|
||||
{!!likesToShow.length && (
|
||||
<ul class="flex list-none flex-wrap overflow-hidden ps-2" role="list">
|
||||
{likesToShow.map((like) => (
|
||||
<li class="p-like h-cite -ms-2">
|
||||
<a
|
||||
class="u-url not-prose ring-global-text hover:ring-link focus-visible:ring-link relative inline-block overflow-hidden rounded-full ring-2 hover:z-10 hover:ring-4 focus-visible:z-10 focus-visible:ring-4"
|
||||
href={like.author?.url}
|
||||
rel="noreferrer"
|
||||
target="_blank"
|
||||
title={like.author?.name}
|
||||
>
|
||||
<span class="p-author h-card">
|
||||
<Image
|
||||
alt={like.author!.name}
|
||||
class="u-photo my-0 inline-block h-12 w-12"
|
||||
height={48}
|
||||
src={like.author!.photo}
|
||||
width={48}
|
||||
/>
|
||||
</span>
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
---
|
||||
import { getWebmentionsForUrl } from "@/utils/webmentions";
|
||||
import Comments from "./Comments.astro";
|
||||
import Likes from "./Likes.astro";
|
||||
|
||||
const url = new URL(Astro.url.pathname, Astro.site);
|
||||
|
||||
const webMentions = await getWebmentionsForUrl(`${url}`);
|
||||
|
||||
// Return if no webmentions
|
||||
if (!webMentions.length) return;
|
||||
---
|
||||
|
||||
<hr class="border-solid" />
|
||||
<h2 class="mb-8 before:hidden">Webmentions for this post</h2>
|
||||
<div class="space-y-10">
|
||||
<Likes mentions={webMentions} />
|
||||
<Comments mentions={webMentions} />
|
||||
</div>
|
||||
<p class="mt-8">
|
||||
Responses powered by{" "}
|
||||
<a href="https://webmention.io" rel="noreferrer" target="_blank">Webmentions</a>
|
||||
</p>
|
||||
@@ -0,0 +1,24 @@
|
||||
---
|
||||
import { menuLinks, siteConfig } from "@/site.config";
|
||||
|
||||
const year = new Date().getFullYear();
|
||||
---
|
||||
|
||||
<footer
|
||||
class="text-muted mt-auto flex w-full flex-col items-center justify-center gap-y-2 pt-20 pb-4 text-center align-top font-semibold sm:flex-row sm:justify-between sm:text-xs"
|
||||
>
|
||||
<div class="me-0 sm:me-4">
|
||||
© {siteConfig.author}
|
||||
{year}.<span class="inline-block"> 🚀 {siteConfig.title} 🌵 </span>
|
||||
</div>
|
||||
<nav aria-labelledby="footer_links" class="sm:divide-muted flex gap-x-2 sm:gap-x-0 sm:divide-x">
|
||||
<p id="footer_links" class="sr-only">More on this site</p>
|
||||
{
|
||||
menuLinks.map((link) => (
|
||||
<a class="hover:text-global-text px-4 py-2 hover:underline sm:py-0" href={link.path}>
|
||||
{link.title}
|
||||
</a>
|
||||
))
|
||||
}
|
||||
</nav>
|
||||
</footer>
|
||||
@@ -0,0 +1,117 @@
|
||||
---
|
||||
import Search from "@/components/Search.astro";
|
||||
import ThemeToggle from "@/components/ThemeToggle.astro";
|
||||
import { menuLinks } from "@/site.config";
|
||||
import { siteConfig } from "../../site.config";
|
||||
---
|
||||
|
||||
<header class="group relative mb-28 flex items-center sm:ps-18" id="main-header">
|
||||
<div class="flex sm:flex-col">
|
||||
<a
|
||||
aria-current={Astro.url.pathname === "/" ? "page" : false}
|
||||
class="inline-flex items-center sm:relative sm:inline-block sm:grayscale sm:hover:filter-none"
|
||||
href="/"
|
||||
>
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
class="me-3 h-10 w-6 sm:absolute sm:-start-18 sm:me-0 sm:h-20 sm:w-12"
|
||||
fill="none"
|
||||
focusable="false"
|
||||
viewBox="0 0 272 480"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<title>Astro Cactus Logo</title>
|
||||
<path
|
||||
fill="#cdffb8"
|
||||
d="M181.334 93.333v-40L226.667 80v40zM136.001 53.333 90.667 26.667v426.666L136.001 480zM45.333 220 0 193.334v140L45.333 360z"
|
||||
></path><path
|
||||
fill="#d482ab"
|
||||
d="M90.667 26.667 136.001 0l45.333 26.667-45.333 26.666zM181.334 53.33l45.333-26.72L272 53.33 226.667 80zM136 240l-45.333-26.67v53.34zM0 193.33l45.333-26.72 45.334 26.72L45.333 220zM181.334 93.277 226.667 120l-45.333 26.67z"
|
||||
></path><path
|
||||
fill="#2abc89"
|
||||
d="m136 53.333 45.333-26.666v120L226.667 120V80L272 53.333V160l-90.667 53.333v240L136 480V306.667L45.334 360V220l45.333-26.667v73.334L136 240z"
|
||||
></path>
|
||||
</svg>
|
||||
<span class="text-xl font-bold sm:text-2xl">{siteConfig.title}</span>
|
||||
</a>
|
||||
<nav
|
||||
aria-label="Main menu"
|
||||
class="bg-global-bg absolute -inset-x-4 top-12 hidden flex-col divide-y px-2 py-4 group-[.menu-open]:z-50 group-[.menu-open]:flex sm:static sm:z-auto sm:-ms-4 sm:mt-1 sm:flex sm:flex-row sm:divide-x sm:divide-y-0 sm:bg-transparent sm:p-0"
|
||||
id="navigation-menu"
|
||||
>
|
||||
{
|
||||
menuLinks.map((link) => (
|
||||
<a
|
||||
aria-current={Astro.url.pathname === link.path ? "page" : false}
|
||||
class="text-accent px-2 py-4 font-semibold sm:px-4 sm:py-0 sm:underline-offset-2 sm:hover:underline"
|
||||
href={link.path}
|
||||
>
|
||||
{link.title}
|
||||
</a>
|
||||
))
|
||||
}
|
||||
</nav>
|
||||
</div>
|
||||
<Search />
|
||||
<ThemeToggle />
|
||||
<mobile-button>
|
||||
<button
|
||||
aria-expanded="false"
|
||||
aria-haspopup="menu"
|
||||
class="group relative ms-4 h-7 w-7 sm:invisible sm:hidden"
|
||||
id="toggle-navigation-menu"
|
||||
type="button"
|
||||
>
|
||||
<span class="sr-only">Open main menu</span>
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
class="absolute start-1/2 top-1/2 h-full w-full -translate-x-1/2 -translate-y-1/2 transition-all group-aria-expanded:scale-0 group-aria-expanded:opacity-0"
|
||||
fill="none"
|
||||
focusable="false"
|
||||
id="line-svg"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path d="M3.75 9h16.5m-16.5 6.75h16.5" stroke-linecap="round" stroke-linejoin="round"
|
||||
></path>
|
||||
</svg>
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
class="text-accent absolute start-1/2 top-1/2 h-full w-full -translate-x-1/2 -translate-y-1/2 scale-0 opacity-0 transition-all group-aria-expanded:scale-100 group-aria-expanded:opacity-100"
|
||||
class="text-accent"
|
||||
fill="none"
|
||||
focusable="false"
|
||||
id="cross-svg"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path d="M6 18L18 6M6 6l12 12" stroke-linecap="round" stroke-linejoin="round"></path>
|
||||
</svg>
|
||||
</button>
|
||||
</mobile-button>
|
||||
</header>
|
||||
|
||||
<script>
|
||||
import { toggleClass } from "@/utils/domElement";
|
||||
|
||||
class MobileNavBtn extends HTMLElement {
|
||||
#menuOpen: boolean = false;
|
||||
|
||||
connectedCallback() {
|
||||
const headerEl = document.getElementById("main-header")!;
|
||||
const mobileButtonEl = this.querySelector<HTMLButtonElement>("button");
|
||||
|
||||
mobileButtonEl?.addEventListener("click", () => {
|
||||
if (headerEl) toggleClass(headerEl, "menu-open");
|
||||
this.#menuOpen = !this.#menuOpen;
|
||||
mobileButtonEl.setAttribute("aria-expanded", this.#menuOpen.toString());
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define("mobile-button", MobileNavBtn);
|
||||
</script>
|
||||
@@ -0,0 +1,46 @@
|
||||
---
|
||||
import { type CollectionEntry, render } from "astro:content";
|
||||
import type { HTMLTag, Polymorphic } from "astro/types";
|
||||
import FormattedDate from "@/components/FormattedDate.astro";
|
||||
|
||||
type Props<Tag extends HTMLTag> = Polymorphic<{ as: Tag }> & {
|
||||
note: CollectionEntry<"note">;
|
||||
isPreview?: boolean | undefined;
|
||||
};
|
||||
|
||||
const { as: Tag = "div", note, isPreview = false } = Astro.props;
|
||||
const { Content } = await render(note);
|
||||
---
|
||||
|
||||
<article
|
||||
class:list={[isPreview && "bg-global-text/5 inline-grid rounded-md px-4 py-3"]}
|
||||
data-pagefind-body={isPreview ? false : true}
|
||||
>
|
||||
<Tag class="title" class:list={{ "text-base": isPreview }}>
|
||||
{
|
||||
isPreview ? (
|
||||
<a class="cactus-link" href={`/notes/${note.id}/`}>
|
||||
{note.data.title}
|
||||
</a>
|
||||
) : (
|
||||
<>{note.data.title}</>
|
||||
)
|
||||
}
|
||||
</Tag>
|
||||
<FormattedDate
|
||||
dateTimeOptions={{
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
year: "2-digit",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
}}
|
||||
date={note.data.publishDate}
|
||||
/>
|
||||
<div
|
||||
class="prose prose-sm prose-cactus mt-4 max-w-none [&>p:last-of-type]:mb-0"
|
||||
class:list={{ "line-clamp-6": isPreview }}
|
||||
>
|
||||
<Content />
|
||||
</div>
|
||||
</article>
|
||||
@@ -0,0 +1,54 @@
|
||||
---
|
||||
import BaseHead from "@/components/BaseHead.astro";
|
||||
import Footer from "@/components/layout/Footer.astro";
|
||||
import Header from "@/components/layout/Header.astro";
|
||||
import SkipLink from "@/components/SkipLink.astro";
|
||||
import ThemeProvider from "@/components/ThemeProvider.astro";
|
||||
import { siteConfig } from "@/site.config";
|
||||
import type { SiteMeta } from "@/types";
|
||||
|
||||
interface Props {
|
||||
meta: SiteMeta;
|
||||
}
|
||||
|
||||
const {
|
||||
meta: { articleDate, description = siteConfig.description, ogImage, title },
|
||||
} = Astro.props;
|
||||
---
|
||||
|
||||
<html class="scroll-smooth" lang={siteConfig.lang}>
|
||||
<head>
|
||||
<BaseHead articleDate={articleDate} description={description} ogImage={ogImage} title={title} />
|
||||
<ThemeProvider />
|
||||
</head>
|
||||
<body
|
||||
class="bg-global-bg text-global-text mx-auto flex min-h-screen max-w-3xl flex-col px-4 pt-16 font-mono text-sm font-normal antialiased sm:px-8"
|
||||
>
|
||||
<SkipLink />
|
||||
<Header />
|
||||
<main id="main">
|
||||
<slot />
|
||||
</main>
|
||||
<Footer />
|
||||
<script is:inline type="speculationrules">
|
||||
{
|
||||
"prefetch": [
|
||||
{
|
||||
"where": {
|
||||
"href_matches": "/*"
|
||||
},
|
||||
"eagerness": "immediate"
|
||||
}
|
||||
],
|
||||
"prerender": [
|
||||
{
|
||||
"where": {
|
||||
"href_matches": "/*"
|
||||
},
|
||||
"eagerness": "moderate"
|
||||
}
|
||||
]
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,80 @@
|
||||
---
|
||||
import { type CollectionEntry, render } from "astro:content";
|
||||
|
||||
import Masthead from "@/components/blog/Masthead.astro";
|
||||
import TOC from "@/components/blog/TOC.astro";
|
||||
import WebMentions from "@/components/blog/webmentions/index.astro";
|
||||
|
||||
import BaseLayout from "./Base.astro";
|
||||
|
||||
interface Props {
|
||||
post: CollectionEntry<"post">;
|
||||
}
|
||||
|
||||
const { post } = Astro.props;
|
||||
const { ogImage, title, description, updatedDate, publishDate } = post.data;
|
||||
const socialImage = ogImage ?? `/og-image/${post.id}.png`;
|
||||
const articleDate = updatedDate?.toISOString() ?? publishDate.toISOString();
|
||||
const { headings, remarkPluginFrontmatter } = await render(post);
|
||||
const readingTime: string = remarkPluginFrontmatter.readingTime;
|
||||
---
|
||||
|
||||
<BaseLayout
|
||||
meta={{
|
||||
articleDate,
|
||||
description,
|
||||
ogImage: socialImage,
|
||||
title,
|
||||
}}
|
||||
>
|
||||
<article class="grow break-words" data-pagefind-body>
|
||||
<div id="blog-hero" class="mb-12"><Masthead content={post} readingTime={readingTime} /></div>
|
||||
<div class="flex flex-col gap-10 lg:flex-row lg:items-start lg:justify-between">
|
||||
{!!headings.length && <TOC headings={headings} />}
|
||||
<div
|
||||
class="prose prose-sm prose-headings:font-semibold prose-headings:text-accent-2 prose-headings:before:absolute prose-headings:before:-ms-4 prose-headings:before:text-muted prose-headings:hover:before:text-accent sm:prose-headings:before:content-['#'] sm:prose-th:before:content-none"
|
||||
>
|
||||
<slot />
|
||||
<WebMentions />
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
<button
|
||||
class="hover:border-link fixed end-4 bottom-8 z-90 flex h-10 w-10 translate-y-28 cursor-pointer items-center justify-center rounded-full border-2 border-transparent bg-zinc-200 text-3xl opacity-0 transition-all transition-discrete duration-300 data-[show=true]:translate-y-0 data-[show=true]:opacity-100 sm:end-8 sm:h-12 sm:w-12 dark:bg-zinc-700"
|
||||
data-show="false"
|
||||
id="to-top-btn"
|
||||
>
|
||||
<span class="sr-only">Back to top</span>
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
class="h-6 w-6"
|
||||
fill="none"
|
||||
focusable="false"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path d="M4.5 15.75l7.5-7.5 7.5 7.5" stroke-linecap="round" stroke-linejoin="round"></path>
|
||||
</svg>
|
||||
</button>
|
||||
</BaseLayout>
|
||||
|
||||
<script>
|
||||
const scrollBtn = document.getElementById("to-top-btn") as HTMLButtonElement;
|
||||
const targetHeader = document.getElementById("blog-hero") as HTMLDivElement;
|
||||
|
||||
function callback(entries: IntersectionObserverEntry[]) {
|
||||
entries.forEach((entry) => {
|
||||
// only show the scroll to top button when the heading is out of view
|
||||
scrollBtn.dataset.show = (!entry.isIntersecting).toString();
|
||||
});
|
||||
}
|
||||
|
||||
scrollBtn.addEventListener("click", () => {
|
||||
document.documentElement.scrollTo({ behavior: "smooth", top: 0 });
|
||||
});
|
||||
|
||||
const observer = new IntersectionObserver(callback);
|
||||
observer.observe(targetHeader);
|
||||
</script>
|
||||
@@ -0,0 +1,92 @@
|
||||
@import "@pagefind/component-ui/css" layer(pagefind);
|
||||
|
||||
:root {
|
||||
--pf-text-muted: var(--color-global-text);
|
||||
--pf-background: var(--color-global-bg);
|
||||
--pf-border-focus: var(--color-accent-2);
|
||||
--pf-outline-focus: var(--color-accent);
|
||||
--pf-outline-offset: none;
|
||||
--pf-font: var(--font-mono);
|
||||
--pf-text: var(--color-global-text);
|
||||
--pf-text-secondary: var(--color-muted);
|
||||
--pf-skeleton: var(--color-global-bg);
|
||||
--pf-icon-search: url("data:image/svg+xml,%3Csvg fill='none' height='16' width='16' stroke='currentColor' stroke-linecap='round' stroke-linejoin='round' stroke-width='1.5' viewBox='0 0 24 24' xmlns='http://www.w3.org/2000/svg' %3E%3Cpath d='M0 0h24v24H0z' stroke='none'%3E%3C/path%3E%3Cpath d='M3 10a7 7 0 1 0 14 0 7 7 0 1 0-14 0M21 21l-6-6'%3E%3C/path%3E%3C/svg%3E");
|
||||
}
|
||||
|
||||
.pf-trigger-btn {
|
||||
--pf-background: #0000;
|
||||
--pf-border-focus: #0000;
|
||||
--pf-hover: var(--color-accent);
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
width: calc(var(--spacing) * 9);
|
||||
height: calc(var(--spacing) * 9);
|
||||
padding: 8px;
|
||||
accent-color: var(--color-accent);
|
||||
border: 0;
|
||||
border-radius: 0;
|
||||
|
||||
&:hover {
|
||||
@media (hover: hover) {
|
||||
--pf-text-muted: var(--color-accent);
|
||||
color: var(--color-accent);
|
||||
}
|
||||
}
|
||||
&:focus-visible {
|
||||
outline: auto;
|
||||
}
|
||||
|
||||
.pf-trigger-icon {
|
||||
width: calc(var(--spacing) * 7);
|
||||
height: calc(var(--spacing) * 7);
|
||||
}
|
||||
}
|
||||
|
||||
dialog.pf-modal::backdrop {
|
||||
backdrop-filter: blur(8px);
|
||||
}
|
||||
|
||||
.pf-modal-body {
|
||||
--pf-hover: var(--color-global-bg);
|
||||
|
||||
.pf-result-link,
|
||||
.pf-heading-link {
|
||||
background-size: 100% 6px;
|
||||
background-position: bottom;
|
||||
background-repeat: repeat-x;
|
||||
background-image: linear-gradient(
|
||||
transparent,
|
||||
transparent 5px,
|
||||
var(--color-global-text) 5px,
|
||||
var(--color-global-text)
|
||||
);
|
||||
|
||||
&:hover {
|
||||
text-decoration: none;
|
||||
background-image: linear-gradient(
|
||||
transparent,
|
||||
transparent 4px,
|
||||
var(--color-link) 4px,
|
||||
var(--color-link)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
.pf-heading-link {
|
||||
align-self: flex-start;
|
||||
}
|
||||
|
||||
.pf-heading-excerpt {
|
||||
mark {
|
||||
--pf-mark: var(--color-accent-2);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.pf-keyboard-hints {
|
||||
kbd {
|
||||
color: var(--color-zinc-800);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
.admonition {
|
||||
--admonition-color: var(--tw-prose-quotes);
|
||||
@apply border-s-2 border-(--admonition-color) py-4 ps-4;
|
||||
|
||||
.admonition-title {
|
||||
@apply my-0! flex items-center gap-2 text-base font-bold text-(--admonition-color) capitalize;
|
||||
&:before {
|
||||
@apply inline-block h-4 w-4 shrink-0 overflow-visible bg-(--admonition-color) align-middle content-[''];
|
||||
mask-size: contain;
|
||||
mask-position: center;
|
||||
mask-repeat: no-repeat;
|
||||
}
|
||||
}
|
||||
|
||||
.admonition-content {
|
||||
> :last-child {
|
||||
@apply mb-0!;
|
||||
}
|
||||
}
|
||||
|
||||
&[data-admonition-type="note"] {
|
||||
--admonition-color: var(--color-blue-400);
|
||||
@apply bg-blue-400/5;
|
||||
|
||||
.admonition-title::before {
|
||||
mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' version='1.1' width='16' height='16' aria-hidden='true'%3E%3Cpath fill='var(--admonitions-color-tip)' d='M0 8a8 8 0 1 1 16 0A8 8 0 0 1 0 8Zm8-6.5a6.5 6.5 0 1 0 0 13 6.5 6.5 0 0 0 0-13ZM6.5 7.75A.75.75 0 0 1 7.25 7h1a.75.75 0 0 1 .75.75v2.75h.25a.75.75 0 0 1 0 1.5h-2a.75.75 0 0 1 0-1.5h.25v-2h-.25a.75.75 0 0 1-.75-.75ZM8 6a1 1 0 1 1 0-2 1 1 0 0 1 0 2Z'%3E%3C/path%3E%3C/svg%3E");
|
||||
}
|
||||
}
|
||||
|
||||
&[data-admonition-type="tip"] {
|
||||
--admonition-color: var(--color-lime-500);
|
||||
@apply bg-lime-500/5;
|
||||
|
||||
.admonition-title::before {
|
||||
mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' version='1.1' width='16' height='16' aria-hidden='true'%3E%3Cpath d='M8 1.5c-2.363 0-4 1.69-4 3.75 0 .984.424 1.625.984 2.304l.214.253c.223.264.47.556.673.848.284.411.537.896.621 1.49a.75.75 0 0 1-1.484.211c-.04-.282-.163-.547-.37-.847a8.456 8.456 0 0 0-.542-.68c-.084-.1-.173-.205-.268-.32C3.201 7.75 2.5 6.766 2.5 5.25 2.5 2.31 4.863 0 8 0s5.5 2.31 5.5 5.25c0 1.516-.701 2.5-1.328 3.259-.095.115-.184.22-.268.319-.207.245-.383.453-.541.681-.208.3-.33.565-.37.847a.751.751 0 0 1-1.485-.212c.084-.593.337-1.078.621-1.489.203-.292.45-.584.673-.848.075-.088.147-.173.213-.253.561-.679.985-1.32.985-2.304 0-2.06-1.637-3.75-4-3.75ZM5.75 12h4.5a.75.75 0 0 1 0 1.5h-4.5a.75.75 0 0 1 0-1.5ZM6 15.25a.75.75 0 0 1 .75-.75h2.5a.75.75 0 0 1 0 1.5h-2.5a.75.75 0 0 1-.75-.75Z'%3E%3C/path%3E%3C/svg%3E");
|
||||
}
|
||||
}
|
||||
|
||||
&[data-admonition-type="important"] {
|
||||
--admonition-color: var(--color-purple-400);
|
||||
@apply bg-purple-400/5;
|
||||
|
||||
.admonition-title::before {
|
||||
mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' version='1.1' width='16' height='16' aria-hidden='true'%3E%3Cpath d='M0 1.75C0 .784.784 0 1.75 0h12.5C15.216 0 16 .784 16 1.75v9.5A1.75 1.75 0 0 1 14.25 13H8.06l-2.573 2.573A1.458 1.458 0 0 1 3 14.543V13H1.75A1.75 1.75 0 0 1 0 11.25Zm1.75-.25a.25.25 0 0 0-.25.25v9.5c0 .138.112.25.25.25h2a.75.75 0 0 1 .75.75v2.19l2.72-2.72a.749.749 0 0 1 .53-.22h6.5a.25.25 0 0 0 .25-.25v-9.5a.25.25 0 0 0-.25-.25Zm7 2.25v2.5a.75.75 0 0 1-1.5 0v-2.5a.75.75 0 0 1 1.5 0ZM9 9a1 1 0 1 1-2 0 1 1 0 0 1 2 0Z'%3E%3C/path%3E%3C/svg%3E");
|
||||
}
|
||||
}
|
||||
|
||||
&[data-admonition-type="caution"] {
|
||||
--admonition-color: var(--color-orange-400);
|
||||
@apply bg-orange-400/5;
|
||||
|
||||
.admonition-title::before {
|
||||
mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' version='1.1' width='16' height='16' aria-hidden='true'%3E%3Cpath d='M4.47.22A.749.749 0 0 1 5 0h6c.199 0 .389.079.53.22l4.25 4.25c.141.14.22.331.22.53v6a.749.749 0 0 1-.22.53l-4.25 4.25A.749.749 0 0 1 11 16H5a.749.749 0 0 1-.53-.22L.22 11.53A.749.749 0 0 1 0 11V5c0-.199.079-.389.22-.53Zm.84 1.28L1.5 5.31v5.38l3.81 3.81h5.38l3.81-3.81V5.31L10.69 1.5ZM8 4a.75.75 0 0 1 .75.75v3.5a.75.75 0 0 1-1.5 0v-3.5A.75.75 0 0 1 8 4Zm0 8a1 1 0 1 1 0-2 1 1 0 0 1 0 2Z'%3E%3C/path%3E%3C/svg%3E");
|
||||
}
|
||||
}
|
||||
|
||||
&[data-admonition-type="warning"] {
|
||||
--admonition-color: var(--color-red-500);
|
||||
@apply bg-red-500/5;
|
||||
|
||||
.admonition-title::before {
|
||||
mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' version='1.1' width='16' height='16' aria-hidden='true'%3E%3Cpath d='M6.457 1.047c.659-1.234 2.427-1.234 3.086 0l6.082 11.378A1.75 1.75 0 0 1 14.082 15H1.918a1.75 1.75 0 0 1-1.543-2.575Zm1.763.707a.25.25 0 0 0-.44 0L1.698 13.132a.25.25 0 0 0 .22.368h12.164a.25.25 0 0 0 .22-.368Zm.53 3.996v2.5a.75.75 0 0 1-1.5 0v-2.5a.75.75 0 0 1 1.5 0ZM9 11a1 1 0 1 1-2 0 1 1 0 0 1 2 0Z'%3E%3C/path%3E%3C/svg%3E");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
.github-card {
|
||||
@apply bg-global-text/5 rounded-md px-4 py-3;
|
||||
|
||||
.gh-title {
|
||||
@apply relative flex items-center gap-2 text-base;
|
||||
|
||||
.gh-avatar {
|
||||
@apply bg-global-text/20 h-6 w-6 flex-none rounded-full bg-none bg-cover bg-center;
|
||||
}
|
||||
.gh-text {
|
||||
@apply line-clamp-2;
|
||||
&:after {
|
||||
@apply absolute inset-0 content-[''];
|
||||
}
|
||||
}
|
||||
.gh-icon {
|
||||
@apply bg-global-text pointer-events-none ms-auto h-6 w-6 flex-none;
|
||||
mask:
|
||||
url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='31' height='32' viewBox='0 0 496 512'><path fill='%23a1f7cb' d='M165.9 397.4c0 2-2.3 3.6-5.2 3.6c-3.3.3-5.6-1.3-5.6-3.6c0-2 2.3-3.6 5.2-3.6c3-.3 5.6 1.3 5.6 3.6m-31.1-4.5c-.7 2 1.3 4.3 4.3 4.9c2.6 1 5.6 0 6.2-2s-1.3-4.3-4.3-5.2c-2.6-.7-5.5.3-6.2 2.3m44.2-1.7c-2.9.7-4.9 2.6-4.6 4.9c.3 2 2.9 3.3 5.9 2.6c2.9-.7 4.9-2.6 4.6-4.6c-.3-1.9-3-3.2-5.9-2.9M244.8 8C106.1 8 0 113.3 0 252c0 110.9 69.8 205.8 169.5 239.2c12.8 2.3 17.3-5.6 17.3-12.1c0-6.2-.3-40.4-.3-61.4c0 0-70 15-84.7-29.8c0 0-11.4-29.1-27.8-36.6c0 0-22.9-15.7 1.6-15.4c0 0 24.9 2 38.6 25.8c21.9 38.6 58.6 27.5 72.9 20.9c2.3-16 8.8-27.1 16-33.7c-55.9-6.2-112.3-14.3-112.3-110.5c0-27.5 7.6-41.3 23.6-58.9c-2.6-6.5-11.1-33.3 2.6-67.9c20.9-6.5 69 27 69 27c20-5.6 41.5-8.5 62.8-8.5s42.8 2.9 62.8 8.5c0 0 48.1-33.6 69-27c13.7 34.7 5.2 61.4 2.6 67.9c16 17.7 25.8 31.5 25.8 58.9c0 96.5-58.9 104.2-114.8 110.5c9.2 7.9 17 22.9 17 46.4c0 33.7-.3 75.4-.3 83.6c0 6.5 4.6 14.4 17.3 12.1C428.2 457.8 496 362.9 496 252C496 113.3 383.5 8 244.8 8M97.2 352.9c-1.3 1-1 3.3.7 5.2c1.6 1.6 3.9 2.3 5.2 1c1.3-1 1-3.3-.7-5.2c-1.6-1.6-3.9-2.3-5.2-1m-10.8-8.1c-.7 1.3.3 2.9 2.3 3.9c1.6 1 3.6.7 4.3-.7c.7-1.3-.3-2.9-2.3-3.9c-2-.6-3.6-.3-4.3.7m32.4 35.6c-1.6 1.3-1 4.3 1.3 6.2c2.3 2.3 5.2 2.6 6.5 1c1.3-1.3.7-4.3-1.3-6.2c-2.2-2.3-5.2-2.6-6.5-1m-11.4-14.7c-1.6 1-1.6 3.6 0 5.9c1.6 2.3 4.3 3.3 5.6 2.3c1.6-1.3 1.6-3.9 0-6.2c-1.4-2.3-4-3.3-5.6-2'/%3E%3C/svg%3E")
|
||||
center center / 24px auto no-repeat;
|
||||
}
|
||||
}
|
||||
.gh-description {
|
||||
@apply mt-4 leading-tight;
|
||||
}
|
||||
.gh-chips {
|
||||
@apply mt-4 flex flex-wrap items-center gap-4;
|
||||
|
||||
.gh-stars,
|
||||
.gh-forks,
|
||||
.gh-license,
|
||||
.gh-followers,
|
||||
.gh-repositories {
|
||||
@apply flex items-center gap-x-1;
|
||||
&:before {
|
||||
@apply bg-global-text block h-5 w-5 content-[''];
|
||||
mask: var(--chip-image) center center / 16px auto no-repeat;
|
||||
}
|
||||
}
|
||||
.gh-stars {
|
||||
--chip-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' height='16' viewBox='0 0 16 16' version='1.1' width='16'><path d='M8 .25a.75.75 0 0 1 .673.418l1.882 3.815 4.21.612a.75.75 0 0 1 .416 1.279l-3.046 2.97.719 4.192a.751.751 0 0 1-1.088.791L8 12.347l-3.766 1.98a.75.75 0 0 1-1.088-.79l.72-4.194L.818 6.374a.75.75 0 0 1 .416-1.28l4.21-.611L7.327.668A.75.75 0 0 1 8 .25Zm0 2.445L6.615 5.5a.75.75 0 0 1-.564.41l-3.097.45 2.24 2.184a.75.75 0 0 1 .216.664l-.528 3.084 2.769-1.456a.75.75 0 0 1 .698 0l2.77 1.456-.53-3.084a.75.75 0 0 1 .216-.664l2.24-2.183-3.096-.45a.75.75 0 0 1-.564-.41L8 2.694Z'/></svg>");
|
||||
}
|
||||
.gh-forks {
|
||||
--chip-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' height='16' viewBox='0 0 16 16' version='1.1' width='16'><path d='M5 5.372v.878c0 .414.336.75.75.75h4.5a.75.75 0 0 0 .75-.75v-.878a2.25 2.25 0 1 1 1.5 0v.878a2.25 2.25 0 0 1-2.25 2.25h-1.5v2.128a2.251 2.251 0 1 1-1.5 0V8.5h-1.5A2.25 2.25 0 0 1 3.5 6.25v-.878a2.25 2.25 0 1 1 1.5 0ZM5 3.25a.75.75 0 1 0-1.5 0 .75.75 0 0 0 1.5 0Zm6.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5Zm-3 8.75a.75.75 0 1 0-1.5 0 .75.75 0 0 0 1.5 0Z'/></svg>");
|
||||
}
|
||||
.gh-license {
|
||||
--chip-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' height='16' viewBox='0 0 16 16' version='1.1' width='16'><path d='M8.75.75V2h.985c.304 0 .603.08.867.231l1.29.736c.038.022.08.033.124.033h2.234a.75.75 0 0 1 0 1.5h-.427l2.111 4.692a.75.75 0 0 1-.154.838l-.53-.53.529.531-.001.002-.002.002-.006.006-.006.005-.01.01-.045.04c-.21.176-.441.327-.686.45C14.556 10.78 13.88 11 13 11a4.498 4.498 0 0 1-2.023-.454 3.544 3.544 0 0 1-.686-.45l-.045-.04-.016-.015-.006-.006-.004-.004v-.001a.75.75 0 0 1-.154-.838L12.178 4.5h-.162c-.305 0-.604-.079-.868-.231l-1.29-.736a.245.245 0 0 0-.124-.033H8.75V13h2.5a.75.75 0 0 1 0 1.5h-6.5a.75.75 0 0 1 0-1.5h2.5V3.5h-.984a.245.245 0 0 0-.124.033l-1.289.737c-.265.15-.564.23-.869.23h-.162l2.112 4.692a.75.75 0 0 1-.154.838l-.53-.53.529.531-.001.002-.002.002-.006.006-.016.015-.045.04c-.21.176-.441.327-.686.45C4.556 10.78 3.88 11 3 11a4.498 4.498 0 0 1-2.023-.454 3.544 3.544 0 0 1-.686-.45l-.045-.04-.016-.015-.006-.006-.004-.004v-.001a.75.75 0 0 1-.154-.838L2.178 4.5H1.75a.75.75 0 0 1 0-1.5h2.234a.249.249 0 0 0 .125-.033l1.288-.737c.265-.15.564-.23.869-.23h.984V.75a.75.75 0 0 1 1.5 0Zm2.945 8.477c.285.135.718.273 1.305.273s1.02-.138 1.305-.273L13 6.327Zm-10 0c.285.135.718.273 1.305.273s1.02-.138 1.305-.273L3 6.327Z'/></svg>");
|
||||
}
|
||||
.gh-followers {
|
||||
--chip-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' text='muted' height='16' viewBox='0 0 16 16' version='1.1' width='16'><path d='M2 5.5a3.5 3.5 0 1 1 5.898 2.549 5.508 5.508 0 0 1 3.034 4.084.75.75 0 1 1-1.482.235 4 4 0 0 0-7.9 0 .75.75 0 0 1-1.482-.236A5.507 5.507 0 0 1 3.102 8.05 3.493 3.493 0 0 1 2 5.5ZM11 4a3.001 3.001 0 0 1 2.22 5.018 5.01 5.01 0 0 1 2.56 3.012.749.749 0 0 1-.885.954.752.752 0 0 1-.549-.514 3.507 3.507 0 0 0-2.522-2.372.75.75 0 0 1-.574-.73v-.352a.75.75 0 0 1 .416-.672A1.5 1.5 0 0 0 11 5.5.75.75 0 0 1 11 4Zm-5.5-.5a2 2 0 1 0-.001 3.999A2 2 0 0 0 5.5 3.5Z'/></svg>");
|
||||
}
|
||||
.gh-repositories {
|
||||
--chip-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' height='16' viewBox='0 0 16 16' version='1.1' width='16'><path d='M2 2.5A2.5 2.5 0 0 1 4.5 0h8.75a.75.75 0 0 1 .75.75v12.5a.75.75 0 0 1-.75.75h-2.5a.75.75 0 0 1 0-1.5h1.75v-2h-8a1 1 0 0 0-.714 1.7.75.75 0 1 1-1.072 1.05A2.495 2.495 0 0 1 2 11.5Zm10.5-1h-8a1 1 0 0 0-1 1v6.708A2.486 2.486 0 0 1 4.5 9h8ZM5 12.25a.25.25 0 0 1 .25-.25h3.5a.25.25 0 0 1 .25.25v3.25a.25.25 0 0 1-.4.2l-1.45-1.087a.249.249 0 0 0-.3 0L5.4 15.7a.25.25 0 0 1-.4-.2Z'/></svg>");
|
||||
}
|
||||
.gh-language,
|
||||
.gh-region {
|
||||
@apply ms-auto;
|
||||
}
|
||||
}
|
||||
|
||||
&.gh-loading {
|
||||
.gh-title .gh-avatar,
|
||||
.gh-description,
|
||||
.gh-chips span {
|
||||
@apply bg-global-text/50 animate-pulse rounded-xl text-transparent;
|
||||
}
|
||||
.gh-chips span:before {
|
||||
@apply bg-transparent;
|
||||
}
|
||||
}
|
||||
|
||||
&.gh-error {
|
||||
.gh-avatar,
|
||||
.gh-description,
|
||||
.gh-chips {
|
||||
@apply hidden;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
@layer theme, base, pagefind, components, utilities;
|
||||
|
||||
@import "tailwindcss/theme.css" layer(theme);
|
||||
@import "tailwindcss/preflight.css" layer(base);
|
||||
@import "tailwindcss/utilities.css" layer(utilities);
|
||||
|
||||
/* tailwind to ignore satori generated og-image */
|
||||
@source not "../pages/og-image";
|
||||
/* config for tailwindcss-typography plugin */
|
||||
@config "../../tailwind.config.ts";
|
||||
|
||||
/* use a selector-based strategy for dark mode */
|
||||
@custom-variant dark (&:where([data-theme="dark"], [data-theme="dark"] *));
|
||||
|
||||
/* register custom colour properties */
|
||||
@property --color-global-bg {
|
||||
syntax: "<color>";
|
||||
inherits: true;
|
||||
initial-value: oklch(98.48% 0 0);
|
||||
}
|
||||
@property --color-global-text {
|
||||
syntax: "<color>";
|
||||
inherits: true;
|
||||
initial-value: oklch(26.99% 0.0096 235.05);
|
||||
}
|
||||
@property --color-muted {
|
||||
syntax: "<color>";
|
||||
inherits: true;
|
||||
initial-value: oklch(44.6% 0.03 256.802);
|
||||
}
|
||||
@property --color-link {
|
||||
syntax: "<color>";
|
||||
inherits: true;
|
||||
initial-value: oklch(55.44% 0.0431 185.69);
|
||||
}
|
||||
@property --color-accent {
|
||||
syntax: "<color>";
|
||||
inherits: true;
|
||||
initial-value: oklch(55.27% 0.195 19.06);
|
||||
}
|
||||
@property --color-accent-2 {
|
||||
syntax: "<color>";
|
||||
inherits: true;
|
||||
initial-value: oklch(18.15% 0 0);
|
||||
}
|
||||
@property --color-quote {
|
||||
syntax: "<color>";
|
||||
inherits: true;
|
||||
initial-value: oklch(55.27% 0.195 19.06);
|
||||
}
|
||||
|
||||
/* you could refactor below to use light-dark(), depending on your target audience */
|
||||
@theme {
|
||||
--color-global-bg: oklch(98.48% 0 0);
|
||||
--color-global-text: oklch(26.99% 0.0096 235.05);
|
||||
--color-muted: oklch(44.6% 0.03 256.802);
|
||||
--color-link: oklch(55.44% 0.0431 185.69);
|
||||
--color-accent: oklch(55.27% 0.195 19.06);
|
||||
--color-accent-2: oklch(18.15% 0 0);
|
||||
--color-quote: oklch(55.27% 0.195 19.06);
|
||||
}
|
||||
|
||||
@layer base {
|
||||
html {
|
||||
color-scheme: light dark;
|
||||
accent-color: var(--color-accent);
|
||||
scrollbar-gutter: stable;
|
||||
|
||||
&[data-theme="light"] {
|
||||
color-scheme: light;
|
||||
}
|
||||
|
||||
&[data-theme="dark"] {
|
||||
color-scheme: dark;
|
||||
--color-global-bg: oklch(23.64% 0.0045 248);
|
||||
--color-global-text: oklch(83.54% 0 264);
|
||||
--color-muted: oklch(70.7% 0.022 261.325);
|
||||
--color-link: oklch(70.44% 0.1133 349);
|
||||
--color-accent: oklch(70.91% 0.1415 163.7);
|
||||
--color-accent-2: oklch(94.66% 0 0);
|
||||
--color-quote: oklch(94.8% 0.106 136.49);
|
||||
}
|
||||
|
||||
--color-mode-transition-duration: 300ms;
|
||||
--color-mode-transition-easing: ease-in-out;
|
||||
|
||||
transition:
|
||||
--color-global-bg var(--color-mode-transition-duration) var(--color-mode-transition-easing),
|
||||
--color-global-text var(--color-mode-transition-duration) var(--color-mode-transition-easing),
|
||||
--color-muted var(--color-mode-transition-duration) var(--color-mode-transition-easing),
|
||||
--color-link var(--color-mode-transition-duration) var(--color-mode-transition-easing),
|
||||
--color-accent var(--color-mode-transition-duration) var(--color-mode-transition-easing),
|
||||
--color-accent-2 var(--color-mode-transition-duration) var(--color-mode-transition-easing),
|
||||
--color-quote var(--color-mode-transition-duration) var(--color-mode-transition-easing);
|
||||
}
|
||||
|
||||
:target {
|
||||
scroll-margin-block: 5ex;
|
||||
}
|
||||
|
||||
@view-transition {
|
||||
navigation: auto;
|
||||
}
|
||||
|
||||
/* Astro image responsive styles, modified from -> https://docs.astro.build/en/guides/images/#responsive-image-styles */
|
||||
:where([data-astro-image]) {
|
||||
object-fit: var(--fit);
|
||||
object-position: var(--pos);
|
||||
}
|
||||
[data-astro-image="full-width"] {
|
||||
width: 100%;
|
||||
}
|
||||
[data-astro-image="constrained"] {
|
||||
max-width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
@layer components {
|
||||
@import "./components/admonition.css";
|
||||
@import "./components/github-card.css";
|
||||
|
||||
.cactus-link {
|
||||
@apply hover:decoration-link underline underline-offset-2 hover:decoration-2;
|
||||
}
|
||||
|
||||
.title {
|
||||
@apply text-accent-2 text-2xl font-semibold;
|
||||
}
|
||||
}
|
||||
|
||||
@utility prose {
|
||||
--tw-prose-body: var(--color-global-text);
|
||||
--tw-prose-bold: var(--color-global-text);
|
||||
--tw-prose-bullets: var(--color-global-text);
|
||||
--tw-prose-code: var(--color-global-text);
|
||||
--tw-prose-headings: var(--color-accent-2);
|
||||
--tw-prose-hr: 0.5px dashed #666;
|
||||
--tw-prose-links: var(--color-global-text);
|
||||
--tw-prose-quotes: var(--color-quote);
|
||||
--tw-prose-th-borders: #666;
|
||||
}
|
||||
@@ -1,47 +0,0 @@
|
||||
pre[class*='language-'] {
|
||||
color: theme('colors.zinc.100');
|
||||
}
|
||||
|
||||
.token.tag,
|
||||
.token.class-name,
|
||||
.token.selector,
|
||||
.token.selector .class,
|
||||
.token.selector.class,
|
||||
.token.function {
|
||||
color: theme('colors.pink.400');
|
||||
}
|
||||
|
||||
.token.attr-name,
|
||||
.token.keyword,
|
||||
.token.rule,
|
||||
.token.pseudo-class,
|
||||
.token.important {
|
||||
color: theme('colors.zinc.300');
|
||||
}
|
||||
|
||||
.token.module {
|
||||
color: theme('colors.pink.400');
|
||||
}
|
||||
|
||||
.token.attr-value,
|
||||
.token.class,
|
||||
.token.string,
|
||||
.token.property {
|
||||
color: theme('colors.teal.300');
|
||||
}
|
||||
|
||||
.token.punctuation,
|
||||
.token.attr-equals {
|
||||
color: theme('colors.zinc.500');
|
||||
}
|
||||
|
||||
.token.unit,
|
||||
.language-css .token.function {
|
||||
color: theme('colors.sky.200');
|
||||
}
|
||||
|
||||
.token.comment,
|
||||
.token.operator,
|
||||
.token.combinator {
|
||||
color: theme('colors.zinc.400');
|
||||
}
|
||||
@@ -1,4 +0,0 @@
|
||||
@import 'tailwindcss/base';
|
||||
@import 'tailwindcss/components';
|
||||
@import './prism.css';
|
||||
@import 'tailwindcss/utilities';
|
||||
Reference in New Issue
Block a user