Add Technical Kiwi website with Go, templ, and HTMX.
Single-page site with gallery by album and event, contact form over SMTP, Docker dev/prod setup, and on-server image derivatives. Gallery photos stay local (app/images/ is gitignored). Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
198
app/internal/gallery/gallery.go
Normal file
198
app/internal/gallery/gallery.go
Normal file
@@ -0,0 +1,198 @@
|
||||
package gallery
|
||||
|
||||
import (
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Image is a gallery entry discovered on disk.
|
||||
type Image struct {
|
||||
RelPath string // e.g. templeoftechno/Anniversary/photo.jpg
|
||||
Album string // top-level folder, e.g. templeoftechno
|
||||
Collection string // event subfolder label, e.g. Anniversary
|
||||
CollectionKey string // slug for filters
|
||||
Filename string // basename
|
||||
URL string
|
||||
ThumbURL string
|
||||
HeroURL string
|
||||
Date time.Time
|
||||
Year int
|
||||
}
|
||||
|
||||
// List returns JPEG images from dir and subfolders (newest first).
|
||||
// Skips generated thumbs/ and hero/ directories.
|
||||
func List(dir string) ([]Image, error) {
|
||||
var images []Image
|
||||
err := filepath.WalkDir(dir, func(path string, d fs.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if d.IsDir() {
|
||||
if d.Name() == thumbDirName || d.Name() == heroDirName {
|
||||
return filepath.SkipDir
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if !isJPEG(d.Name()) {
|
||||
return nil
|
||||
}
|
||||
|
||||
rel, err := filepath.Rel(dir, path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rel = filepath.ToSlash(rel)
|
||||
if !safeRelPath(rel) {
|
||||
return nil
|
||||
}
|
||||
|
||||
album := albumFromRel(rel)
|
||||
cKey, cLabel := collectionFromRel(rel)
|
||||
date, year := imageDate(path, d.Name())
|
||||
images = append(images, Image{
|
||||
RelPath: rel,
|
||||
Album: album,
|
||||
Collection: cLabel,
|
||||
CollectionKey: cKey,
|
||||
Filename: d.Name(),
|
||||
URL: "/images/" + URLPath(rel),
|
||||
ThumbURL: derivativeURL(dir, thumbDirName, rel),
|
||||
HeroURL: derivativeURL(dir, heroDirName, rel),
|
||||
Date: date,
|
||||
Year: year,
|
||||
})
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
sort.Slice(images, func(i, j int) bool {
|
||||
if images[i].Date.Equal(images[j].Date) {
|
||||
return images[i].RelPath < images[j].RelPath
|
||||
}
|
||||
return images[i].Date.After(images[j].Date)
|
||||
})
|
||||
|
||||
return images, nil
|
||||
}
|
||||
|
||||
// Collections returns sorted collection keys for an album (empty album = all).
|
||||
func Collections(images []Image, album string) []string {
|
||||
seen := make(map[string]string)
|
||||
var keys []string
|
||||
for _, img := range images {
|
||||
if img.CollectionKey == "" {
|
||||
continue
|
||||
}
|
||||
if album != "" && img.Album != album {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[img.CollectionKey]; ok {
|
||||
continue
|
||||
}
|
||||
seen[img.CollectionKey] = img.Collection
|
||||
keys = append(keys, img.CollectionKey)
|
||||
}
|
||||
sort.Slice(keys, func(i, j int) bool {
|
||||
return seen[keys[i]] < seen[keys[j]]
|
||||
})
|
||||
return keys
|
||||
}
|
||||
|
||||
// Albums returns sorted unique album folder names.
|
||||
func Albums(images []Image) []string {
|
||||
seen := make(map[string]struct{})
|
||||
var albums []string
|
||||
for _, img := range images {
|
||||
if img.Album == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[img.Album]; ok {
|
||||
continue
|
||||
}
|
||||
seen[img.Album] = struct{}{}
|
||||
albums = append(albums, img.Album)
|
||||
}
|
||||
sort.Strings(albums)
|
||||
return albums
|
||||
}
|
||||
|
||||
// AlbumLabel returns a display name for an album folder.
|
||||
func AlbumLabel(album string) string {
|
||||
labels := map[string]string{
|
||||
"escaperoom": "Escape room",
|
||||
"ledlyra": "LED Lyra",
|
||||
"misc": "Misc",
|
||||
"portal": "Portal",
|
||||
"templeoftechno": "Temple of techno",
|
||||
"connectionmachine": "Connection machine",
|
||||
}
|
||||
if label, ok := labels[album]; ok {
|
||||
return label
|
||||
}
|
||||
return strings.ReplaceAll(album, "-", " ")
|
||||
}
|
||||
|
||||
func albumFromRel(rel string) string {
|
||||
if i := strings.Index(rel, "/"); i >= 0 {
|
||||
return rel[:i]
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// SafeRelPath reports whether rel is safe to serve (exported for handlers).
|
||||
func SafeRelPath(rel string) bool {
|
||||
return safeRelPath(rel)
|
||||
}
|
||||
|
||||
func safeRelPath(rel string) bool {
|
||||
if rel == "" || strings.Contains(rel, "..") {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func derivativeURL(imagesDir, subdir, rel string) string {
|
||||
if _, err := os.Stat(filepath.Join(imagesDir, subdir, filepath.FromSlash(rel))); err == nil {
|
||||
return "/images/" + subdir + "/" + URLPath(rel)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func imageDate(fullPath, filename string) (time.Time, int) {
|
||||
if t, y := parseDate(filename); !t.IsZero() {
|
||||
return t, y
|
||||
}
|
||||
info, err := os.Stat(fullPath)
|
||||
if err != nil {
|
||||
return time.Time{}, 0
|
||||
}
|
||||
mod := info.ModTime()
|
||||
return mod, mod.Year()
|
||||
}
|
||||
|
||||
func parseDate(filename string) (time.Time, int) {
|
||||
base := strings.TrimSuffix(filename, filepath.Ext(filename))
|
||||
|
||||
// IMG_YYYYMMDD_HHMMSS.jpg
|
||||
if strings.HasPrefix(base, "IMG_") && len(base) >= 12 {
|
||||
raw := strings.TrimPrefix(base, "IMG_")
|
||||
if t, err := time.Parse("20060102", raw[:8]); err == nil {
|
||||
return t, t.Year()
|
||||
}
|
||||
}
|
||||
|
||||
// 20220723_231556.jpg
|
||||
if len(base) >= 15 && base[8] == '_' {
|
||||
if t, err := time.Parse("20060102_150405", base[:15]); err == nil {
|
||||
return t, t.Year()
|
||||
}
|
||||
}
|
||||
|
||||
return time.Time{}, 0
|
||||
}
|
||||
Reference in New Issue
Block a user