diff options
Diffstat (limited to 'src/components')
| -rw-r--r-- | src/components/BaseHead.astro | 86 | ||||
| -rw-r--r-- | src/components/FormattedDate.astro | 16 | ||||
| -rw-r--r-- | src/components/Paginator.astro | 29 | ||||
| -rw-r--r-- | src/components/Search.astro | 162 | ||||
| -rw-r--r-- | src/components/SkipLink.astro | 3 | ||||
| -rw-r--r-- | src/components/SocialList.astro | 42 | ||||
| -rw-r--r-- | src/components/ThemeProvider.astro | 44 | ||||
| -rw-r--r-- | src/components/ThemeToggle.astro | 90 | ||||
| -rw-r--r-- | src/components/blog/Masthead.astro | 84 | ||||
| -rw-r--r-- | src/components/blog/PostPreview.astro | 24 | ||||
| -rw-r--r-- | src/components/blog/TOC.astro | 22 | ||||
| -rw-r--r-- | src/components/blog/TOCHeading.astro | 27 | ||||
| -rw-r--r-- | src/components/blog/webmentions/Comments.astro | 87 | ||||
| -rw-r--r-- | src/components/blog/webmentions/Likes.astro | 52 | ||||
| -rw-r--r-- | src/components/blog/webmentions/index.astro | 23 | ||||
| -rw-r--r-- | src/components/layout/Footer.astro | 27 | ||||
| -rw-r--r-- | src/components/layout/Header.astro | 118 | ||||
| -rw-r--r-- | src/components/note/Note.astro | 48 |
18 files changed, 984 insertions, 0 deletions
diff --git a/src/components/BaseHead.astro b/src/components/BaseHead.astro new file mode 100644 index 0000000..7ff861f --- /dev/null +++ b/src/components/BaseHead.astro @@ -0,0 +1,86 @@ +--- +import { WEBMENTION_PINGBACK, WEBMENTION_URL } from "astro:env/client"; +import { siteConfig } from "@/site.config"; +import type { SiteMeta } from "@/types"; +import "@/styles/global.css"; + +type Props = SiteMeta; + +const { articleDate, description, ogImage, title } = Astro.props; + +const titleSeparator = "•"; +const siteTitle = `${title} ${titleSeparator} ${siteConfig.title}`; +const canonicalURL = new URL(Astro.url.pathname, Astro.site); +const socialImageURL = new URL(ogImage ? ogImage : "/social-card.png", Astro.url).href; +--- + +<meta charset="utf-8" /> +<meta content="width=device-width, initial-scale=1.0" name="viewport" /> +<title>{siteTitle}</title> + +{/* Icons */} +<link href="/icon.svg" rel="icon" type="image/svg+xml" /> +{ + import.meta.env.PROD && ( + <> + {/* Favicon & Apple Icon */} + <link rel="icon" href="/favicon-32x32.png" type="image/png" /> + <link href="/icons/apple-touch-icon.png" rel="apple-touch-icon" /> + {/* Manifest */} + <link href="/manifest.webmanifest" rel="manifest" /> + </> + ) +} + +{/* Canonical URL */} +<link href={canonicalURL} rel="canonical" /> + +{/* Primary Meta Tags */} +<meta content={siteTitle} name="title" /> +<meta content={description} name="description" /> +<meta content={siteConfig.author} name="author" /> + +{/* Open Graph / Facebook */} +<meta content={articleDate ? "article" : "website"} property="og:type" /> +<meta content={title} property="og:title" /> +<meta content={description} property="og:description" /> +<meta content={canonicalURL} property="og:url" /> +<meta content={siteConfig.title} property="og:site_name" /> +<meta content={siteConfig.ogLocale} property="og:locale" /> +<meta content={socialImageURL} property="og:image" /> +<meta content="1200" property="og:image:width" /> +<meta content="630" property="og:image:height" /> +{ + articleDate && ( + <> + <meta content={siteConfig.author} property="article:author" /> + <meta content={articleDate} property="article:published_time" /> + </> + ) +} + +{/* Twitter */} +<meta content="summary_large_image" property="twitter:card" /> +<meta content={canonicalURL} property="twitter:url" /> +<meta content={title} property="twitter:title" /> +<meta content={description} property="twitter:description" /> +<meta content={socialImageURL} property="twitter:image" /> + +{/* Sitemap */} +<link href="/sitemap-index.xml" rel="sitemap" /> + +{/* RSS auto-discovery */} +<link href="/rss.xml" title="Blog" rel="alternate" type="application/rss+xml" /> +<link href="/notes/rss.xml" title="Notes" rel="alternate" type="application/rss+xml" /> + +{/* Webmentions */} +{ + WEBMENTION_URL && ( + <> + <link href={WEBMENTION_URL} rel="webmention" /> + {WEBMENTION_PINGBACK && <link href={WEBMENTION_PINGBACK} rel="pingback" />} + </> + ) +} + +<meta content={Astro.generator} name="generator" /> diff --git a/src/components/FormattedDate.astro b/src/components/FormattedDate.astro new file mode 100644 index 0000000..aba7f4d --- /dev/null +++ b/src/components/FormattedDate.astro @@ -0,0 +1,16 @@ +--- +import { getFormattedDate } from "@/utils/date"; +import type { HTMLAttributes } from "astro/types"; + +type Props = HTMLAttributes<"time"> & { + date: Date; + dateTimeOptions?: Intl.DateTimeFormatOptions; +}; + +const { date, dateTimeOptions, ...attrs } = Astro.props; + +const postDate = getFormattedDate(date, dateTimeOptions); +const ISO = date.toISOString(); +--- + +<time datetime={ISO} title={ISO} {...attrs}>{postDate}</time> diff --git a/src/components/Paginator.astro b/src/components/Paginator.astro new file mode 100644 index 0000000..0678487 --- /dev/null +++ b/src/components/Paginator.astro @@ -0,0 +1,29 @@ +--- +import type { PaginationLink } from "@/types"; + +interface Props { + nextUrl?: PaginationLink; + prevUrl?: PaginationLink; +} + +const { nextUrl, prevUrl } = Astro.props; +--- + +{ + (prevUrl || nextUrl) && ( + <nav class="mt-8 flex items-center gap-x-4"> + {prevUrl && ( + <a class="hover:text-accent me-auto py-2" data-astro-prefetch href={prevUrl.url}> + {prevUrl.srLabel && <span class="sr-only">{prevUrl.srLabel}</span>} + {prevUrl.text ? prevUrl.text : "Previous"} + </a> + )} + {nextUrl && ( + <a class="hover:text-accent ms-auto py-2" data-astro-prefetch href={nextUrl.url}> + {nextUrl.srLabel && <span class="sr-only">{nextUrl.srLabel}</span>} + {nextUrl.text ? nextUrl.text : "Next"} + </a> + )} + </nav> + ) +} diff --git a/src/components/Search.astro b/src/components/Search.astro new file mode 100644 index 0000000..7b98c1a --- /dev/null +++ b/src/components/Search.astro @@ -0,0 +1,162 @@ +--- +// Heavy inspiration taken from Astro Starlight -> https://github.com/withastro/starlight/blob/main/packages/starlight/components/Search.astro + +import "@/styles/blocks/search.css"; +--- + +<site-search class="ms-auto" id="search"> + <button + class="hover:text-accent flex h-9 w-9 cursor-pointer items-center justify-center rounded-md" + aria-keyshortcuts="Control+K Meta+K" + data-open-modal + disabled + > + <svg + aria-hidden="true" + class="h-7 w-7" + fill="none" + height="16" + stroke="currentColor" + stroke-linecap="round" + stroke-linejoin="round" + stroke-width="1.5" + viewBox="0 0 24 24" + width="16" + xmlns="http://www.w3.org/2000/svg" + > + <path d="M0 0h24v24H0z" stroke="none"></path> + <path d="M3 10a7 7 0 1 0 14 0 7 7 0 1 0-14 0M21 21l-6-6"></path> + </svg> + <span class="sr-only">Open Search</span> + </button> + <dialog + aria-label="search" + class="bg-global-bg h-full max-h-full w-full max-w-full border border-zinc-400 shadow-sm backdrop:backdrop-blur-sm open:flex sm:mx-auto sm:mt-16 sm:mb-auto sm:h-max sm:max-h-[calc(100%-8rem)] sm:min-h-[15rem] sm:w-5/6 sm:max-w-[48rem] sm:rounded-md" + > + <div class="dialog-frame flex grow flex-col gap-4 p-6 pt-12 sm:pt-6"> + <button + class="ms-auto cursor-pointer rounded-md bg-zinc-200 p-2 font-semibold dark:bg-zinc-700" + data-close-modal>Close</button + > + { + import.meta.env.DEV ? ( + <div class="mx-auto text-center"> + <p> + Search is only available in production builds. <br /> + Try building and previewing the site to test it out locally. + </p> + </div> + ) : ( + <div class="search-container"> + <div id="cactus__search" /> + </div> + ) + } + </div> + </dialog> +</site-search> + +<script> + class SiteSearch extends HTMLElement { + #closeBtn: HTMLButtonElement | null; + #dialog: HTMLDialogElement | null; + #dialogFrame: HTMLDivElement | null; + #openBtn: HTMLButtonElement | null; + #controller: AbortController; + + constructor() { + super(); + this.#openBtn = this.querySelector<HTMLButtonElement>("button[data-open-modal]"); + this.#closeBtn = this.querySelector<HTMLButtonElement>("button[data-close-modal]"); + this.#dialog = this.querySelector<HTMLDialogElement>("dialog"); + this.#dialogFrame = this.querySelector(".dialog-frame"); + this.#controller = new AbortController(); + + // Set up events + if (this.#openBtn) { + this.#openBtn.addEventListener("click", this.openModal); + this.#openBtn.disabled = false; + } else { + console.warn("Search button not found"); + } + + if (this.#closeBtn) { + this.#closeBtn.addEventListener("click", this.closeModal); + } else { + console.warn("Close button not found"); + } + + if (this.#dialog) { + this.#dialog.addEventListener("close", () => { + window.removeEventListener("click", this.onWindowClick); + }); + } else { + console.warn("Dialog not found"); + } + + // only add pagefind in production + if (import.meta.env.DEV) return; + const onIdle = window.requestIdleCallback || ((cb) => setTimeout(cb, 1)); + onIdle(async () => { + const { PagefindUI } = await import("@pagefind/default-ui"); + new PagefindUI({ + baseUrl: import.meta.env.BASE_URL, + bundlePath: import.meta.env.BASE_URL.replace(/\/$/, "") + "/pagefind/", + element: "#cactus__search", + showImages: false, + showSubResults: true, + }); + }); + } + + connectedCallback() { + // window events, requires cleanup + window.addEventListener("keydown", this.onWindowKeydown, { signal: this.#controller.signal }); + } + + disconnectedCallback() { + this.#controller.abort(); + } + + openModal = (event?: MouseEvent) => { + if (!this.#dialog) { + console.warn("Dialog not found"); + return; + } + + this.#dialog.showModal(); + this.querySelector("input")?.focus(); + event?.stopPropagation(); + window.addEventListener("click", this.onWindowClick, { signal: this.#controller.signal }); + }; + + closeModal = () => this.#dialog?.close(); + + onWindowClick = (event: MouseEvent) => { + // check if it's a link + const isLink = "href" in (event.target || {}); + // make sure the click is either a link or outside of the dialog + if ( + isLink || + (document.body.contains(event.target as Node) && + !this.#dialogFrame?.contains(event.target as Node)) + ) { + this.closeModal(); + } + }; + + onWindowKeydown = (e: KeyboardEvent) => { + if (!this.#dialog) { + console.warn("Dialog not found"); + return; + } + // check if it's the Control+K or ⌘+K shortcut + if ((e.metaKey === true || e.ctrlKey === true) && e.key === "k") { + this.#dialog.open ? this.closeModal() : this.openModal(); + e.preventDefault(); + } + }; + } + + customElements.define("site-search", SiteSearch); +</script> diff --git a/src/components/SkipLink.astro b/src/components/SkipLink.astro new file mode 100644 index 0000000..dae6bad --- /dev/null +++ b/src/components/SkipLink.astro @@ -0,0 +1,3 @@ +<a class="sr-only focus:not-sr-only focus:fixed focus:start-1 focus:top-1.5" href="#main" + >skip to content +</a> diff --git a/src/components/SocialList.astro b/src/components/SocialList.astro new file mode 100644 index 0000000..00e7f97 --- /dev/null +++ b/src/components/SocialList.astro @@ -0,0 +1,42 @@ +--- +import { Icon } from "astro-icon/components"; + +/** + Uses https://www.astroicon.dev/getting-started/ + Find icons via guide: https://www.astroicon.dev/guides/customization/#open-source-icon-sets + Only installed pack is: @iconify-json/mdi +*/ +const socialLinks: { + friendlyName: string; + isWebmention?: boolean; + link: string; + name: string; +}[] = [ + { + friendlyName: "Github", + link: "https://github.com/chrismwilliams/astro-cactus", + name: "mdi:github", + }, +]; +--- + +<div class="flex flex-wrap items-end gap-x-2"> + <p>Find me on</p> + <ul class="flex flex-1 items-center gap-x-2 sm:flex-initial"> + { + socialLinks.map(({ friendlyName, isWebmention, link, name }) => ( + <li class="flex"> + <a + class="hover:text-link inline-block" + href={link} + rel={`noreferrer ${isWebmention ? "me authn" : ""}`} + target="_blank" + > + <Icon aria-hidden="true" class="h-8 w-8" focusable="false" name={name} /> + <span class="sr-only">{friendlyName}</span> + </a> + </li> + )) + } + </ul> +</div> diff --git a/src/components/ThemeProvider.astro b/src/components/ThemeProvider.astro new file mode 100644 index 0000000..5f0723d --- /dev/null +++ b/src/components/ThemeProvider.astro @@ -0,0 +1,44 @@ +{/* Inlined to avoid FOUC. This is a parser blocking script. */} +<script is:inline> + const lightModePref = window.matchMedia("(prefers-color-scheme: light)"); + + function getUserPref() { + const storedTheme = typeof localStorage !== "undefined" && localStorage.getItem("theme"); + return storedTheme || (lightModePref.matches ? "light" : "dark"); + } + + function setTheme(newTheme) { + if (newTheme !== "light" && newTheme !== "dark") { + return console.warn( + `Invalid theme value '${newTheme}' received. Expected 'light' or 'dark'.`, + ); + } + + const root = document.documentElement; + + // root already set to newTheme, exit early + if (newTheme === root.getAttribute("data-theme")) { + return; + } + + root.setAttribute("data-theme", newTheme); + + if (typeof localStorage !== "undefined") { + localStorage.setItem("theme", newTheme); + } + } + + // initial setup + setTheme(getUserPref()); + + // View Transitions hook to restore theme + document.addEventListener("astro:after-swap", () => setTheme(getUserPref())); + + // listen for theme-change custom event, fired in src/components/ThemeToggle.astro + document.addEventListener("theme-change", (e) => { + setTheme(e.detail.theme); + }); + + // listen for prefers-color-scheme change. + lightModePref.addEventListener("change", (e) => setTheme(e.matches ? "light" : "dark")); +</script> diff --git a/src/components/ThemeToggle.astro b/src/components/ThemeToggle.astro new file mode 100644 index 0000000..7621daf --- /dev/null +++ b/src/components/ThemeToggle.astro @@ -0,0 +1,90 @@ +<theme-toggle class="ms-2 sm:ms-4"> + <button class="hover:text-accent relative h-9 w-9 cursor-pointer rounded-md p-2" type="button"> + <span class="sr-only">Dark Theme</span> + <svg + aria-hidden="true" + class="absolute start-1/2 top-1/2 h-7 w-7 -translate-x-1/2 -translate-y-1/2 scale-100 opacity-100 transition-all dark:scale-0 dark:opacity-0" + fill="none" + focusable="false" + id="sun-svg" + stroke-width="1.5" + viewBox="0 0 24 24" + xmlns="http://www.w3.org/2000/svg" + > + <path + d="M12 18C15.3137 18 18 15.3137 18 12C18 8.68629 15.3137 6 12 6C8.68629 6 6 8.68629 6 12C6 15.3137 8.68629 18 12 18Z" + stroke="currentColor" + stroke-linecap="round" + stroke-linejoin="round"></path> + <path d="M22 12L23 12" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" + ></path> + <path d="M12 2V1" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"></path> + <path d="M12 23V22" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" + ></path> + <path d="M20 20L19 19" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" + ></path> + <path d="M20 4L19 5" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" + ></path> + <path d="M4 20L5 19" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" + ></path> + <path d="M4 4L5 5" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" + ></path> + <path d="M1 12L2 12" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" + ></path> + </svg> + <svg + aria-hidden="true" + class="absolute start-1/2 top-1/2 h-7 w-7 -translate-x-1/2 -translate-y-1/2 scale-0 opacity-0 transition-all dark:scale-100 dark:opacity-100" + fill="none" + focusable="false" + id="moon-svg" + stroke="currentColor" + stroke-width="1.5" + viewBox="0 0 24 24" + xmlns="http://www.w3.org/2000/svg" + > + <path d="M0 0h24v24H0z" fill="none" stroke="none"></path> + <path d="M12 3c.132 0 .263 0 .393 0a7.5 7.5 0 0 0 7.92 12.446a9 9 0 1 1 -8.313 -12.454z" + ></path> + <path d="M17 4a2 2 0 0 0 2 2a2 2 0 0 0 -2 2a2 2 0 0 0 -2 -2a2 2 0 0 0 2 -2"></path> + <path d="M19 11h2m-1 -1v2"></path> + </svg> + </button> +</theme-toggle> + +<script> + // Note that if you fire the theme-change event outside of this component, it will not be reflected in the button's aria-checked attribute. You will need to add an event listener if you want that. + import { rootInDarkMode } from "@/utils/domElement"; + + class ThemeToggle extends HTMLElement { + constructor() { + super(); + const button = this.querySelector<HTMLButtonElement>("button"); + + if (button) { + // set aria role value + button.setAttribute("role", "switch"); + button.setAttribute("aria-checked", String(rootInDarkMode())); + + // button event + button.addEventListener("click", () => { + // invert theme + let themeChangeEvent = new CustomEvent("theme-change", { + detail: { + theme: rootInDarkMode() ? "light" : "dark", + }, + }); + // dispatch event -> ThemeProvider.astro + document.dispatchEvent(themeChangeEvent); + + // set the aria-checked attribute + button.setAttribute("aria-checked", String(rootInDarkMode())); + }); + } else { + console.warn("Theme Toggle: No button found"); + } + } + } + + customElements.define("theme-toggle", ThemeToggle); +</script> diff --git a/src/components/blog/Masthead.astro b/src/components/blog/Masthead.astro new file mode 100644 index 0000000..1f52383 --- /dev/null +++ b/src/components/blog/Masthead.astro @@ -0,0 +1,84 @@ +--- +import { Image } from "astro:assets"; +import type { CollectionEntry } from "astro:content"; +import FormattedDate from "@/components/FormattedDate.astro"; + +interface Props { + content: CollectionEntry<"post">; + readingTime: string; +} + +const { + content: { data }, + readingTime, +} = Astro.props; + +const dateTimeOptions: Intl.DateTimeFormatOptions = { + month: "long", +}; +--- + +{ + data.coverImage && ( + <div class="mb-6 aspect-video"> + <Image + alt={data.coverImage.alt} + layout="constrained" + width={748} + height={420} + priority + src={data.coverImage.src} + /> + </div> + ) +} +{data.draft ? <span class="text-base text-red-500">(Draft)</span> : null} +<h1 class="title"> + {data.title} +</h1> +<div class="flex flex-wrap items-center gap-x-3 gap-y-2"> + <p class="font-semibold"> + <FormattedDate date={data.publishDate} dateTimeOptions={dateTimeOptions} /> /{" "} + {readingTime} + </p> + { + data.updatedDate && ( + <span class="bg-quote/5 text-quote rounded-lg px-2 py-1"> + Updated: + <FormattedDate class="ms-1" date={data.updatedDate} dateTimeOptions={dateTimeOptions} /> + </span> + ) + } +</div> +{ + !!data.tags?.length && ( + <div class="mt-2"> + <svg + aria-hidden="true" + class="inline-block h-6 w-6" + fill="none" + focusable="false" + stroke="currentColor" + stroke-linecap="round" + stroke-linejoin="round" + stroke-width="1.5" + viewBox="0 0 24 24" + xmlns="http://www.w3.org/2000/svg" + > + <path d="M0 0h24v24H0z" fill="none" stroke="none" /> + <path d="M7.859 6h-2.834a2.025 2.025 0 0 0 -2.025 2.025v2.834c0 .537 .213 1.052 .593 1.432l6.116 6.116a2.025 2.025 0 0 0 2.864 0l2.834 -2.834a2.025 2.025 0 0 0 0 -2.864l-6.117 -6.116a2.025 2.025 0 0 0 -1.431 -.593z" /> + <path d="M17.573 18.407l2.834 -2.834a2.025 2.025 0 0 0 0 -2.864l-7.117 -7.116" /> + <path d="M6 9h-.01" /> + </svg> + {data.tags.map((tag, i) => ( + <> + {/* prettier-ignore */} + <span class="contents"> + <a class="cactus-link inline-block before:content-['#']" data-pagefind-filter={`tag:${tag}`} href={`/tags/${tag}/`}><span class="sr-only">View more blogs with the tag </span>{tag} + </a>{i < data.tags.length - 1 && ", "} + </span> + </> + ))} + </div> + ) +} diff --git a/src/components/blog/PostPreview.astro b/src/components/blog/PostPreview.astro new file mode 100644 index 0000000..fc1a9a3 --- /dev/null +++ b/src/components/blog/PostPreview.astro @@ -0,0 +1,24 @@ +--- +import type { CollectionEntry } from "astro:content"; +import FormattedDate from "@/components/FormattedDate.astro"; +import type { HTMLTag, Polymorphic } from "astro/types"; + +type Props<Tag extends HTMLTag> = Polymorphic<{ as: Tag }> & { + post: CollectionEntry<"post">; + withDesc?: boolean; +}; + +const { as: Tag = "div", post, withDesc = false } = Astro.props; +--- + +<FormattedDate + class="min-w-30 font-semibold text-gray-600 dark:text-gray-400" + date={post.data.publishDate} +/> +<Tag> + {post.data.draft && <span class="text-red-500">(Draft) </span>} + <a class="cactus-link" data-astro-prefetch href={`/posts/${post.id}/`}> + {post.data.title} + </a> +</Tag> +{withDesc && <q class="line-clamp-3 italic">{post.data.description}</q>} diff --git a/src/components/blog/TOC.astro b/src/components/blog/TOC.astro new file mode 100644 index 0000000..6649546 --- /dev/null +++ b/src/components/blog/TOC.astro @@ -0,0 +1,22 @@ +--- +import { generateToc } from "@/utils/generateToc"; +import type { MarkdownHeading } from "astro"; +import TOCHeading from "./TOCHeading.astro"; + +interface Props { + headings: MarkdownHeading[]; +} + +const { headings } = Astro.props; + +const toc = generateToc(headings); +--- + +<details open class="lg:sticky lg:top-12 lg:order-2 lg:-me-32 lg:basis-64"> + <summary class="title hover:marker:text-accent cursor-pointer text-lg">Table of Contents</summary> + <nav class="ms-4 lg:w-full"> + <ol class="mt-4"> + {toc.map((heading) => <TOCHeading heading={heading} />)} + </ol> + </nav> +</details> diff --git a/src/components/blog/TOCHeading.astro b/src/components/blog/TOCHeading.astro new file mode 100644 index 0000000..b9dd486 --- /dev/null +++ b/src/components/blog/TOCHeading.astro @@ -0,0 +1,27 @@ +--- +import type { TocItem } from "@/utils/generateToc"; + +interface Props { + heading: TocItem; +} + +const { + heading: { children, depth, slug, text }, +} = Astro.props; +--- + +<li class={`${depth > 2 ? "ms-2" : ""}`}> + <a + class={`line-clamp-2 hover:text-accent ${depth <= 2 ? "mt-3" : "mt-2 text-xs"}`} + href={`#${slug}`}><span aria-hidden="true" class="me-0.5">#</span>{text}</a + > + { + !!children.length && ( + <ol> + {children.map((subheading) => ( + <Astro.self heading={subheading} /> + ))} + </ol> + ) + } +</li> diff --git a/src/components/blog/webmentions/Comments.astro b/src/components/blog/webmentions/Comments.astro new file mode 100644 index 0000000..5177d57 --- /dev/null +++ b/src/components/blog/webmentions/Comments.astro @@ -0,0 +1,87 @@ +--- +import { Image } from "astro:assets"; +import type { WebmentionsChildren } from "@/types"; +import { Icon } from "astro-icon/components"; + +interface Props { + mentions: WebmentionsChildren[]; +} + +const { mentions } = Astro.props; + +const validComments = ["mention-of", "in-reply-to"]; + +const comments = mentions.filter( + (mention) => validComments.includes(mention["wm-property"]) && mention.content?.text, +); +--- + +{ + !!comments.length && ( + <div> + <p class="text-accent-2 mb-0"> + <strong>{comments.length}</strong> Mention{comments.length > 1 ? "s" : ""} + </p> + <ul class="divide-global-text/20 mt-0 divide-y ps-0" role="list"> + {comments.map((mention) => ( + <li class="p-comment h-cite my-0 flex items-start gap-x-5 py-5"> + {mention.author?.photo && mention.author.photo !== "" ? ( + mention.author.url && mention.author.url !== "" ? ( + <a + class="u-author not-prose ring-global-text hover:ring-link focus-visible:ring-link shrink-0 overflow-hidden rounded-full ring-2 hover:ring-4 focus-visible:ring-4" + href={mention.author.url} + rel="noreferrer" + target="_blank" + title={mention.author.name} + > + <Image + alt={mention.author?.name} + class="u-photo my-0 h-12 w-12" + height={48} + src={mention.author?.photo} + width={48} + /> + </a> + ) : ( + <Image + alt={mention.author?.name} + class="u-photo my-0 h-12 w-12 rounded-full" + height={48} + src={mention.author?.photo} + width={48} + /> + ) + ) : null} + <div class="flex-auto"> + <div class="p-author h-card flex items-center justify-between gap-x-2"> + <p class="p-name text-accent-2 my-0 line-clamp-1 font-semibold"> + {mention.author?.name} + </p> + <a + aria-labelledby="cmt-source" + class="u-url not-prose hover:text-link" + href={mention.url} + rel="noreferrer" + target="_blank" + > + <span class="hidden" id="cmt-source"> + Visit the source of this webmention + </span> + <Icon + aria-hidden="true" + class="h-5 w-5" + focusable="false" + name="mdi:open-in-new" + /> + </a> + </div> + <p class="comment-content mt-1 mb-0 break-words [word-break:break-word]"> + {mention.content?.text} + </p> + </div> + </li> + ))} + </ul> + </div> + ) +} diff --git a/src/components/blog/webmentions/Likes.astro b/src/components/blog/webmentions/Likes.astro new file mode 100644 index 0000000..7862c43 --- /dev/null +++ b/src/components/blog/webmentions/Likes.astro @@ -0,0 +1,52 @@ +--- +import { Image } from "astro:assets"; +import type { WebmentionsChildren } from "@/types"; + +interface Props { + mentions: WebmentionsChildren[]; +} + +const { mentions } = Astro.props; +const MAX_LIKES = 10; + +const likes = mentions.filter((mention) => mention["wm-property"] === "like-of"); +const likesToShow = likes + .filter((like) => like.author?.photo && like.author.photo !== "") + .slice(0, MAX_LIKES); +--- + +{ + !!likes.length && ( + <div> + <p class="text-accent-2 mb-0"> + <strong>{likes.length}</strong> + {likes.length > 1 ? " People" : " Person"} liked this + </p> + {!!likesToShow.length && ( + <ul class="flex list-none flex-wrap overflow-hidden ps-2" role="list"> + {likesToShow.map((like) => ( + <li class="p-like h-cite -ms-2"> + <a + class="u-url not-prose ring-global-text hover:ring-link focus-visible:ring-link relative inline-block overflow-hidden rounded-full ring-2 hover:z-10 hover:ring-4 focus-visible:z-10 focus-visible:ring-4" + href={like.author?.url} + rel="noreferrer" + target="_blank" + title={like.author?.name} + > + <span class="p-author h-card"> + <Image + alt={like.author!.name} + class="u-photo my-0 inline-block h-12 w-12" + height={48} + src={like.author!.photo} + width={48} + /> + </span> + </a> + </li> + ))} + </ul> + )} + </div> + ) +} diff --git a/src/components/blog/webmentions/index.astro b/src/components/blog/webmentions/index.astro new file mode 100644 index 0000000..232b4f3 --- /dev/null +++ b/src/components/blog/webmentions/index.astro @@ -0,0 +1,23 @@ +--- +import { getWebmentionsForUrl } from "@/utils/webmentions"; +import Comments from "./Comments.astro"; +import Likes from "./Likes.astro"; + +const url = new URL(Astro.url.pathname, Astro.site); + +const webMentions = await getWebmentionsForUrl(`${url}`); + +// Return if no webmentions +if (!webMentions.length) return; +--- + +<hr class="border-solid" /> +<h2 class="mb-8 before:hidden">Webmentions for this post</h2> +<div class="space-y-10"> + <Likes mentions={webMentions} /> + <Comments mentions={webMentions} /> +</div> +<p class="mt-8"> + Responses powered by{" "} + <a href="https://webmention.io" rel="noreferrer" target="_blank">Webmentions</a> +</p> diff --git a/src/components/layout/Footer.astro b/src/components/layout/Footer.astro new file mode 100644 index 0000000..5eea6e8 --- /dev/null +++ b/src/components/layout/Footer.astro @@ -0,0 +1,27 @@ +--- +import { menuLinks, siteConfig } from "@/site.config"; + +const year = new Date().getFullYear(); +--- + +<footer + class="mt-auto flex w-full flex-col items-center justify-center gap-y-2 pt-20 pb-4 text-center align-top font-semibold text-gray-600 sm:flex-row sm:justify-between sm:text-xs dark:text-gray-400" +> + <div class="me-0 sm:me-4"> + © {siteConfig.author} + {year}.<span class="inline-block"> 🚀 {siteConfig.title}</span> + </div> + <nav + aria-labelledby="footer_links" + class="flex gap-x-2 sm:gap-x-0 sm:divide-x sm:divide-gray-500" + > + <p id="footer_links" class="sr-only">More on this site</p> + { + menuLinks.map((link) => ( + <a class="hover:text-global-text px-4 py-2 hover:underline sm:py-0" href={link.path}> + {link.title} + </a> + )) + } + </nav> +</footer> diff --git a/src/components/layout/Header.astro b/src/components/layout/Header.astro new file mode 100644 index 0000000..65ea5cc --- /dev/null +++ b/src/components/layout/Header.astro @@ -0,0 +1,118 @@ +--- +import Search from "@/components/Search.astro"; +import ThemeToggle from "@/components/ThemeToggle.astro"; +import { menuLinks } from "@/site.config"; +import {siteConfig} from "../../site.config"; +--- + +<header class="group relative mb-28 flex items-center sm:ps-18" id="main-header"> + <div class="flex sm:flex-col"> + <a + aria-current={Astro.url.pathname === "/" ? "page" : false} + class="inline-flex items-center grayscale hover:filter-none sm:relative sm:inline-block" + href="/" + > + <svg + aria-hidden="true" + class="me-3 h-10 w-6 sm:absolute sm:-start-18 sm:me-0 sm:h-20 sm:w-12" + fill="none" + focusable="false" + viewBox="0 0 272 480" + xmlns="http://www.w3.org/2000/svg" + > + <title>Logo</title> + <path + d="M181.334 93.333v-40L226.667 80v40l-45.333-26.667ZM136.001 53.333 90.667 26.667v426.666L136.001 480V53.333Z" + fill="#B04304"></path> + <path + d="m136.001 119.944 45.333-26.667 45.333 26.667-45.333 26.667-45.333-26.667ZM90.667 26.667 136.001 0l45.333 26.667-45.333 26.666-45.334-26.666ZM181.334 53.277l45.333-26.666L272 53.277l-45.333 26.667-45.333-26.667ZM0 213.277l45.333-26.667 45.334 26.667-45.334 26.667L0 213.277ZM136 239.944l-45.333-26.667v53.333L136 239.944Z" + fill="#FF5D01"></path> + <path + d="m136 53.333 45.333-26.666v120L226.667 120V80L272 53.333V160l-90.667 53.333v240L136 480V306.667L45.334 360V240l45.333-26.667v53.334L136 240V53.333Z" + fill="#53C68C"></path> + <path d="M45.334 240 0 213.334v120L45.334 360V240Z" fill="#B04304"></path> + </svg> + <span class="text-xl font-bold sm:text-2xl">{siteConfig.title}</span> + </a> + <nav + aria-label="Main menu" + class="bg-global-bg/85 text-accent sm:divide-accent absolute -inset-x-4 top-14 hidden flex-col items-end gap-y-4 rounded-md py-4 shadow backdrop-blur-sm group-[.menu-open]:z-50 group-[.menu-open]:flex sm:static sm:z-auto sm:-ms-4 sm:mt-1 sm:flex sm:flex-row sm:items-center sm:divide-x sm:rounded-none sm:bg-transparent sm:py-0 sm:shadow-none sm:backdrop-blur-none" + id="navigation-menu" + > + { + menuLinks.map((link) => ( + <a + aria-current={Astro.url.pathname === link.path ? "page" : false} + class="px-4 py-4 underline-offset-2 hover:underline sm:py-0" + data-astro-prefetch + href={link.path} + > + {link.title} + </a> + )) + } + </nav> + </div> + <Search /> + <ThemeToggle /> + <mobile-button> + <button + aria-expanded="false" + aria-haspopup="menu" + class="group relative ms-4 h-7 w-7 sm:invisible sm:hidden" + id="toggle-navigation-menu" + type="button" + > + <span class="sr-only">Open main menu</span> + <svg + aria-hidden="true" + class="absolute start-1/2 top-1/2 h-full w-full -translate-x-1/2 -translate-y-1/2 transition-all group-aria-expanded:scale-0 group-aria-expanded:opacity-0" + fill="none" + focusable="false" + id="line-svg" + stroke="currentColor" + stroke-width="1.5" + viewBox="0 0 24 24" + xmlns="http://www.w3.org/2000/svg" + > + <path d="M3.75 9h16.5m-16.5 6.75h16.5" stroke-linecap="round" stroke-linejoin="round" + ></path> + </svg> + <svg + aria-hidden="true" + class="text-accent absolute start-1/2 top-1/2 h-full w-full -translate-x-1/2 -translate-y-1/2 scale-0 opacity-0 transition-all group-aria-expanded:scale-100 group-aria-expanded:opacity-100" + class="text-accent" + fill="none" + focusable="false" + id="cross-svg" + stroke="currentColor" + stroke-width="1.5" + viewBox="0 0 24 24" + xmlns="http://www.w3.org/2000/svg" + > + <path d="M6 18L18 6M6 6l12 12" stroke-linecap="round" stroke-linejoin="round"></path> + </svg> + </button> + </mobile-button> +</header> + +<script> + import { toggleClass } from "@/utils/domElement"; + + class MobileNavBtn extends HTMLElement { + #menuOpen: boolean = false; + + connectedCallback() { + const headerEl = document.getElementById("main-header")!; + const mobileButtonEl = this.querySelector<HTMLButtonElement>("button"); + + mobileButtonEl?.addEventListener("click", () => { + if (headerEl) toggleClass(headerEl, "menu-open"); + this.#menuOpen = !this.#menuOpen; + mobileButtonEl.setAttribute("aria-expanded", this.#menuOpen.toString()); + }); + } + } + + customElements.define("mobile-button", MobileNavBtn); +</script> diff --git a/src/components/note/Note.astro b/src/components/note/Note.astro new file mode 100644 index 0000000..cff3414 --- /dev/null +++ b/src/components/note/Note.astro @@ -0,0 +1,48 @@ +--- +import { type CollectionEntry, render } from "astro:content"; +import FormattedDate from "@/components/FormattedDate.astro"; +import type { HTMLTag, Polymorphic } from "astro/types"; + +type Props<Tag extends HTMLTag> = Polymorphic<{ as: Tag }> & { + note: CollectionEntry<"note">; + isPreview?: boolean | undefined; +}; + +const { as: Tag = "div", note, isPreview = false } = Astro.props; +const { Content } = await render(note); +--- + +<article + class:list={[ + isPreview && "inline-grid rounded-md bg-[rgb(240,240,240)] px-4 py-3 dark:bg-[rgb(33,35,38)]", + ]} + data-pagefind-body={isPreview ? false : true} +> + <Tag class="title" class:list={{ "text-base": isPreview }}> + { + isPreview ? ( + <a class="cactus-link" href={`/notes/${note.id}/`}> + {note.data.title} + </a> + ) : ( + <>{note.data.title}</> + ) + } + </Tag> + <FormattedDate + dateTimeOptions={{ + hour: "2-digit", + minute: "2-digit", + year: "2-digit", + month: "2-digit", + day: "2-digit", + }} + date={note.data.publishDate} + /> + <div + class="prose prose-sm prose-cactus mt-4 max-w-none [&>p:last-of-type]:mb-0" + class:list={{ "line-clamp-6": isPreview }} + > + <Content /> + </div> +</article> |
