refactor(all): complete rewrite

This commit is contained in:
z0x 2025-02-04 23:37:40 -05:00
parent 9f928f4786
commit 757d21f0e8
67 changed files with 4053 additions and 974 deletions

View file

@ -1,26 +0,0 @@
---
import type { CollectionEntry } from "astro:content";
type Props = {
entry: CollectionEntry<"blog">;
};
const { entry } = Astro.props as {
entry: CollectionEntry<"blog">;
};
---
<a href={`/${entry.id}`} class="not-prose group relative flex flex-nowrap rounded-lg border border-black/15 px-4 py-3 pr-10 transition-colors duration-300 ease-in-out hover:bg-black/5 hover:text-black focus-visible:bg-black/5 focus-visible:text-black dark:border-white/20 dark:hover:bg-white/5 dark:hover:text-white dark:focus-visible:bg-white/5 dark:focus-visible:text-white">
<div class="flex flex-1 flex-col truncate">
<div class="font-semibold">
{entry.data.title}
</div>
<div class="text-sm">
{entry.data.description}
</div>
</div>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" class="absolute right-2 top-1/2 size-5 -translate-y-1/2 fill-none stroke-current stroke-2">
<line x1="5" y1="12" x2="19" y2="12" class="translate-x-3 scale-x-0 transition-transform duration-300 ease-in-out group-hover:translate-x-0 group-hover:scale-x-100 group-focus-visible:translate-x-0 group-focus-visible:scale-x-100"></line>
<polyline points="12 5 19 12 12 19" class="-translate-x-1 transition-transform duration-300 ease-in-out group-hover:translate-x-0 group-focus-visible:translate-x-0"></polyline>
</svg>
</a>

View file

@ -1,23 +0,0 @@
<button
id="back-to-top"
class="group relative flex w-fit flex-nowrap rounded border border-black/15 py-1.5 pl-8 pr-3 transition-colors duration-300 ease-in-out hover:bg-black/5 hover:text-black focus-visible:bg-black/5 focus-visible:text-black dark:border-white/20 dark:hover:bg-white/5 dark:hover:text-white dark:focus-visible:bg-white/5 dark:focus-visible:text-white"
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
class="absolute left-2 top-1/2 size-4 -translate-y-1/2 rotate-90 fill-none stroke-current stroke-2"
>
<line
x1="5"
y1="12"
x2="19"
y2="12"
class="translate-x-2 scale-x-0 transition-transform duration-300 ease-in-out group-hover:translate-x-0 group-hover:scale-x-100 group-focus-visible:translate-x-0 group-focus-visible:scale-x-100"
></line>
<polyline
points="12 5 5 12 12 19"
class="translate-x-1 transition-transform duration-300 ease-in-out group-hover:translate-x-0 group-focus-visible:translate-x-0"
></polyline>
</svg>
<div class="text-sm">Back to top</div>
</button>

View file

@ -0,0 +1,53 @@
---
import { Separator } from "@/components/ui/separator";
import { formatDate, readingTime } from "@/lib/utils";
import { Image } from "astro:assets";
import type { CollectionEntry } from "astro:content";
import Link from "./Link.astro";
type Props = {
entry: CollectionEntry<"blog">;
};
const { entry } = Astro.props as {
entry: CollectionEntry<"blog">;
};
const formattedDate = formatDate(entry.data.date);
const readTime = readingTime(entry.body!);
---
<div
class="not-prose rounded-xl border p-4 transition-colors duration-300 ease-in-out hover:bg-secondary/50"
>
<Link href={`/${entry.id}`} class="flex flex-col gap-4 sm:flex-row">
{
entry.data.image && (
<div class="max-w-[200px] sm:flex-shrink-0">
<Image
src={entry.data.image}
alt={entry.data.title}
width={1200}
height={630}
class="object-cover"
/>
</div>
)
}
<div class="flex-grow">
<h3 class="mb-1 text-lg font-semibold">
{entry.data.title}
</h3>
<p class="mb-2 text-sm text-muted-foreground">
{entry.data.description}
</p>
<div
class="mb-2 flex flex-wrap items-center gap-x-2 text-xs text-muted-foreground"
>
<span>{formattedDate}</span>
<Separator orientation="vertical" className="h-4" />
<span>{readTime}</span>
</div>
</div>
</Link>
</div>

View file

@ -0,0 +1,53 @@
---
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[]
class?: string
}
const { items, class: className } = Astro.props
---
<Breadcrumb className={className}>
<BreadcrumbList>
{
items.map((item, index) => (
<>
{index !== 0 && <BreadcrumbSeparator />}
<BreadcrumbItem>
{index === items.length - 1 ? (
<BreadcrumbPage>
<span class="flex items-center gap-x-2">
{item.icon && <Icon name={item.icon} class="size-4" />}
{item.label}
</span>
</BreadcrumbPage>
) : (
<BreadcrumbLink href={item.href}>
<span class="flex items-center gap-x-2">
{item.icon && <Icon name={item.icon} class="size-4" />}
{item.label}
</span>
</BreadcrumbLink>
)}
</BreadcrumbItem>
</>
))
}
</BreadcrumbList>
</Breadcrumb>

View file

@ -1,44 +0,0 @@
---
interface Props {
type: "default" | "info" | "warning" | "error";
}
const { type = "default" } = Astro.props;
let emoji = "💡";
if (type === "info") {
emoji = "";
} else if (type === "warning") {
emoji = "⚠️";
} else if (type === "error") {
emoji = "🚨";
}
---
<div class={`not-prose callout callout-${type}`}>
<span class="emoji pointer-events-none select-none">{emoji}</span>
<slot />
</div>
<style>
.callout {
@apply relative my-4 flex rounded border border-orange-800 bg-orange-100 p-3 text-orange-950 dark:border-orange-200/20 dark:bg-orange-950/20 dark:text-orange-200;
}
.emoji {
@apply pr-3 text-xl;
}
.callout-info {
@apply border-blue-800 bg-blue-100 text-blue-950 dark:border-blue-200/20 dark:bg-blue-950/20 dark:text-blue-200;
}
.callout-warning {
@apply border-yellow-800 bg-yellow-100 text-yellow-950 dark:border-yellow-200/20 dark:bg-yellow-950/20 dark:text-yellow-200;
}
.callout-error {
@apply border-red-800 bg-red-100 text-red-950 dark:border-red-200/20 dark:bg-red-950/20 dark:text-red-200;
}
</style>

View file

@ -1 +1,11 @@
<div class="mx-auto max-w-screen-sm px-3"><slot /></div>
---
import { cn } from '@/lib/utils'
interface Props {
class?: string
}
const { class: className } = Astro.props
---
<div class={cn('mx-auto max-w-screen-md px-4', className)}><slot /></div>

View file

@ -1,113 +1,24 @@
---
import BackToTop from "@components/BackToTop.astro";
import Container from "@components/Container.astro";
import { SITE } from "@consts";
import Container from '@/components/Container.astro'
import { Separator } from '@/components/ui/separator'
import SocialIcons from './SocialIcons.astro'
---
<footer>
<footer class="py-4">
<Container>
<div class="relative">
<div class="absolute -top-12 right-0">
<BackToTop />
</div>
</div>
<div class="flex items-center justify-between">
<div>&copy; {new Date().getFullYear()} • {SITE.TITLE}</div>
<div class="flex flex-wrap items-center gap-1.5">
<a
aria-label="RSS"
href="/rss.xml"
target="_blank"
class="group flex size-9 items-center justify-center rounded border border-black/15 hover:bg-black/5 focus-visible:bg-black/5 dark:border-white/20 dark:hover:bg-white/5 dark:focus-visible:bg-white/5"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="round"
stroke-linejoin="round"
class="transition-colors duration-300 ease-in-out group-hover:animate-pulse group-hover:stroke-black group-focus-visible:animate-pulse group-focus-visible:stroke-black group-hover:dark:stroke-white dark:group-focus-visible:stroke-white"
>
<path d="M5 19m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0"></path>
<path d="M4 4a16 16 0 0 1 16 16"></path>
<path d="M4 11a9 9 0 0 1 9 9"></path>
</svg>
</a>
<button
id="light-theme-button"
aria-label="Light theme"
class="group flex size-9 items-center justify-center rounded border border-black/15 hover:bg-black/5 focus-visible:bg-black/5 dark:border-white/20 dark:hover:bg-white/5 dark:focus-visible:bg-white/5"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="round"
stroke-linejoin="round"
class="transition-colors duration-300 ease-in-out group-hover:animate-pulse group-hover:stroke-black group-focus-visible:animate-pulse group-focus-visible:stroke-black group-hover:dark:stroke-white dark:group-focus-visible:stroke-white"
>
<circle cx="12" cy="12" r="5"></circle>
<line x1="12" y1="1" x2="12" y2="3"></line>
<line x1="12" y1="21" x2="12" y2="23"></line>
<line x1="4.22" y1="4.22" x2="5.64" y2="5.64"></line>
<line x1="18.36" y1="18.36" x2="19.78" y2="19.78"></line>
<line x1="1" y1="12" x2="3" y2="12"></line>
<line x1="21" y1="12" x2="23" y2="12"></line>
<line x1="4.22" y1="19.78" x2="5.64" y2="18.36"></line>
<line x1="18.36" y1="5.64" x2="19.78" y2="4.22"></line>
</svg>
</button>
<button
id="dark-theme-button"
aria-label="Dark theme"
class="group flex size-9 items-center justify-center rounded border border-black/15 hover:bg-black/5 focus-visible:bg-black/5 dark:border-white/20 dark:hover:bg-white/5 dark:focus-visible:bg-white/5"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="round"
stroke-linejoin="round"
class="transition-colors duration-300 ease-in-out group-hover:animate-pulse group-hover:stroke-black group-focus-visible:animate-pulse group-focus-visible:stroke-black group-hover:dark:stroke-white dark:group-focus-visible:stroke-white"
>
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"></path>
</svg>
</button>
<button
id="system-theme-button"
aria-label="System theme"
class="group flex size-9 items-center justify-center rounded border border-black/15 hover:bg-black/5 focus-visible:bg-black/5 dark:border-white/20 dark:hover:bg-white/5 dark:focus-visible:bg-white/5"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="round"
stroke-linejoin="round"
class="transition-colors duration-300 ease-in-out group-hover:animate-pulse group-hover:stroke-black group-focus-visible:animate-pulse group-focus-visible:stroke-black group-hover:dark:stroke-white dark:group-focus-visible:stroke-white"
>
<rect x="2" y="3" width="20" height="14" rx="2" ry="2"></rect>
<line x1="8" y1="21" x2="16" y2="21"></line>
<line x1="12" y1="17" x2="12" y2="21"></line>
</svg>
</button>
<div
class="flex flex-col items-center justify-center gap-y-2 sm:flex-row sm:justify-between"
>
<div class="flex items-center gap-x-2">
<span class="text-center text-sm text-muted-foreground">
&copy; {new Date().getFullYear()} • z0x
</span>
<Separator orientation="vertical" className="h-4" />
<p class="text-center text-sm text-muted-foreground">
All rights reserved.
</p>
</div>
<SocialIcons />
</div>
</Container>
</footer>

View file

@ -1,17 +0,0 @@
---
interface Props {
date: Date;
}
const { date } = Astro.props;
---
<time datetime={date.toISOString()}>
{
date.toLocaleDateString("en-SE", {
year: "numeric",
month: "2-digit",
day: "2-digit",
})
}
</time>

View file

@ -1,7 +1,9 @@
---
import "../styles/global.css";
import "../styles/callout.css";
import "@fontsource-variable/nunito";
import { SITE } from "@/consts";
import { ClientRouter } from "astro:transitions";
interface Props {
title: string;
@ -9,20 +11,35 @@ interface Props {
image?: string;
}
const { title, description, image = "/blog-placeholder-1.jpg" } = Astro.props;
const canonicalURL = new URL(Astro.url.pathname, Astro.site);
const { title, description, image = "/static/twitter-card.png" } = Astro.props;
---
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="generator" content={Astro.generator} />
<link rel="canonical" href={canonicalURL} />
<link rel="sitemap" href="/sitemap-index.xml" />
<link
rel="stylesheet"
href="https://cdn.jsdelivr.net/npm/katex@0.16.21/dist/katex.min.css"
integrity="sha384-zh0CIslj+VczCZtlzBcjt5ppRcsAmDnRem7ESsYwWwg3m/OaJ2l4x7YBZl9Kxxib"
crossorigin="anonymous"
/>
<title>{title}</title>
<meta name="title" content={title} />
<meta name="description" content={description} />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png" />
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png" />
<meta property="og:type" content="website" />
<meta property="og:url" content={Astro.url} />
<meta property="og:site_name" content={SITE.TITLE} />
<meta property="og:title" content={title} />
<meta property="og:description" content={description} />
<meta property="og:image" content={new URL(image, Astro.url)} />
@ -33,172 +50,39 @@ const { title, description, image = "/blog-placeholder-1.jpg" } = Astro.props;
<meta property="twitter:description" content={description} />
<meta property="twitter:image" content={new URL(image, Astro.url)} />
<ClientRouter />
<script is:inline>
function init() {
preloadTheme();
onScroll();
updateThemeButtons();
addCopyCodeButtons();
function setDarkMode(document) {
const getThemePreference = () => {
if (
typeof localStorage !== "undefined" &&
localStorage.getItem("theme")
) {
return localStorage.getItem("theme");
}
return window.matchMedia("(prefers-color-scheme: dark)").matches
? "dark"
: "theme-light";
};
const isDark = getThemePreference() === "dark";
document.documentElement.classList[isDark ? "add" : "remove"]("dark");
const backToTop = document.getElementById("back-to-top");
backToTop?.addEventListener("click", (event) => scrollToTop(event));
const backToPrev = document.getElementById("back-to-prev");
backToPrev?.addEventListener("click", () => window.history.back());
const lightThemeButton = document.getElementById("light-theme-button");
lightThemeButton?.addEventListener("click", () => {
localStorage.setItem("theme", "light");
toggleTheme(false);
updateThemeButtons();
});
const darkThemeButton = document.getElementById("dark-theme-button");
darkThemeButton?.addEventListener("click", () => {
localStorage.setItem("theme", "dark");
toggleTheme(true);
updateThemeButtons();
});
const systemThemeButton = document.getElementById("system-theme-button");
systemThemeButton?.addEventListener("click", () => {
localStorage.setItem("theme", "system");
toggleTheme(window.matchMedia("(prefers-color-scheme: dark)").matches);
updateThemeButtons();
});
window
.matchMedia("(prefers-color-scheme: dark)")
.addEventListener("change", (event) => {
if (localStorage.theme === "system") {
toggleTheme(event.matches);
}
if (typeof localStorage !== "undefined") {
const observer = new MutationObserver(() => {
const isDark = document.documentElement.classList.contains("dark");
localStorage.setItem("theme", isDark ? "dark" : "theme-light");
});
document.addEventListener("scroll", onScroll);
}
function updateThemeButtons() {
const theme = localStorage.getItem("theme");
const lightThemeButton = document.getElementById("light-theme-button");
const darkThemeButton = document.getElementById("dark-theme-button");
const systemThemeButton = document.getElementById("system-theme-button");
function removeActiveButtonTheme(button) {
button?.classList.remove("bg-black/5");
button?.classList.remove("dark:bg-white/5");
}
function addActiveButtonTheme(button) {
button?.classList.add("bg-black/5");
button?.classList.add("dark:bg-white/5");
}
removeActiveButtonTheme(lightThemeButton);
removeActiveButtonTheme(darkThemeButton);
removeActiveButtonTheme(systemThemeButton);
if (theme === "light") {
addActiveButtonTheme(lightThemeButton);
} else if (theme === "dark") {
addActiveButtonTheme(darkThemeButton);
} else {
addActiveButtonTheme(systemThemeButton);
}
}
function onScroll() {
if (window.scrollY > 0) {
document.documentElement.classList.add("scrolled");
} else {
document.documentElement.classList.remove("scrolled");
}
}
function scrollToTop(event) {
event.preventDefault();
window.scrollTo({
top: 0,
behavior: "smooth",
});
}
function toggleTheme(dark) {
const css = document.createElement("style");
css.appendChild(
document.createTextNode(
`* {
-webkit-transition: none !important;
-moz-transition: none !important;
-o-transition: none !important;
-ms-transition: none !important;
transition: none !important;
}
`,
),
);
document.head.appendChild(css);
if (dark) {
document.documentElement.classList.add("dark");
} else {
document.documentElement.classList.remove("dark");
}
window.getComputedStyle(css).opacity;
document.head.removeChild(css);
}
function preloadTheme() {
const userTheme = localStorage.theme;
if (userTheme === "light" || userTheme === "dark") {
toggleTheme(userTheme === "dark");
} else {
toggleTheme(window.matchMedia("(prefers-color-scheme: dark)").matches);
}
}
function addCopyCodeButtons() {
let copyButtonLabel = "📋";
let codeBlocks = Array.from(document.querySelectorAll("pre"));
async function copyCode(codeBlock, copyButton) {
const codeText = codeBlock.innerText;
const buttonText = copyButton.innerText;
const textToCopy = codeText.replace(buttonText, "");
await navigator.clipboard.writeText(textToCopy);
copyButton.innerText = "✅";
setTimeout(() => {
copyButton.innerText = copyButtonLabel;
}, 2000);
}
for (let codeBlock of codeBlocks) {
const wrapper = document.createElement("div");
wrapper.style.position = "relative";
const copyButton = document.createElement("button");
copyButton.innerText = copyButtonLabel;
copyButton.classList = "copy-code";
codeBlock.setAttribute("tabindex", "0");
codeBlock.appendChild(copyButton);
codeBlock.parentNode.insertBefore(wrapper, codeBlock);
wrapper.appendChild(codeBlock);
copyButton?.addEventListener("click", async () => {
await copyCode(codeBlock, copyButton);
observer.observe(document.documentElement, {
attributes: true,
attributeFilter: ["class"],
});
}
}
document.addEventListener("DOMContentLoaded", () => init());
document.addEventListener("astro:after-swap", () => init());
preloadTheme();
setDarkMode(document);
document.addEventListener("astro:before-swap", (ev) => {
setDarkMode(ev.newDocument);
});
</script>

View file

@ -0,0 +1,25 @@
---
import Container from "@/components/Container.astro";
import Link from "@/components/Link.astro";
import { ModeToggle } from "@/components/ui/mode-toggle";
import { SITE } from "@/consts";
---
<header
class="sticky top-0 z-10 bg-background/50 backdrop-blur-md"
transition:persist
>
<Container>
<div class="flex flex-wrap items-center justify-between gap-4 py-4">
<Link
href="https://z0x.ca"
class="flex flex-shrink-0 items-center gap-2 text-xl font-semibold transition-colors duration-300 hover:text-primary"
>
{SITE.TITLE}
</Link>
<div class="flex items-center gap-2 md:gap-4">
<ModeToggle client:load transition:persist />
</div>
</div>
</Container>
</header>

View file

@ -1,29 +1,25 @@
---
import { cn } from "@lib/utils";
import { cn } from '@/lib/utils'
type Props = {
href: string;
external?: boolean;
underline?: boolean;
group?: boolean;
};
href: string
external?: boolean
class?: string
underline?: boolean
[key: string]: any
}
const {
href,
external,
underline = true,
group = false,
...rest
} = Astro.props;
const { href, external, class: className, underline, ...rest } = Astro.props
---
<a
href={href}
target={external ? "_blank" : "_self"}
target={external ? '_blank' : '_self'}
class={cn(
"inline-block decoration-black/30 dark:decoration-white/30 hover:decoration-black/50 focus-visible:decoration-black/50 dark:hover:decoration-white/50 dark:focus-visible:decoration-white/50 text-current hover:text-black focus-visible:text-black dark:hover:text-white dark:focus-visible:text-white transition-colors duration-300 ease-in-out",
underline && "underline underline-offset-[3px]",
group && "group"
'inline-block transition-colors duration-300 ease-in-out',
underline &&
'underline decoration-muted-foreground underline-offset-[3px] hover:decoration-foreground',
className,
)}
{...rest}
>

View file

@ -1,33 +1,56 @@
---
const { prevPost, nextPost } = Astro.props;
import Link from '@/components/Link.astro'
import { buttonVariants } from '@/components/ui/button'
import { cn } from '@/lib/utils'
import { Icon } from 'astro-icon/components'
const { prevPost, nextPost } = Astro.props
---
<div class="grid grid-cols-2 gap-1.5 sm:gap-3">
{
prevPost?.id ? (
<a href={`/${prevPost?.id}`} class="group relative flex flex-nowrap rounded-lg border border-black/15 px-4 py-3 pl-10 no-underline transition-colors duration-300 ease-in-out hover:bg-black/5 hover:text-black focus-visible:bg-black/5 focus-visible:text-black dark:border-white/20 dark:hover:bg-white/5 dark:hover:text-white dark:focus-visible:bg-white/5 dark:focus-visible:text-white">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" class="absolute left-2 top-1/2 size-5 -translate-y-1/2 fill-none stroke-current stroke-2">
<line x1="5" y1="12" x2="19" y2="12" class="translate-x-3 scale-x-0 transition-transform duration-300 ease-in-out group-hover:translate-x-0 group-hover:scale-x-100 group-focus-visible:translate-x-0 group-focus-visible:scale-x-100" />
<polyline points="12 5 5 12 12 19" class="translate-x-1 transition-transform duration-300 ease-in-out group-hover:translate-x-0 group-focus-visible:translate-x-0" />
</svg>
<div class="flex items-center text-sm">{prevPost?.data.title}</div>
</a>
) : (
<div class="invisible" />
)
}
{
nextPost?.id ? (
<a href={`/${nextPost?.id}`} class="group relative flex flex-grow flex-row-reverse flex-nowrap rounded-lg border border-black/15 px-4 py-4 pr-10 no-underline transition-colors duration-300 ease-in-out hover:bg-black/5 hover:text-black focus-visible:bg-black/5 focus-visible:text-black dark:border-white/20 dark:hover:bg-white/5 dark:hover:text-white dark:focus-visible:bg-white/5 dark:focus-visible:text-white">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" class="absolute right-2 top-1/2 size-5 -translate-y-1/2 fill-none stroke-current stroke-2">
<line x1="5" y1="12" x2="19" y2="12" class="translate-x-3 scale-x-0 transition-transform duration-300 ease-in-out group-hover:translate-x-0 group-hover:scale-x-100 group-focus-visible:translate-x-0 group-focus-visible:scale-x-100" />
<polyline points="12 5 19 12 12 19" class="-translate-x-1 transition-transform duration-300 ease-in-out group-hover:translate-x-0 group-focus-visible:translate-x-0" />
</svg>
<div class="flex items-center text-sm">{nextPost?.data.title}</div>
</a>
) : (
<div class="invisible" />
)
}
<div class="col-start-2 flex flex-col gap-4 sm:flex-row">
<Link
href={nextPost ? `/${nextPost.id}` : '#'}
class={cn(
buttonVariants({ variant: 'outline' }),
'rounded-xl group flex items-center justify-start w-full sm:w-1/2 h-fit',
!nextPost && 'pointer-events-none opacity-50 cursor-not-allowed',
)}
aria-disabled={!nextPost}
>
<div class="mr-2 flex-shrink-0">
<Icon
name="lucide:arrow-left"
class="size-4 transition-transform group-hover:-translate-x-1"
/>
</div>
<div class="flex flex-col items-start overflow-hidden">
<span class="text-left text-xs text-muted-foreground">Next Post</span>
<span class="w-full truncate text-left text-sm"
>{nextPost?.data.title || 'Latest post!'}</span
>
</div>
</Link>
<Link
href={prevPost ? `/${prevPost.id}` : '#'}
class={cn(
buttonVariants({ variant: 'outline' }),
'rounded-xl group flex items-center justify-end w-full sm:w-1/2 h-fit',
!prevPost && 'pointer-events-none opacity-50 cursor-not-allowed',
)}
aria-disabled={!prevPost}
>
<div class="flex flex-col items-end overflow-hidden">
<span class="text-right text-xs text-muted-foreground">Previous Post</span
>
<span class="w-full truncate text-right text-sm"
>{prevPost?.data.title || 'Last post!'}</span
>
</div>
<div class="ml-2 flex-shrink-0">
<Icon
name="lucide:arrow-right"
class="size-4 transition-transform group-hover:translate-x-1"
/>
</div>
</Link>
</div>

View file

@ -0,0 +1,19 @@
---
import Link from '@/components/Link.astro'
import { buttonVariants } from '@/components/ui/button'
import { Icon } from 'astro-icon/components'
---
<ul class="not-prose flex flex-wrap gap-2" role="list">
<li>
<Link
href="/rss.xml"
aria-label="RSS"
title="RSS"
class={buttonVariants({ variant: 'outline', size: 'icon' })}
external
>
<Icon name="lucide:rss" class="size-4" />
</Link>
</li>
</ul>

View file

@ -1,50 +1,117 @@
---
import TableOfContentsHeading from "./TableOfContentsHeading.astro";
const { headings } = Astro.props;
const toc = buildToc(headings);
import { ScrollArea } from '@/components/ui/scroll-area'
import { Icon } from 'astro-icon/components'
import TableOfContentsHeading from './TableOfContentsHeading.astro'
export interface Heading {
depth: number;
slug: string;
text: string;
depth: number
slug: string
text: string
subheadings: Heading[]
}
function buildToc(headings: Heading[]) {
const toc: Heading[] = [];
const parentHeadings = new Map();
const { headings } = Astro.props
const toc = buildToc(headings)
function buildToc(headings: Heading[]): Heading[] {
const toc: Heading[] = []
const stack: Heading[] = []
headings.forEach((h) => {
const heading = { ...h, subheadings: [] };
parentHeadings.set(heading.depth, heading);
if (heading.depth === 2) {
toc.push(heading);
} else {
parentHeadings.get(heading.depth - 1).subheadings.push(heading);
const heading = { ...h, subheadings: [] }
while (stack.length > 0 && stack[stack.length - 1].depth >= heading.depth) {
stack.pop()
}
});
return toc;
if (stack.length === 0) {
toc.push(heading)
} else {
stack[stack.length - 1].subheadings.push(heading)
}
stack.push(heading)
})
return toc
}
---
<details open class="rounded-lg border border-black/15 dark:border-white/20">
<summary>Table of Contents</summary>
<nav class="">
<ul class="py-3">
<details
open
class="group col-start-2 mx-4 block rounded-xl border p-4 xl:hidden"
>
<summary
class="flex cursor-pointer items-center justify-between text-xl font-semibold group-open:pb-4"
>
Table of Contents
<Icon
name="lucide:chevron-down"
class="size-5 transition-transform group-open:rotate-180"
/>
</summary>
<ScrollArea
client:load
className="flex max-h-64 flex-col overflow-y-auto"
type="always"
>
<nav>
<ul></ul>
{toc.map((heading) => <TableOfContentsHeading heading={heading} />)}
</ul>
</nav>
</nav>
</ScrollArea>
</details>
<style>
summary {
@apply cursor-pointer rounded-t-lg px-3 py-1.5 font-medium transition-colors;
<nav
class="sticky top-[5.5rem] col-start-1 hidden h-[calc(100vh-5.5rem)] text-xs leading-4 xl:block"
>
<div class="flex justify-end">
<ScrollArea client:load className="max-h-[calc(100vh-8rem)]" type="always">
<ul
class="flex flex-col justify-end gap-y-2 overflow-y-auto px-8"
id="toc-container"
>
<li>
<h2 class="mb-2 text-lg font-semibold">Table of Contents</h2>
</li>
{toc.map((heading) => <TableOfContentsHeading heading={heading} />)}
</ul>
</ScrollArea>
</div>
</nav>
<script>
function setupToc() {
const header = document.querySelector('header')
const headerHeight = header ? header.offsetHeight : 0
const observer = new IntersectionObserver(
(sections) => {
sections.forEach((section) => {
const heading = section.target.querySelector('h2, h3, h4, h5, h6')
if (!heading) return
const id = heading.getAttribute('id')
const link = document.querySelector(
`#toc-container li a[href="#${id}"]`,
)
if (!link) return
const addRemove = section.isIntersecting ? 'add' : 'remove'
link.classList[addRemove]('text-foreground')
})
},
{
rootMargin: `-${headerHeight}px 0px 0px 0px`,
},
)
const sections = document.querySelectorAll('.prose section')
sections.forEach((section) => {
observer.observe(section)
})
}
summary:hover {
@apply bg-black/5 dark:bg-white/5;
}
details[open] summary {
@apply bg-black/5 dark:bg-white/5;
}
</style>
document.addEventListener('astro:page-load', setupToc)
document.addEventListener('astro:after-swap', setupToc)
</script>

View file

@ -1,17 +1,22 @@
---
import type { Heading } from "./TableOfContents.astro";
import Link from "./Link.astro";
import Link from './Link.astro'
import type { Heading } from './TableOfContents.astro'
const { heading } = Astro.props;
const { heading } = Astro.props
---
<li class="list-inside list-disc px-6 py-1.5 text-sm">
<Link href={"#" + heading.slug} underline>
<li
class="list-inside list-disc px-4 text-sm text-foreground/60 xl:list-none xl:p-0"
>
<Link
href={'#' + heading.slug}
class="underline decoration-transparent underline-offset-[3px] transition-colors duration-200 hover:decoration-inherit py-1 xl:py-0"
>
{heading.text}
</Link>
{
heading.subheadings.length > 0 && (
<ul class="translate-x-3">
<ul class="translate-x-3 xl:ml-4 xl:mt-2 xl:flex xl:translate-x-0 xl:flex-col xl:gap-2">
{heading.subheadings.map((subheading: Heading) => (
<Astro.self heading={subheading} />
))}

View file

@ -0,0 +1,114 @@
import { cn } from '@/lib/utils'
import { ChevronRightIcon, DotsHorizontalIcon } from '@radix-ui/react-icons'
import { Slot } from '@radix-ui/react-slot'
import * as React from 'react'
const Breadcrumb = React.forwardRef<
HTMLElement,
React.ComponentPropsWithoutRef<'nav'> & {
separator?: React.ReactNode
}
>(({ ...props }, ref) => <nav ref={ref} aria-label="breadcrumb" {...props} />)
Breadcrumb.displayName = 'Breadcrumb'
const BreadcrumbList = React.forwardRef<
HTMLOListElement,
React.ComponentPropsWithoutRef<'ol'>
>(({ className, ...props }, ref) => (
<ol
ref={ref}
className={cn(
'flex flex-wrap items-center gap-1.5 break-words text-sm text-muted-foreground sm:gap-2.5',
className,
)}
{...props}
/>
))
BreadcrumbList.displayName = 'BreadcrumbList'
const BreadcrumbItem = React.forwardRef<
HTMLLIElement,
React.ComponentPropsWithoutRef<'li'>
>(({ className, ...props }, ref) => (
<li
ref={ref}
className={cn('inline-flex items-center gap-1.5', className)}
{...props}
/>
))
BreadcrumbItem.displayName = 'BreadcrumbItem'
const BreadcrumbLink = React.forwardRef<
HTMLAnchorElement,
React.ComponentPropsWithoutRef<'a'> & {
asChild?: boolean
}
>(({ asChild, className, ...props }, ref) => {
const Comp = asChild ? Slot : 'a'
return (
<Comp
ref={ref}
className={cn('transition-colors hover:text-foreground', className)}
{...props}
/>
)
})
BreadcrumbLink.displayName = 'BreadcrumbLink'
const BreadcrumbPage = React.forwardRef<
HTMLSpanElement,
React.ComponentPropsWithoutRef<'span'>
>(({ className, ...props }, ref) => (
<span
ref={ref}
role="link"
aria-disabled="true"
aria-current="page"
className={cn('font-normal text-foreground', className)}
{...props}
/>
))
BreadcrumbPage.displayName = 'BreadcrumbPage'
const BreadcrumbSeparator = ({
children,
className,
...props
}: React.ComponentProps<'li'>) => (
<li
role="presentation"
aria-hidden="true"
className={cn('[&>svg]:size-3.5', className)}
{...props}
>
{children ?? <ChevronRightIcon />}
</li>
)
BreadcrumbSeparator.displayName = 'BreadcrumbSeparator'
const BreadcrumbEllipsis = ({
className,
...props
}: React.ComponentProps<'span'>) => (
<span
role="presentation"
aria-hidden="true"
className={cn('flex h-9 w-9 items-center justify-center', className)}
{...props}
>
<DotsHorizontalIcon className="size-4" />
<span className="sr-only">More</span>
</span>
)
BreadcrumbEllipsis.displayName = 'BreadcrumbElipssis'
export {
Breadcrumb,
BreadcrumbEllipsis,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbList,
BreadcrumbPage,
BreadcrumbSeparator,
}

View file

@ -0,0 +1,54 @@
import { cn } from '@/lib/utils'
import { Slot } from '@radix-ui/react-slot'
import { type VariantProps, cva } from 'class-variance-authority'
import * as React from 'react'
const buttonVariants = cva(
'inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50',
{
variants: {
variant: {
default: 'bg-primary text-primary-foreground hover:bg-secondary/50',
destructive:
'bg-destructive text-destructive-foreground over:bg-destructive/50',
outline: 'border border-input bg-background hover:bg-secondary/50',
secondary:
'bg-secondary text-secondary-foreground hover:bg-secondary/80',
ghost: 'hover:bg-accent hover:text-accent-foreground',
link: 'text-primary underline-offset-4 hover:underline',
},
size: {
default: 'h-9 px-4 py-2',
sm: 'h-8 rounded-md px-3 text-xs',
lg: 'h-10 rounded-md px-8',
icon: 'h-9 w-9',
},
},
defaultVariants: {
variant: 'default',
size: 'default',
},
},
)
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : 'button'
return (
<Comp
className={cn(buttonVariants({ variant, size, className }))}
ref={ref}
{...props}
/>
)
},
)
Button.displayName = 'Button'
export { Button, buttonVariants }

View file

@ -0,0 +1,202 @@
import { cn } from '@/lib/utils'
import * as DropdownMenuPrimitive from '@radix-ui/react-dropdown-menu'
import {
CheckIcon,
ChevronRightIcon,
DotFilledIcon,
} from '@radix-ui/react-icons'
import * as React from 'react'
const DropdownMenu = DropdownMenuPrimitive.Root
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger
const DropdownMenuGroup = DropdownMenuPrimitive.Group
const DropdownMenuPortal = DropdownMenuPrimitive.Portal
const DropdownMenuSub = DropdownMenuPrimitive.Sub
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup
const DropdownMenuSubTrigger = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & {
inset?: boolean
}
>(({ className, inset, children, ...props }, ref) => (
<DropdownMenuPrimitive.SubTrigger
ref={ref}
className={cn(
'flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent data-[state=open]:bg-accent',
inset && 'pl-8',
className,
)}
{...props}
>
{children}
<ChevronRightIcon className="ml-auto size-4" />
</DropdownMenuPrimitive.SubTrigger>
))
DropdownMenuSubTrigger.displayName =
DropdownMenuPrimitive.SubTrigger.displayName
const DropdownMenuSubContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent>
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.SubContent
ref={ref}
className={cn(
'bg-popover text-popover-foreground z-50 min-w-[8rem] overflow-hidden rounded-md border p-1 shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
className,
)}
{...props}
/>
))
DropdownMenuSubContent.displayName =
DropdownMenuPrimitive.SubContent.displayName
const DropdownMenuContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
>(({ className, sideOffset = 4, ...props }, ref) => (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
ref={ref}
sideOffset={sideOffset}
className={cn(
'bg-popover text-popover-foreground z-50 min-w-[8rem] overflow-hidden rounded-md border p-1 shadow-md',
'data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
className,
)}
{...props}
/>
</DropdownMenuPrimitive.Portal>
))
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName
const DropdownMenuItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {
inset?: boolean
}
>(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Item
ref={ref}
className={cn(
'relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
inset && 'pl-8',
className,
)}
{...props}
/>
))
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName
const DropdownMenuCheckboxItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.CheckboxItem>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.CheckboxItem>
>(({ className, children, checked, ...props }, ref) => (
<DropdownMenuPrimitive.CheckboxItem
ref={ref}
className={cn(
'relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
className,
)}
checked={checked}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<CheckIcon className="size-4" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.CheckboxItem>
))
DropdownMenuCheckboxItem.displayName =
DropdownMenuPrimitive.CheckboxItem.displayName
const DropdownMenuRadioItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.RadioItem>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.RadioItem>
>(({ className, children, ...props }, ref) => (
<DropdownMenuPrimitive.RadioItem
ref={ref}
className={cn(
'relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
className,
)}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<DotFilledIcon className="size-4 fill-current" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.RadioItem>
))
DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName
const DropdownMenuLabel = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & {
inset?: boolean
}
>(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Label
ref={ref}
className={cn(
'px-2 py-1.5 text-sm font-semibold',
inset && 'pl-8',
className,
)}
{...props}
/>
))
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName
const DropdownMenuSeparator = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator>
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.Separator
ref={ref}
className={cn('-mx-1 my-1 h-px bg-muted', className)}
{...props}
/>
))
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName
const DropdownMenuShortcut = ({
className,
...props
}: React.HTMLAttributes<HTMLSpanElement>) => {
return (
<span
className={cn('ml-auto text-xs tracking-widest opacity-60', className)}
{...props}
/>
)
}
DropdownMenuShortcut.displayName = 'DropdownMenuShortcut'
export {
DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuPortal,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuTrigger,
}

View file

@ -0,0 +1,70 @@
import { Button } from '@/components/ui/button'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import { Laptop, Moon, Sun } from 'lucide-react'
import * as React from 'react'
export function ModeToggle() {
const [theme, setThemeState] = React.useState<
'theme-light' | 'dark' | 'system'
>('theme-light')
React.useEffect(() => {
const isDarkMode = document.documentElement.classList.contains('dark')
setThemeState(isDarkMode ? 'dark' : 'theme-light')
}, [])
React.useEffect(() => {
const isDark =
theme === 'dark' ||
(theme === 'system' &&
window.matchMedia('(prefers-color-scheme: dark)').matches)
document.documentElement.classList.add('disable-transitions')
document.documentElement.classList[isDark ? 'add' : 'remove']('dark')
window
.getComputedStyle(document.documentElement)
.getPropertyValue('opacity')
requestAnimationFrame(() => {
document.documentElement.classList.remove('disable-transitions')
})
}, [theme])
return (
<DropdownMenu modal={false}>
<DropdownMenuTrigger asChild>
<Button
variant="outline"
size="icon"
className="group"
title="Toggle theme"
>
<Sun className="size-4 rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0" />
<Moon className="absolute size-4 rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100" />
<span className="sr-only">Toggle theme</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="bg-background">
<DropdownMenuItem onClick={() => setThemeState('theme-light')}>
<Sun className="mr-2 size-4" />
<span>Light</span>
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setThemeState('dark')}>
<Moon className="mr-2 size-4" />
<span>Dark</span>
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setThemeState('system')}>
<Laptop className="mr-2 size-4" />
<span>System</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)
}

View file

@ -0,0 +1,174 @@
import * as React from 'react'
import { ChevronLeft, ChevronRight, MoreHorizontal } from 'lucide-react'
import { cn } from '@/lib/utils'
import { type ButtonProps, buttonVariants } from '@/components/ui/button'
const Pagination = ({ className, ...props }: React.ComponentProps<'nav'>) => (
<nav
role="navigation"
aria-label="pagination"
className={cn('mx-auto flex w-full justify-center', className)}
{...props}
/>
)
Pagination.displayName = 'Pagination'
const PaginationContent = React.forwardRef<
HTMLUListElement,
React.ComponentProps<'ul'>
>(({ className, ...props }, ref) => (
<ul
ref={ref}
className={cn('flex flex-row items-center gap-1', className)}
{...props}
/>
))
PaginationContent.displayName = 'PaginationContent'
const PaginationItem = React.forwardRef<
HTMLLIElement,
React.ComponentProps<'li'>
>(({ className, ...props }, ref) => (
<li ref={ref} className={cn('', className)} {...props} />
))
PaginationItem.displayName = 'PaginationItem'
type PaginationLinkProps = {
isActive?: boolean
isDisabled?: boolean
} & Pick<ButtonProps, 'size'> &
React.ComponentProps<'a'>
const PaginationLink = ({
className,
isActive,
isDisabled,
size = 'icon',
...props
}: PaginationLinkProps) => (
<a
aria-current={isActive ? 'page' : undefined}
className={cn(
buttonVariants({
variant: isActive ? 'outline' : 'ghost',
size,
}),
isDisabled && 'pointer-events-none opacity-50',
className,
)}
{...props}
/>
)
PaginationLink.displayName = 'PaginationLink'
const PaginationPrevious = ({
className,
isDisabled,
...props
}: React.ComponentProps<typeof PaginationLink>) => (
<PaginationLink
aria-label="Go to previous page"
size="default"
className={cn('gap-1 pl-2.5', className)}
isDisabled={isDisabled}
{...props}
>
<ChevronLeft className="h-4 w-4" />
<span>Previous</span>
</PaginationLink>
)
PaginationPrevious.displayName = 'PaginationPrevious'
const PaginationNext = ({
className,
isDisabled,
...props
}: React.ComponentProps<typeof PaginationLink>) => (
<PaginationLink
aria-label="Go to next page"
size="default"
className={cn('gap-1 pr-2.5', className)}
isDisabled={isDisabled}
{...props}
>
<span>Next</span>
<ChevronRight className="h-4 w-4" />
</PaginationLink>
)
PaginationNext.displayName = 'PaginationNext'
const PaginationEllipsis = ({
className,
...props
}: React.ComponentProps<'span'>) => (
<span
aria-hidden
className={cn('flex h-9 w-9 items-center justify-center', className)}
{...props}
>
<MoreHorizontal className="h-4 w-4" />
<span className="sr-only">More pages</span>
</span>
)
PaginationEllipsis.displayName = 'PaginationEllipsis'
interface PaginationProps {
currentPage: number
totalPages: number
baseUrl: string
}
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>
)
}
export default PaginationComponent

View file

@ -0,0 +1,46 @@
import * as React from 'react'
import * as ScrollAreaPrimitive from '@radix-ui/react-scroll-area'
import { cn } from '@/lib/utils'
const ScrollArea = React.forwardRef<
React.ElementRef<typeof ScrollAreaPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.Root>
>(({ className, children, ...props }, ref) => (
<ScrollAreaPrimitive.Root
ref={ref}
className={cn('relative overflow-hidden', className)}
{...props}
>
<ScrollAreaPrimitive.Viewport className="h-full w-full rounded-[inherit]">
{children}
</ScrollAreaPrimitive.Viewport>
<ScrollBar />
<ScrollAreaPrimitive.Corner />
</ScrollAreaPrimitive.Root>
))
ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName
const ScrollBar = React.forwardRef<
React.ElementRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>,
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>
>(({ className, orientation = 'vertical', ...props }, ref) => (
<ScrollAreaPrimitive.ScrollAreaScrollbar
ref={ref}
orientation={orientation}
className={cn(
'flex touch-none select-none transition-colors',
orientation === 'vertical' &&
'h-full w-2.5 border-l border-l-transparent p-[1px]',
orientation === 'horizontal' &&
'h-2.5 flex-col border-t border-t-transparent p-[1px]',
className,
)}
{...props}
>
<ScrollAreaPrimitive.ScrollAreaThumb className="relative flex-1 rounded-full bg-border" />
</ScrollAreaPrimitive.ScrollAreaScrollbar>
))
ScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName
export { ScrollArea, ScrollBar }

View file

@ -0,0 +1,28 @@
import { cn } from '@/lib/utils'
import * as SeparatorPrimitive from '@radix-ui/react-separator'
import * as React from 'react'
const Separator = React.forwardRef<
React.ElementRef<typeof SeparatorPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof SeparatorPrimitive.Root>
>(
(
{ className, orientation = 'horizontal', decorative = true, ...props },
ref,
) => (
<SeparatorPrimitive.Root
ref={ref}
decorative={decorative}
orientation={orientation}
className={cn(
'shrink-0 bg-border',
orientation === 'horizontal' ? 'h-[1px] w-full' : 'h-full w-[1px]',
className,
)}
{...props}
/>
),
)
Separator.displayName = SeparatorPrimitive.Root.displayName
export { Separator }