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:
1 parent
f33bdd55b1
commit
a42abfab48
11 files changed
+465
-2
No files matched your search
@@ -36,10 +36,15 @@ In production nginx sits in front: serves `public/` directly, proxies `/upload`
|
|||||||
```toml
|
```toml
|
||||||
caption = "Tempelhof at sunrise"
|
caption = "Tempelhof at sunrise"
|
||||||
date = "2026-06-14"
|
date = "2026-06-14"
|
||||||
|
tags = ["berlin", "sunrise"]
|
||||||
```
|
```
|
||||||
|
|
||||||
Missing sidecar → filename as caption, today as date. Photos sort newest first by that date.
|
Missing sidecar → filename as caption, today as date. Photos sort newest first by that date.
|
||||||
|
|
||||||
|
`tags` is optional, free-text, and defines albums (see below). Tags are
|
||||||
|
normalized to lowercase on load/upload (`parseTags` in `albums.go`); a tag may
|
||||||
|
contain spaces (`"berlin trip"`), and its URL slug is derived with `sanitiseSlug`.
|
||||||
|
|
||||||
## Design
|
## Design
|
||||||
|
|
||||||
- Gruvbox dark hard colour scheme
|
- Gruvbox dark hard colour scheme
|
||||||
@@ -51,6 +56,18 @@ Missing sidecar → filename as caption, today as date. Photos sort newest first
|
|||||||
- Each timeline date heading links to a **single-day page** (`/day/<YYYY-MM-DD>/`) holding just that day's photos. `renderDayPages` writes one per `groupByDay` run; the ISO label doubles as the URL slug.
|
- Each timeline date heading links to a **single-day page** (`/day/<YYYY-MM-DD>/`) holding just that day's photos. `renderDayPages` writes one per `groupByDay` run; the ISO label doubles as the URL slug.
|
||||||
- Single photo page with sidebar and newer/older navigation
|
- Single photo page with sidebar and newer/older navigation
|
||||||
|
|
||||||
|
### Albums (tags)
|
||||||
|
|
||||||
|
A second grouping axis, parallel to days: an album is just a tag (`albums.go`).
|
||||||
|
`groupByTag` mirrors `groupByDay`, and `renderAlbumPages` writes a paginated
|
||||||
|
square-grid album per tag at `/album/<slug>/` (like the grid view). `/albums/`
|
||||||
|
is the index of every tag with counts; `/tags.json` is the machine-readable tag
|
||||||
|
list (`[{tag, slug, count}]`, served statically like `feed.xml`). Tags show on
|
||||||
|
each photo page and link to their album; an **Albums** link sits in the
|
||||||
|
timeline/grid header. Tags are assigned at upload via the `tags` field on
|
||||||
|
`POST /upload` (comma-separated) or by editing a sidecar's `tags`. All derived at
|
||||||
|
build time — no database.
|
||||||
|
|
||||||
### Link previews
|
### Link previews
|
||||||
|
|
||||||
Photo pages carry Open Graph + Twitter-card meta tags so a shared link unfurls
|
Photo pages carry Open Graph + Twitter-card meta tags so a shared link unfurls
|
||||||
@@ -142,12 +159,15 @@ nix build # succeeds
|
|||||||
|---|---|
|
|---|---|
|
||||||
| `main.go` | Flags, wires generator + watcher + server |
|
| `main.go` | Flags, wires generator + watcher + server |
|
||||||
| `generator.go` | Scans content dir, renders HTML + Atom feed, generates thumbs/mediums |
|
| `generator.go` | Scans content dir, renders HTML + Atom feed, generates thumbs/mediums |
|
||||||
|
| `albums.go` | Tag parsing/normalization, `groupByTag`, album pages + `/albums/` + `/tags.json` |
|
||||||
| `server.go` | Upload API + optional static file serving |
|
| `server.go` | Upload API + optional static file serving |
|
||||||
| `watcher.go` | fsnotify watcher with 500ms debounce |
|
| `watcher.go` | fsnotify watcher with 500ms debounce |
|
||||||
| `assets.go` | `//go:embed` for templates and static |
|
| `assets.go` | `//go:embed` for templates and static |
|
||||||
| `templates/timeline.html` | Site root: photos grouped by day, paginated |
|
| `templates/timeline.html` | Site root: photos grouped by day, paginated |
|
||||||
| `templates/grid.html` | Flat square grid at `/grid/`, paginated |
|
| `templates/grid.html` | Flat square grid at `/grid/`, paginated |
|
||||||
| `templates/day.html` | Single-day page at `/day/<date>/` |
|
| `templates/day.html` | Single-day page at `/day/<date>/` |
|
||||||
|
| `templates/album.html` | Album (tag) page at `/album/<slug>/`, paginated |
|
||||||
|
| `templates/albums.html` | Album index at `/albums/` |
|
||||||
| `templates/photo.html` | Single photo page, neighbour links and prefetch |
|
| `templates/photo.html` | Single photo page, neighbour links and prefetch |
|
||||||
| `static/photo-nav.js` | Arrow-key and swipe navigation |
|
| `static/photo-nav.js` | Arrow-key and swipe navigation |
|
||||||
| `module.nix` | NixOS module (systemd service, tmpfiles, nginx vhost) |
|
| `module.nix` | NixOS module (systemd service, tmpfiles, nginx vhost) |
|
||||||
|
|||||||
@@ -0,0 +1,204 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"log"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"sort"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Tag is a normalized album tag: a lowercase free-text Name and its URL Slug.
|
||||||
|
type Tag struct {
|
||||||
|
Name string
|
||||||
|
Slug string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Album is all photos sharing one tag, newest first.
|
||||||
|
type Album struct {
|
||||||
|
Tag Tag
|
||||||
|
Count int
|
||||||
|
Photos []Photo
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseTags normalizes raw tag strings into unique Tags. Each input may itself
|
||||||
|
// be comma-separated (as typed in the upload form), so entries are split on
|
||||||
|
// commas, lowercased, trimmed, de-duplicated by name, and given a URL slug via
|
||||||
|
// sanitiseSlug. Entries that are empty or slug to nothing are dropped.
|
||||||
|
func parseTags(raw ...string) []Tag {
|
||||||
|
var tags []Tag
|
||||||
|
seen := map[string]bool{}
|
||||||
|
for _, entry := range raw {
|
||||||
|
for _, part := range strings.Split(entry, ",") {
|
||||||
|
name := strings.ToLower(strings.TrimSpace(part))
|
||||||
|
if name == "" || seen[name] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
slug := sanitiseSlug(name)
|
||||||
|
if slug == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[name] = true
|
||||||
|
tags = append(tags, Tag{Name: name, Slug: slug})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return tags
|
||||||
|
}
|
||||||
|
|
||||||
|
// groupByTag collapses photos (already sorted newest first) into one Album per
|
||||||
|
// distinct tag name, preserving photo order within each album. Albums are
|
||||||
|
// returned sorted alphabetically by tag name. If two different names produce the
|
||||||
|
// same URL slug, the later one is suffixed (-2, -3, …) and a warning is logged,
|
||||||
|
// so album URLs stay unique.
|
||||||
|
func groupByTag(photos []Photo) []Album {
|
||||||
|
byName := map[string][]Photo{}
|
||||||
|
for _, p := range photos {
|
||||||
|
for _, t := range p.Tags {
|
||||||
|
byName[t.Name] = append(byName[t.Name], p)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
names := make([]string, 0, len(byName))
|
||||||
|
for name := range byName {
|
||||||
|
names = append(names, name)
|
||||||
|
}
|
||||||
|
sort.Strings(names)
|
||||||
|
|
||||||
|
slugOwner := map[string]string{}
|
||||||
|
albums := make([]Album, 0, len(names))
|
||||||
|
for _, name := range names {
|
||||||
|
slug := sanitiseSlug(name)
|
||||||
|
if owner, taken := slugOwner[slug]; taken && owner != name {
|
||||||
|
base := slug
|
||||||
|
for n := 2; ; n++ {
|
||||||
|
cand := base + "-" + strconv.Itoa(n)
|
||||||
|
if _, t := slugOwner[cand]; !t {
|
||||||
|
slug = cand
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
log.Printf("album slug collision: %q and %q both slug to %q; using %q for the latter",
|
||||||
|
owner, name, base, slug)
|
||||||
|
}
|
||||||
|
slugOwner[slug] = name
|
||||||
|
ps := byName[name]
|
||||||
|
albums = append(albums, Album{
|
||||||
|
Tag: Tag{Name: name, Slug: slug},
|
||||||
|
Count: len(ps),
|
||||||
|
Photos: ps,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return albums
|
||||||
|
}
|
||||||
|
|
||||||
|
// baseData returns the profile-header and site-wide fields every page template
|
||||||
|
// expects, so the album templates stay in step with the others.
|
||||||
|
func (g *Generator) baseData() map[string]any {
|
||||||
|
return map[string]any{
|
||||||
|
"BaseURL": g.baseURL,
|
||||||
|
"Year": time.Now().Year(),
|
||||||
|
"Author": g.author,
|
||||||
|
"Handle": g.handle,
|
||||||
|
"Bio": g.bio,
|
||||||
|
"BioAlt": g.bioAlt,
|
||||||
|
"Initial": g.initial(),
|
||||||
|
"FontsURL": g.fontsURL,
|
||||||
|
"SerifFamily": g.serifFamily,
|
||||||
|
"MlFamily": g.mlFamily,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// renderAlbumPages writes one paginated album per tag under public/album/<slug>/,
|
||||||
|
// a flat square grid like the grid view. total is the site-wide photo count, for
|
||||||
|
// the profile header.
|
||||||
|
func (g *Generator) renderAlbumPages(albums []Album, total int) error {
|
||||||
|
for _, al := range albums {
|
||||||
|
albumDir := filepath.Join(g.outputDir, "album", al.Tag.Slug)
|
||||||
|
if err := os.MkdirAll(albumDir, 0755); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
base := "/album/" + al.Tag.Slug + "/"
|
||||||
|
|
||||||
|
totalPages := (al.Count + g.pageSize - 1) / g.pageSize
|
||||||
|
if totalPages == 0 {
|
||||||
|
totalPages = 1
|
||||||
|
}
|
||||||
|
|
||||||
|
for i := 0; i < totalPages; i++ {
|
||||||
|
start := i * g.pageSize
|
||||||
|
end := start + g.pageSize
|
||||||
|
if end > al.Count {
|
||||||
|
end = al.Count
|
||||||
|
}
|
||||||
|
|
||||||
|
dst := filepath.Join(albumDir, "index.html")
|
||||||
|
if i > 0 {
|
||||||
|
dir := filepath.Join(albumDir, "page", strconv.Itoa(i+1))
|
||||||
|
if err := os.MkdirAll(dir, 0755); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
dst = filepath.Join(dir, "index.html")
|
||||||
|
}
|
||||||
|
|
||||||
|
prev := ""
|
||||||
|
next := ""
|
||||||
|
switch i {
|
||||||
|
case 0:
|
||||||
|
// no prev
|
||||||
|
case 1:
|
||||||
|
prev = base
|
||||||
|
default:
|
||||||
|
prev = base + "page/" + strconv.Itoa(i) + "/"
|
||||||
|
}
|
||||||
|
if i+1 < totalPages {
|
||||||
|
next = base + "page/" + strconv.Itoa(i+2) + "/"
|
||||||
|
}
|
||||||
|
|
||||||
|
data := g.baseData()
|
||||||
|
data["Tag"] = al.Tag
|
||||||
|
data["Count"] = al.Count
|
||||||
|
data["Photos"] = al.Photos[start:end]
|
||||||
|
data["Total"] = total
|
||||||
|
data["Page"] = i + 1
|
||||||
|
data["TotalPages"] = totalPages
|
||||||
|
data["PrevURL"] = prev
|
||||||
|
data["NextURL"] = next
|
||||||
|
if err := g.render("album.html", dst, data); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// renderAlbumsIndex writes public/albums/index.html — every tag with its count,
|
||||||
|
// linking to each album.
|
||||||
|
func (g *Generator) renderAlbumsIndex(albums []Album, total int) error {
|
||||||
|
data := g.baseData()
|
||||||
|
data["Albums"] = albums
|
||||||
|
data["Total"] = total
|
||||||
|
return g.render("albums.html", filepath.Join(g.outputDir, "albums", "index.html"), data)
|
||||||
|
}
|
||||||
|
|
||||||
|
type tagJSON struct {
|
||||||
|
Tag string `json:"tag"`
|
||||||
|
Slug string `json:"slug"`
|
||||||
|
Count int `json:"count"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// renderTagsJSON writes public/tags.json — the machine-readable tag list, served
|
||||||
|
// statically like feed.xml.
|
||||||
|
func (g *Generator) renderTagsJSON(albums []Album) error {
|
||||||
|
out := make([]tagJSON, 0, len(albums))
|
||||||
|
for _, a := range albums {
|
||||||
|
out = append(out, tagJSON{Tag: a.Tag.Name, Slug: a.Tag.Slug, Count: a.Count})
|
||||||
|
}
|
||||||
|
b, err := json.MarshalIndent(out, "", " ")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return os.WriteFile(filepath.Join(g.outputDir, "tags.json"), b, 0644)
|
||||||
|
}
|
||||||
+14
-1
@@ -43,6 +43,7 @@ var ogQualityLadder = []int{85, 78, 70, 62}
|
|||||||
type PhotoMeta struct {
|
type PhotoMeta struct {
|
||||||
Caption string `toml:"caption"`
|
Caption string `toml:"caption"`
|
||||||
Date string `toml:"date"` // YYYY-MM-DD
|
Date string `toml:"date"` // YYYY-MM-DD
|
||||||
|
Tags []string `toml:"tags,omitempty"` // free-text, lowercased; each tag is an album
|
||||||
}
|
}
|
||||||
|
|
||||||
// Photo is the in-memory representation of one image.
|
// Photo is the in-memory representation of one image.
|
||||||
@@ -56,6 +57,7 @@ type Photo struct {
|
|||||||
OGHeight int
|
OGHeight int
|
||||||
Caption string
|
Caption string
|
||||||
Date time.Time
|
Date time.Time
|
||||||
|
Tags []Tag // albums this photo belongs to
|
||||||
}
|
}
|
||||||
|
|
||||||
// DayGroup is a run of photos sharing the same calendar day, for the timeline.
|
// DayGroup is a run of photos sharing the same calendar day, for the timeline.
|
||||||
@@ -121,7 +123,7 @@ func (g *Generator) Build() error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, d := range []string{"images", "thumbs", "medium", "og", "photo", "grid", "day", "static"} {
|
for _, d := range []string{"images", "thumbs", "medium", "og", "photo", "grid", "day", "album", "albums", "static"} {
|
||||||
if err := os.MkdirAll(filepath.Join(g.outputDir, d), 0755); err != nil {
|
if err := os.MkdirAll(filepath.Join(g.outputDir, d), 0755); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -142,6 +144,16 @@ func (g *Generator) Build() error {
|
|||||||
if err := g.renderDayPages(photos); err != nil {
|
if err := g.renderDayPages(photos); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
albums := groupByTag(photos)
|
||||||
|
if err := g.renderAlbumPages(albums, len(photos)); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := g.renderAlbumsIndex(albums, len(photos)); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := g.renderTagsJSON(albums); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
for i := range photos {
|
for i := range photos {
|
||||||
var newer, older *Photo
|
var newer, older *Photo
|
||||||
if i > 0 {
|
if i > 0 {
|
||||||
@@ -209,6 +221,7 @@ func (g *Generator) loadPhotos() ([]Photo, error) {
|
|||||||
OG: "og/" + slug + ".jpg",
|
OG: "og/" + slug + ".jpg",
|
||||||
Caption: meta.Caption,
|
Caption: meta.Caption,
|
||||||
Date: date,
|
Date: date,
|
||||||
|
Tags: parseTags(meta.Tags...),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -67,6 +67,7 @@ func (s *Server) handleUploadPage(w http.ResponseWriter, r *http.Request) {
|
|||||||
// image — the image file (jpg or png)
|
// image — the image file (jpg or png)
|
||||||
// caption — human-readable caption (optional, defaults to filename)
|
// caption — human-readable caption (optional, defaults to filename)
|
||||||
// date — YYYY-MM-DD (optional, defaults to today)
|
// date — YYYY-MM-DD (optional, defaults to today)
|
||||||
|
// tags — comma-separated album tags (optional; lowercased, deduped)
|
||||||
func (s *Server) handleUpload(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleUpload(w http.ResponseWriter, r *http.Request) {
|
||||||
if err := r.ParseMultipartForm(32 << 20); err != nil {
|
if err := r.ParseMultipartForm(32 << 20); err != nil {
|
||||||
http.Error(w, "bad request", http.StatusBadRequest)
|
http.Error(w, "bad request", http.StatusBadRequest)
|
||||||
@@ -95,6 +96,13 @@ func (s *Server) handleUpload(w http.ResponseWriter, r *http.Request) {
|
|||||||
caption = strings.TrimSuffix(header.Filename, ext)
|
caption = strings.TrimSuffix(header.Filename, ext)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Normalize the tags once here so the sidecar stores clean lowercase names;
|
||||||
|
// the generator derives album slugs from them at build time.
|
||||||
|
var tags []string
|
||||||
|
for _, t := range parseTags(r.FormValue("tags")) {
|
||||||
|
tags = append(tags, t.Name)
|
||||||
|
}
|
||||||
|
|
||||||
slug := sanitiseSlug(strings.TrimSuffix(header.Filename, ext))
|
slug := sanitiseSlug(strings.TrimSuffix(header.Filename, ext))
|
||||||
if slug == "" {
|
if slug == "" {
|
||||||
slug = fmt.Sprintf("photo-%d", time.Now().UnixMilli())
|
slug = fmt.Sprintf("photo-%d", time.Now().UnixMilli())
|
||||||
@@ -121,7 +129,7 @@ func (s *Server) handleUpload(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
defer tf.Close()
|
defer tf.Close()
|
||||||
if err := toml.NewEncoder(tf).Encode(PhotoMeta{Caption: caption, Date: dateStr}); err != nil {
|
if err := toml.NewEncoder(tf).Encode(PhotoMeta{Caption: caption, Date: dateStr, Tags: tags}); err != nil {
|
||||||
http.Error(w, "could not write metadata", http.StatusInternalServerError)
|
http.Error(w, "could not write metadata", http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -422,3 +422,57 @@ body {
|
|||||||
.dlayout { flex-direction: column; }
|
.dlayout { flex-direction: column; }
|
||||||
.dsidebar { width: 100%; border-left: none; border-top: 1px solid var(--line); }
|
.dsidebar { width: 100%; border-left: none; border-top: 1px solid var(--line); }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Tags / albums ─────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.dtags {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 6px;
|
||||||
|
margin: 10px 0 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tag,
|
||||||
|
.album-chip {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 3px 10px;
|
||||||
|
background: var(--bg0);
|
||||||
|
border: 1px solid var(--bg2);
|
||||||
|
color: var(--fg3);
|
||||||
|
font-family: monospace;
|
||||||
|
font-size: 12px;
|
||||||
|
text-decoration: none;
|
||||||
|
border-radius: 2px;
|
||||||
|
transition: color 0.15s, border-color 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tag:hover,
|
||||||
|
.album-chip:hover {
|
||||||
|
color: var(--aqua);
|
||||||
|
border-color: var(--fg4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.album-list {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 10px;
|
||||||
|
margin-top: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.album-chip {
|
||||||
|
font-size: 14px;
|
||||||
|
padding: 7px 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.album-chip-count {
|
||||||
|
color: var(--fg4);
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.album-empty {
|
||||||
|
color: var(--fg4);
|
||||||
|
font-family: var(--serif);
|
||||||
|
margin-top: 16px;
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -24,6 +24,7 @@
|
|||||||
<p class="pcount"><strong>{{.Total}}</strong> posts</p>
|
<p class="pcount"><strong>{{.Total}}</strong> posts</p>
|
||||||
<p class="pbio">{{.Bio}}{{if .BioAlt}}<br><span class="ml">{{.BioAlt}}</span>{{end}}</p>
|
<p class="pbio">{{.Bio}}{{if .BioAlt}}<br><span class="ml">{{.BioAlt}}</span>{{end}}</p>
|
||||||
<a href="/" class="rss-a">Timeline</a>
|
<a href="/" class="rss-a">Timeline</a>
|
||||||
|
<a href="/albums/" class="rss-a">Albums</a>
|
||||||
<a href="/feed.xml" class="rss-a">
|
<a href="/feed.xml" class="rss-a">
|
||||||
<svg width="14" height="14" viewBox="0 0 14 14" fill="currentColor" aria-hidden="true">
|
<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"/>
|
<circle cx="2.5" cy="11.5" r="1.5"/>
|
||||||
|
|||||||
@@ -58,6 +58,11 @@
|
|||||||
</div>
|
</div>
|
||||||
<p class="dcaption">{{.Photo.Caption}}</p>
|
<p class="dcaption">{{.Photo.Caption}}</p>
|
||||||
<p class="ddate">{{.Photo.Date.Format "Jan 02, 2006"}}</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>
|
<p class="dfull"><a href="/{{.Photo.File}}" download>↓ Full size</a></p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -24,6 +24,7 @@
|
|||||||
<p class="pcount"><strong>{{.Total}}</strong> posts</p>
|
<p class="pcount"><strong>{{.Total}}</strong> posts</p>
|
||||||
<p class="pbio">{{.Bio}}{{if .BioAlt}}<br><span class="ml">{{.BioAlt}}</span>{{end}}</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="/grid/" class="rss-a">Grid</a>
|
||||||
|
<a href="/albums/" class="rss-a">Albums</a>
|
||||||
<a href="/feed.xml" class="rss-a">
|
<a href="/feed.xml" class="rss-a">
|
||||||
<svg width="14" height="14" viewBox="0 0 14 14" fill="currentColor" aria-hidden="true">
|
<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"/>
|
<circle cx="2.5" cy="11.5" r="1.5"/>
|
||||||
|
|||||||
@@ -161,6 +161,11 @@
|
|||||||
<input type="date" id="date">
|
<input type="date" id="date">
|
||||||
</div>
|
</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>
|
<button class="submit-btn" id="submit-btn" disabled>Upload photo</button>
|
||||||
<div id="feedback"></div>
|
<div id="feedback"></div>
|
||||||
</div>
|
</div>
|
||||||
@@ -256,6 +261,7 @@
|
|||||||
form.append('image', file);
|
form.append('image', file);
|
||||||
form.append('caption', document.getElementById('caption').value);
|
form.append('caption', document.getElementById('caption').value);
|
||||||
form.append('date', dateInput.value);
|
form.append('date', dateInput.value);
|
||||||
|
form.append('tags', document.getElementById('tags').value);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/upload', { method: 'POST', body: form });
|
const res = await fetch('/upload', { method: 'POST', body: form });
|
||||||
@@ -268,6 +274,7 @@
|
|||||||
prompt.style.display = 'flex';
|
prompt.style.display = 'flex';
|
||||||
dropZone.classList.remove('has-image');
|
dropZone.classList.remove('has-image');
|
||||||
document.getElementById('caption').value = '';
|
document.getElementById('caption').value = '';
|
||||||
|
document.getElementById('tags').value = '';
|
||||||
submitBtn.disabled = true;
|
submitBtn.disabled = true;
|
||||||
} else {
|
} else {
|
||||||
feedback.className = 'err';
|
feedback.className = 'err';
|
||||||
|
|||||||
Reference in new issue
Block a user