feat(all): first init
This commit is contained in:
commit
67c6e47f58
34 changed files with 2155 additions and 0 deletions
26
src/components/ArrowCard.astro
Normal file
26
src/components/ArrowCard.astro
Normal file
|
@ -0,0 +1,26 @@
|
|||
---
|
||||
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>
|
27
src/components/BackToTop.astro
Normal file
27
src/components/BackToTop.astro
Normal file
|
@ -0,0 +1,27 @@
|
|||
---
|
||||
|
||||
---
|
||||
|
||||
<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>
|
44
src/components/Callout.astro
Normal file
44
src/components/Callout.astro
Normal file
|
@ -0,0 +1,44 @@
|
|||
---
|
||||
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>
|
5
src/components/Container.astro
Normal file
5
src/components/Container.astro
Normal file
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
|
||||
---
|
||||
|
||||
<div class="mx-auto max-w-screen-sm px-3"><slot /></div>
|
52
src/components/Footer.astro
Normal file
52
src/components/Footer.astro
Normal file
|
@ -0,0 +1,52 @@
|
|||
---
|
||||
import Container from "@components/Container.astro";
|
||||
import BackToTop from "@components/BackToTop.astro";
|
||||
import { SITE } from "@consts";
|
||||
---
|
||||
|
||||
<footer class="animate">
|
||||
<Container>
|
||||
<div class="relative">
|
||||
<div class="absolute -top-12 right-0">
|
||||
<BackToTop />
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<div>© {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>
|
||||
</div>
|
||||
</Container>
|
||||
</footer>
|
17
src/components/FormattedDate.astro
Normal file
17
src/components/FormattedDate.astro
Normal file
|
@ -0,0 +1,17 @@
|
|||
---
|
||||
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>
|
251
src/components/Head.astro
Normal file
251
src/components/Head.astro
Normal file
|
@ -0,0 +1,251 @@
|
|||
---
|
||||
import "../styles/global.css";
|
||||
import "../styles/callout.css";
|
||||
|
||||
import { ClientRouter } from "astro:transitions";
|
||||
|
||||
import "@fontsource/geist-sans/100.css";
|
||||
import "@fontsource/geist-sans/200.css";
|
||||
import "@fontsource/geist-sans/300.css";
|
||||
import "@fontsource/geist-sans/400.css";
|
||||
import "@fontsource/geist-sans/500.css";
|
||||
import "@fontsource/geist-sans/600.css";
|
||||
import "@fontsource/geist-sans/700.css";
|
||||
import "@fontsource/geist-sans/800.css";
|
||||
import "@fontsource/geist-sans/900.css";
|
||||
import "@fontsource/geist-mono/100.css";
|
||||
import "@fontsource/geist-mono/200.css";
|
||||
import "@fontsource/geist-mono/300.css";
|
||||
import "@fontsource/geist-mono/400.css";
|
||||
import "@fontsource/geist-mono/500.css";
|
||||
import "@fontsource/geist-mono/600.css";
|
||||
import "@fontsource/geist-mono/700.css";
|
||||
import "@fontsource/geist-mono/800.css";
|
||||
import "@fontsource/geist-mono/900.css";
|
||||
|
||||
interface Props {
|
||||
title: string;
|
||||
description: string;
|
||||
image?: string;
|
||||
}
|
||||
|
||||
const { title, description, image = "/blog-placeholder-1.jpg" } = 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" />
|
||||
<link rel="icon" href="data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 100 100%22><text y=%22.9em%22 font-size=%2290%22></text></svg>" />
|
||||
<meta name="generator" content={Astro.generator} />
|
||||
|
||||
<title>{title}</title>
|
||||
<meta name="title" content={title} />
|
||||
<meta name="description" content={description} />
|
||||
|
||||
<meta property="og:type" content="website" />
|
||||
<meta property="og:url" content={Astro.url} />
|
||||
<meta property="og:title" content={title} />
|
||||
<meta property="og:description" content={description} />
|
||||
<meta property="og:image" content={new URL(image, Astro.url)} />
|
||||
|
||||
<meta property="twitter:card" content="summary_large_image" />
|
||||
<meta property="twitter:url" content={Astro.url} />
|
||||
<meta property="twitter:title" content={title} />
|
||||
<meta property="twitter:description" content={description} />
|
||||
<meta property="twitter:image" content={new URL(image, Astro.url)} />
|
||||
|
||||
<ClientRouter />
|
||||
|
||||
<script is:inline>
|
||||
function init() {
|
||||
preloadTheme();
|
||||
onScroll();
|
||||
animate();
|
||||
updateThemeButtons();
|
||||
addCopyCodeButtons();
|
||||
setGiscusTheme();
|
||||
|
||||
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);
|
||||
}
|
||||
});
|
||||
|
||||
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 animate() {
|
||||
const animateElements = document.querySelectorAll(".animate");
|
||||
|
||||
animateElements.forEach((element, index) => {
|
||||
setTimeout(() => {
|
||||
element.classList.add("show");
|
||||
}, index * 100);
|
||||
});
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
setGiscusTheme();
|
||||
}
|
||||
|
||||
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);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const setGiscusTheme = () => {
|
||||
const giscus = document.querySelector(".giscus-frame");
|
||||
|
||||
const isDark = document.documentElement.classList.contains("dark");
|
||||
|
||||
if (giscus) {
|
||||
const url = new URL(giscus.src);
|
||||
url.searchParams.set("theme", isDark ? "dark" : "light");
|
||||
giscus.src = url.toString();
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener("DOMContentLoaded", () => init());
|
||||
document.addEventListener("astro:after-swap", () => init());
|
||||
preloadTheme();
|
||||
</script>
|
31
src/components/Link.astro
Normal file
31
src/components/Link.astro
Normal file
|
@ -0,0 +1,31 @@
|
|||
---
|
||||
import { cn } from "@lib/utils";
|
||||
|
||||
type Props = {
|
||||
href: string;
|
||||
external?: boolean;
|
||||
underline?: boolean;
|
||||
group?: boolean;
|
||||
};
|
||||
|
||||
const {
|
||||
href,
|
||||
external,
|
||||
underline = true,
|
||||
group = false,
|
||||
...rest
|
||||
} = Astro.props;
|
||||
---
|
||||
|
||||
<a
|
||||
href={href}
|
||||
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"
|
||||
)}
|
||||
{...rest}
|
||||
>
|
||||
<slot />
|
||||
</a>
|
33
src/components/PostNavigation.astro
Normal file
33
src/components/PostNavigation.astro
Normal file
|
@ -0,0 +1,33 @@
|
|||
---
|
||||
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>
|
50
src/components/TableOfContents.astro
Normal file
50
src/components/TableOfContents.astro
Normal file
|
@ -0,0 +1,50 @@
|
|||
---
|
||||
import TableOfContentsHeading from "./TableOfContentsHeading.astro";
|
||||
|
||||
const { headings } = Astro.props;
|
||||
const toc = buildToc(headings);
|
||||
|
||||
export interface Heading {
|
||||
depth: number;
|
||||
id: string;
|
||||
text: string;
|
||||
}
|
||||
|
||||
function buildToc(headings: Heading[]) {
|
||||
const toc: Heading[] = [];
|
||||
const parentHeadings = new Map();
|
||||
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);
|
||||
}
|
||||
});
|
||||
return toc;
|
||||
}
|
||||
---
|
||||
|
||||
<details open class="animate rounded-lg border border-black/15 dark:border-white/20">
|
||||
<summary>Table of Contents</summary>
|
||||
<nav class="">
|
||||
<ul class="py-3">
|
||||
{toc.map((heading) => <TableOfContentsHeading heading={heading} />)}
|
||||
</ul>
|
||||
</nav>
|
||||
</details>
|
||||
|
||||
<style>
|
||||
summary {
|
||||
@apply cursor-pointer rounded-t-lg px-3 py-1.5 font-medium transition-colors;
|
||||
}
|
||||
|
||||
summary:hover {
|
||||
@apply bg-black/5 dark:bg-white/5;
|
||||
}
|
||||
|
||||
details[open] summary {
|
||||
@apply bg-black/5 dark:bg-white/5;
|
||||
}
|
||||
</style>
|
21
src/components/TableOfContentsHeading.astro
Normal file
21
src/components/TableOfContentsHeading.astro
Normal file
|
@ -0,0 +1,21 @@
|
|||
---
|
||||
import type { Heading } from "./TableOfContents.astro";
|
||||
import Link from "./Link.astro";
|
||||
|
||||
const { heading } = Astro.props;
|
||||
---
|
||||
|
||||
<li class="list-inside list-disc px-6 py-1.5 text-sm">
|
||||
<Link href={"#" + heading.id} underline>
|
||||
{heading.text}
|
||||
</Link>
|
||||
{
|
||||
heading.subheadings.length > 0 && (
|
||||
<ul class="translate-x-3">
|
||||
{heading.subheadings.map((subheading: Heading) => (
|
||||
<Astro.self heading={subheading} />
|
||||
))}
|
||||
</ul>
|
||||
)
|
||||
}
|
||||
</li>
|
Loading…
Add table
Add a link
Reference in a new issue