| 1 | --- |
| 2 | import type { CollectionEntry } from "astro:content"; |
| 3 | |
| 4 | import BaseLayout from "./Base.astro"; |
| 5 | import BlogHero from "@/components/blog/Hero"; |
| 6 | |
| 7 | interface Props { |
| 8 | post: CollectionEntry<"post">; |
| 9 | } |
| 10 | |
| 11 | const { post } = Astro.props; |
| 12 | const { |
| 13 | data: { title, description, ogImage, publishDate }, |
| 14 | slug, |
| 15 | } = post; |
| 16 | const socialImage = ogImage ?? `/og-image/${slug}.png`; |
| 17 | const articleDate = publishDate.toISOString(); |
| 18 | const { headings } = await post.render(); |
| 19 | --- |
| 20 | |
| 21 | <script> |
| 22 | const scrollBtn = document.getElementById("to-top-btn") as HTMLButtonElement; |
| 23 | const targetHeader = document.getElementById("blog-hero") as HTMLDivElement; |
| 24 | |
| 25 | function callback(entries: IntersectionObserverEntry[]) { |
| 26 | entries.forEach((entry) => { |
| 27 | // only show the scroll to top button when the heading is out of view |
| 28 | scrollBtn.dataset.show = (!entry.isIntersecting).toString(); |
| 29 | }); |
| 30 | } |
| 31 | |
| 32 | scrollBtn.addEventListener("click", () => { |
| 33 | document.documentElement.scrollTo({ top: 0, behavior: "smooth" }); |
| 34 | }); |
| 35 | |
| 36 | const observer = new IntersectionObserver(callback); |
| 37 | observer.observe(targetHeader); |
| 38 | </script> |
| 39 | |
| 40 | <BaseLayout meta={{ title, description, articleDate, ogImage: socialImage }}> |
| 41 | <div class="flex items-start gap-x-10"> |
| 42 | { |
| 43 | !!headings.length && ( |
| 44 | <aside class="sticky top-20 order-2 -mr-32 hidden basis-64 lg:block"> |
| 45 | <h2 class="font-semibold">Table of Contents</h2> |
| 46 | <ul class="mt-4 text-xs"> |
| 47 | {headings.map(({ depth, slug, text }) => ( |
| 48 | <li class="line-clamp-2 hover:text-accent"> |
| 49 | <a |
| 50 | class={`block ${depth <= 2 ? "mt-3" : "mt-2 pl-3 text-[0.6875rem]"}`} |
| 51 | href={`#${slug}`} |
| 52 | aria-label={`Scroll to section: ${text}`} |
| 53 | > |
| 54 | <span>{"#".repeat(depth)}</span> {text} |
| 55 | </a> |
| 56 | </li> |
| 57 | ))} |
| 58 | </ul> |
| 59 | </aside> |
| 60 | ) |
| 61 | } |
| 62 | <article class="flex-grow break-words"> |
| 63 | <div id="blog-hero"><BlogHero content={post} /></div> |
| 64 | <div |
| 65 | class="prose prose-sm prose-cactus mt-12 prose-headings:font-semibold prose-headings:before:absolute prose-headings:before:-ml-4 prose-headings:before:text-accent prose-headings:before:content-['#'] prose-th:before:content-none" |
| 66 | > |
| 67 | <slot /> |
| 68 | </div> |
| 69 | </article> |
| 70 | </div> |
| 71 | <button |
| 72 | id="to-top-btn" |
| 73 | class="z-90 fixed bottom-8 right-4 flex h-10 w-10 translate-y-28 items-center justify-center rounded-full border-2 border-transparent bg-zinc-200 text-3xl opacity-0 transition-all duration-300 hover:border-zinc-400 data-[show=true]:translate-y-0 data-[show=true]:opacity-100 dark:bg-zinc-700 sm:right-8 sm:h-12 sm:w-12" |
| 74 | aria-label="Back to Top" |
| 75 | data-show="false" |
| 76 | ><svg |
| 77 | xmlns="http://www.w3.org/2000/svg" |
| 78 | aria-hidden="true" |
| 79 | focusable="false" |
| 80 | fill="none" |
| 81 | viewBox="0 0 24 24" |
| 82 | stroke-width="2" |
| 83 | stroke="currentColor" |
| 84 | class="h-6 w-6" |
| 85 | > |
| 86 | <path stroke-linecap="round" stroke-linejoin="round" d="M4.5 15.75l7.5-7.5 7.5 7.5"></path> |
| 87 | </svg> |
| 88 | </button> |
| 89 | </BaseLayout> |