diff options
Diffstat (limited to 'src/components')
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"> + © {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 } |
