From ca34a11ad4ce57de21ab079a8949d7effbeccadc Mon Sep 17 00:00:00 2001
From: Cory Dransfeldt <hi@coryd.dev>
Date: Sat, 16 Nov 2024 22:03:37 -0800
Subject: [PATCH] feat: numerous other pages

---
 .env                                          |   1 +
 _redirects                                    |  10 +-
 astro.config.mjs                              |   2 +-
 package-lock.json                             |  76 ++++++++-
 package.json                                  |   4 +-
 src/components/BlockRenderer.astro            |  35 ----
 src/components/Footer.astro                   |   2 +-
 src/components/Header.astro                   |   2 +-
 src/components/Metadata.astro                 | 155 ++++++++++++++++++
 src/components/blocks/BlockRenderer.astro     |  64 ++++++++
 src/components/blocks/MastodonPost.astro      |   7 -
 src/components/blocks/NowPlaying.astro        |   2 +-
 src/components/blocks/YouTubePlayer.astro     |   9 +-
 src/components/blocks/banners/Mastodon.astro  |  15 ++
 src/components/blocks/links/AddonLinks.astro  |  10 ++
 .../blocks/links/PopularPosts.astro           |  21 +++
 src/components/blocks/links/RecentLinks.astro |  26 +++
 src/components/{ => home}/Intro.astro         |   0
 .../{ => home}/RecentActivity.astro           |   0
 src/components/{ => home}/RecentPosts.astro   |   2 +-
 src/components/media/Grid.astro               |  70 ++++++++
 src/components/media/ProgressBar.astro        |   9 +
 src/components/media/music/Chart.astro        |  29 ++++
 src/components/media/music/Recent.astro       |  41 +++++
 src/components/media/watching/Hero.astro      |  18 ++
 src/env.d.ts                                  |   1 +
 src/layouts/Layout.astro                      |   6 +-
 src/pages/.well-known/webfinger.js            |  46 ++++++
 src/pages/[permalink].astro                   |   2 +-
 src/pages/feeds/json/all.json.js              |  22 +++
 src/pages/feeds/json/books.json.js            |  22 +++
 src/pages/feeds/json/links.json.js            |  22 +++
 src/pages/feeds/json/movies.json.js           |  22 +++
 src/pages/feeds/json/posts.json.js            |  22 +++
 src/pages/feeds/rss/all.xml.js                |  22 +++
 src/pages/feeds/rss/books.xml.js              |  22 +++
 src/pages/feeds/rss/links.xml.js              |  22 +++
 src/pages/feeds/rss/movies.xml.js             |  22 +++
 src/pages/feeds/rss/posts.xml.js              |  22 +++
 src/pages/feeds/{ => rss}/syndication.xml.js  |   4 +-
 src/pages/humans.txt.js                       |  29 ++++
 src/pages/index.astro                         |  10 +-
 src/pages/music/releases.ics.js               |  24 +++
 src/pages/posts/[...page].astro               |   2 +-
 src/pages/posts/[year]/[title].astro          |  23 ++-
 src/pages/robots.txt.js                       |   2 +-
 src/styles/components/banners.css             |   4 +
 src/styles/components/mastodon-post.css       |  22 ---
 src/styles/index.css                          |   1 -
 src/utils/albumReleasesCalendar.js            |  38 +++++
 src/utils/data/analytics.js                   |  34 ++++
 src/utils/generateJsonFeed.js                 |  39 +++++
 src/utils/generateRssFeed.js                  |  45 +++++
 src/utils/getPopularPosts.js                  |  13 ++
 54 files changed, 1074 insertions(+), 101 deletions(-)
 delete mode 100644 src/components/BlockRenderer.astro
 create mode 100644 src/components/Metadata.astro
 create mode 100644 src/components/blocks/BlockRenderer.astro
 delete mode 100644 src/components/blocks/MastodonPost.astro
 create mode 100644 src/components/blocks/banners/Mastodon.astro
 create mode 100644 src/components/blocks/links/AddonLinks.astro
 create mode 100644 src/components/blocks/links/PopularPosts.astro
 create mode 100644 src/components/blocks/links/RecentLinks.astro
 rename src/components/{ => home}/Intro.astro (100%)
 rename src/components/{ => home}/RecentActivity.astro (100%)
 rename src/components/{ => home}/RecentPosts.astro (93%)
 create mode 100644 src/components/media/Grid.astro
 create mode 100644 src/components/media/ProgressBar.astro
 create mode 100644 src/components/media/music/Chart.astro
 create mode 100644 src/components/media/music/Recent.astro
 create mode 100644 src/components/media/watching/Hero.astro
 create mode 100644 src/env.d.ts
 create mode 100644 src/pages/.well-known/webfinger.js
 create mode 100644 src/pages/feeds/json/all.json.js
 create mode 100644 src/pages/feeds/json/books.json.js
 create mode 100644 src/pages/feeds/json/links.json.js
 create mode 100644 src/pages/feeds/json/movies.json.js
 create mode 100644 src/pages/feeds/json/posts.json.js
 create mode 100644 src/pages/feeds/rss/all.xml.js
 create mode 100644 src/pages/feeds/rss/books.xml.js
 create mode 100644 src/pages/feeds/rss/links.xml.js
 create mode 100644 src/pages/feeds/rss/movies.xml.js
 create mode 100644 src/pages/feeds/rss/posts.xml.js
 rename src/pages/feeds/{ => rss}/syndication.xml.js (93%)
 create mode 100644 src/pages/humans.txt.js
 create mode 100644 src/pages/music/releases.ics.js
 delete mode 100644 src/styles/components/mastodon-post.css
 create mode 100644 src/utils/albumReleasesCalendar.js
 create mode 100644 src/utils/data/analytics.js
 create mode 100644 src/utils/generateJsonFeed.js
 create mode 100644 src/utils/generateRssFeed.js
 create mode 100644 src/utils/getPopularPosts.js

diff --git a/.env b/.env
index 706bf39..a999c62 100644
--- a/.env
+++ b/.env
@@ -1,4 +1,5 @@
 ACCOUNT_ID_PLEX=
+API_KEY_PLAUSIBLE=
 SUPABASE_URL=
 SUPABASE_KEY=
 CF_ACCOUNT_ID=
diff --git a/_redirects b/_redirects
index 6b2361f..6e0cb07 100644
--- a/_redirects
+++ b/_redirects
@@ -5,11 +5,11 @@
 # feeds
 /feed.xml /feeds/posts 301
 /follow.xml /feeds/all 301
-/feeds/posts.xml /feeds/posts 301
-/feeds/links.xml /feeds/links 301
-/feeds/books.xml /feeds/books 301
-/feeds/movies.xml /feeds/movies 301
-/feeds/all.xml /feeds/all 301
+/feeds/posts /feeds/posts.xml 301
+/feeds/links /feeds/links.xml 301
+/feeds/books /feeds/books.xml 301
+/feeds/movies /feeds/movies.xml 301
+/feeds/all /feeds/all.xml 301
 /feeds/posts/ /feeds/posts 301
 /feeds/links/ /feeds/links 301
 /feeds/books/ /feeds/books 301
diff --git a/astro.config.mjs b/astro.config.mjs
index 1b19b5b..0e37b3b 100644
--- a/astro.config.mjs
+++ b/astro.config.mjs
@@ -15,10 +15,10 @@ export default defineConfig({
     },
     resolve: {
       alias: {
-        "@cdransf": "node_modules/@cdransf",
         "@components": "/src/components",
         "@data": "/src/utils/data",
         "@layouts": "/src/layouts",
+        "@npm": "/node_modules",
         "@scripts": "/src/scripts",
         "@styles": "/src/styles",
         "@utils": "/src/utils",
diff --git a/package-lock.json b/package-lock.json
index c9e75be..3ad6f3a 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -12,10 +12,12 @@
         "@astrojs/react": "^3.6.2",
         "@tabler/icons-react": "^3.19.0",
         "astro": "^4.16.13",
-        "luxon": "^3.5.0"
+        "luxon": "^3.5.0",
+        "youtube-video-element": "^1.1.6"
       },
       "devDependencies": {
         "@supabase/supabase-js": "^2.45.4",
+        "ics": "^3.8.1",
         "markdown-it": "^14.1.0",
         "markdown-it-anchor": "^9.2.0",
         "markdown-it-footnote": "^4.0.0",
@@ -3405,6 +3407,18 @@
       "integrity": "sha512-er295DKPVsV82j5kw1Gjt+ADA/XYHsajl82cGNQG2eyoPkvgUhX+nDIyelzhIWbbsXP39EHcI6l5tYs2FYqYXQ==",
       "license": "BSD-2-Clause"
     },
+    "node_modules/ics": {
+      "version": "3.8.1",
+      "resolved": "https://registry.npmjs.org/ics/-/ics-3.8.1.tgz",
+      "integrity": "sha512-UqQlfkajfhrS4pUGQfGIJMYz/Jsl/ob3LqcfEhUmLbwumg+ZNkU0/6S734Vsjq3/FYNpEcZVKodLBoe+zBM69g==",
+      "dev": true,
+      "license": "ISC",
+      "dependencies": {
+        "nanoid": "^3.1.23",
+        "runes2": "^1.1.2",
+        "yup": "^1.2.0"
+      }
+    },
     "node_modules/import-meta-resolve": {
       "version": "4.1.0",
       "resolved": "https://registry.npmjs.org/import-meta-resolve/-/import-meta-resolve-4.1.0.tgz",
@@ -5108,6 +5122,13 @@
         "node": ">=6"
       }
     },
+    "node_modules/property-expr": {
+      "version": "2.0.6",
+      "resolved": "https://registry.npmjs.org/property-expr/-/property-expr-2.0.6.tgz",
+      "integrity": "sha512-SVtmxhRE/CGkn3eZY1T6pC8Nln6Fr/lu1mKSgRud0eC73whjGfoAogbn78LkD8aFL0zz3bAFerKSnOl7NlErBA==",
+      "dev": true,
+      "license": "MIT"
+    },
     "node_modules/property-information": {
       "version": "6.5.0",
       "resolved": "https://registry.npmjs.org/property-information/-/property-information-6.5.0.tgz",
@@ -5598,6 +5619,13 @@
         "queue-microtask": "^1.2.2"
       }
     },
+    "node_modules/runes2": {
+      "version": "1.1.4",
+      "resolved": "https://registry.npmjs.org/runes2/-/runes2-1.1.4.tgz",
+      "integrity": "sha512-LNPnEDPOOU4ehF71m5JoQyzT2yxwD6ZreFJ7MxZUAoMKNMY1XrAo60H1CUoX5ncSm0rIuKlqn9JZNRrRkNou2g==",
+      "dev": true,
+      "license": "MIT"
+    },
     "node_modules/scheduler": {
       "version": "0.23.2",
       "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz",
@@ -5877,6 +5905,13 @@
         "url": "https://github.com/sponsors/ljharb"
       }
     },
+    "node_modules/tiny-case": {
+      "version": "1.0.3",
+      "resolved": "https://registry.npmjs.org/tiny-case/-/tiny-case-1.0.3.tgz",
+      "integrity": "sha512-Eet/eeMhkO6TX8mnUteS9zgPbUMQa4I6Kkp5ORiBD5476/m+PIRiumP5tmh5ioJpH7k51Kehawy2UDfsnxxY8Q==",
+      "dev": true,
+      "license": "MIT"
+    },
     "node_modules/tiny-glob": {
       "version": "0.2.9",
       "resolved": "https://registry.npmjs.org/tiny-glob/-/tiny-glob-0.2.9.tgz",
@@ -5911,6 +5946,13 @@
         "node": ">=8.0"
       }
     },
+    "node_modules/toposort": {
+      "version": "2.0.2",
+      "resolved": "https://registry.npmjs.org/toposort/-/toposort-2.0.2.tgz",
+      "integrity": "sha512-0a5EOkAUp8D4moMi2W8ZF8jcga7BgZd91O/yabJCFY8az+XSzeGyTKs0Aoo897iV1Nj6guFq8orWDS96z91oGg==",
+      "dev": true,
+      "license": "MIT"
+    },
     "node_modules/tr46": {
       "version": "0.0.3",
       "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz",
@@ -6943,6 +6985,38 @@
         "stacktracey": "^2.1.8"
       }
     },
+    "node_modules/youtube-video-element": {
+      "version": "1.1.6",
+      "resolved": "https://registry.npmjs.org/youtube-video-element/-/youtube-video-element-1.1.6.tgz",
+      "integrity": "sha512-EaHyEh68twtuWn6S7cCEghJkLfaOD82wmJhczeWSTxT71yOG6lL7EXu6EAHADj6wPQJ9+lZpaos3f/Bh8Lzvjg==",
+      "license": "MIT"
+    },
+    "node_modules/yup": {
+      "version": "1.4.0",
+      "resolved": "https://registry.npmjs.org/yup/-/yup-1.4.0.tgz",
+      "integrity": "sha512-wPbgkJRCqIf+OHyiTBQoJiP5PFuAXaWiJK6AmYkzQAh5/c2K9hzSApBZG5wV9KoKSePF7sAxmNSvh/13YHkFDg==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "property-expr": "^2.0.5",
+        "tiny-case": "^1.0.3",
+        "toposort": "^2.0.2",
+        "type-fest": "^2.19.0"
+      }
+    },
+    "node_modules/yup/node_modules/type-fest": {
+      "version": "2.19.0",
+      "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-2.19.0.tgz",
+      "integrity": "sha512-RAH822pAdBgcNMAfWnCBU3CFZcfZ/i1eZjwFU/dsLKumyuuP3niueg2UAukXYF0E2AAoc82ZSSf9J0WQBinzHA==",
+      "dev": true,
+      "license": "(MIT OR CC0-1.0)",
+      "engines": {
+        "node": ">=12.20"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/sindresorhus"
+      }
+    },
     "node_modules/zod": {
       "version": "3.23.8",
       "resolved": "https://registry.npmjs.org/zod/-/zod-3.23.8.tgz",
diff --git a/package.json b/package.json
index 2606701..4a4aa90 100644
--- a/package.json
+++ b/package.json
@@ -16,10 +16,12 @@
     "@astrojs/react": "^3.6.2",
     "@tabler/icons-react": "^3.19.0",
     "astro": "^4.16.13",
-    "luxon": "^3.5.0"
+    "luxon": "^3.5.0",
+    "youtube-video-element": "^1.1.6"
   },
   "devDependencies": {
     "@supabase/supabase-js": "^2.45.4",
+    "ics": "^3.8.1",
     "markdown-it": "^14.1.0",
     "markdown-it-anchor": "^9.2.0",
     "markdown-it-footnote": "^4.0.0",
diff --git a/src/components/BlockRenderer.astro b/src/components/BlockRenderer.astro
deleted file mode 100644
index 17e391d..0000000
--- a/src/components/BlockRenderer.astro
+++ /dev/null
@@ -1,35 +0,0 @@
----
-import AssociatedMedia from '@components/blocks//AssociatedMedia.astro';
-import GitHub from '@components/blocks/banners/GitHub.astro';
-import Hero from '@components/blocks//Hero.astro';
-import Modal from '@components/blocks//Modal.astro';
-import YouTubePlayer from '@components/blocks//YouTubePlayer.astro';
-import { md } from '@utils/helpers.js';
-
-const { block } = Astro.props;
-const htmlContent = block.type === 'markdown' ? md(block.text) : '';
----
-
-{block.type === 'youtube_player' && (
-  <YouTubePlayer url={block.url} />
-)}
-
-{block.type === 'hero' && (
-  <Hero image={block.image} alt={block.alt} />
-)}
-
-{block.type === 'markdown' && (
-  <div set:html={htmlContent}></div>
-)}
-
-{block.type === 'modal' && (
-  <Modal content={block.content} />
-)}
-
-{block.type === 'associated_media' && (
-  <AssociatedMedia media={block.media} />
-)}
-
-{block.type === 'github_banner' && (
-  <GitHub url={block.url} />
-)}
\ No newline at end of file
diff --git a/src/components/Footer.astro b/src/components/Footer.astro
index 13f4c49..78720e1 100644
--- a/src/components/Footer.astro
+++ b/src/components/Footer.astro
@@ -1,5 +1,5 @@
 ---
-import NavLink from './nav/NavLink.astro';
+import NavLink from '@components/nav/NavLink.astro';
 
 const { nav, updated } = Astro.props;
 ---
diff --git a/src/components/Header.astro b/src/components/Header.astro
index 7ed6a85..a1c48f4 100644
--- a/src/components/Header.astro
+++ b/src/components/Header.astro
@@ -1,5 +1,5 @@
 ---
-import Menu from './nav/Menu.astro';
+import Menu from '@components/nav/Menu.astro';
 
 const { nav, siteName, url } = Astro.props;
 const isHomePage = url === '/';
diff --git a/src/components/Metadata.astro b/src/components/Metadata.astro
new file mode 100644
index 0000000..f93a169
--- /dev/null
+++ b/src/components/Metadata.astro
@@ -0,0 +1,155 @@
+---
+const {
+  schema,
+  page,
+  globals,
+  post,
+  title,
+  description,
+  artist,
+  movie,
+  movies,
+  show,
+  tv,
+  book,
+  books,
+  genre,
+  year,
+} = Astro.props;
+
+const fullUrl = `${globals.url}${page.url}`;
+let pageTitle = globals.site_name;
+let pageDescription = globals.site_description;
+let ogImage = `${globals.cdn_url}${globals.avatar}`;
+
+switch (schema) {
+  case "blog":
+    pageTitle = post?.title || pageTitle;
+    pageDescription = post?.description || pageDescription;
+    ogImage = `${globals.cdn_url}${post?.image}`;
+    break;
+  case "music":
+  case "music-index":
+  case "music-period":
+    pageTitle = schema === "music" ? `Music / ${page.title}` : "Music";
+    if (schema === "music") {
+      ogImage = `${globals.cdn_url}${page?.image}`;
+    } else if (schema === "music-index") {
+      ogImage = `${globals.cdn_url}${movies?.week?.artists[0]?.grid?.image}`;
+    } else if (schema === "music-period") {
+      ogImage = `${globals.cdn_url}${page?.image}`;
+    }
+    break;
+  case "artist":
+    pageTitle = `Artists / ${artist?.name}`;
+    pageDescription = artist?.description || pageDescription;
+    ogImage = `${globals.cdn_url}${artist?.image}`;
+    break;
+  case "genre":
+    pageTitle = `Music / ${genre?.name}`;
+    pageDescription = genre?.description || pageDescription;
+    ogImage = `${globals.cdn_url}${genre?.artists[0]?.image}`;
+    break;
+  case "book":
+    pageTitle = `Books / ${book?.title} by ${book?.author}`;
+    pageDescription = book?.review || book?.description || pageDescription;
+    ogImage = `${globals.cdn_url}${book?.image}`;
+    break;
+  case "books":
+    pageTitle = "Books";
+    const overviewBook = books?.all?.find((b) => b.status === "started");
+    ogImage = `${globals.cdn_url}${overviewBook?.image}`;
+    break;
+  case "books-year":
+    const bookYear = year?.data?.find((b) => b.status === "finished");
+    ogImage = `${globals.cdn_url}${bookYear?.image}`;
+    break;
+  case "movie":
+    pageTitle = `Movies / ${movie?.title}`;
+    if (movie?.rating) {
+      pageTitle += ` (${movie.rating})`;
+    }
+    pageDescription = movie?.review || movie?.description || pageDescription;
+    ogImage = `${globals.cdn_url}${movie?.backdrop}`;
+    break;
+  case "favorite-movies":
+    pageTitle = "Favorite movies";
+    const favoriteMovie = movies?.favorites?.[0];
+    ogImage = `${globals.cdn_url}${favoriteMovie?.backdrop}`;
+    break;
+  case "show":
+    pageTitle = `Show / ${show?.title}`;
+    pageDescription = show?.review || show?.description || pageDescription;
+    ogImage = `${globals.cdn_url}${show?.backdrop}`;
+    break;
+  case "favorite-shows":
+    pageTitle = "Favorite shows";
+    const favoriteShow = tv?.favorites?.[0];
+    ogImage = `${globals.cdn_url}${favoriteShow?.backdrop}`;
+    break;
+  case "watching":
+    pageTitle = "Watching";
+    const overviewMovie = movies?.recentlyWatched?.[0];
+    ogImage = `${globals.cdn_url}${overviewMovie?.backdrop}`;
+    break;
+  case "page":
+    pageTitle = page?.title || pageTitle;
+    pageDescription = page?.description || pageDescription;
+    break;
+  default:
+    break;
+}
+
+if (title) pageTitle = title;
+if (description) pageDescription = description;
+if (pageTitle !== globals.site_name && schema !== "blog") pageTitle = `${pageTitle} / ${globals.site_name}`;
+
+const escapedPageDescription =
+  pageDescription?.replace(
+    /["'<>&]/g,
+    (c) =>
+      ({
+        '"': "&quot;",
+        "'": "&#39;",
+        "<": "&lt;",
+        ">": "&gt;",
+        "&": "&amp;",
+      })[c]
+  ) || "";
+---
+
+<meta charset="utf-8" />
+<meta name="viewport" content="width=device-width, initial-scale=1" />
+<meta name="color-scheme" content="light dark" />
+<title>{pageTitle}</title>
+<link rel="canonical" href={fullUrl} />
+<meta property="og:title" content={pageTitle} />
+<meta name="description" content={escapedPageDescription} />
+<meta property="og:description" content={escapedPageDescription} />
+<meta property="og:type" content="article" />
+<meta property="og:url" content={fullUrl} />
+<meta property="og:image" content={`${ogImage}?class=w800`} />
+<meta name="theme-color" content={globals.theme_color} />
+<meta name="fediverse:creator" content={globals.mastodon} />
+<meta name="generator" content="Astro" />
+<meta name="robots" content="noai, noimageai" />
+<link
+  href={`${globals.cdn_url}${globals.avatar_transparent}?class=w50`}
+  rel="icon"
+  sizes="any"
+/>
+<link
+  href={`${globals.cdn_url}${globals.avatar_transparent}?class=w50&type=svg`}
+  rel="icon"
+  type="image/svg+xml"
+/>
+<link
+  href={`${globals.cdn_url}${globals.avatar}?class=w800`}
+  rel="apple-touch-icon"
+/>
+<link
+  type="application/atom+xml"
+  rel="alternate"
+  title={`Posts / ${globals.site_name}`}
+  href="https://coryd.dev/feeds/posts"
+/>
diff --git a/src/components/blocks/BlockRenderer.astro b/src/components/blocks/BlockRenderer.astro
new file mode 100644
index 0000000..c182293
--- /dev/null
+++ b/src/components/blocks/BlockRenderer.astro
@@ -0,0 +1,64 @@
+---
+import { fetchAllPosts } from "@data/posts.js";
+import { fetchAnalyticsData } from "@data/analytics.js";
+import { fetchLinks } from "@data/links.js";
+
+import AddonLinks from '@components/blocks/links/AddonLinks.astro';
+import AssociatedMedia from '@components/blocks//AssociatedMedia.astro';
+import GitHub from '@components/blocks/banners/GitHub.astro';
+import Hero from '@components/blocks//Hero.astro';
+import Modal from '@components/blocks//Modal.astro';
+import Npm from '@components/blocks/banners/Npm.astro';
+import Rss from '@components/blocks/banners/Rss.astro';
+import YouTubePlayer from '@components/blocks//YouTubePlayer.astro';
+
+import { md } from '@utils/helpers.js';
+import { getPopularPosts } from '@utils/getPopularPosts.js';
+
+const analytics = await fetchAnalyticsData();
+const links = await fetchLinks();
+const posts = await fetchAllPosts();
+const popularPosts = getPopularPosts(posts, analytics);
+
+const { block } = Astro.props;
+---
+
+{block.type === 'addon_links' && (
+  <AddonLinks popularPosts={popularPosts} links={links} />
+)}
+
+{block.type === 'associated_media' && (
+  <AssociatedMedia media={block.media} />
+)}
+
+{block.type === 'divider' && (
+  <div set:html={md(block.markup)}></div>
+)}
+
+{block.type === 'github_banner' && (
+  <GitHub url={block.url} />
+)}
+
+{block.type === 'hero' && (
+  <Hero image={block.image} alt={block.alt} />
+)}
+
+{block.type === 'markdown' && (
+  <div set:html={md(block.text)}></div>
+)}
+
+{block.type === 'npm_banner' && (
+  <Npm url={block.url} command={block.command} />
+)}
+
+{block.type === 'modal' && (
+  <Modal content={block.content} />
+)}
+
+{block.type === 'rss_banner' && (
+  <Rss url={block.url} text={block.text} />
+)}
+
+{block.type === 'youtube_player' && (
+  <YouTubePlayer url={block.url} />
+)}
\ No newline at end of file
diff --git a/src/components/blocks/MastodonPost.astro b/src/components/blocks/MastodonPost.astro
deleted file mode 100644
index 06baf3e..0000000
--- a/src/components/blocks/MastodonPost.astro
+++ /dev/null
@@ -1,7 +0,0 @@
----
-const { post } = Astro.props;
----
-
-<article class="mastodon-post">
-  <p>{post.content}</p>
-</article>
\ No newline at end of file
diff --git a/src/components/blocks/NowPlaying.astro b/src/components/blocks/NowPlaying.astro
index cd629af..cfcaf08 100644
--- a/src/components/blocks/NowPlaying.astro
+++ b/src/components/blocks/NowPlaying.astro
@@ -1,5 +1,5 @@
 ---
-import { fetchNowPlaying } from '../../utils/data/nowPlaying.js';
+import { fetchNowPlaying } from '@utils/data/nowPlaying.js';
 
 const isProduction = import.meta.env.MODE === 'production';
 const nowPlayingData = await fetchNowPlaying();
diff --git a/src/components/blocks/YouTubePlayer.astro b/src/components/blocks/YouTubePlayer.astro
index 98339c1..1040ecd 100644
--- a/src/components/blocks/YouTubePlayer.astro
+++ b/src/components/blocks/YouTubePlayer.astro
@@ -1,10 +1,7 @@
 ---
+import YoutubeVideo from '@npm/youtube-video-element/dist/react.js';
+
 const { url } = Astro.props;
 ---
 
-<iframe
-  width="560"
-  height="315"
-  src={url}
-  allowfullscreen>
-</iframe>
\ No newline at end of file
+<YoutubeVideo src={url} controls />
\ No newline at end of file
diff --git a/src/components/blocks/banners/Mastodon.astro b/src/components/blocks/banners/Mastodon.astro
new file mode 100644
index 0000000..275a536
--- /dev/null
+++ b/src/components/blocks/banners/Mastodon.astro
@@ -0,0 +1,15 @@
+---
+import { IconBrandMastodon } from "@tabler/icons-react";
+
+const { url } = Astro.props;
+---
+<div class="banner mastodon">
+  <p>
+    <a
+      class="mastodon plausible-event-name=Discuss+on+Mastodon+post+footer"
+      href={url}
+    >
+      <IconBrandMastodon size={24} /> Discuss this post on Mastodon.
+    </a>
+  </p>
+</div>
diff --git a/src/components/blocks/links/AddonLinks.astro b/src/components/blocks/links/AddonLinks.astro
new file mode 100644
index 0000000..cdd04d4
--- /dev/null
+++ b/src/components/blocks/links/AddonLinks.astro
@@ -0,0 +1,10 @@
+---
+import PopularPosts from './PopularPosts.astro';
+import RecentLinks from './RecentLinks.astro';
+
+const { popularPosts, links } = Astro.props;
+---
+<div class="addon-links">
+  <PopularPosts popularPosts={popularPosts} />
+  <RecentLinks links={links} />
+</div>
\ No newline at end of file
diff --git a/src/components/blocks/links/PopularPosts.astro b/src/components/blocks/links/PopularPosts.astro
new file mode 100644
index 0000000..e52f5bc
--- /dev/null
+++ b/src/components/blocks/links/PopularPosts.astro
@@ -0,0 +1,21 @@
+---
+const { popularPosts } = Astro.props;
+import { IconChartBarPopular } from '@tabler/icons-react';
+---
+{popularPosts && popularPosts.length > 0 && (
+  <article>
+    <h3>
+      <a class="article" href="/posts">
+        <IconChartBarPopular size={24} />
+        Popular posts
+      </a>
+    </h3>
+    <ol type="1">
+      {popularPosts.slice(0, 5).map((post) => (
+        <li>
+          <a href={post.url}>{post.title}</a>
+        </li>
+      ))}
+    </ol>
+  </article>
+)}
diff --git a/src/components/blocks/links/RecentLinks.astro b/src/components/blocks/links/RecentLinks.astro
new file mode 100644
index 0000000..71930cf
--- /dev/null
+++ b/src/components/blocks/links/RecentLinks.astro
@@ -0,0 +1,26 @@
+---
+const { links } = Astro.props;
+import { IconLink } from '@tabler/icons-react';
+---
+{links && links.length > 0 && (
+  <article>
+    <h3>
+      <a class="link" href="/links">
+        <IconLink size={24} />
+        Recent links
+      </a>
+    </h3>
+    <ul>
+      {links.slice(0, 5).map((link) => (
+        <li>
+          <a href={link.link} title={link.title}>
+            {link.title}
+          </a>
+          {link.author && (
+            <> via <a href={link.author.url}>{link.author.name}</a></>
+          )}
+        </li>
+      ))}
+    </ul>
+  </article>
+)}
diff --git a/src/components/Intro.astro b/src/components/home/Intro.astro
similarity index 100%
rename from src/components/Intro.astro
rename to src/components/home/Intro.astro
diff --git a/src/components/RecentActivity.astro b/src/components/home/RecentActivity.astro
similarity index 100%
rename from src/components/RecentActivity.astro
rename to src/components/home/RecentActivity.astro
diff --git a/src/components/RecentPosts.astro b/src/components/home/RecentPosts.astro
similarity index 93%
rename from src/components/RecentPosts.astro
rename to src/components/home/RecentPosts.astro
index 5b19e0e..a222d3d 100644
--- a/src/components/RecentPosts.astro
+++ b/src/components/home/RecentPosts.astro
@@ -1,6 +1,6 @@
 ---
 import { IconClock, IconStar, IconArrowRight } from '@tabler/icons-react';
-import { fetchAllPosts } from '../utils/data/posts.js';
+import { fetchAllPosts } from '@utils/data/posts.js';
 import { md } from '@utils/helpers.js';
 
 const posts = await fetchAllPosts();
diff --git a/src/components/media/Grid.astro b/src/components/media/Grid.astro
new file mode 100644
index 0000000..9292eda
--- /dev/null
+++ b/src/components/media/Grid.astro
@@ -0,0 +1,70 @@
+---
+import Paginator from '@components/nav/Paginator.astro';
+
+const { data, globals, count, shape, pagination, loading = "lazy" } = Astro.props;
+const pageCount = pagination?.pages?.length || 0;
+const hidePagination = pageCount <= 1;
+
+function getImageAttributes(item, shape) {
+  let imageUrl = item.grid.image;
+  let imageClass = '';
+  let width = 0;
+  let height = 0;
+
+  switch (shape) {
+    case 'poster':
+      imageUrl = item.grid.backdrop;
+      imageClass = 'banner';
+      width = 256;
+      height = 170;
+      break;
+    case 'square':
+      imageClass = 'square';
+      width = 200;
+      height = 200;
+      break;
+    case 'vertical':
+      imageClass = 'vertical';
+      width = 200;
+      height = 307;
+      break;
+  }
+
+  return { imageUrl, imageClass, width, height };
+}
+---
+
+<div class={`media-grid ${shape}`}>
+  {data.slice(0, count).map((item) => {
+    const alt = item.grid.alt?.replace(/['"]/g, '');
+    const { imageUrl, imageClass, width, height } = getImageAttributes(item, shape);
+
+    return (
+      <a href={item.grid.url} title={alt}>
+        <div class="item media-overlay">
+          <div class="meta-text">
+            <div class="header">{item.grid.title}</div>
+            <div class="subheader">{item.grid.subtext}</div>
+          </div>
+          <img
+            srcSet={`
+              ${globals.cdn_url}${imageUrl}?class=${imageClass}sm&type=webp ${width}w,
+              ${globals.cdn_url}${imageUrl}?class=${imageClass}md&type=webp ${width * 2}w
+            `}
+            sizes={`(max-width: 450px) ${width}px, ${width * 2}px`}
+            src={`${globals.cdn_url}${imageUrl}?class=${imageClass}sm&type=webp`}
+            alt={alt}
+            loading={loading}
+            decoding="async"
+            width={width}
+            height={height}
+          />
+        </div>
+      </a>
+    );
+  })}
+</div>
+
+{!hidePagination && (
+  <Paginator pagination={pagination} />
+)}
diff --git a/src/components/media/ProgressBar.astro b/src/components/media/ProgressBar.astro
new file mode 100644
index 0000000..1c9799e
--- /dev/null
+++ b/src/components/media/ProgressBar.astro
@@ -0,0 +1,9 @@
+---
+const { percentage } = Astro.props;
+---
+
+{percentage && (
+  <div class="progress-bar-wrapper" title={percentage}>
+    <div style={`width: ${percentage}`} class="progress-bar"></div>
+  </div>
+)}
\ No newline at end of file
diff --git a/src/components/media/music/Chart.astro b/src/components/media/music/Chart.astro
new file mode 100644
index 0000000..b4670f3
--- /dev/null
+++ b/src/components/media/music/Chart.astro
@@ -0,0 +1,29 @@
+---
+import ProgressBar from '@components/media/ProgressBar.astro';
+
+const { data, count } = Astro.props;
+---
+
+<div class="music-chart">
+  <ol type="1">
+    {data.slice(0, count).map((item) => {
+      const percentage = `${item.chart.percentage}%`;
+      const playsLabel = item.chart.plays === 1 ? 'play' : 'plays';
+
+      return (
+        <li value={item.chart.rank}>
+          <div class="item">
+            <div class="info">
+              <a class="title" href={item.chart.url}>{item.chart.title}</a>
+              <span class="subtext">{item.chart.artist}</span>
+              <span class="subtext">
+                {item.chart.plays} {playsLabel}
+              </span>
+            </div>
+            <ProgressBar percentage={percentage} />
+          </div>
+        </li>
+      );
+    })}
+  </ol>
+</div>
\ No newline at end of file
diff --git a/src/components/media/music/Recent.astro b/src/components/media/music/Recent.astro
new file mode 100644
index 0000000..9921178
--- /dev/null
+++ b/src/components/media/music/Recent.astro
@@ -0,0 +1,41 @@
+---
+const { data, globals } = Astro.props;
+---
+
+<div class="music-chart">
+  {data.slice(0, 10).map((item) => (
+    <div class="item">
+      <div class="meta">
+        <a href={item.chart.url}>
+          <img
+            srcSet={`
+              ${globals.cdn_url}${item.chart.image}?class=w50&type=webp 50w,
+              ${globals.cdn_url}${item.chart.image}?class=w100&type=webp 100w
+            `}
+            sizes="(max-width: 450px) 50px, 100px"
+            src={`${globals.cdn_url}${item.chart.image}?class=w50&type=webp`}
+            alt={item.chart.alt.replace(/['"]/g, '')}
+            loading="lazy"
+            decoding="async"
+            width="64"
+            height="64"
+          />
+        </a>
+        <div class="meta-text">
+          <a class="title" href={item.chart.url}>{item.chart.title}</a>
+          <span class="subtext">{item.chart.subtext}</span>
+        </div>
+      </div>
+      <time dateTime={item.chart.played_at}>
+        {new Date(item.chart.played_at).toLocaleString("en-US", {
+          timeZone: "America/Los_Angeles",
+          month: "long",
+          day: "numeric",
+          hour: "numeric",
+          minute: "numeric",
+          hour12: true,
+        })}
+      </time>
+    </div>
+  ))}
+</div>
diff --git a/src/components/media/watching/Hero.astro b/src/components/media/watching/Hero.astro
new file mode 100644
index 0000000..078ef55
--- /dev/null
+++ b/src/components/media/watching/Hero.astro
@@ -0,0 +1,18 @@
+---
+import Hero from "@components/blocks/Hero.astro";
+
+const { movie, globals } = Astro.props;
+---
+
+<a href={movie.url}>
+  <div class="watching media-overlay hero">
+    <div class="meta-text">
+      <div class="header">{movie.title}</div>
+      <div class="subheader">
+        {movie.rating && <span class="rating">{movie.rating} </span>}
+        ({movie.year})
+      </div>
+    </div>
+    <Hero globals={globals} image={movie.backdrop} alt={movie.title} />
+  </div>
+</a>
diff --git a/src/env.d.ts b/src/env.d.ts
new file mode 100644
index 0000000..9bc5cb4
--- /dev/null
+++ b/src/env.d.ts
@@ -0,0 +1 @@
+/// <reference path="../.astro/types.d.ts" />
\ No newline at end of file
diff --git a/src/layouts/Layout.astro b/src/layouts/Layout.astro
index d7fe958..cbb9e18 100644
--- a/src/layouts/Layout.astro
+++ b/src/layouts/Layout.astro
@@ -1,8 +1,8 @@
 ---
 import "@styles/index.css";
-import Header from "../components/Header.astro";
-import Footer from "../components/Footer.astro";
-import { fetchNavigation } from "../utils/data/nav.js";
+import Header from "@components/Header.astro";
+import Footer from "@components/Footer.astro";
+import { fetchNavigation } from "@utils/data/nav.js";
 
 const currentUrl = Astro.url.pathname;
 const nav = await fetchNavigation();
diff --git a/src/pages/.well-known/webfinger.js b/src/pages/.well-known/webfinger.js
new file mode 100644
index 0000000..cddfeea
--- /dev/null
+++ b/src/pages/.well-known/webfinger.js
@@ -0,0 +1,46 @@
+import { fetchGlobals } from "@utils/data/globals";
+
+export async function GET() {
+  try {
+    const globals = await fetchGlobals();
+
+    const webfingerResponse = {
+      subject: `acct:${globals.webfinger_username}@${globals.webfinger_hostname}`,
+      aliases: [
+        `https://${globals.webfinger_hostname}/@${globals.webfinger_username}`,
+        `https://${globals.webfinger_hostname}/users/${globals.webfinger_username}`,
+      ],
+      links: [
+        {
+          rel: "http://webfinger.net/rel/profile-page",
+          type: "text/html",
+          href: `https://${globals.webfinger_hostname}/@${globals.webfinger_username}`,
+        },
+        {
+          rel: "self",
+          type: "application/activity+json",
+          href: `https://${globals.webfinger_hostname}/users/${globals.webfinger_username}`,
+        },
+        {
+          rel: "http://ostatus.org/schema/1.0/subscribe",
+          template: `https://${globals.webfinger_hostname}/authorize_interaction?uri={uri}`,
+        },
+        {
+          rel: "http://webfinger.net/rel/avatar",
+          type: "image/png",
+          href: `${globals.cdn_url}${globals.avatar}?class=squarebase`,
+        },
+      ],
+    };
+
+    return new Response(JSON.stringify(webfingerResponse), {
+      status: 200,
+      headers: {
+        "Content-Type": "application/jrd+json",
+      },
+    });
+  } catch (error) {
+    console.error("Error generating WebFinger response:", error);
+    return new Response("Error generating WebFinger response", { status: 500 });
+  }
+}
diff --git a/src/pages/[permalink].astro b/src/pages/[permalink].astro
index 2378f13..06faa28 100644
--- a/src/pages/[permalink].astro
+++ b/src/pages/[permalink].astro
@@ -1,6 +1,6 @@
 ---
 import Layout from '@layouts/Layout.astro';
-import BlockRenderer from '@components/BlockRenderer.astro';
+import BlockRenderer from '@components/blocks/BlockRenderer.astro';
 import { fetchGlobals } from '@utils/data/globals.js';
 import { fetchPages } from '@utils/data/pages';
 
diff --git a/src/pages/feeds/json/all.json.js b/src/pages/feeds/json/all.json.js
new file mode 100644
index 0000000..6cc1a4d
--- /dev/null
+++ b/src/pages/feeds/json/all.json.js
@@ -0,0 +1,22 @@
+import { generateJsonFeed } from '@utils/generateJsonFeed';
+import { fetchGlobals } from '@utils/data/globals';
+import { fetchActivity } from '@utils/data/activity';
+
+export async function GET() {
+  const globals = await fetchGlobals();
+  const activity = await fetchActivity();
+
+  const feed = generateJsonFeed({
+    permalink: "/feeds/all.json",
+    title: "All activity / Cory Dransfeldt",
+    globals,
+    data: activity,
+  });
+
+  return new Response(feed, {
+    status: 200,
+    headers: {
+      "Content-Type": "application/json",
+    },
+  });
+}
diff --git a/src/pages/feeds/json/books.json.js b/src/pages/feeds/json/books.json.js
new file mode 100644
index 0000000..54e2a61
--- /dev/null
+++ b/src/pages/feeds/json/books.json.js
@@ -0,0 +1,22 @@
+import { generateJsonFeed } from '@utils/generateJsonFeed';
+import { fetchGlobals } from '@utils/data/globals';
+import { fetchBooks } from '@utils/data/books';
+
+export async function GET() {
+  const globals = await fetchGlobals();
+  const books = await fetchBooks();
+
+  const feed = generateJsonFeed({
+    permalink: "/feeds/books.json",
+    title: "Books / Cory Dransfeldt",
+    globals,
+    data: books.feed,
+  });
+
+  return new Response(feed, {
+    status: 200,
+    headers: {
+      "Content-Type": "application/json",
+    },
+  });
+}
diff --git a/src/pages/feeds/json/links.json.js b/src/pages/feeds/json/links.json.js
new file mode 100644
index 0000000..98fdd5f
--- /dev/null
+++ b/src/pages/feeds/json/links.json.js
@@ -0,0 +1,22 @@
+import { generateJsonFeed } from '@utils/generateJsonFeed';
+import { fetchGlobals } from '@utils/data/globals';
+import { fetchLinks } from '@utils/data/links';
+
+export async function GET() {
+  const globals = await fetchGlobals();
+  const links = await fetchLinks();
+
+  const feed = generateJsonFeed({
+    permalink: "/feeds/links.json",
+    title: "Links / Cory Dransfeldt",
+    globals,
+    data: links,
+  });
+
+  return new Response(feed, {
+    status: 200,
+    headers: {
+      "Content-Type": "application/json",
+    },
+  });
+}
diff --git a/src/pages/feeds/json/movies.json.js b/src/pages/feeds/json/movies.json.js
new file mode 100644
index 0000000..852eeea
--- /dev/null
+++ b/src/pages/feeds/json/movies.json.js
@@ -0,0 +1,22 @@
+import { generateJsonFeed } from '@utils/generateJsonFeed';
+import { fetchGlobals } from '@utils/data/globals';
+import { fetchMovies } from '@utils/data/movies';
+
+export async function GET() {
+  const globals = await fetchGlobals();
+  const movies = await fetchMovies();
+
+  const feed = generateJsonFeed({
+    permalink: "/feeds/movies.json",
+    title: "Movies / Cory Dransfeldt",
+    globals,
+    data: movies.feed,
+  });
+
+  return new Response(feed, {
+    status: 200,
+    headers: {
+      "Content-Type": "application/json",
+    },
+  });
+}
diff --git a/src/pages/feeds/json/posts.json.js b/src/pages/feeds/json/posts.json.js
new file mode 100644
index 0000000..ea6e978
--- /dev/null
+++ b/src/pages/feeds/json/posts.json.js
@@ -0,0 +1,22 @@
+import { generateJsonFeed } from '@utils/generateJsonFeed';
+import { fetchGlobals } from '@utils/data/globals';
+import { fetchAllPosts } from '@utils/data/posts';
+
+export async function GET() {
+  const globals = await fetchGlobals();
+  const posts = await fetchAllPosts();
+
+  const feed = generateJsonFeed({
+    permalink: "/feeds/posts.json",
+    title: "Posts / Cory Dransfeldt",
+    globals,
+    data: posts,
+  });
+
+  return new Response(feed, {
+    status: 200,
+    headers: {
+      "Content-Type": "application/json",
+    },
+  });
+}
diff --git a/src/pages/feeds/rss/all.xml.js b/src/pages/feeds/rss/all.xml.js
new file mode 100644
index 0000000..77bac08
--- /dev/null
+++ b/src/pages/feeds/rss/all.xml.js
@@ -0,0 +1,22 @@
+import { generateRssFeed } from "@utils/generateRssFeed";
+import { fetchGlobals } from "@utils/data/globals";
+import { fetchActivity } from "@utils/data/activity";
+
+export async function GET() {
+  const globals = await fetchGlobals();
+  const activity = await fetchActivity();
+
+  const rss = generateRssFeed({
+    permalink: "/feeds/all.xml",
+    title: "All activity / Cory Dransfeldt",
+    globals,
+    data: activity,
+  });
+
+  return new Response(rss, {
+    status: 200,
+    headers: {
+      "Content-Type": "application/rss+xml",
+    },
+  });
+}
diff --git a/src/pages/feeds/rss/books.xml.js b/src/pages/feeds/rss/books.xml.js
new file mode 100644
index 0000000..187c346
--- /dev/null
+++ b/src/pages/feeds/rss/books.xml.js
@@ -0,0 +1,22 @@
+import { generateRssFeed } from "@utils/generateRssFeed";
+import { fetchGlobals } from "@utils/data/globals";
+import { fetchBooks } from '@utils/data/books';
+
+export async function GET() {
+  const globals = await fetchGlobals();
+  const books = await fetchBooks();
+
+  const rss = generateRssFeed({
+    permalink: "/feeds/books.xml",
+    title: "Books / Cory Dransfeldt",
+    globals,
+    data: books.feed,
+  });
+
+  return new Response(rss, {
+    status: 200,
+    headers: {
+      "Content-Type": "application/rss+xml",
+    },
+  });
+}
diff --git a/src/pages/feeds/rss/links.xml.js b/src/pages/feeds/rss/links.xml.js
new file mode 100644
index 0000000..e2219cd
--- /dev/null
+++ b/src/pages/feeds/rss/links.xml.js
@@ -0,0 +1,22 @@
+import { generateRssFeed } from "@utils/generateRssFeed";
+import { fetchGlobals } from "@utils/data/globals";
+import { fetchLinks } from '@utils/data/links';
+
+export async function GET() {
+  const globals = await fetchGlobals();
+  const links = await fetchLinks();
+
+  const rss = generateRssFeed({
+    permalink: "/feeds/links.xml",
+    title: "Links / Cory Dransfeldt",
+    globals,
+    data: links,
+  });
+
+  return new Response(rss, {
+    status: 200,
+    headers: {
+      "Content-Type": "application/rss+xml",
+    },
+  });
+}
diff --git a/src/pages/feeds/rss/movies.xml.js b/src/pages/feeds/rss/movies.xml.js
new file mode 100644
index 0000000..78a314d
--- /dev/null
+++ b/src/pages/feeds/rss/movies.xml.js
@@ -0,0 +1,22 @@
+import { generateRssFeed } from "@utils/generateRssFeed";
+import { fetchGlobals } from "@utils/data/globals";
+import { fetchMovies } from '@utils/data/movies';
+
+export async function GET() {
+  const globals = await fetchGlobals();
+  const movies = await fetchMovies();
+
+  const rss = generateRssFeed({
+    permalink: "/feeds/movies.xml",
+    title: "Movies / Cory Dransfeldt",
+    globals,
+    data: movies.feed,
+  });
+
+  return new Response(rss, {
+    status: 200,
+    headers: {
+      "Content-Type": "application/rss+xml",
+    },
+  });
+}
diff --git a/src/pages/feeds/rss/posts.xml.js b/src/pages/feeds/rss/posts.xml.js
new file mode 100644
index 0000000..53f9890
--- /dev/null
+++ b/src/pages/feeds/rss/posts.xml.js
@@ -0,0 +1,22 @@
+import { generateRssFeed } from "@utils/generateRssFeed";
+import { fetchGlobals } from "@utils/data/globals";
+import { fetchAllPosts } from '@utils/data/posts';
+
+export async function GET() {
+  const globals = await fetchGlobals();
+  const posts = await fetchAllPosts();
+
+  const rss = generateRssFeed({
+    permalink: "/feeds/posts.xml",
+    title: "Posts / Cory Dransfeldt",
+    globals,
+    data: posts,
+  });
+
+  return new Response(rss, {
+    status: 200,
+    headers: {
+      "Content-Type": "application/rss+xml",
+    },
+  });
+}
diff --git a/src/pages/feeds/syndication.xml.js b/src/pages/feeds/rss/syndication.xml.js
similarity index 93%
rename from src/pages/feeds/syndication.xml.js
rename to src/pages/feeds/rss/syndication.xml.js
index 2d704bc..b75af08 100644
--- a/src/pages/feeds/syndication.xml.js
+++ b/src/pages/feeds/rss/syndication.xml.js
@@ -1,5 +1,5 @@
-import fetchSyndication from '../../utils/data/syndication.js';
-import { fetchGlobals } from '../../utils/data/globals.js';
+import fetchSyndication from '@utils/data/syndication.js';
+import { fetchGlobals } from '@utils/data/globals.js';
 
 export async function GET() {
   const globals = await fetchGlobals();
diff --git a/src/pages/humans.txt.js b/src/pages/humans.txt.js
new file mode 100644
index 0000000..abe30cf
--- /dev/null
+++ b/src/pages/humans.txt.js
@@ -0,0 +1,29 @@
+import { fetchGlobals } from '@utils/data/globals';
+
+export async function GET() {
+  try {
+    const globals = await fetchGlobals();
+
+    const humansTxt = `
+## team
+
+${globals.site_name}
+${globals.url}
+${globals.mastodon}
+
+## colophon
+
+${globals.url}/colophon
+    `.trim();
+
+    return new Response(humansTxt, {
+      status: 200,
+      headers: {
+        'Content-Type': 'text/plain',
+      },
+    });
+  } catch (error) {
+    console.error('Error generating humans.txt:', error);
+    return new Response('Error generating humans.txt', { status: 500 });
+  }
+}
diff --git a/src/pages/index.astro b/src/pages/index.astro
index d8a2a25..51d8289 100644
--- a/src/pages/index.astro
+++ b/src/pages/index.astro
@@ -1,9 +1,9 @@
 ---
-import Layout from '../layouts/Layout.astro';
-import Intro from '../components/Intro.astro';
-import { fetchGlobals } from '../utils/data/globals';
-import RecentActivity from '../components/RecentActivity.astro';
-import RecentPosts from '../components/RecentPosts.astro';
+import { fetchGlobals } from '@utils/data/globals';
+import Layout from '@layouts/Layout.astro';
+import Intro from '@components/home/Intro.astro';
+import RecentActivity from '@components/home/RecentActivity.astro';
+import RecentPosts from '@components/home/RecentPosts.astro';
 
 const globals = await fetchGlobals();
 const schema = 'blog';
diff --git a/src/pages/music/releases.ics.js b/src/pages/music/releases.ics.js
new file mode 100644
index 0000000..3b2e2b0
--- /dev/null
+++ b/src/pages/music/releases.ics.js
@@ -0,0 +1,24 @@
+import { albumReleasesCalendar } from '@utils/albumReleasesCalendar';
+import { fetchAlbumReleases } from '@utils/data/albumReleases';
+
+export async function GET() {
+  try {
+    const { all: albumReleases } = await fetchAlbumReleases();
+    const icsContent = await albumReleasesCalendar(albumReleases);
+
+    if (!icsContent) return new Response('Error generating ICS file', { status: 500 });
+
+    return new Response(icsContent, {
+      status: 200,
+      headers: {
+        'Content-Type': 'text/calendar',
+        'Content-Disposition': 'attachment; filename="releases.ics"',
+      },
+    });
+  } catch (error) {
+    console.error('Error generating album releases ICS file:', error);
+    return new Response('Error generating album releases ICS file', {
+      status: 500,
+    });
+  }
+}
diff --git a/src/pages/posts/[...page].astro b/src/pages/posts/[...page].astro
index 395d6e1..93404b2 100644
--- a/src/pages/posts/[...page].astro
+++ b/src/pages/posts/[...page].astro
@@ -55,5 +55,5 @@ const pagination = {
         </article>
       ))}
 
-      <Paginator pagination={pagination} appVersion="1.0.0" />
+      <Paginator pagination={pagination} />
     </Layout>
\ No newline at end of file
diff --git a/src/pages/posts/[year]/[title].astro b/src/pages/posts/[year]/[title].astro
index 78a0c56..1a298f2 100644
--- a/src/pages/posts/[year]/[title].astro
+++ b/src/pages/posts/[year]/[title].astro
@@ -1,14 +1,26 @@
 ---
 import { IconStar } from "@tabler/icons-react";
+
 import { fetchAllPosts } from "@data/posts.js";
+import { fetchAnalyticsData } from "@data/analytics.js";
 import { fetchGlobals } from "@data/globals.js";
+import { fetchLinks } from "@data/links.js";
+
 import { md } from '@utils/helpers.js';
-import OldPost from "@components/blocks/banners/OldPost.astro";
-import BlockRenderer from "@components/BlockRenderer.astro";
+import { getPopularPosts } from '@utils/getPopularPosts.js';
+
+const analytics = await fetchAnalyticsData();
+const links = await fetchLinks();
+const posts = await fetchAllPosts();
+const popularPosts = getPopularPosts(posts, analytics);
+
+import AddonLinks from '@components/blocks/links/AddonLinks.astro';
 import AssociatedMedia from "@components/blocks/AssociatedMedia.astro";
-import MastodonPost from "@components/blocks/MastodonPost.astro";
-import Layout from "@layouts/Layout.astro";
+import BlockRenderer from "@components/blocks/BlockRenderer.astro";
 import Coffee from "@components/blocks/banners/Coffee.astro";
+import Layout from "@layouts/Layout.astro";
+import Mastodon from "@components/blocks/banners/Mastodon.astro";
+import OldPost from "@components/blocks/banners/OldPost.astro";
 
 export const prerender = true;
 
@@ -87,7 +99,7 @@ const htmlContent = md(post.content);
         post.blocks &&
           post.blocks.map((block) => <BlockRenderer block={block} />)
       }
-      <!-- {post.mastodon_url && <MastodonPost url={post.mastodon_url} />} -->
+      {post.mastodon_url && <Mastodon url={post.mastodon_url} />}
       <AssociatedMedia
         artists={post.artists}
         books={post.books}
@@ -97,6 +109,7 @@ const htmlContent = md(post.content);
         shows={post.shows}
       />
       <Coffee />
+      <AddonLinks popularPosts={popularPosts} links={links} />
     </div>
   </article>
 </Layout>
diff --git a/src/pages/robots.txt.js b/src/pages/robots.txt.js
index fbc6036..6c26b13 100644
--- a/src/pages/robots.txt.js
+++ b/src/pages/robots.txt.js
@@ -1,4 +1,4 @@
-import { fetchAllRobots } from '../utils//data/robots.js';
+import { fetchAllRobots } from '@utils//data/robots.js';
 
 export async function GET() {
   try {
diff --git a/src/styles/components/banners.css b/src/styles/components/banners.css
index 117ae9b..9a2cca4 100644
--- a/src/styles/components/banners.css
+++ b/src/styles/components/banners.css
@@ -24,6 +24,7 @@
   &.coffee,
   &.error,
   &.github,
+  &.mastodon,
   &.npm,
   &.old-post,
   &.rss,
@@ -37,6 +38,9 @@
     &.github {
       --banner-accent-color: var(--brand-github);
     }
+    &.mastodon {
+      --banner-accent-color: var(--brand-mastodon);
+    }
     &.npm {
       --banner-accent-color: var(--brand-npm);
     }
diff --git a/src/styles/components/mastodon-post.css b/src/styles/components/mastodon-post.css
deleted file mode 100644
index d6f794f..0000000
--- a/src/styles/components/mastodon-post.css
+++ /dev/null
@@ -1,22 +0,0 @@
-mastodon-post {
-  width: 100%;
-
-  .mastodon-post-wrapper {
-    & dl,
-    & dt {
-      display: flex;
-    }
-
-    & dl {
-      align-items: center;
-
-      & dd {
-        margin-left: var(--spacing-xs);
-
-        &:not(:last-child) {
-          margin-right: var(--spacing-lg);
-        }
-      }
-    }
-  }
-}
diff --git a/src/styles/index.css b/src/styles/index.css
index 38205f9..8bee591 100644
--- a/src/styles/index.css
+++ b/src/styles/index.css
@@ -29,7 +29,6 @@
 @import url("./components/banners.css") layer(components);
 @import url("./components/buttons.css") layer(components);
 @import url("./components/forms.css") layer(components);
-@import url("./components/mastodon-post.css") layer(components);
 @import url("./components/media-grid.css") layer(components);
 @import url("./components/menu.css") layer(components);
 @import url("./components/modal.css") layer(components);
diff --git a/src/utils/albumReleasesCalendar.js b/src/utils/albumReleasesCalendar.js
new file mode 100644
index 0000000..b67ec7c
--- /dev/null
+++ b/src/utils/albumReleasesCalendar.js
@@ -0,0 +1,38 @@
+import { DateTime } from "luxon";
+import { createEvents } from "ics";
+
+export async function albumReleasesCalendar(albumReleases) {
+  if (!albumReleases || albumReleases.length === 0) return "";
+
+  const events = albumReleases
+    .map((album) => {
+      const date = DateTime.fromISO(album["release_date"]);
+      if (!date.isValid) return null;
+
+      return {
+        start: [date.year, date.month, date.day],
+        startInputType: "local",
+        startOutputType: "local",
+        title: `Release: ${album["artist"]["name"]} - ${album["title"]}`,
+        description: `Check out this new album release: ${album["url"]}. Read more about ${album["artist"]["name"]} at https://coryd.dev${album["artist"]["url"]}`,
+        url: album["url"],
+        uid: `${date.toFormat("yyyyMMdd")}-${album["artist"]["name"]}-${album["title"]}@coryd.dev`,
+        timestamp: DateTime.now().toUTC().toFormat("yyyyMMdd'T'HHmmss'Z'"),
+      };
+    })
+    .filter((event) => event !== null);
+
+  const { error, value } = createEvents(events, {
+    calName: "Album releases calendar / coryd.dev",
+  });
+
+  if (error) {
+    console.error("Error creating events: ", error);
+    events.forEach((event, index) => {
+      console.error(`Event ${index}:`, event);
+    });
+    return "";
+  }
+
+  return value;
+}
diff --git a/src/utils/data/analytics.js b/src/utils/data/analytics.js
new file mode 100644
index 0000000..0f17e30
--- /dev/null
+++ b/src/utils/data/analytics.js
@@ -0,0 +1,34 @@
+let cachedPages = null;
+
+export async function fetchAnalyticsData() {
+  if (import.meta.env.MODE === "development" && cachedPages) return cachedPages;
+
+  const API_KEY_PLAUSIBLE = import.meta.env.API_KEY_PLAUSIBLE;
+  const url =
+    "https://plausible.io/api/v1/stats/breakdown?site_id=coryd.dev&period=6mo&property=event:page&limit=30";
+
+  try {
+    const res = await fetch(url, {
+      headers: {
+        Authorization: `Bearer ${API_KEY_PLAUSIBLE}`,
+      },
+    });
+
+    if (!res.ok) {
+      console.error(`Error fetching Plausible data: ${res.statusText}`);
+      return [];
+    }
+
+    const pages = await res.json();
+    const filteredPages = pages["results"].filter((p) =>
+      p["page"].includes("posts")
+    );
+
+    if (import.meta.env.MODE === "development") cachedPages = filteredPages;
+
+    return filteredPages;
+  } catch (error) {
+    console.error("Error fetching Plausible data:", error);
+    return [];
+  }
+}
diff --git a/src/utils/generateJsonFeed.js b/src/utils/generateJsonFeed.js
new file mode 100644
index 0000000..7c58c3a
--- /dev/null
+++ b/src/utils/generateJsonFeed.js
@@ -0,0 +1,39 @@
+export function generateJsonFeed({
+  permalink,
+  title,
+  globals,
+  data,
+}) {
+  const feed = {
+    version: "https://jsonfeed.org/version/1",
+    title,
+    home_page_url: globals.url,
+    feed_url: `${globals.url}${permalink}`,
+    description: globals.site_description,
+    icon: `${globals.cdn_url}${globals.avatar}?class=w200`,
+    author: {
+      name: globals.site_name,
+      url: globals.url,
+      avatar: `${globals.cdn_url}${globals.avatar}?class=w200`,
+    },
+    items: data.slice(0, 20).map((entry) => {
+      const text = entry.feed.description
+        ?.replace(/(<([^>]+)>)/gi, "")
+        ?.trim()
+        ?.replace(/\s+/g, " ")
+        ?.slice(0, 200);
+
+      return {
+        id: entry.feed.url,
+        url: entry.feed.url,
+        title: entry.feed.title,
+        content_html: text,
+        content_text: text,
+        summary: text,
+        date_published: new Date(entry.feed.date).toISOString(),
+      };
+    }),
+  };
+
+  return JSON.stringify(feed, null, 2);
+}
diff --git a/src/utils/generateRssFeed.js b/src/utils/generateRssFeed.js
new file mode 100644
index 0000000..f0c347f
--- /dev/null
+++ b/src/utils/generateRssFeed.js
@@ -0,0 +1,45 @@
+import { DateTime } from "luxon";
+
+export function generateRssFeed({ permalink, title, globals, data }) {
+  const rssItems = data.slice(0, 20).map((entry) => {
+    const entryFeed = entry.feed;
+    const rating = entry.rating;
+    const entryTitle = `${entryFeed.title}${
+      entryFeed.artist?.name ? ` via ${entryFeed.artist.name}` : ""
+    }${rating ? ` (${rating})` : ""}`;
+
+    return `
+      <item>
+        <title><![CDATA[${entryTitle}]]></title>
+        <link>${entryFeed.url}</link>
+        <pubDate>${DateTime.fromISO(entryFeed.date).toRFC2822()}</pubDate>
+        <guid isPermaLink="false">${entryFeed.url}</guid>
+        ${
+          entryFeed.image
+            ? `<enclosure url="${globals.cdn_url}${entryFeed.image}?class=w800&type=jpg" type="image/jpeg" />`
+            : ""
+        }
+        <description><![CDATA[${entryFeed.description}]]></description>
+      </item>`;
+  });
+
+  return `
+<?xml version="1.0" encoding="UTF-8" ?>
+<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
+  <channel>
+    <atom:link href="${globals.url}${permalink}" rel="self" type="application/rss+xml" />
+    <title><![CDATA[${title}]]></title>
+    <description><![CDATA[${globals.site_description}]]></description>
+    <link>${globals.url}${permalink}</link>
+    <lastBuildDate>${DateTime.now().toUTC().toRFC2822()}</lastBuildDate>
+    <image>
+      <title><![CDATA[${title}]]></title>
+      <link>${globals.url}${permalink}</link>
+      <url>${globals.cdn_url}${globals.avatar}?class=w200</url>
+      <width>144</width>
+      <height>144</height>
+    </image>
+    ${rssItems.join("\n")}
+  </channel>
+</rss>`;
+}
diff --git a/src/utils/getPopularPosts.js b/src/utils/getPopularPosts.js
new file mode 100644
index 0000000..7e33805
--- /dev/null
+++ b/src/utils/getPopularPosts.js
@@ -0,0 +1,13 @@
+export function getPopularPosts(posts, analytics) {
+  const filteredPosts = posts.filter((post) =>
+    analytics.some((p) => p.page.includes(post.url))
+  );
+
+  const sortedPosts = filteredPosts.sort((a, b) => {
+    const visitors = (page) =>
+      analytics.find((p) => p.page.includes(page.url))?.visitors || 0;
+    return visitors(b) - visitors(a);
+  });
+
+  return sortedPosts;
+}