aboutsummaryrefslogtreecommitdiffstats
path: root/src/pages
diff options
context:
space:
mode:
Diffstat (limited to 'src/pages')
-rw-r--r--src/pages/404.astro29
-rw-r--r--src/pages/about.astro47
-rw-r--r--src/pages/authors/[...id].astro58
-rw-r--r--src/pages/authors/index.astro27
-rw-r--r--src/pages/blog/[...id].astro299
-rw-r--r--src/pages/blog/[...page].astro58
-rw-r--r--src/pages/index.astro85
-rw-r--r--src/pages/robots.txt.ts13
-rw-r--r--src/pages/rss.xml.ts25
-rw-r--r--src/pages/tags/[...id].astro52
-rw-r--r--src/pages/tags/index.astro33
11 files changed, 726 insertions, 0 deletions
diff --git a/src/pages/404.astro b/src/pages/404.astro
new file mode 100644
index 0000000..5591bc7
--- /dev/null
+++ b/src/pages/404.astro
@@ -0,0 +1,29 @@
+---
+import Breadcrumbs from '@/components/Breadcrumbs.astro'
+import Link from '@/components/Link.astro'
+import PageHead from '@/components/PageHead.astro'
+import { buttonVariants } from '@/components/ui/button'
+import Layout from '@/layouts/Layout.astro'
+import { cn } from '@/lib/utils'
+---
+
+<Layout class="max-w-3xl">
+ <PageHead slot="head" title="404" />
+ <Breadcrumbs items={[{ label: '???', icon: 'lucide:circle-help' }]} />
+
+ <section
+ class="flex flex-col items-center justify-center gap-y-4 text-center"
+ >
+ <div class="max-w-md">
+ <h1 class="mb-4 text-3xl font-medium">404: Page not found</h1>
+ <p class="prose">Oops! The page you're looking for doesn't exist.</p>
+ </div>
+ <Link
+ href="/"
+ class={cn(buttonVariants({ variant: 'outline' }), 'flex gap-x-1.5 group')}
+ >
+ <span class="transition-transform group-hover:-translate-x-1">&larr;</span
+ > Go to home page
+ </Link>
+ </section>
+</Layout>
diff --git a/src/pages/about.astro b/src/pages/about.astro
new file mode 100644
index 0000000..680c1b2
--- /dev/null
+++ b/src/pages/about.astro
@@ -0,0 +1,47 @@
+---
+import Breadcrumbs from '@/components/Breadcrumbs.astro'
+import Link from '@/components/Link.astro'
+import PageHead from '@/components/PageHead.astro'
+import ProjectCard from '@/components/ProjectCard.astro'
+import Layout from '@/layouts/Layout.astro'
+import { getAllProjects } from '@/lib/data-utils'
+
+const projects = await getAllProjects()
+---
+
+<Layout class="max-w-3xl">
+ <PageHead slot="head" title="About" />
+ <Breadcrumbs items={[{ label: 'About', icon: 'lucide:info' }]} />
+
+ <section>
+ <div class="min-w-full">
+ <div class="prose mb-8">
+ <p class="mt-0">
+ astro-erudite is an opinionated, unstyled static blogging template
+ that prioritizes simplicity and performance, built with <Link
+ href="https://astro.build"
+ external
+ underline>Astro</Link
+ >, <Link href="https://tailwindcss.com" external underline
+ >Tailwind</Link
+ >, and <Link href="https://ui.shadcn.com" external underline
+ >shadcn/ui</Link
+ >. It provides a clean foundation for your content while being
+ extremely easy to customize.
+ </p>
+ <p>
+ To learn more about the philosophy behind this template, check out the
+ following blog post: <Link
+ href="/blog/the-state-of-static-blogs"
+ underline>The State of Static Blogs in 2024</Link
+ >.
+ </p>
+ </div>
+
+ <h2 class="mb-4 text-2xl font-medium">Example Projects Listing</h2>
+ <div class="flex flex-col gap-4">
+ {projects.map((project) => <ProjectCard project={project} />)}
+ </div>
+ </div>
+ </section>
+</Layout>
diff --git a/src/pages/authors/[...id].astro b/src/pages/authors/[...id].astro
new file mode 100644
index 0000000..d71fcf8
--- /dev/null
+++ b/src/pages/authors/[...id].astro
@@ -0,0 +1,58 @@
+---
+import AuthorCard from '@/components/AuthorCard.astro'
+import BlogCard from '@/components/BlogCard.astro'
+import Breadcrumbs from '@/components/Breadcrumbs.astro'
+import PageHead from '@/components/PageHead.astro'
+import Layout from '@/layouts/Layout.astro'
+import { getAllAuthors, getPostsByAuthor } from '@/lib/data-utils'
+
+export async function getStaticPaths() {
+ const authors = await getAllAuthors()
+ return authors.map((author) => ({
+ params: { id: author.id },
+ props: { author },
+ }))
+}
+
+const { author } = Astro.props
+const authorPosts = await getPostsByAuthor(author.id)
+---
+
+<Layout class="max-w-3xl">
+ <PageHead
+ slot="head"
+ title={`${author.data.name} (Author)`}
+ description={author.data.bio || `Profile of ${author.data.name}.`}
+ noindex
+ />
+ <Breadcrumbs
+ items={[
+ { href: '/authors', label: 'Authors', icon: 'lucide:users' },
+ { label: author.data.name, icon: 'lucide:user' },
+ ]}
+ />
+
+ <section>
+ <AuthorCard author={author} />
+ </section>
+ <section class="flex flex-col gap-y-4">
+ <h2 class="text-2xl font-medium">Posts by {author.data.name}</h2>
+ {
+ authorPosts.length > 0 ? (
+ <ul class="flex flex-col gap-4">
+ {authorPosts
+ .filter((post) => !post.data.draft)
+ .map((post) => (
+ <li>
+ <BlogCard entry={post} />
+ </li>
+ ))}
+ </ul>
+ ) : (
+ <p class="text-muted-foreground">
+ No posts available from this author.
+ </p>
+ )
+ }
+ </section>
+</Layout>
diff --git a/src/pages/authors/index.astro b/src/pages/authors/index.astro
new file mode 100644
index 0000000..ba311a0
--- /dev/null
+++ b/src/pages/authors/index.astro
@@ -0,0 +1,27 @@
+---
+import AuthorCard from '@/components/AuthorCard.astro'
+import Breadcrumbs from '@/components/Breadcrumbs.astro'
+import PageHead from '@/components/PageHead.astro'
+import Layout from '@/layouts/Layout.astro'
+import { getAllAuthors } from '@/lib/data-utils'
+
+const authors = await getAllAuthors()
+---
+
+<Layout class="max-w-3xl">
+ <PageHead slot="head" title="Authors" />
+ <Breadcrumbs items={[{ label: 'Authors', icon: 'lucide:users' }]} />
+ {
+ authors.length > 0 ? (
+ <ul class="flex flex-col gap-4">
+ {authors.map((author) => (
+ <li>
+ <AuthorCard author={author} />
+ </li>
+ ))}
+ </ul>
+ ) : (
+ <p class="text-muted-foreground text-center">No authors found.</p>
+ )
+ }
+</Layout>
diff --git a/src/pages/blog/[...id].astro b/src/pages/blog/[...id].astro
new file mode 100644
index 0000000..5cc9eab
--- /dev/null
+++ b/src/pages/blog/[...id].astro
@@ -0,0 +1,299 @@
+---
+import Breadcrumbs from '@/components/Breadcrumbs.astro'
+import Link from '@/components/Link.astro'
+import PostHead from '@/components/PostHead.astro'
+import PostNavigation from '@/components/PostNavigation.astro'
+import SubpostsHeader from '@/components/SubpostsHeader.astro'
+import SubpostsSidebar from '@/components/SubpostsSidebar.astro'
+import TOCHeader from '@/components/TOCHeader.astro'
+import TOCSidebar from '@/components/TOCSidebar.astro'
+import { badgeVariants } from '@/components/ui/badge'
+import { Button } from '@/components/ui/button'
+import Layout from '@/layouts/Layout.astro'
+import {
+ getAdjacentPosts,
+ getAllPostsAndSubposts,
+ getCombinedReadingTime,
+ getParentId,
+ getParentPost,
+ getPostReadingTime,
+ getSubpostCount,
+ getTOCSections,
+ hasSubposts,
+ isSubpost,
+ parseAuthors,
+} from '@/lib/data-utils'
+import { formatDate } from '@/lib/utils'
+import { Icon } from 'astro-icon/components'
+import { Image } from 'astro:assets'
+import { render } from 'astro:content'
+
+export async function getStaticPaths() {
+ const posts = await getAllPostsAndSubposts()
+ return posts.map((post) => ({
+ params: { id: post.id },
+ props: post,
+ }))
+}
+
+const post = Astro.props
+const currentPostId = Astro.params.id
+const { Content, headings } = await render(post)
+const authors = await parseAuthors(post.data.authors ?? [])
+
+const isCurrentSubpost = isSubpost(currentPostId)
+const navigation = await getAdjacentPosts(currentPostId)
+const parentPost = isCurrentSubpost ? await getParentPost(currentPostId) : null
+
+const hasChildPosts = await hasSubposts(currentPostId)
+const subpostCount = !isCurrentSubpost
+ ? await getSubpostCount(currentPostId)
+ : 0
+const postReadingTime = await getPostReadingTime(currentPostId)
+const combinedReadingTime =
+ hasChildPosts && !isCurrentSubpost
+ ? await getCombinedReadingTime(currentPostId)
+ : null
+
+const tocSections = await getTOCSections(currentPostId)
+---
+
+<Layout>
+ <PostHead slot="head" post={post} />
+ {
+ (hasChildPosts || isCurrentSubpost) && (
+ <SubpostsHeader
+ slot="subposts-navigation"
+ parentId={isCurrentSubpost ? getParentId(currentPostId) : currentPostId}
+ />
+ )
+ }
+ {
+ headings?.length > 0 &&
+ !(
+ isCurrentSubpost &&
+ headings.length === 1 &&
+ headings[0].text === post.data.title
+ ) && <TOCHeader slot="table-of-contents" headings={headings} />
+ }
+
+ <section
+ class="grid grid-cols-[minmax(0px,1fr)_min(calc(var(--breakpoint-md)-2rem),100%)_minmax(0px,1fr)] gap-y-6"
+ >
+ <div class="col-start-2">
+ <Breadcrumbs
+ items={[
+ { href: '/blog', label: 'Blog', icon: 'lucide:library-big' },
+ ...(isCurrentSubpost && parentPost
+ ? [
+ {
+ href: `/blog/${parentPost.id}`,
+ label: parentPost.data.title,
+ icon: 'lucide:book-open',
+ },
+ {
+ href: `/blog/${currentPostId}`,
+ label: post.data.title,
+ icon: 'lucide:file-text',
+ },
+ ]
+ : [
+ {
+ href: `/blog/${currentPostId}`,
+ label: post.data.title,
+ icon: 'lucide:book-open-text',
+ },
+ ]),
+ ]}
+ />
+ </div>
+
+ {
+ post.data.image && (
+ <Image
+ src={post.data.image}
+ alt={post.data.title}
+ width={1200}
+ height={630}
+ class="col-span-full mx-auto w-full max-w-5xl object-cover"
+ />
+ )
+ }
+
+ <section class="col-start-2 flex flex-col gap-y-6 text-center">
+ <div class="flex flex-col">
+ <h1
+ class="mb-2 scroll-mt-31 text-3xl leading-tight font-medium sm:text-4xl"
+ id="post-title"
+ >
+ {post.data.title}
+ </h1>
+
+ <div
+ class="text-muted-foreground divide-border mb-4 flex flex-col items-center justify-center divide-y text-xs sm:flex-row sm:flex-wrap sm:divide-x sm:divide-y-0 sm:text-sm"
+ >
+ {
+ authors.length > 0 && (
+ <div class="flex w-full items-center justify-center gap-x-2 py-2 sm:w-fit sm:px-2 sm:py-0 first:sm:pl-0 last:sm:pr-0">
+ {authors.map((author) => (
+ <div class="flex items-center gap-x-1.5">
+ <Image
+ src={author.avatar}
+ alt={author.name}
+ width={20}
+ height={20}
+ class="rounded-full"
+ />
+ {author.isRegistered ? (
+ <Link
+ href={`/authors/${author.id}`}
+ underline
+ class="text-foreground"
+ >
+ <span>{author.name}</span>
+ </Link>
+ ) : (
+ <span>{author.name}</span>
+ )}
+ </div>
+ ))}
+ </div>
+ )
+ }
+
+ <div
+ class="flex w-full items-center justify-center gap-2 py-2 sm:w-fit sm:px-2 sm:py-0 first:sm:pl-0 last:sm:pr-0"
+ >
+ <span>{formatDate(post.data.date)}</span>
+ </div>
+
+ <div
+ class="flex w-full items-center justify-center gap-2 py-2 sm:w-fit sm:px-2 sm:py-0 first:sm:pl-0 last:sm:pr-0"
+ >
+ <span>
+ {postReadingTime}
+ {
+ combinedReadingTime &&
+ combinedReadingTime !== postReadingTime && (
+ <span class="text-muted-foreground">
+ {' '}
+ ({combinedReadingTime} total)
+ </span>
+ )
+ }
+ </span>
+ </div>
+
+ {
+ subpostCount > 0 && (
+ <div class="flex w-full items-center justify-center gap-1 py-2 sm:w-fit sm:px-2 sm:py-0 first:sm:pl-0 last:sm:pr-0">
+ <Icon name="lucide:file-text" class="size-3" />
+ {subpostCount} subpost{subpostCount === 1 ? '' : 's'}
+ </div>
+ )
+ }
+ </div>
+ <div class="flex flex-wrap justify-center gap-2">
+ {
+ post.data.tags &&
+ post.data.tags.length > 0 &&
+ post.data.tags.map((tag) => (
+ <a
+ href={`/tags/${tag}`}
+ class={badgeVariants({ variant: 'muted' })}
+ >
+ <Icon name="lucide:hash" class="size-3" />
+ {tag}
+ </a>
+ ))
+ }
+ </div>
+ </div>
+
+ <PostNavigation
+ newerPost={navigation.newer}
+ olderPost={navigation.older}
+ parentPost={isCurrentSubpost ? navigation.parent : undefined}
+ />
+ </section>
+
+ {
+ tocSections.length > 0 && (
+ <TOCSidebar sections={tocSections} currentPostId={currentPostId} />
+ )
+ }
+
+ <article class="prose col-start-2 max-w-none">
+ <Content />
+ </article>
+
+ {
+ (hasChildPosts || isCurrentSubpost) && (
+ <SubpostsSidebar
+ parentId={
+ isCurrentSubpost ? getParentId(currentPostId) : currentPostId
+ }
+ className="w-64"
+ />
+ )
+ }
+
+ <PostNavigation
+ newerPost={navigation.newer}
+ olderPost={navigation.older}
+ parentPost={isCurrentSubpost ? navigation.parent : undefined}
+ />
+ </section>
+
+ <Button
+ variant="outline"
+ size="icon"
+ className="group fixed right-8 bottom-8 z-50 hidden"
+ id="scroll-to-top"
+ title="Scroll to top"
+ aria-label="Scroll to top"
+ >
+ <Icon
+ name="lucide:arrow-up"
+ class="mx-auto size-4 transition-all group-hover:-translate-y-0.5"
+ />
+ </Button>
+
+ <script>
+ document.addEventListener('astro:page-load', () => {
+ const scrollToTopButton = document.getElementById('scroll-to-top')
+ const footer = document.querySelector('footer')
+
+ if (scrollToTopButton && footer) {
+ scrollToTopButton.addEventListener('click', () => {
+ window.scrollTo({ top: 0, behavior: 'smooth' })
+ })
+
+ window.addEventListener('scroll', () => {
+ const footerRect = footer.getBoundingClientRect()
+ const isFooterVisible = footerRect.top <= window.innerHeight
+
+ scrollToTopButton.classList.toggle(
+ 'hidden',
+ window.scrollY <= 300 || isFooterVisible,
+ )
+ })
+ }
+ })
+ </script>
+</Layout>
+
+<script>
+ const ensureKatexStyles = () => {
+ if (!document.querySelector('.katex')) return
+ if (document.querySelector('link[href*="katex.min.css"]')) return
+
+ const link = document.createElement('link')
+ link.rel = 'stylesheet'
+ link.href = 'https://cdn.jsdelivr.net/npm/katex@0.16.22/dist/katex.min.css'
+ document.head.appendChild(link)
+ }
+
+ document.addEventListener('astro:page-load', ensureKatexStyles)
+ document.addEventListener('astro:after-swap', ensureKatexStyles)
+</script> \ No newline at end of file
diff --git a/src/pages/blog/[...page].astro b/src/pages/blog/[...page].astro
new file mode 100644
index 0000000..eb196db
--- /dev/null
+++ b/src/pages/blog/[...page].astro
@@ -0,0 +1,58 @@
+---
+import BlogCard from '@/components/BlogCard.astro'
+import Breadcrumbs from '@/components/Breadcrumbs.astro'
+import PageHead from '@/components/PageHead.astro'
+import PaginationComponent from '@/components/ui/pagination'
+import { SITE } from '@/consts'
+import Layout from '@/layouts/Layout.astro'
+import { getAllPosts, groupPostsByYear } from '@/lib/data-utils'
+import type { PaginateFunction } from 'astro'
+
+export async function getStaticPaths({
+ paginate,
+}: {
+ paginate: PaginateFunction
+}) {
+ const allPosts = await getAllPosts()
+ return paginate(allPosts, { pageSize: SITE.postsPerPage })
+}
+
+const { page } = Astro.props
+
+const postsByYear = groupPostsByYear(page.data)
+const years = Object.keys(postsByYear).sort((a, b) => parseInt(b) - parseInt(a))
+---
+
+<Layout class="max-w-3xl">
+ <PageHead slot="head" title="Blog" />
+ <Breadcrumbs
+ items={[
+ { label: 'Blog', href: '/blog', icon: 'lucide:library-big' },
+ { label: `Page ${page.currentPage}`, icon: 'lucide:book-copy' },
+ ]}
+ />
+
+ <div class="flex min-h-[calc(100vh-18rem)] flex-col gap-y-8">
+ {
+ years.map((year) => (
+ <section class="flex flex-col gap-y-4">
+ <div class="font-medium">{year}</div>
+ <ul class="flex flex-col gap-4">
+ {postsByYear[year].map((post) => (
+ <li>
+ <BlogCard entry={post} />
+ </li>
+ ))}
+ </ul>
+ </section>
+ ))
+ }
+ </div>
+
+ <PaginationComponent
+ currentPage={page.currentPage}
+ totalPages={page.lastPage}
+ baseUrl="/blog/"
+ client:load
+ />
+</Layout>
diff --git a/src/pages/index.astro b/src/pages/index.astro
new file mode 100644
index 0000000..019283d
--- /dev/null
+++ b/src/pages/index.astro
@@ -0,0 +1,85 @@
+---
+import BlogCard from '@/components/BlogCard.astro'
+import Link from '@/components/Link.astro'
+import PageHead from '@/components/PageHead.astro'
+import { buttonVariants } from '@/components/ui/button'
+import { SITE } from '@/consts'
+import Layout from '@/layouts/Layout.astro'
+import { getRecentPosts } from '@/lib/data-utils'
+
+const blog = await getRecentPosts(SITE.featuredPostCount)
+---
+
+<Layout class="max-w-3xl">
+ <PageHead slot="head" title="Home" />
+ <section class="rounded-lg border">
+ <div class="flex flex-col space-y-1.5 p-6">
+ <h3 class="text-3xl leading-none font-medium">er·u·dite</h3>
+ <p class="text-muted-foreground text-sm">
+ /ˈer(y)əˌdīt/ &bull; <span class="font-medium">adjective</span>
+ </p>
+ </div>
+ <div class="p-6 pt-0">
+ <p class="text-muted-foreground mb-2 text-sm">
+ astro-erudite is an opinionated, unstyled static blogging template built
+ with <Link
+ href="https://astro.build"
+ class="text-foreground"
+ external
+ underline>Astro</Link
+ >, <Link
+ href="https://tailwindcss.com"
+ class="text-foreground"
+ external
+ underline>Tailwind</Link
+ >, and <Link
+ href="https://ui.shadcn.com"
+ class="text-foreground"
+ external
+ underline>shadcn/ui</Link
+ >. Extraordinarily loosely based on the <Link
+ href="https://astro-micro.vercel.app/"
+ class="text-foreground"
+ external
+ underline>Astro Micro</Link
+ > theme.
+ </p>
+ <p class="text-muted-foreground text-sm">
+ To use this template, check out the <Link
+ href="https://github.com/jktrn/astro-erudite"
+ class="text-foreground"
+ underline
+ external>GitHub</Link
+ > repository. To learn more about why this template exists, read this blog
+ post: <Link
+ href="/blog/the-state-of-static-blogs"
+ class="text-foreground"
+ underline>The State of Static Blogs in 2024</Link
+ >.
+ </p>
+ </div>
+ </section>
+ <section class="flex flex-col gap-y-4">
+ <h2 class="text-2xl font-medium">Latest posts</h2>
+ <ul class="flex flex-col gap-y-4">
+ {
+ blog.map((post) => (
+ <li>
+ <BlogCard entry={post} />
+ </li>
+ ))
+ }
+ </ul>
+ <div class="flex justify-center">
+ <Link
+ href="/blog"
+ class={buttonVariants({ variant: 'ghost' }) + ' group'}
+ >
+ See all posts <span
+ class="ml-1.5 transition-transform group-hover:translate-x-1"
+ >&rarr;</span
+ >
+ </Link>
+ </div>
+ </section>
+</Layout>
diff --git a/src/pages/robots.txt.ts b/src/pages/robots.txt.ts
new file mode 100644
index 0000000..679a44d
--- /dev/null
+++ b/src/pages/robots.txt.ts
@@ -0,0 +1,13 @@
+import type { APIRoute } from 'astro'
+
+const getRobotsTxt = (sitemapURL: URL) => `
+User-agent: *
+Allow: /
+
+Sitemap: ${sitemapURL.href}
+`
+
+export const GET: APIRoute = ({ site }) => {
+ const sitemapURL = new URL('sitemap-index.xml', site)
+ return new Response(getRobotsTxt(sitemapURL))
+}
diff --git a/src/pages/rss.xml.ts b/src/pages/rss.xml.ts
new file mode 100644
index 0000000..fe7e9d2
--- /dev/null
+++ b/src/pages/rss.xml.ts
@@ -0,0 +1,25 @@
+import { SITE } from '@/consts'
+import rss from '@astrojs/rss'
+import type { APIContext } from 'astro'
+import { getAllPosts } from '@/lib/data-utils'
+
+export async function GET(context: APIContext) {
+ try {
+ const posts = await getAllPosts()
+
+ return rss({
+ title: SITE.title,
+ description: SITE.description,
+ site: context.site ?? SITE.href,
+ items: posts.map((post) => ({
+ title: post.data.title,
+ description: post.data.description,
+ pubDate: post.data.date,
+ link: `/blog/${post.id}/`,
+ })),
+ })
+ } catch (error) {
+ console.error('Error generating RSS feed:', error)
+ return new Response('Error generating RSS feed', { status: 500 })
+ }
+}
diff --git a/src/pages/tags/[...id].astro b/src/pages/tags/[...id].astro
new file mode 100644
index 0000000..dfd4bb8
--- /dev/null
+++ b/src/pages/tags/[...id].astro
@@ -0,0 +1,52 @@
+---
+import BlogCard from '@/components/BlogCard.astro'
+import Breadcrumbs from '@/components/Breadcrumbs.astro'
+import PageHead from '@/components/PageHead.astro'
+import Layout from '@/layouts/Layout.astro'
+import { getAllTags, getPostsByTag } from '@/lib/data-utils'
+
+export async function getStaticPaths() {
+ const tagMap = await getAllTags()
+ const uniqueTags = Array.from(tagMap.keys())
+
+ return Promise.all(
+ uniqueTags.map(async (tag) => {
+ const posts = await getPostsByTag(tag)
+ return {
+ params: { id: tag },
+ props: {
+ tag,
+ posts,
+ },
+ }
+ }),
+ )
+}
+
+const { tag, posts } = Astro.props
+---
+
+<Layout class="max-w-3xl">
+ <PageHead
+ slot="head"
+ title={`Posts tagged with "${tag}"`}
+ description={`A collection of posts tagged with ${tag}.`}
+ noindex
+ />
+ <Breadcrumbs
+ items={[
+ { href: '/tags', label: 'Tags', icon: 'lucide:tags' },
+ { label: tag, icon: 'lucide:tag' },
+ ]}
+ />
+
+ <ul class="flex flex-col gap-y-4">
+ {
+ posts.map((post) => (
+ <li>
+ <BlogCard entry={post} />
+ </li>
+ ))
+ }
+ </ul>
+</Layout>
diff --git a/src/pages/tags/index.astro b/src/pages/tags/index.astro
new file mode 100644
index 0000000..3d2c75f
--- /dev/null
+++ b/src/pages/tags/index.astro
@@ -0,0 +1,33 @@
+---
+import Breadcrumbs from '@/components/Breadcrumbs.astro'
+import Link from '@/components/Link.astro'
+import PageHead from '@/components/PageHead.astro'
+import { badgeVariants } from '@/components/ui/badge'
+import Layout from '@/layouts/Layout.astro'
+import { getSortedTags } from '@/lib/data-utils'
+import { Icon } from 'astro-icon/components'
+
+const sortedTags = await getSortedTags()
+---
+
+<Layout class="max-w-3xl">
+ <PageHead slot="head" title="Tags" />
+ <Breadcrumbs items={[{ label: 'Tags', icon: 'lucide:tags' }]} />
+
+ <div class="flex flex-col gap-4">
+ <div class="flex flex-wrap gap-2">
+ {
+ sortedTags.map(({ tag, count }) => (
+ <Link
+ href={`/tags/${tag}`}
+ class={badgeVariants({ variant: 'muted' })}
+ >
+ <Icon name="lucide:hash" class="size-3" />
+ {tag}
+ <span class="text-muted-foreground ml-1.5">({count})</span>
+ </Link>
+ ))
+ }
+ </div>
+ </div>
+</Layout>