Files
website/app/internal/gallery/gallery.go
jimmy 45b31be9a7 Add gallery admin and video media support.
This updates gallery handling to support video playback with generated poster thumbnails, adds authenticated admin upload/delete flows, and improves dev/runtime behavior including reliable thumbnail generation and media-safe response handling.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-02 23:01:02 +12:00

209 lines
4.8 KiB
Go

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
IsVideo bool
Date time.Time
Year int
}
// List returns gallery JPEGs and videos 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 !isGalleryMedia(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())
video := isVideo(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),
IsVideo: video,
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 {
lookup := rel
if subdir == thumbDirName || subdir == heroDirName {
lookup = thumbLookupRel(rel)
}
if isVideo(filepath.Base(rel)) && subdir == heroDirName {
return ""
}
if _, err := os.Stat(filepath.Join(imagesDir, subdir, filepath.FromSlash(lookup))); err == nil {
return "/images/" + subdir + "/" + URLPath(lookup)
}
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
}