Add albums: group photos by free-text tags

An album is a tag. Photos carry lowercase free-text tags in their .toml
sidecar (`tags = [...]`), parallel to the existing day grouping — no
database, all derived at build time.

- albums.go: parseTags (lowercase/trim/dedupe, slug via sanitiseSlug),
  groupByTag mirroring groupByDay, and rendering for album pages, the
  index, and the JSON API. Slug collisions between distinct names are
  suffixed and logged.
- Routes: /album/<slug>/ (paginated square grid per tag), /albums/ (index
  with counts), /tags.json (machine-readable tag list, served statically
  like feed.xml).
- POST /upload gains a `tags` field written into the sidecar; the web
  upload form gains a tags input.
- Tags shown on each photo page linking to their album; an Albums link in
  the timeline/grid header.
This commit is contained in:
puttaalu committed 2026-10-08 16:14:09 +02:00
1 parent f33bdd55b1
commit a42abfab48
11 files changed
+469 -6

No files matched your search

+96
View File
@@ -0,0 +1,96 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{.Tag.Name}} · Photo Gallery</title>
<link rel="stylesheet" href="{{.FontsURL}}">
<link rel="stylesheet" href="/static/style.css">
<link rel="icon" href="/static/favicon.ico" sizes="any">
<link rel="icon" type="image/png" href="/static/web-32.png" sizes="32x32">
<link rel="apple-touch-icon" href="/static/web-192.png">
<style>:root { --serif: '{{.SerifFamily}}', Georgia, serif; --mj: '{{.MlFamily}}', sans-serif; }</style>
<link rel="alternate" type="application/atom+xml" href="/feed.xml" title="Photo Feed">
</head>
<body>
<header class="profile-hdr">
<div class="profile-inner">
<div class="avatar-ring">
<span class="avatar-initials">{{.Initial}}</span>
</div>
<div class="pinfo">
<h1>{{.Author}}</h1>
<p class="handle">{{.Handle}}</p>
<p class="pcount"><strong>{{.Total}}</strong> posts</p>
<p class="pbio">{{.Bio}}{{if .BioAlt}}<br><span class="ml">{{.BioAlt}}</span>{{end}}</p>
<a href="/" class="rss-a">Timeline</a>
<a href="/albums/" class="rss-a">← Albums</a>
</div>
</div>
</header>
<main>
<div class="grid-wrap">
<section class="tl-day">
<h2 class="tl-date">{{.Tag.Name}}<span class="tl-count">{{.Count}}</span></h2>
<div class="pgrid">
{{range .Photos}}
<a href="/photo/{{.Slug}}/" class="ptile">
<img src="/{{.Thumb}}" alt="{{.Caption}}" loading="lazy">
</a>
{{end}}
</div>
</section>
{{if or .PrevURL .NextURL}}
<nav class="pager">
{{if .PrevURL}}<a class="pager-newer" href="{{.PrevURL}}">← Newer</a>{{end}}
{{if .NextURL}}<a class="pager-older" href="{{.NextURL}}" data-load-more>Older →</a>{{end}}
</nav>
{{end}}
</div>
</main>
<footer class="site-footer">
© 2000–{{.Year}} {{.Author}}. All rights reserved.
</footer>
<script>
(function () {
const btn = document.querySelector('[data-load-more]');
if (!btn) return;
const grid = document.querySelector('.pgrid');
let loading = false;
async function loadMore(url) {
if (loading) return;
loading = true;
try {
const res = await fetch(url);
if (!res.ok) return;
const html = await res.text();
const doc = new DOMParser().parseFromString(html, 'text/html');
doc.querySelectorAll('.pgrid > a').forEach(t => grid.appendChild(t));
const nextBtn = doc.querySelector('[data-load-more]');
if (nextBtn) btn.href = nextBtn.getAttribute('href');
else btn.remove();
} finally {
loading = false;
}
}
btn.addEventListener('click', (e) => {
e.preventDefault();
loadMore(btn.href);
});
const io = new IntersectionObserver((entries) => {
if (entries[0].isIntersecting && document.body.contains(btn)) {
loadMore(btn.href);
}
}, { rootMargin: '400px' });
io.observe(btn);
})();
</script>
</body>
</html>
+54
View File
@@ -0,0 +1,54 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Albums · Photo Gallery</title>
<link rel="stylesheet" href="{{.FontsURL}}">
<link rel="stylesheet" href="/static/style.css">
<link rel="icon" href="/static/favicon.ico" sizes="any">
<link rel="icon" type="image/png" href="/static/web-32.png" sizes="32x32">
<link rel="apple-touch-icon" href="/static/web-192.png">
<style>:root { --serif: '{{.SerifFamily}}', Georgia, serif; --mj: '{{.MlFamily}}', sans-serif; }</style>
<link rel="alternate" type="application/atom+xml" href="/feed.xml" title="Photo Feed">
</head>
<body>
<header class="profile-hdr">
<div class="profile-inner">
<div class="avatar-ring">
<span class="avatar-initials">{{.Initial}}</span>
</div>
<div class="pinfo">
<h1>{{.Author}}</h1>
<p class="handle">{{.Handle}}</p>
<p class="pcount"><strong>{{.Total}}</strong> posts</p>
<p class="pbio">{{.Bio}}{{if .BioAlt}}<br><span class="ml">{{.BioAlt}}</span>{{end}}</p>
<a href="/" class="rss-a">← Timeline</a>
</div>
</div>
</header>
<main>
<div class="grid-wrap">
<section class="tl-day">
<h2 class="tl-date">Albums<span class="tl-count">{{len .Albums}}</span></h2>
{{if .Albums}}
<div class="album-list">
{{range .Albums}}
<a href="/album/{{.Tag.Slug}}/" class="album-chip">
{{.Tag.Name}}<span class="album-chip-count">{{.Count}}</span>
</a>
{{end}}
</div>
{{else}}
<p class="album-empty">No albums yet — add tags to a photo to create one.</p>
{{end}}
</section>
</div>
</main>
<footer class="site-footer">
© 2000–{{.Year}} {{.Author}}. All rights reserved.
</footer>
</body>
</html>
+1
View File
@@ -24,6 +24,7 @@
<p class="pcount"><strong>{{.Total}}</strong> posts</p>
<p class="pbio">{{.Bio}}{{if .BioAlt}}<br><span class="ml">{{.BioAlt}}</span>{{end}}</p>
<a href="/" class="rss-a">Timeline</a>
<a href="/albums/" class="rss-a">Albums</a>
<a href="/feed.xml" class="rss-a">
<svg width="14" height="14" viewBox="0 0 14 14" fill="currentColor" aria-hidden="true">
<circle cx="2.5" cy="11.5" r="1.5"/>
+5
View File
@@ -58,6 +58,11 @@
</div>
<p class="dcaption">{{.Photo.Caption}}</p>
<p class="ddate">{{.Photo.Date.Format "Jan 02, 2006"}}</p>
{{if .Photo.Tags}}
<div class="dtags">
{{range .Photo.Tags}}<a href="/album/{{.Slug}}/" class="tag">{{.Name}}</a>{{end}}
</div>
{{end}}
<p class="dfull"><a href="/{{.Photo.File}}" download>↓ Full size</a></p>
</div>
</div>
+1
View File
@@ -24,6 +24,7 @@
<p class="pcount"><strong>{{.Total}}</strong> posts</p>
<p class="pbio">{{.Bio}}{{if .BioAlt}}<br><span class="ml">{{.BioAlt}}</span>{{end}}</p>
<a href="/grid/" class="rss-a">Grid</a>
<a href="/albums/" class="rss-a">Albums</a>
<a href="/feed.xml" class="rss-a">
<svg width="14" height="14" viewBox="0 0 14 14" fill="currentColor" aria-hidden="true">
<circle cx="2.5" cy="11.5" r="1.5"/>
+7
View File
@@ -161,6 +161,11 @@
<input type="date" id="date">
</div>
<div class="field">
<label for="tags">Tags</label>
<input type="text" id="tags" placeholder="comma-separated, lowercase" autocomplete="off">
</div>
<button class="submit-btn" id="submit-btn" disabled>Upload photo</button>
<div id="feedback"></div>
</div>
@@ -256,6 +261,7 @@
form.append('image', file);
form.append('caption', document.getElementById('caption').value);
form.append('date', dateInput.value);
form.append('tags', document.getElementById('tags').value);
try {
const res = await fetch('/upload', { method: 'POST', body: form });
@@ -268,6 +274,7 @@
prompt.style.display = 'flex';
dropZone.classList.remove('has-image');
document.getElementById('caption').value = '';
document.getElementById('tags').value = '';
submitBtn.disabled = true;
} else {
feedback.className = 'err';