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>
209 lines
4.8 KiB
Go
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
|
|
}
|