diff --git a/src/components/ArticleLayout.tsx b/src/components/ArticleLayout.tsx deleted file mode 100644 index 0b0f4b8..0000000 --- a/src/components/ArticleLayout.tsx +++ /dev/null @@ -1,70 +0,0 @@ -'use client' - -import { useContext } from 'react' -import { useRouter } from 'next/navigation' - -import { AppContext } from '@/app/providers' -import { Container } from '@/components/Container' -import { Prose } from '@/components/Prose' -import { type ArticleWithSlug } from '@/lib/articles' -import { formatDate } from '@/lib/formatDate' - -function ArrowLeftIcon(props: React.ComponentPropsWithoutRef<'svg'>) { - return ( - - ) -} - -export function ArticleLayout({ - article, - children, -}: { - article: ArticleWithSlug - children: React.ReactNode -}) { - let router = useRouter() - let { previousPathname } = useContext(AppContext) - - return ( - -
-
- {previousPathname && ( - - )} -
-
-

- {article.title} -

- -
- - {children} - -
-
-
-
- ) -} diff --git a/src/components/BaseHead.astro b/src/components/BaseHead.astro new file mode 100644 index 0000000..5b48687 --- /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; +--- + + + +{siteTitle} + +{/* Icons */} + +{ + import.meta.env.PROD && ( + <> + {/* Favicon & Apple Icon, Generated at build */} + + + {/* Manifest */} + + + ) +} + +{/* Canonical URL */} + + +{/* Primary Meta Tags */} + + + + +{/* Open Graph / Facebook */} + + + + + + + + + +{ + articleDate && ( + <> + + + + ) +} + +{/* Twitter */} + + + + + + +{/* Sitemap */} + + +{/* RSS auto-discovery */} + + + +{/* Webmentions */} +{ + WEBMENTION_URL && ( + <> + + {WEBMENTION_PINGBACK && } + + ) +} + + diff --git a/src/components/Button.tsx b/src/components/Button.tsx deleted file mode 100644 index a17201c..0000000 --- a/src/components/Button.tsx +++ /dev/null @@ -1,34 +0,0 @@ -import Link from 'next/link' -import clsx from 'clsx' - -const variantStyles = { - primary: - 'bg-zinc-800 font-semibold text-zinc-100 hover:bg-zinc-700 active:bg-zinc-800 active:text-zinc-100/70 dark:bg-zinc-700 dark:hover:bg-zinc-600 dark:active:bg-zinc-700 dark:active:text-zinc-100/70', - secondary: - 'bg-zinc-50 font-medium text-zinc-900 hover:bg-zinc-100 active:bg-zinc-100 active:text-zinc-900/60 dark:bg-zinc-800/50 dark:text-zinc-300 dark:hover:bg-zinc-800 dark:hover:text-zinc-50 dark:active:bg-zinc-800/50 dark:active:text-zinc-50/70', -} - -type ButtonProps = { - variant?: keyof typeof variantStyles -} & ( - | (React.ComponentPropsWithoutRef<'button'> & { href?: undefined }) - | React.ComponentPropsWithoutRef -) - -export function Button({ - variant = 'primary', - className, - ...props -}: ButtonProps) { - className = clsx( - 'inline-flex items-center gap-2 justify-center rounded-md py-2 px-3 text-sm outline-offset-2 transition active:transition-none', - variantStyles[variant], - className, - ) - - return typeof props.href === 'undefined' ? ( - - ) -} - -function clamp(number: number, a: number, b: number) { - let min = Math.min(a, b) - let max = Math.max(a, b) - return Math.min(Math.max(number, min), max) -} - -function AvatarContainer({ - className, - ...props -}: React.ComponentPropsWithoutRef<'div'>) { - return ( -
- ) -} - -function Avatar({ - large = false, - className, - ...props -}: Omit, 'href'> & { - large?: boolean -}) { - return ( - - - - ) -} - -export function Header() { - let isHomePage = usePathname() === '/' - - let headerRef = useRef>(null) - let avatarRef = useRef>(null) - let isInitial = useRef(true) - - useEffect(() => { - let downDelay = avatarRef.current?.offsetTop ?? 0 - let upDelay = 64 - - function setProperty(property: string, value: string) { - document.documentElement.style.setProperty(property, value) - } - - function removeProperty(property: string) { - document.documentElement.style.removeProperty(property) - } - - function updateHeaderStyles() { - if (!headerRef.current) { - return - } - - let { top, height } = headerRef.current.getBoundingClientRect() - let scrollY = clamp( - window.scrollY, - 0, - document.body.scrollHeight - window.innerHeight, - ) - - if (isInitial.current) { - setProperty('--header-position', 'sticky') - } - - setProperty('--content-offset', `${downDelay}px`) - - if (isInitial.current || scrollY < downDelay) { - setProperty('--header-height', `${downDelay + height}px`) - setProperty('--header-mb', `${-downDelay}px`) - } else if (top + height < -upDelay) { - let offset = Math.max(height, scrollY - upDelay) - setProperty('--header-height', `${offset}px`) - setProperty('--header-mb', `${height - offset}px`) - } else if (top === 0) { - setProperty('--header-height', `${scrollY + height}px`) - setProperty('--header-mb', `${-scrollY}px`) - } - - if (top === 0 && scrollY > 0 && scrollY >= downDelay) { - setProperty('--header-inner-position', 'fixed') - removeProperty('--header-top') - removeProperty('--avatar-top') - } else { - removeProperty('--header-inner-position') - setProperty('--header-top', '0px') - setProperty('--avatar-top', '0px') - } - } - - function updateAvatarStyles() { - if (!isHomePage) { - return - } - - let fromScale = 1 - let toScale = 36 / 64 - let fromX = 0 - let toX = 2 / 16 - - let scrollY = downDelay - window.scrollY - - let scale = (scrollY * (fromScale - toScale)) / downDelay + toScale - scale = clamp(scale, fromScale, toScale) - - let x = (scrollY * (fromX - toX)) / downDelay + toX - x = clamp(x, fromX, toX) - - setProperty( - '--avatar-image-transform', - `translate3d(${x}rem, 0, 0) scale(${scale})`, - ) - - let borderScale = 1 / (toScale / scale) - let borderX = (-toX + x) * borderScale - let borderTransform = `translate3d(${borderX}rem, 0, 0) scale(${borderScale})` - - setProperty('--avatar-border-transform', borderTransform) - setProperty('--avatar-border-opacity', scale === toScale ? '1' : '0') - } - - function updateStyles() { - updateHeaderStyles() - updateAvatarStyles() - isInitial.current = false - } - - updateStyles() - window.addEventListener('scroll', updateStyles, { passive: true }) - window.addEventListener('resize', updateStyles) - - return () => { - window.removeEventListener('scroll', updateStyles) - window.removeEventListener('resize', updateStyles) - } - }, [isHomePage]) - - return ( - <> -
- {isHomePage && ( - <> -
- -
-
- - -
-
-
- - )} -
- -
-
- {!isHomePage && ( - - - - )} -
-
- - -
-
-
- -
-
-
-
-
-
- {isHomePage && ( -
- )} - - ) -} diff --git a/src/components/Layout.tsx b/src/components/Layout.tsx deleted file mode 100644 index 12c48eb..0000000 --- a/src/components/Layout.tsx +++ /dev/null @@ -1,19 +0,0 @@ -import { Footer } from '@/components/Footer' -import { Header } from '@/components/Header' - -export function Layout({ children }: { children: React.ReactNode }) { - return ( - <> -
-
-
-
-
-
-
-
{children}
-
-
- - ) -} diff --git a/src/components/Paginator.astro b/src/components/Paginator.astro new file mode 100644 index 0000000..f05638f --- /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) && ( + + ) +} diff --git a/src/components/Prose.tsx b/src/components/Prose.tsx deleted file mode 100644 index 843ac9c..0000000 --- a/src/components/Prose.tsx +++ /dev/null @@ -1,10 +0,0 @@ -import clsx from 'clsx' - -export function Prose({ - className, - ...props -}: React.ComponentPropsWithoutRef<'div'>) { - return ( -
- ) -} diff --git a/src/components/Search.astro b/src/components/Search.astro new file mode 100644 index 0000000..e374249 --- /dev/null +++ b/src/components/Search.astro @@ -0,0 +1,19 @@ +--- +import "@/styles/blocks/search.css"; +--- + + + + + + diff --git a/src/components/Section.tsx b/src/components/Section.tsx deleted file mode 100644 index 4a2fc73..0000000 --- a/src/components/Section.tsx +++ /dev/null @@ -1,28 +0,0 @@ -import { useId } from 'react' - -export function Section({ - title, - children, -}: { - title: string - children: React.ReactNode -}) { - let id = useId() - - return ( -
-
-

- {title} -

-
{children}
-
-
- ) -} diff --git a/src/components/SimpleLayout.tsx b/src/components/SimpleLayout.tsx deleted file mode 100644 index 2023a38..0000000 --- a/src/components/SimpleLayout.tsx +++ /dev/null @@ -1,25 +0,0 @@ -import { Container } from '@/components/Container' - -export function SimpleLayout({ - title, - intro, - children, -}: { - title: string - intro: string - children?: React.ReactNode -}) { - return ( - -
-

- {title} -

-

- {intro} -

-
- {children &&
{children}
} -
- ) -} 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 @@ +skip to content + diff --git a/src/components/SocialIcons.tsx b/src/components/SocialIcons.tsx deleted file mode 100644 index ae7daab..0000000 --- a/src/components/SocialIcons.tsx +++ /dev/null @@ -1,44 +0,0 @@ -export function TwitterIcon(props: React.ComponentPropsWithoutRef<'svg'>) { - return ( - - ) -} - -export function MediumIcon(props: React.ComponentPropsWithoutRef<'svg'>) { - return ( - - ) -} - -export function InstagramIcon(props: React.ComponentPropsWithoutRef<'svg'>) { - return ( - - ) -} - -export function GitHubIcon(props: React.ComponentPropsWithoutRef<'svg'>) { - return ( - - ) -} - -export function LinkedInIcon(props: React.ComponentPropsWithoutRef<'svg'>) { - return ( - - ) -} 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", + }, +]; +--- + +
+

Find me on

+
    + { + socialLinks.map(({ friendlyName, isWebmention, link, name }) => ( +
  • + + +
  • + )) + } +
+
diff --git a/src/components/ThemeProvider.astro b/src/components/ThemeProvider.astro new file mode 100644 index 0000000..0aad79e --- /dev/null +++ b/src/components/ThemeProvider.astro @@ -0,0 +1,48 @@ +{/* Inlined to avoid FOUC. This is a parser blocking script. */} + diff --git a/src/components/ThemeToggle.astro b/src/components/ThemeToggle.astro new file mode 100644 index 0000000..8316886 --- /dev/null +++ b/src/components/ThemeToggle.astro @@ -0,0 +1,90 @@ + + + + + diff --git a/src/components/blog/Masthead.astro b/src/components/blog/Masthead.astro new file mode 100644 index 0000000..10783cd --- /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 && ( +
+ {data.coverImage.alt} +
+ ) +} +{data.draft ? (Draft) : null} +

+ {data.title} +

+
+

+ /{" "} + {readingTime} +

+ { + data.updatedDate && ( + + Updated: + + + ) + } +
+{ + !!data.tags?.length && ( +
+ + {data.tags.map((tag, i) => ( + <> + {/* prettier-ignore */} + + View more blogs with the tag {tag} + {i < data.tags.length - 1 && ", "} + + + ))} +
+ ) +} diff --git a/src/components/blog/PostPreview.astro b/src/components/blog/PostPreview.astro new file mode 100644 index 0000000..b31a712 --- /dev/null +++ b/src/components/blog/PostPreview.astro @@ -0,0 +1,21 @@ +--- +import type { CollectionEntry } from "astro:content"; +import type { HTMLTag, Polymorphic } from "astro/types"; +import FormattedDate from "@/components/FormattedDate.astro"; + +type Props = Polymorphic<{ as: Tag }> & { + post: CollectionEntry<"post">; + withDesc?: boolean; +}; + +const { as: Tag = "div", post, withDesc = false } = Astro.props; +--- + + + + {post.data.draft && (Draft) } + + {post.data.title} + + +{withDesc && {post.data.description}} diff --git a/src/components/blog/TOC.astro b/src/components/blog/TOC.astro new file mode 100644 index 0000000..2a45124 --- /dev/null +++ b/src/components/blog/TOC.astro @@ -0,0 +1,22 @@ +--- +import type { MarkdownHeading } from "astro"; +import { generateToc } from "@/utils/generateToc"; +import TOCHeading from "./TOCHeading.astro"; + +interface Props { + headings: MarkdownHeading[]; +} + +const { headings } = Astro.props; + +const toc = generateToc(headings); +--- + +
+ Table of Contents + +
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; +--- + +
  • 2 ? "ms-2" : ""}`}> + {text} + { + !!children.length && ( +
      + {children.map((subheading) => ( + + ))} +
    + ) + } +
  • diff --git a/src/components/blog/webmentions/Comments.astro b/src/components/blog/webmentions/Comments.astro new file mode 100644 index 0000000..838c562 --- /dev/null +++ b/src/components/blog/webmentions/Comments.astro @@ -0,0 +1,87 @@ +--- +import { Image } from "astro:assets"; +import { Icon } from "astro-icon/components"; +import type { WebmentionsChildren } from "@/types"; + +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 && ( +
    +

    + {comments.length} Mention{comments.length > 1 ? "s" : ""} +

    +
      + {comments.map((mention) => ( +
    • + {mention.author?.photo && mention.author.photo !== "" ? ( + mention.author.url && mention.author.url !== "" ? ( + + {mention.author?.name} + + ) : ( + {mention.author?.name} + ) + ) : null} +
      +
      +

      + {mention.author?.name} +

      + + + +
      +

      + {mention.content?.text} +

      +
      +
    • + ))} +
    +
    + ) +} 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 && ( +
    +

    + {likes.length} + {likes.length > 1 ? " People" : " Person"} liked this +

    + {!!likesToShow.length && ( +
      + {likesToShow.map((like) => ( +
    • + + + {like.author!.name} + + +
    • + ))} +
    + )} +
    + ) +} 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; +--- + +
    +

    Webmentions for this post

    +
    + + +
    +

    + Responses powered by{" "} + Webmentions +

    diff --git a/src/components/layout/Footer.astro b/src/components/layout/Footer.astro new file mode 100644 index 0000000..04960a9 --- /dev/null +++ b/src/components/layout/Footer.astro @@ -0,0 +1,24 @@ +--- +import { menuLinks, siteConfig } from "@/site.config"; + +const year = new Date().getFullYear(); +--- + +
    +
    + © {siteConfig.author} + {year}. 🚀 {siteConfig.title} 🌵  +
    + +
    diff --git a/src/components/layout/Header.astro b/src/components/layout/Header.astro new file mode 100644 index 0000000..965b413 --- /dev/null +++ b/src/components/layout/Header.astro @@ -0,0 +1,117 @@ +--- +import Search from "@/components/Search.astro"; +import ThemeToggle from "@/components/ThemeToggle.astro"; +import { menuLinks } from "@/site.config"; +import { siteConfig } from "../../site.config"; +--- + +
    +
    + + + {siteConfig.title} + + +
    + + + + + +
    + + diff --git a/src/components/note/Note.astro b/src/components/note/Note.astro new file mode 100644 index 0000000..e415692 --- /dev/null +++ b/src/components/note/Note.astro @@ -0,0 +1,46 @@ +--- +import { type CollectionEntry, render } from "astro:content"; +import type { HTMLTag, Polymorphic } from "astro/types"; +import FormattedDate from "@/components/FormattedDate.astro"; + +type Props = Polymorphic<{ as: Tag }> & { + note: CollectionEntry<"note">; + isPreview?: boolean | undefined; +}; + +const { as: Tag = "div", note, isPreview = false } = Astro.props; +const { Content } = await render(note); +--- + + diff --git a/src/layouts/Base.astro b/src/layouts/Base.astro new file mode 100644 index 0000000..a776168 --- /dev/null +++ b/src/layouts/Base.astro @@ -0,0 +1,54 @@ +--- +import BaseHead from "@/components/BaseHead.astro"; +import Footer from "@/components/layout/Footer.astro"; +import Header from "@/components/layout/Header.astro"; +import SkipLink from "@/components/SkipLink.astro"; +import ThemeProvider from "@/components/ThemeProvider.astro"; +import { siteConfig } from "@/site.config"; +import type { SiteMeta } from "@/types"; + +interface Props { + meta: SiteMeta; +} + +const { + meta: { articleDate, description = siteConfig.description, ogImage, title }, +} = Astro.props; +--- + + + + + + + + +
    +
    + +
    +