Add new posts for Image Voice Memos, Initial VPS Setup on Debian, Local Webmention Avatars, Security Headers for Astro with Caddy, and Setting up Forgejo Actions Runner

- Created a new post on Image Voice Memos detailing a macOS app for browsing photos and recording voice memos with automatic transcription.
- Added a guide for Initial VPS Setup on Debian covering system updates, user creation, and SSH hardening.
- Introduced a post on caching webmention avatars locally at build time to enhance privacy and comply with CSP.
- Documented the implementation of security headers for an Astro site behind Caddy, focusing on GDPR compliance and CSP.
- Set up a Forgejo Actions runner for self-hosted CI/CD, detailing the installation and configuration process for automated deployments.
This commit is contained in:
Adrian Altner 2026-04-22 23:00:10 +02:00
parent 9d22d93361
commit 4bf4eb03b1
69 changed files with 4904 additions and 344 deletions

View file

@ -0,0 +1,139 @@
---
title: 'Photo Albums with Astro''s Content Layer'
description: How the albums section is structured — from content collection to justified grid, album-scoped photo routes, and a sub-nav to switch between stream and albums.
pubDate: '2026-03-19T09:23:00+01:00'
category: en/development
tags:
- astro
- photography
seriesParent: building-the-photo-stream
seriesOrder: 1
---
The photos section of this site had a single view for a while: a chronological stream of every image across every location. That works as a photostream but makes it hard to follow a specific trip. I wanted album pages on top of the existing stream without rebuilding the stream itself.
## The setup
- **Astro 6** with the content layer — no `@astrojs/mdx` installed.
- **Album sources** under `src/content/photos/albums/<album>/`, each containing an `.md` file plus an `img/` folder with JPGs and Vision-generated JSON sidecars.
- **Existing stream** at `/photos` that renders all sidecars in one justified grid — untouched by this change.
## Content structure
Each album is a folder:
```
src/content/photos/albums/
chiang-mai/
chiang-mai.md ← metadata + editorial text
img/
2025-10-06-121017.jpg
2025-10-06-121017.json ← Vision sidecar
...
phuket/
phuket.md
img/
...
```
The `.md` file is registered as a content collection called `photos` in `content.config.ts`:
```ts
const photos = defineCollection({
loader: glob({
pattern: "**/*.{md,mdx}",
base: "./src/content/photos/albums",
}),
schema: z.object({
title: z.string(),
description: z.string(),
location: z.string().optional(),
publishDate: z.coerce.date().optional(),
draft: z.boolean().default(false),
}),
});
```
One non-obvious detail — the files must be `.md`, not `.mdx`. The project doesn't use `@astrojs/mdx`, so the glob loader has no handler for `.mdx` files and silently skips them. The collection appears empty with no error beyond a `[WARN] No entry type found` in the server log. Worth knowing before you spend twenty minutes staring at a blank listing page.
## Route structure
Three pages handle the albums section:
| Route | File |
|---|---|
| `/photos/albums` | `src/pages/photos/albums/index.astro` |
| `/photos/albums/[album]` | `src/pages/photos/albums/[album]/index.astro` |
| `/photos/albums/[album]/[id]` | `src/pages/photos/albums/[album]/[id].astro` |
The individual photo detail page at `[album]/[id]` keeps the user inside the album context. Prev/next navigation steps through photos in that album only, sorted by date. The back link in the middle of the pagination bar returns to the album grid.
## Albums listing page
**Problem:** The listing page needs a cover image per album — but the album `.md` file doesn't carry one, and I didn't want to hand-pick covers in frontmatter for every new album.
**Implementation:** Read all non-draft entries from the `photos` collection, then use `import.meta.glob` to pick the first image from each album folder as a cover:
```ts
const imageModules = import.meta.glob<{ default: ImageMetadata }>(
"/src/content/photos/albums/**/*.jpg",
{ eager: true },
);
const covers = Object.fromEntries(
albums.map((album) => {
const folder = album.id.split("/")[0] ?? album.id;
const cover = Object.entries(imageModules)
.filter(([p]) => p.includes(`/albums/${folder}/`))
.sort(([a], [b]) => a.localeCompare(b))[0];
return [folder, cover?.[1]?.default ?? null];
}),
);
```
The album id from the glob loader is a path like `chiang-mai/chiang-mai`, so `.split("/")[0]` gives the folder name.
**Solution:** Covers are chosen by filename sort — my filenames start with the capture date, so the earliest photo of the trip becomes the cover. No frontmatter field required.
## Album detail page
The album detail page renders the markdown body from the `.md` file alongside the photo grid. In Astro's content layer, `render()` is a standalone function imported from `astro:content`, not a method on the entry:
```ts
import { getCollection, render } from "astro:content";
const { Content } = await render(album);
```
Photos are loaded with `import.meta.glob`, filtered to the current album folder, and sorted by date. Each photo links to its album-scoped detail route:
```ts
<a href={`/photos/albums/${folder}/${photo.sidecar.id}`}>
```
The justified grid is the same layout engine used in the stream — `justified-layout` from Flickr, driven by aspect ratios and a target row height of 280px, positioned absolutely within a container whose height is set by the layout result.
## Sub-nav
Both `/photos` and `/photos/albums` share a small sub-nav that lets you switch between stream and albums view. The active link is hardcoded per page:
```html
<!-- on /photos -->
<a href="/photos" class="sub-nav__link is-active">Stream</a>
<a href="/photos/albums" class="sub-nav__link">Albums</a>
<!-- on /photos/albums -->
<a href="/photos" class="sub-nav__link">Stream</a>
<a href="/photos/albums" class="sub-nav__link is-active">Albums</a>
```
## What stays separate
The original stream at `/photos` and the single-photo route at `/photos/[id]` are untouched. The albums section is an additive layer — same images on disk, different entry points and navigation context.
## What to take away
- The content layer's glob loader only handles extensions you have integrations for — `.mdx` without `@astrojs/mdx` silently disappears. Stick to `.md` unless you need MDX.
- Pick album covers by filename sort when your filenames are date-prefixed; it beats adding a `cover:` frontmatter field you have to maintain.
- In Astro's content layer, `render()` is imported from `astro:content` — it's not a method on the entry any more.
- Additive album routes on top of an existing flat stream cost very little — same JPGs, same sidecars, just different `getStaticPaths` shapes.