Building a project gallery in Hugo

meta coding
⚠ Geek content ahead! ⚠
This post is about geeky tech stuff.
If you're not into that, maybe skip this one! (What's this?)

One of the key features I wanted to build for this website was a custom gallery. I previously had a basic one on Wordpress - it was entirely manually managed through their visual block editor. Huge hassle, and very slow and awkward. Eventually I did actually build a database-driven Wordpress plugin to automate it, which turned into a custom page type, but then before I ever put it live, I got fed up with Wordpress and decided to leave the whole ecosystem.

On the other hand, building my gallery in Hugo has been pretty straight forward. The gallery was one of the first things I did in Hugo, so I was learning the tool as I went, and yet it still came together rather quickly! And that’s also considering that I spent ages faffing around with the stylesheets.

I don’t open source my site code, for an element of mystique (by which I really mean I don’t want my draft posts public). But I do want to share some snippets to help anyone who’s trying to build something similar. This isn’t a tutorial, nor a complete explainer of the gallery - it’s more a collection of notes documenting my approach and choices. Please reach out if you want to know any more details!

My gallery as of December 2024 (pre go-live!)
My gallery as of December 2024 (pre go-live!)

My feature requirements

From the outset, I wanted to build:

Out of scope for MVP

How I built it with Hugo

NB. I am going to assume that you have a rudimentary knowledge of Hugo.

These are the design decisions I made:

Let’s dig into it.

Directory structure

This is what my structure looks like:

content
└── gallery
    ├── 2023
    │   ├── 2023-03-01-ankara-helmi-top
    │   │   ├── index.md                    // Project content and data
    │   │   └── helmi.jpg                   // Project photo
    │   └── _index.md                       // Section index for the year
    ├── _index.md                           // Section index for the gallery
    └── all                                 
        └── _index.md                       // Section index for "all"

There’s already a lot going on here, so let’s have a look.

The project directory name contains the date

content
└── gallery
    ├── 2023
    │   ├── 2023-03-01-ankara-helmi-top
    │   │   ├── index.md
    │   │   └── helmi.jpg
    │   └── _index.md
    ├── _index.md
    └── all
        └── _index.md

Prefixing the project directory name with the completion date of the project helps me organise my files chronologically. The date gets stripped out of the title and slug automatically by the archetype (more on that later).

On the presentation layer, I only display the month and year (because for older projects that’s all the data I have). The day is actually arbitrary and is just used for sort order.

Years are directories, and so is “all”

content
└── gallery
    ├── 2023 
    │   ├── 2023-03-01-ankara-helmi-top 
    │   │   ├── index.md
    │   │   └── helmi.jpg
    │   └── _index.md
    ├── _index.md
    └── all 
        └── _index.md

In order to be able to filter the gallery by year, I’ve created directories for each year. The idea here is that you can visit /gallery/2023/ and it will show you the projects from 2023. I’ve also created an “all” directory, which will show projects from every year.

But this alone isn’t enough - I also needed to set up some data and tweak the template.

1) _index.md in the year directory

Each year directory contains an _index.md file to signal that it is a section. And that file needs to specify the gallery type to match the parent section. This tells Hugo to use the gallery list template when it renders this page. (Bonus: this file also gives me a space to enter some commentary on each year, in the content section at the bottom.)

/content/gallery/2023/_index.md:

+++ 
title = '2023'
sort_key = '2023'
type = 'gallery'
+++

In 2023 I finished a total of 19 sewing projects [...] 

Speaking of templates…

The standard way to list pages in Hugo is with .Pages, but this only goes one level deep. To recurse through the subdirectories, I needed to use .RegularPagesRecursive. However… I’m using this same list template for three key pages, which display different things:

A little bit of conditional logic is needed to determine the base page from which to recurse, and whether to only display the top 3:

/layouts/gallery/list.html:

{{- if eq .Path "/gallery" }}
  <h2>Latest Projects</h2>
  {{- range .RegularPagesRecursive | first 3 }}
    {{ .Render "gallery_card" }}
  {{- end }}
{{- else }}
  {{- $basePage := .Page }}
  {{- if eq .Path "/gallery/all" }}
    {{- $basePage = .Parent }}
  {{- end }}
  {{ range (.Paginate $basePage.RegularPagesRecursive).Pages }}
    {{ .Render "gallery_card" }}
  {{- end }}
{{- end }}

I keep the same list of links at the top of each list page, starting with “Latest” (which is just /gallery), iterating through each of the years, and finishing with “All” at the end. This seems fairly straight forward on the surface, but I hit an unexpected quirk while rendering it.

/layouts/gallery/list.html:

{{- if eq .Path "/gallery" }}
  Latest</span>
{{- else }}
  <a href="{{ relURL "/gallery" }}">Latest</a>
{{- end }}

{{- $thisTitle := .Page.Title }}
{{- $galleryRoot := .Site.GetPage "gallery" }}
{{ range sort $galleryRoot.Sections ".Params.sort_key" "desc" }}
  <span>&bull;</span>
  {{ if eq $thisTitle .Params.title }}
    <span>{{ .Params.title }}</span>
  {{- else }}
    <span><a href="{{ relURL .Path }}">{{ .Params.title }}</a></span>
  {{- end }}
{{ end }}

Notice that I’m using .Params.sort_key to order the years (you might have spotted that in my 2022 _index.md example earlier). Why do I need to do this, when I just want them to display in order of title? Can’t I sort by the title, rather than having to define a new field?

Well, there’s a weird quirk in the sorting of my particular directory names. This is what happened when I sorted by title:

  2024
  2023
  2022
  2021
  2020
  2019
  All
  2018 and Earlier

Eh??

Turns out lexicographical sorting means that the combination of letters and numbers in “2018 and Earlier” gets treated differently from the number-only strings like “2018”. To be clear, they’re all strings. I even tried casting them to strings. Something is going on under the hood here to sort them in a way that I did not expect.

Anyway, there’s probably a cleaner solution than this, but using a sort key was an easy enough fix.

Tags & categories

I set up new taxonomies called gallery_tags and gallery_categories to differentiate from the tags and categories used in the blog. This works absolutely fine out of the box, but I wanted to tweak a few things.

1. Changing the taxonomies’ display names

I didn’t want these taxonomies showing up as “Gallery Tags” all over the site. “Tags” and “Categories” is sufficient. So I created the directories /content/gallery_tags and /content/gallery_categories, adding index.md files in which I could specify titles in the front matter as with any other page. Again, I could add some content to these pages here if I wanted to.

2. Positioning the taxonomies in the hierarchy

I wanted the gallery taxonomies to show under the Gallery in the breadcrumb. Like this:

Instead of the default:

To do this I injected an extra link into the basic loop of page ancestors, in the case of taxonomies. I did the same for my blog tags, so they have the same hierarchical structure (tags show under Blog). Here’s the breadcrumb code now:

/layouts/partials/breadcrumbs.html:

{{- range .Ancestors.Reverse | append .Page}}
    {{- if eq .Kind "taxonomy" }}
        {{ $parentPath := index .Site.Params.parents .Section }}
        {{ $parent := .Site.GetPage $parentPath }}
           <li><a href="{{ $parent.RelPermalink }}">{{ $parent.Title }}</a></li>
    {{- end }}
    <li><a href="{{ .RelPermalink }}">{{ .Title }}</a></li>
{{- end }}

It’s relying on some custom params that I stuck in my site configuration to define the hierarchy:

/hugo.toml:

[params.parents]
gallery_tags = "/gallery"
gallery_categories = "/gallery"

I also added some config to make the URLs mirror the breadcrumb hierarchy:

/hugo.toml:

[permalinks.taxonomy]
gallery_tags = "/gallery/tags/"
gallery_categories = "/gallery/categories/"

[permalinks.term]
gallery_tags = "/gallery/tags/:slug"
gallery_categories = "/gallery/categories/:slug"

The archetype in Hugo is the template from which new content is created. I use this to set up a few default values.

/archetypes/gallery.md:

+++
type = 'gallery'
{{- $nameWithoutDate := replaceRE "^\\d{4}-\\d{2}-\\d{2}-" "" .Page.File.ContentBaseName }}
{{- $dateFromName := replaceRE "(^\\d{4}-\\d{2}-\\d{2})-.*" "$1" .Page.File.ContentBaseName }}
date = '{{ $dateFromName }}'
title = '{{ replace $nameWithoutDate "-" " " | title }}'
+++

Yes, it’s NUTS, but I wouldn’t be me if I didn’t try to sneak in a little regex somewhere.

All it’s really doing is taking the directory name and extracting the date and title into their correct locations. In combination with a tweak to the permalink in the site config, this gives me nice URLs and titles that don’t have any date information in them at all, while maintaining my date-based file organisation. (I have the exact same set up on the blog.)

/hugo.toml:

[permalinks]
gallery = "/gallery/:title/"

Storing the project data

From here, it’s pretty straight forward stuff. All the project data is stored in front matter:

+++
draft = false
type = 'gallery'
date = '2024-11-01'
title = 'Clair De Lune Sweater'
gallery_categories = ['knitting']
gallery_tags = ['the petite knitter']

[params.project_data]
completed_date = '2024-11-01'
pattern_name = 'Clair de Lune'
pattern_designer = 'The Petite Knitter'
materials = 'Drops Alaska'
materials_from = 'Wool Warehouse'

[[resources]]
name = 'mainImage'
src = 'clairdelune.jpg'
+++
Notes go here, like content on any other page.

I use that mainImage resource to access the cover image from the gallery list page, so I can display those cute cards.

(NB. Currently I have completed_date as a separate field from the project date, as the publish date mught be different for projects that I can only share a while after I’ve made them - gifts, pattern tests etc.)

I have a custom template for the gallery list and gallery single. It’s straightforward stuff - reading the parameters out of the front matter - so I’m not going to go into any detail. The styling took me absolutely ages, as I’m not a CSS expert by a long way - but if you want to be nosy about that, you can go and view source directly!

Adding a new project looks like this:

$ hugo new content content/gallery/2024/2024-11-01-clair-de-lune-sweater

I add my photo into the directory, edit the generated index.md, and there she goes!

The gallery card showing my Clair De Lune knitting project
The gallery card showing my Clair De Lune knitting project

Conclusion

I really enjoyed building this, and I’m enjoying using it even more. If this info was useful to you at all, I’d love to hear about it. I don’t have a contact form here yet, but you can drop me a line on Instagram. I’d also really like to hear any comments or suggestions you might have on my approach!

Want to share your thoughts?
✎ Reply to this post by sending me an email, or message me on Instagram.