diff --git a/README.md b/README.md
index d63f5a0..aec7760 100644
--- a/README.md
+++ b/README.md
@@ -5,7 +5,7 @@ Static website built in Astro + Typescript + TailwindCSS.
 ## Requirements
 
 - Node.js 20+
-- (optional) rsync, for remote deployment.
+- (optional) rsync or LFTP, for remote deployment.
 - (optional) LibreOffice, for the story export script.
 
 ## Development
@@ -22,10 +22,18 @@ npm install
 
 ```bash
 npm run dev       # Start development server (quit with Ctrl-C)
-npm run sync      # Rebuild types from src/content/ files
+npm run sync      # Rebuild types from Astro config and src/content/ files
 npm run prettier  # Prettier formatting
 ```
 
+### Configuration
+
+The following optional environment variables can be set with `.env`:
+
+| Name | Type | Description | 
+|-|-|-|
+| `APACHE_CONFIG` | boolean | Whether to generate an `.htaccess` Apache config file at the root of the output directory or not. |
+
 ### Export story for upload
 
 Requires `libreoffice` to be installed and in your path.
@@ -40,8 +48,19 @@ npm run export-story -- -o ~/Documents/TO_UPLOAD slug-for-story-to-export
 npm run build
 ```
 
-Then, after configuring the `gallerybm` host (or the name of your choosing) in `~/.ssh/config`, you can use a command like:
+Then, if you're using rsync, after configuring the `gallerybm` host (or the name of your choosing) in `~/.ssh/config`, you can use a command like:
 
 ```bash
 rsync --delete-after -acP dist/ gallerybm:/home/public
 ```
+
+Or if you prefer LFTP, create a `.env` file at the root of the project:
+
+```env
+DEPLOY_LFTP_HOST=https://example-webdav-server.com
+DEPLOY_LFTP_USER=example_user
+DEPLOY_LFTP_PASSWORD=sup3r_s3cr3t_password
+DEPLOY_LFTP_TARGETFOLDER=sites/gallery.badmanners.xyz/
+```
+
+Then run `npm run deploy-lftp`.
diff --git a/astro.config.mjs b/astro.config.mjs
index 12c378e..f347120 100644
--- a/astro.config.mjs
+++ b/astro.config.mjs
@@ -1,4 +1,4 @@
-import { defineConfig } from "astro/config";
+import { defineConfig, envField } from "astro/config";
 import tailwindIntegration from "@astrojs/tailwind";
 import markdownIntegration from "@astropub/md";
 import pagefindIntegration from "astro-pagefind";
@@ -23,4 +23,11 @@ export default defineConfig({
   redirects: {
     "/stories": "/stories/1",
   },
+  experimental: {
+    env: {
+      schema: {
+        APACHE_CONFIG: envField.boolean({ context: "server", access: "public", default: false }),
+      },
+    },
+  },
 });
diff --git a/package-lock.json b/package-lock.json
index d7e4a48..cf66dbb 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
 {
   "name": "gallery.badmanners.xyz",
-  "version": "1.7.3",
+  "version": "1.7.4",
   "lockfileVersion": 3,
   "requires": true,
   "packages": {
     "": {
       "name": "gallery.badmanners.xyz",
-      "version": "1.7.3",
+      "version": "1.7.4",
       "hasInstallScript": true,
       "dependencies": {
         "@astrojs/check": "^0.9.2",
diff --git a/package.json b/package.json
index dfc0616..57c6412 100644
--- a/package.json
+++ b/package.json
@@ -1,7 +1,7 @@
 {
   "name": "gallery.badmanners.xyz",
   "type": "module",
-  "version": "1.7.3",
+  "version": "1.7.4",
   "scripts": {
     "postinstall": "astro sync",
     "dev": "astro dev",
@@ -12,7 +12,8 @@
     "check": "astro check",
     "astro": "astro",
     "prettier": "prettier --write .",
-    "export-story": "tsx scripts/export-story.ts"
+    "export-story": "tsx scripts/export-story.ts",
+    "deploy-lftp": "dotenv tsx scripts/deploy-lftp.ts --"
   },
   "dependencies": {
     "@astrojs/check": "^0.9.2",
diff --git a/public/.htaccess b/public/.htaccess
deleted file mode 100644
index 7228162..0000000
--- a/public/.htaccess
+++ /dev/null
@@ -1,4 +0,0 @@
-ErrorDocument 404 /404.html
-RedirectMatch 301 ^/stories(\/(index.html)?)?$ /stories/1/
-Redirect 301 /story/ /stories/
-Redirect 301 /game/ /games/
diff --git a/scripts/deploy-lftp.ts b/scripts/deploy-lftp.ts
new file mode 100644
index 0000000..fb41ffa
--- /dev/null
+++ b/scripts/deploy-lftp.ts
@@ -0,0 +1,69 @@
+import { spawn } from "node:child_process";
+import { join } from "node:path";
+import { program, Option } from "commander";
+
+interface DeployLftpOptions {
+  host: string;
+  user: string;
+  password: string;
+  targetFolder: string;
+  sourceFolder: string;
+  assetsFolder: string;
+}
+
+async function deployLftp({ host, user, password, targetFolder, sourceFolder, assetsFolder }: DeployLftpOptions) {
+  const process = spawn(
+    "lftp",
+    [
+      "-c",
+      [
+        `open -u ${user},${password} ${host}`,
+        `mirror --reverse --include-glob ${join(assetsFolder, "*")} --delete --only-missing --no-perms --verbose ${sourceFolder} ${targetFolder}`,
+        `mirror --reverse --exclude-glob ${join(assetsFolder, "*")} --delete                --no-perms --verbose ${sourceFolder} ${targetFolder}`,
+        `bye`,
+      ].join("\n"),
+    ],
+    {
+      stdio: "inherit",
+    },
+  );
+  await new Promise<void>((resolve, reject) => {
+    process.on("close", (code) => {
+      if (code === 0) {
+        resolve();
+      } else {
+        reject(`deploy-lftp failed with code ${code}`);
+      }
+    });
+  });
+}
+
+await program
+  .name("deploy-lftp")
+  .description("Deploy files to remote server with LFTP")
+  .addOption(
+    new Option("-h, --host <hostname>", "Hostname of the LFTP (i.e. WebDav, SCP, SFTP...) remote.").env(
+      "DEPLOY_LFTP_HOST",
+    ),
+  )
+  .addOption(new Option("-u, --user <username>", "Username portion of the LFTP credentials").env("DEPLOY_LFTP_USER"))
+  .addOption(
+    new Option("-p, --password <pass>", "Password portion of the LFTP credentials").env("DEPLOY_LFTP_PASSWORD"),
+  )
+  .addOption(
+    new Option("-t, --target-folder <remoteDir>", "Folder to mirror files to in the LFTP remote").env(
+      "DEPLOY_LFTP_TARGETFOLDER",
+    ),
+  )
+  .addOption(
+    new Option("-s, --source-folder <localDir>", "Folder to read files from in the local machine")
+      .env("DEPLOY_LFTP_SOURCEFOLDER")
+      .default("dist/"),
+  )
+  .addOption(
+    new Option("-a, --assets-folder <localDir>", "Directory inside of --source-folder of assets with hash-based names")
+      .env("DEPLOY_LFTP_ASSETSFOLDER")
+      .default("assets/"),
+  )
+  .action(deployLftp)
+  .parseAsync();
diff --git a/src/env.d.ts b/src/env.d.ts
index acef35f..338ba9e 100644
--- a/src/env.d.ts
+++ b/src/env.d.ts
@@ -1,2 +1,3 @@
+/// <reference path="../.astro/env.d.ts" />
 /// <reference path="../.astro/types.d.ts" />
 /// <reference types="astro/client" />
diff --git a/src/layouts/BaseLayout.astro b/src/layouts/BaseLayout.astro
index 7e0c3a5..2cd6138 100644
--- a/src/layouts/BaseLayout.astro
+++ b/src/layouts/BaseLayout.astro
@@ -5,11 +5,11 @@ import DarkModeScript from "../components/DarkModeScript.astro";
 import AgeRestrictedModal from "../components/AgeRestrictedModal.astro";
 
 type Props = {
-  pageTitle?: string;
+  pageTitle: string;
   lang?: string;
 };
 
-const { pageTitle = "Gallery", lang = "en" } = Astro.props;
+const { pageTitle, lang = "en" } = Astro.props;
 ---
 
 <html lang={lang}>
@@ -25,6 +25,7 @@ const { pageTitle = "Gallery", lang = "en" } = Astro.props;
     <meta name="viewport" content="width=device-width, initial-scale=1.0" />
     <meta name="generator" content={Astro.generator} />
     <title>{pageTitle} | Bad Manners</title>
+    <link rel="me" href="https://badmanners.xyz" />
     <link rel="me" href="https://meow.social/@BadManners" />
     <link
       rel="alternate"
diff --git a/src/layouts/GalleryLayout.astro b/src/layouts/GalleryLayout.astro
index 8d0fa53..a18b991 100644
--- a/src/layouts/GalleryLayout.astro
+++ b/src/layouts/GalleryLayout.astro
@@ -6,11 +6,12 @@ import logoBM from "../assets/images/logo_bm.png";
 import { t } from "../i18n";
 
 type Props = {
-  pageTitle?: string;
+  pageTitle: string;
   enablePagefind?: boolean;
+  class?: string;
 };
 
-const { pageTitle, enablePagefind } = Astro.props;
+const { pageTitle, enablePagefind, class: className } = Astro.props;
 const logo = await getImage({ src: logoBM, width: 192 });
 const currentYear = new Date().getFullYear().toString();
 ---
@@ -85,7 +86,7 @@ const currentYear = new Date().getFullYear().toString();
       </div>
     </div>
     <main
-      class="ml-0 max-w-6xl px-2 pb-12 pt-4 md:ml-60 md:px-4 print:pb-0"
+      class:list={[className, "ml-0 max-w-6xl px-2 pb-12 pt-4 md:ml-60 md:px-4 print:pb-0"]}
       data-pagefind-body={enablePagefind ? "" : undefined}
     >
       <slot />
diff --git a/src/pages/[...config].ts b/src/pages/[...config].ts
new file mode 100644
index 0000000..b470f7c
--- /dev/null
+++ b/src/pages/[...config].ts
@@ -0,0 +1,18 @@
+import type { APIRoute, GetStaticPaths } from "astro";
+import { APACHE_CONFIG } from "astro:env/server";
+
+const htaccess = `
+ErrorDocument 404 /404.html
+RedirectMatch 301 ^/stories(\/(index.html)?)?$ /stories/1/
+Redirect 301 /story/ /stories/
+Redirect 301 /game/ /games/
+`.trim();
+
+export const getStaticPaths: GetStaticPaths = async () => {
+  if (APACHE_CONFIG) {
+    return [{ params: { config: ".htaccess" }, props: { body: htaccess } }];
+  }
+  return [];
+};
+
+export const GET: APIRoute = ({ props: { body } }) => new Response(body);
diff --git a/src/pages/games.astro b/src/pages/games.astro
index da5af07..8ea9d46 100644
--- a/src/pages/games.astro
+++ b/src/pages/games.astro
@@ -11,10 +11,10 @@ const games = (
 ).sort((a, b) => b.data.pubDate.getTime() - a.data.pubDate.getTime());
 ---
 
-<GalleryLayout pageTitle="Games">
+<GalleryLayout pageTitle="Games" class="h-feed">
   <meta slot="head-description" property="og:description" content="Bad Manners || A game that I've gone and done." />
-  <h1 class="m-2 text-2xl font-semibold text-stone-800 dark:text-stone-100">Games</h1>
-  <p class="my-4">A game that I've gone and done.</p>
+  <h1 class="p-name m-2 text-2xl font-semibold text-stone-800 dark:text-stone-100">Games</h1>
+  <p class="p-summary my-4">A game that I've gone and done.</p>
   <ul class="my-6 flex flex-wrap items-start justify-center gap-4 text-center md:justify-normal">
     {
       games.map((game) => (
diff --git a/src/pages/index.astro b/src/pages/index.astro
index 57333b9..6435ada 100644
--- a/src/pages/index.astro
+++ b/src/pages/index.astro
@@ -55,25 +55,27 @@ const latestItems: LatestItemsEntry[] = [
   .slice(0, MAX_ITEMS);
 ---
 
-<GalleryLayout pageTitle="Gallery">
+<GalleryLayout pageTitle="Gallery" class="h-feed">
   <meta slot="head-description" property="og:description" content="Bad Manners || Welcome to my gallery!" />
-  <h1 class="m-2 text-2xl font-semibold text-stone-800 dark:text-stone-100">My gallery</h1>
-  <p class="my-4">
-    Welcome to my gallery! You can expect lots of safe vore/endosoma ahead. Use the navigation menu to navigate through
-    my content.
-  </p>
-  <ul class="list-disc pl-8">
-    <li><a class="text-link underline" href="/stories/1">Read my stories!</a></li>
-    <li><a class="text-link underline" href="/games/crossing-over">Play my visual novel!</a></li>
-    <li><a class="text-link underline" href="/tags">Find all content with a certain tag!</a></li>
-  </ul>
-  <p class="my-4">
-    For more information about me, please check out <a
-      class="text-link underline"
-      href="https://badmanners.xyz/"
-      target="_blank">my main website</a
-    >.
-  </p>
+  <h1 class="p-name m-2 text-2xl font-semibold text-stone-800 dark:text-stone-100">My gallery</h1>
+  <div class="p-summary">
+    <p class="my-4">
+      Welcome to my gallery! You can expect lots of safe vore/endosoma ahead. Use the navigation menu to navigate through
+      my content.
+    </p>
+    <ul class="list-disc pl-8">
+      <li><a class="text-link underline" href="/stories/1">Read my stories!</a></li>
+      <li><a class="text-link underline" href="/games/crossing-over">Play my visual novel!</a></li>
+      <li><a class="text-link underline" href="/tags">Find all content with a certain tag!</a></li>
+    </ul>
+    <p class="my-4">
+      For more information about me, please check out <a
+        class="text-link underline"
+        href="https://badmanners.xyz/"
+        target="_blank">my main website</a
+      >.
+    </p>
+  </div>
   <section class="my-2" aria-labelledby="latest-uploads">
     <h2 id="latest-uploads" class="p-2 text-xl font-semibold text-stone-800 dark:text-stone-100">Latest uploads</h2>
     <ul class="flex flex-wrap items-start justify-center gap-4 text-center md:justify-normal">
diff --git a/src/pages/stories/[page].astro b/src/pages/stories/[page].astro
index e6db5f2..a0e14da 100644
--- a/src/pages/stories/[page].astro
+++ b/src/pages/stories/[page].astro
@@ -22,17 +22,19 @@ const { page } = Astro.props;
 const totalPages = Math.ceil(page.total / page.size);
 ---
 
-<GalleryLayout pageTitle="Stories">
+<GalleryLayout pageTitle="Stories" class="h-feed">
   <meta slot="head-description" property="og:description" content={`Bad Manners || ${page.total} stories.`} />
-  <h1 class="m-2 text-2xl font-semibold text-stone-800 dark:text-stone-100">Stories</h1>
-  <p class="my-4">The bulk of my content!</p>
-  <p class="text-center font-light text-stone-950 dark:text-white">
-    {
-      page.start == page.end
-        ? `Displaying story #${page.start + 1}`
-        : `Displaying stories #${page.start + 1}–${page.end + 1}`
-    } / {page.total}
-  </p>
+  <h1 class="p-name m-2 text-2xl font-semibold text-stone-800 dark:text-stone-100">Stories</h1>
+  <div class="p-summary">
+    <p class="my-4">The bulk of my content!</p>
+    <p class="text-center font-light text-stone-950 dark:text-white">
+      {
+        page.start == page.end
+          ? `Displaying story #${page.start + 1}`
+          : `Displaying stories #${page.start + 1}–${page.end + 1}`
+      } / {page.total}
+    </p>
+  </div>
   <div class="mx-auto mb-6 mt-2 flex w-fit rounded-lg border border-stone-400 dark:border-stone-500">
     {
       page.url.prev && (
diff --git a/src/pages/stories/the-lost-of-the-marshes.astro b/src/pages/stories/the-lost-of-the-marshes.astro
index 56f8e9b..93a0ca4 100644
--- a/src/pages/stories/the-lost-of-the-marshes.astro
+++ b/src/pages/stories/the-lost-of-the-marshes.astro
@@ -20,14 +20,14 @@ const bonusChapters = stories
 const mainChaptersWithSummaries = mainChapters.filter((story) => story.data.summary);
 ---
 
-<GalleryLayout pageTitle={series.data.name} enablePagefind={true}>
+<GalleryLayout pageTitle={series.data.name} enablePagefind={true} class="h-feed">
   <meta
     slot="head-description"
     property="og:description"
     content="The Lost of the Marshes || The story of Quince, Nikili, and Suu."
   />
-  <h1 class="m-2 text-2xl font-semibold text-stone-800 dark:text-stone-100">{series.data.name}</h1>
-  <p class="my-4">This is the main hub for the story of Quince, Nikili, and Suu, as well as all bonus content.</p>
+  <h1 class="p-name m-2 text-2xl font-semibold text-stone-800 dark:text-stone-100">{series.data.name}</h1>
+  <p class="p-summary my-4">This is the main hub for the story of Quince, Nikili, and Suu, as well as all bonus content.</p>
   <section class="my-2" aria-labelledby="main-chapters">
     <h2
       id="main-chapters"
@@ -73,7 +73,13 @@ const mainChaptersWithSummaries = mainChapters.filter((story) => story.data.summ
                   height={story.data.thumbnailHeight}
                 />
               ) : null}
-              <div class="p-name max-w-48 text-sm">{story.data.title}</div>
+              <div class="max-w-48 text-sm">
+                <span class="p-name">{story.data.title}</span>
+                <br />
+                <time class="dt-published italic" datetime={story.data.pubDate.toISOString().slice(0, 10)}>
+                  {story.data.pubDate.toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" })}
+                </time>
+              </div>
             </a>
           </li>
         ))
@@ -96,7 +102,13 @@ const mainChaptersWithSummaries = mainChapters.filter((story) => story.data.summ
                   height={story.data.thumbnailHeight}
                 />
               ) : null}
-              <div class="p-name max-w-48 text-sm">{story.data.title}</div>
+              <div class="max-w-48 text-sm">
+                <span class="p-name">{story.data.title}</span>
+                <br />
+                <time class="dt-published italic" datetime={story.data.pubDate.toISOString().slice(0, 10)}>
+                  {story.data.pubDate.toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" })}
+                </time>
+              </div>
             </a>
           </li>
         ))