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:
parent
9d22d93361
commit
4bf4eb03b1
69 changed files with 4904 additions and 344 deletions
|
|
@ -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.
|
||||
Loading…
Add table
Add a link
Reference in a new issue