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>
This commit is contained in:
@@ -0,0 +1 @@
|
||||
|
||||
|
||||
99
app/internal/auth/auth.go
Normal file
99
app/internal/auth/auth.go
Normal file
@@ -0,0 +1,99 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/subtle"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"os"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
const sessionCookie = "tk_admin_session"
|
||||
|
||||
// Config holds admin credentials from the environment.
|
||||
type Config struct {
|
||||
Username string
|
||||
Password string
|
||||
Enabled bool
|
||||
}
|
||||
|
||||
// Load reads admin auth settings. Admin is enabled when both user and password are set.
|
||||
func Load() Config {
|
||||
user := os.Getenv("ADMIN_USER")
|
||||
pass := os.Getenv("ADMIN_PASSWORD")
|
||||
return Config{
|
||||
Username: user,
|
||||
Password: pass,
|
||||
Enabled: user != "" && pass != "",
|
||||
}
|
||||
}
|
||||
|
||||
// Sessions tracks active login tokens in memory.
|
||||
type Sessions struct {
|
||||
mu sync.RWMutex
|
||||
tokens map[string]time.Time
|
||||
ttl time.Duration
|
||||
}
|
||||
|
||||
// NewSessions creates a session store.
|
||||
func NewSessions() *Sessions {
|
||||
return &Sessions{
|
||||
tokens: make(map[string]time.Time),
|
||||
ttl: 24 * time.Hour,
|
||||
}
|
||||
}
|
||||
|
||||
// Create issues a new session token.
|
||||
func (s *Sessions) Create() (string, error) {
|
||||
b := make([]byte, 32)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
return "", err
|
||||
}
|
||||
token := base64.RawURLEncoding.EncodeToString(b)
|
||||
exp := time.Now().Add(s.ttl)
|
||||
s.mu.Lock()
|
||||
s.tokens[token] = exp
|
||||
s.mu.Unlock()
|
||||
return token, nil
|
||||
}
|
||||
|
||||
// Valid reports whether a session token is still active.
|
||||
func (s *Sessions) Valid(token string) bool {
|
||||
if token == "" {
|
||||
return false
|
||||
}
|
||||
s.mu.RLock()
|
||||
exp, ok := s.tokens[token]
|
||||
s.mu.RUnlock()
|
||||
if !ok || time.Now().After(exp) {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// Delete removes a session token.
|
||||
func (s *Sessions) Delete(token string) {
|
||||
s.mu.Lock()
|
||||
delete(s.tokens, token)
|
||||
s.mu.Unlock()
|
||||
}
|
||||
|
||||
// CookieName returns the session cookie name.
|
||||
func CookieName() string {
|
||||
return sessionCookie
|
||||
}
|
||||
|
||||
// CheckCredentials compares username and password to config using constant-time compare.
|
||||
func CheckCredentials(cfg Config, username, password string) bool {
|
||||
if !cfg.Enabled {
|
||||
return false
|
||||
}
|
||||
uOK := subtle.ConstantTimeCompare([]byte(username), []byte(cfg.Username)) == 1
|
||||
pOK := subtle.ConstantTimeCompare([]byte(password), []byte(cfg.Password)) == 1
|
||||
return uOK && pOK
|
||||
}
|
||||
|
||||
// ErrDisabled is returned when admin auth is not configured.
|
||||
var ErrDisabled = errors.New("admin auth is not configured")
|
||||
124
app/internal/gallery/files.go
Normal file
124
app/internal/gallery/files.go
Normal file
@@ -0,0 +1,124 @@
|
||||
package gallery
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
var albumNameRe = regexp.MustCompile(`^[a-z0-9][a-z0-9_-]{0,63}$`)
|
||||
|
||||
// AlbumsOnDisk returns top-level album folder names (excluding thumbs/hero).
|
||||
func AlbumsOnDisk(imagesDir string) ([]string, error) {
|
||||
entries, err := os.ReadDir(imagesDir)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var albums []string
|
||||
for _, e := range entries {
|
||||
if !e.IsDir() || e.Name() == thumbDirName || e.Name() == heroDirName {
|
||||
continue
|
||||
}
|
||||
if e.Name()[0] == '.' {
|
||||
continue
|
||||
}
|
||||
albums = append(albums, e.Name())
|
||||
}
|
||||
return albums, nil
|
||||
}
|
||||
|
||||
// SanitizeAlbum normalizes a folder name for new uploads.
|
||||
func SanitizeAlbum(name string) (string, error) {
|
||||
name = strings.ToLower(strings.TrimSpace(name))
|
||||
name = strings.ReplaceAll(name, " ", "-")
|
||||
if !albumNameRe.MatchString(name) {
|
||||
return "", fmt.Errorf("invalid album name")
|
||||
}
|
||||
return name, nil
|
||||
}
|
||||
|
||||
// SanitizeUploadName keeps a safe JPEG filename.
|
||||
func SanitizeUploadName(name string) (string, error) {
|
||||
name = filepath.Base(strings.TrimSpace(name))
|
||||
name = strings.ReplaceAll(name, " ", "_")
|
||||
if !isJPEG(name) {
|
||||
return "", fmt.Errorf("only .jpg and .jpeg files are allowed")
|
||||
}
|
||||
if strings.Contains(name, "..") {
|
||||
return "", fmt.Errorf("invalid filename")
|
||||
}
|
||||
return name, nil
|
||||
}
|
||||
|
||||
// SaveUpload writes an uploaded JPEG into the gallery tree.
|
||||
func SaveUpload(imagesDir, album, filename string, r io.Reader) (string, error) {
|
||||
album, err := SanitizeAlbum(album)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
filename, err = SanitizeUploadName(filename)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
dir := filepath.Join(imagesDir, album)
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
return "", err
|
||||
}
|
||||
dst := filepath.Join(dir, filename)
|
||||
f, err := os.Create(dst)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer f.Close()
|
||||
if _, err := io.Copy(f, r); err != nil {
|
||||
os.Remove(dst)
|
||||
return "", err
|
||||
}
|
||||
rel := filepath.ToSlash(filepath.Join(album, filename))
|
||||
return rel, nil
|
||||
}
|
||||
|
||||
// DeleteImage removes an image and its derivatives.
|
||||
func DeleteImage(imagesDir, rel string) error {
|
||||
if !safeRelPath(rel) {
|
||||
return fmt.Errorf("invalid path")
|
||||
}
|
||||
paths := []string{filepath.Join(imagesDir, filepath.FromSlash(rel))}
|
||||
if isVideo(filepath.Base(rel)) {
|
||||
paths = append(paths, filepath.Join(imagesDir, thumbDirName, filepath.FromSlash(videoPosterRel(rel))))
|
||||
} else {
|
||||
paths = append(paths,
|
||||
filepath.Join(imagesDir, thumbDirName, filepath.FromSlash(rel)),
|
||||
filepath.Join(imagesDir, heroDirName, filepath.FromSlash(rel)),
|
||||
)
|
||||
}
|
||||
for _, p := range paths {
|
||||
if err := os.Remove(p); err != nil && !os.IsNotExist(err) {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// EnsureDerivatives rebuilds thumb and hero for one image.
|
||||
func EnsureDerivatives(imagesDir, rel string) error {
|
||||
if !safeRelPath(rel) {
|
||||
return fmt.Errorf("invalid path")
|
||||
}
|
||||
src := filepath.Join(imagesDir, filepath.FromSlash(rel))
|
||||
thumbDst := filepath.Join(imagesDir, thumbDirName, filepath.FromSlash(rel))
|
||||
heroDst := filepath.Join(imagesDir, heroDirName, filepath.FromSlash(rel))
|
||||
if err := os.MkdirAll(filepath.Dir(thumbDst), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(heroDst), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := writeThumb(src, thumbDst, gridThumbMaxEdge, gridThumbQuality); err != nil {
|
||||
return err
|
||||
}
|
||||
return writeThumb(src, heroDst, heroThumbMaxEdge, heroThumbQuality)
|
||||
}
|
||||
@@ -19,11 +19,12 @@ type Image struct {
|
||||
URL string
|
||||
ThumbURL string
|
||||
HeroURL string
|
||||
IsVideo bool
|
||||
Date time.Time
|
||||
Year int
|
||||
}
|
||||
|
||||
// List returns JPEG images from dir and subfolders (newest first).
|
||||
// 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
|
||||
@@ -37,7 +38,7 @@ func List(dir string) ([]Image, error) {
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if !isJPEG(d.Name()) {
|
||||
if !isGalleryMedia(d.Name()) {
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -53,6 +54,7 @@ func List(dir string) ([]Image, error) {
|
||||
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,
|
||||
@@ -62,6 +64,7 @@ func List(dir string) ([]Image, error) {
|
||||
URL: "/images/" + URLPath(rel),
|
||||
ThumbURL: derivativeURL(dir, thumbDirName, rel),
|
||||
HeroURL: derivativeURL(dir, heroDirName, rel),
|
||||
IsVideo: video,
|
||||
Date: date,
|
||||
Year: year,
|
||||
})
|
||||
@@ -158,8 +161,15 @@ func safeRelPath(rel string) bool {
|
||||
}
|
||||
|
||||
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)
|
||||
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 ""
|
||||
}
|
||||
|
||||
@@ -19,12 +19,17 @@ func HeroRelPath() string {
|
||||
func SelectHero(images []Image) (Image, bool) {
|
||||
want := HeroRelPath()
|
||||
for _, img := range images {
|
||||
if img.IsVideo {
|
||||
continue
|
||||
}
|
||||
if img.RelPath == want {
|
||||
return img, true
|
||||
}
|
||||
}
|
||||
if len(images) > 0 {
|
||||
return images[0], true
|
||||
for _, img := range images {
|
||||
if !img.IsVideo {
|
||||
return img, true
|
||||
}
|
||||
}
|
||||
return Image{}, false
|
||||
}
|
||||
|
||||
36
app/internal/gallery/media.go
Normal file
36
app/internal/gallery/media.go
Normal file
@@ -0,0 +1,36 @@
|
||||
package gallery
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func isVideo(name string) bool {
|
||||
switch strings.ToLower(filepath.Ext(name)) {
|
||||
case ".mp4", ".webm", ".mov":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func isGalleryMedia(name string) bool {
|
||||
return isJPEG(name) || isVideo(name)
|
||||
}
|
||||
|
||||
// thumbLookupRel is the path under thumbs/ (and hero/) used to find derivatives.
|
||||
// Videos use a JPEG poster: clip.mp4 → clip.jpg.
|
||||
func thumbLookupRel(rel string) string {
|
||||
if isVideo(filepath.Base(rel)) {
|
||||
return videoPosterRel(rel)
|
||||
}
|
||||
return rel
|
||||
}
|
||||
|
||||
func videoPosterRel(rel string) string {
|
||||
ext := filepath.Ext(rel)
|
||||
if ext == "" {
|
||||
return rel + ".jpg"
|
||||
}
|
||||
return strings.TrimSuffix(rel, ext) + ".jpg"
|
||||
}
|
||||
44
app/internal/gallery/media_test.go
Normal file
44
app/internal/gallery/media_test.go
Normal file
@@ -0,0 +1,44 @@
|
||||
package gallery
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestVideoPosterRel(t *testing.T) {
|
||||
if got := videoPosterRel("portal/clip.mp4"); got != "portal/clip.jpg" {
|
||||
t.Fatalf("got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestList_includesVideo(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
album := filepath.Join(dir, "portal")
|
||||
if err := os.MkdirAll(album, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
name := "showreel.mp4"
|
||||
if err := os.WriteFile(filepath.Join(album, name), []byte("fake"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
images, err := List(dir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(images) != 1 || !images[0].IsVideo || images[0].RelPath != "portal/"+name {
|
||||
t.Fatalf("got %+v", images)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSelectHero_skipsVideo(t *testing.T) {
|
||||
images := []Image{
|
||||
{RelPath: "portal/showreel.mp4", IsVideo: true},
|
||||
{RelPath: "portal/photo.jpg", IsVideo: false},
|
||||
}
|
||||
hero, ok := SelectHero(images)
|
||||
if !ok || hero.RelPath != "portal/photo.jpg" {
|
||||
t.Fatalf("got %+v ok=%v", hero, ok)
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,13 @@
|
||||
package gallery
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"image"
|
||||
"image/jpeg"
|
||||
"io/fs"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
@@ -29,7 +32,8 @@ func EnsureThumbnails(imagesDir string) error {
|
||||
return err
|
||||
}
|
||||
|
||||
return filepath.WalkDir(imagesDir, func(path string, d fs.DirEntry, err error) error {
|
||||
var failures []string
|
||||
walkErr := filepath.WalkDir(imagesDir, func(path string, d fs.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -39,7 +43,7 @@ func EnsureThumbnails(imagesDir string) error {
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if !isJPEG(d.Name()) {
|
||||
if !isGalleryMedia(d.Name()) {
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -52,19 +56,43 @@ func EnsureThumbnails(imagesDir string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
if isVideo(d.Name()) {
|
||||
thumbDst := filepath.Join(imagesDir, thumbDirName, filepath.FromSlash(videoPosterRel(rel)))
|
||||
if err := os.MkdirAll(filepath.Dir(thumbDst), 0o755); err != nil {
|
||||
failures = append(failures, fmt.Sprintf("%s: %v", rel, err))
|
||||
return nil
|
||||
}
|
||||
if err := writeVideoPoster(path, thumbDst, gridThumbMaxEdge); err != nil {
|
||||
failures = append(failures, fmt.Sprintf("%s: %v", rel, err))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
thumbDst := filepath.Join(imagesDir, thumbDirName, filepath.FromSlash(rel))
|
||||
heroDst := filepath.Join(imagesDir, heroDirName, filepath.FromSlash(rel))
|
||||
if err := os.MkdirAll(filepath.Dir(thumbDst), 0o755); err != nil {
|
||||
return err
|
||||
failures = append(failures, fmt.Sprintf("%s: %v", rel, err))
|
||||
return nil
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(heroDst), 0o755); err != nil {
|
||||
return err
|
||||
failures = append(failures, fmt.Sprintf("%s: %v", rel, err))
|
||||
return nil
|
||||
}
|
||||
if err := writeThumb(path, thumbDst, gridThumbMaxEdge, gridThumbQuality); err != nil {
|
||||
return err
|
||||
failures = append(failures, fmt.Sprintf("%s thumb: %v", rel, err))
|
||||
}
|
||||
return writeThumb(path, heroDst, heroThumbMaxEdge, heroThumbQuality)
|
||||
if err := writeThumb(path, heroDst, heroThumbMaxEdge, heroThumbQuality); err != nil {
|
||||
failures = append(failures, fmt.Sprintf("%s hero: %v", rel, err))
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if walkErr != nil {
|
||||
return walkErr
|
||||
}
|
||||
if len(failures) > 0 {
|
||||
return fmt.Errorf("%d file(s): %s", len(failures), strings.Join(failures, "; "))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// EnsurePriority generates thumbnails for one image before first page load.
|
||||
@@ -87,6 +115,41 @@ func EnsurePriority(imagesDir, relPath string) error {
|
||||
return writeThumb(src, thumbDst, gridThumbMaxEdge, gridThumbQuality)
|
||||
}
|
||||
|
||||
func writeVideoPoster(src, dst string, maxEdge int) error {
|
||||
srcInfo, err := os.Stat(src)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if dstInfo, err := os.Stat(dst); err == nil && !dstInfo.ModTime().Before(srcInfo.ModTime()) {
|
||||
return nil
|
||||
}
|
||||
if _, err := exec.LookPath("ffmpeg"); err != nil {
|
||||
return nil
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
scale := fmt.Sprintf("scale=%d:%d:force_original_aspect_ratio=decrease", maxEdge, maxEdge)
|
||||
cmd := exec.Command("ffmpeg",
|
||||
"-hide_banner", "-loglevel", "error", "-y",
|
||||
"-i", src,
|
||||
"-vf", scale,
|
||||
"-frames:v", "1",
|
||||
"-q:v", "5",
|
||||
dst,
|
||||
)
|
||||
var stderr bytes.Buffer
|
||||
cmd.Stderr = &stderr
|
||||
if err := cmd.Run(); err != nil {
|
||||
msg := strings.TrimSpace(stderr.String())
|
||||
if msg == "" {
|
||||
return err
|
||||
}
|
||||
return fmt.Errorf("%w: %s", err, msg)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func writeThumb(src, dst string, maxEdge, quality int) error {
|
||||
srcInfo, err := os.Stat(src)
|
||||
if err != nil {
|
||||
|
||||
201
app/internal/handlers/admin.go
Normal file
201
app/internal/handlers/admin.go
Normal file
@@ -0,0 +1,201 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"log"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"technical.kiwi/website/internal/auth"
|
||||
"technical.kiwi/website/internal/gallery"
|
||||
"technical.kiwi/website/templates"
|
||||
)
|
||||
|
||||
const maxUploadBytes = 32 << 20 // 32 MiB
|
||||
|
||||
func (s *Server) adminEnabled() bool {
|
||||
return s.Auth.Enabled
|
||||
}
|
||||
|
||||
func (s *Server) sessionToken(r *http.Request) string {
|
||||
c, err := r.Cookie(auth.CookieName())
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return c.Value
|
||||
}
|
||||
|
||||
func (s *Server) isAdmin(r *http.Request) bool {
|
||||
return s.Sessions.Valid(s.sessionToken(r))
|
||||
}
|
||||
|
||||
func (s *Server) setSession(w http.ResponseWriter, token string) {
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: auth.CookieName(),
|
||||
Value: token,
|
||||
Path: "/admin",
|
||||
HttpOnly: true,
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
MaxAge: int((24 * time.Hour).Seconds()),
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) clearSession(w http.ResponseWriter) {
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: auth.CookieName(),
|
||||
Value: "",
|
||||
Path: "/admin",
|
||||
HttpOnly: true,
|
||||
MaxAge: -1,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) requireAdmin(fn http.HandlerFunc) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.adminEnabled() {
|
||||
http.Error(w, "admin disabled", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
if !s.isAdmin(r) {
|
||||
http.Redirect(w, r, "/admin/login", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
fn(w, r)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) reloadGallery() error {
|
||||
s.galleryMu.Lock()
|
||||
defer s.galleryMu.Unlock()
|
||||
|
||||
images, err := gallery.List(s.ImagesDir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
hero, hasHero := gallery.SelectHero(images)
|
||||
s.Images = images
|
||||
s.Hero = hero
|
||||
s.HasHero = hasHero
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Server) adminLogin(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.adminEnabled() {
|
||||
http.Error(w, "admin disabled: set ADMIN_USER and ADMIN_PASSWORD", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
if s.isAdmin(r) {
|
||||
http.Redirect(w, r, "/admin/", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
templates.AdminLogin("").Render(r.Context(), w)
|
||||
}
|
||||
|
||||
func (s *Server) adminLoginPost(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.adminEnabled() {
|
||||
http.Error(w, "admin disabled", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
if err := r.ParseForm(); err != nil {
|
||||
templates.AdminLogin("Invalid request.").Render(r.Context(), w)
|
||||
return
|
||||
}
|
||||
user := r.FormValue("username")
|
||||
pass := r.FormValue("password")
|
||||
if !auth.CheckCredentials(s.Auth, user, pass) {
|
||||
templates.AdminLogin("Invalid username or password.").Render(r.Context(), w)
|
||||
return
|
||||
}
|
||||
token, err := s.Sessions.Create()
|
||||
if err != nil {
|
||||
http.Error(w, "session error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
s.setSession(w, token)
|
||||
http.Redirect(w, r, "/admin/", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func (s *Server) adminLogout(w http.ResponseWriter, r *http.Request) {
|
||||
s.Sessions.Delete(s.sessionToken(r))
|
||||
s.clearSession(w)
|
||||
http.Redirect(w, r, "/admin/login", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func (s *Server) adminIndex(w http.ResponseWriter, r *http.Request) {
|
||||
images, _, _ := s.snapshot()
|
||||
albums, _ := gallery.AlbumsOnDisk(s.ImagesDir)
|
||||
w.Header().Set("Cache-Control", "no-cache")
|
||||
templates.AdminDashboard(images, albums, "").Render(r.Context(), w)
|
||||
}
|
||||
|
||||
func (s *Server) adminUpload(w http.ResponseWriter, r *http.Request) {
|
||||
r.Body = http.MaxBytesReader(w, r.Body, maxUploadBytes)
|
||||
if err := r.ParseMultipartForm(maxUploadBytes); err != nil {
|
||||
s.adminIndexError(w, r, "Upload too large or invalid.")
|
||||
return
|
||||
}
|
||||
|
||||
album := r.FormValue("album")
|
||||
if album == "new" {
|
||||
album = r.FormValue("album_new")
|
||||
}
|
||||
|
||||
file, header, err := r.FormFile("image")
|
||||
if err != nil {
|
||||
s.adminIndexError(w, r, "Choose a JPEG image to upload.")
|
||||
return
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
rel, err := gallery.SaveUpload(s.ImagesDir, album, header.Filename, file)
|
||||
if err != nil {
|
||||
s.adminIndexError(w, r, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
if err := gallery.EnsureDerivatives(s.ImagesDir, rel); err != nil {
|
||||
log.Printf("admin upload derivatives: %v", err)
|
||||
}
|
||||
if err := s.reloadGallery(); err != nil {
|
||||
log.Printf("admin reload gallery: %v", err)
|
||||
}
|
||||
|
||||
if r.Header.Get("HX-Request") == "true" {
|
||||
images, _, _ := s.snapshot()
|
||||
templates.AdminImageTable(images).Render(r.Context(), w)
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, "/admin/", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func (s *Server) adminDelete(w http.ResponseWriter, r *http.Request) {
|
||||
if err := r.ParseForm(); err != nil {
|
||||
http.Error(w, "bad request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
rel := r.FormValue("path")
|
||||
if err := gallery.DeleteImage(s.ImagesDir, rel); err != nil {
|
||||
s.adminIndexError(w, r, err.Error())
|
||||
return
|
||||
}
|
||||
if err := s.reloadGallery(); err != nil {
|
||||
log.Printf("admin reload gallery: %v", err)
|
||||
}
|
||||
|
||||
if r.Header.Get("HX-Request") == "true" {
|
||||
images, _, _ := s.snapshot()
|
||||
templates.AdminImageTable(images).Render(r.Context(), w)
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, "/admin/", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func (s *Server) adminIndexError(w http.ResponseWriter, r *http.Request, msg string) {
|
||||
images, _, _ := s.snapshot()
|
||||
albums, _ := gallery.AlbumsOnDisk(s.ImagesDir)
|
||||
if r.Header.Get("HX-Request") == "true" {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
templates.AdminFlash(msg).Render(r.Context(), w)
|
||||
return
|
||||
}
|
||||
templates.AdminDashboard(images, albums, msg).Render(r.Context(), w)
|
||||
}
|
||||
@@ -4,8 +4,10 @@ import (
|
||||
"log"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"technical.kiwi/website/internal/auth"
|
||||
"technical.kiwi/website/internal/contact"
|
||||
"technical.kiwi/website/internal/gallery"
|
||||
"technical.kiwi/website/internal/mail"
|
||||
@@ -22,6 +24,9 @@ type Server struct {
|
||||
HasHero bool
|
||||
Mail *mail.Config
|
||||
ContactEnabled bool
|
||||
Auth auth.Config
|
||||
Sessions *auth.Sessions
|
||||
galleryMu sync.RWMutex
|
||||
}
|
||||
|
||||
// New builds a Server after loading the image gallery.
|
||||
@@ -43,6 +48,7 @@ func New(imagesDir, staticDir string, mailCfg *mail.Config) (*Server, error) {
|
||||
}
|
||||
log.Printf("hero image: %s", hero.RelPath)
|
||||
}
|
||||
authCfg := auth.Load()
|
||||
srv := &Server{
|
||||
ImagesDir: imagesDir,
|
||||
StaticDir: staticDir,
|
||||
@@ -51,20 +57,32 @@ func New(imagesDir, staticDir string, mailCfg *mail.Config) (*Server, error) {
|
||||
HasHero: hasHero,
|
||||
Mail: mailCfg,
|
||||
ContactEnabled: mailCfg != nil,
|
||||
Auth: authCfg,
|
||||
Sessions: auth.NewSessions(),
|
||||
}
|
||||
go func() {
|
||||
if err := gallery.EnsureThumbnails(imagesDir); err != nil {
|
||||
log.Printf("gallery thumbnails: %v", err)
|
||||
return
|
||||
}
|
||||
if authCfg.Enabled {
|
||||
log.Print("admin: enabled at /admin/")
|
||||
} else {
|
||||
log.Print("admin: disabled (set ADMIN_USER and ADMIN_PASSWORD)")
|
||||
}
|
||||
if err := gallery.EnsureThumbnails(imagesDir); err != nil {
|
||||
log.Printf("gallery thumbnails: %v", err)
|
||||
} else {
|
||||
log.Print("gallery thumbnails: ready")
|
||||
}()
|
||||
}
|
||||
return srv, nil
|
||||
}
|
||||
|
||||
func (s *Server) snapshot() ([]gallery.Image, gallery.Image, bool) {
|
||||
s.galleryMu.RLock()
|
||||
defer s.galleryMu.RUnlock()
|
||||
return s.Images, s.Hero, s.HasHero
|
||||
}
|
||||
|
||||
func (s *Server) index(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Cache-Control", "no-cache")
|
||||
templates.Home(s.Images, s.Hero, s.HasHero, s.ContactEnabled).Render(r.Context(), w)
|
||||
images, hero, hasHero := s.snapshot()
|
||||
templates.Home(images, hero, hasHero, s.ContactEnabled).Render(r.Context(), w)
|
||||
}
|
||||
|
||||
func (s *Server) contact(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -107,54 +125,31 @@ func (s *Server) contact(w http.ResponseWriter, r *http.Request) {
|
||||
templates.ContactSuccess().Render(r.Context(), w)
|
||||
}
|
||||
|
||||
func (s *Server) galleryPartial(w http.ResponseWriter, r *http.Request) {
|
||||
album := r.URL.Query().Get("album")
|
||||
collection := r.URL.Query().Get("collection")
|
||||
filtered := s.filterImages(album, collection)
|
||||
templates.GalleryGrid(filtered).Render(r.Context(), w)
|
||||
}
|
||||
|
||||
func (s *Server) imageModal(w http.ResponseWriter, r *http.Request) {
|
||||
rel := r.PathValue("path")
|
||||
if !gallery.SafeRelPath(rel) {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
img, ok := s.findImage(rel)
|
||||
images, _, _ := s.snapshot()
|
||||
img, ok := findImage(images, rel)
|
||||
if !ok {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
idx := s.indexOf(rel)
|
||||
idx := indexOf(images, rel)
|
||||
prev, next := "", ""
|
||||
if idx > 0 {
|
||||
prev = s.Images[idx-1].RelPath
|
||||
prev = images[idx-1].RelPath
|
||||
}
|
||||
if idx >= 0 && idx < len(s.Images)-1 {
|
||||
next = s.Images[idx+1].RelPath
|
||||
if idx >= 0 && idx < len(images)-1 {
|
||||
next = images[idx+1].RelPath
|
||||
}
|
||||
templates.ImageModal(img, prev, next).Render(r.Context(), w)
|
||||
}
|
||||
|
||||
func (s *Server) filterImages(album, collection string) []gallery.Image {
|
||||
if album == "" && collection == "" {
|
||||
return s.Images
|
||||
}
|
||||
var out []gallery.Image
|
||||
for _, img := range s.Images {
|
||||
if album != "" && img.Album != album {
|
||||
continue
|
||||
}
|
||||
if collection != "" && img.CollectionKey != collection {
|
||||
continue
|
||||
}
|
||||
out = append(out, img)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (s *Server) findImage(rel string) (gallery.Image, bool) {
|
||||
for _, img := range s.Images {
|
||||
func findImage(images []gallery.Image, rel string) (gallery.Image, bool) {
|
||||
for _, img := range images {
|
||||
if img.RelPath == rel {
|
||||
return img, true
|
||||
}
|
||||
@@ -162,8 +157,8 @@ func (s *Server) findImage(rel string) (gallery.Image, bool) {
|
||||
return gallery.Image{}, false
|
||||
}
|
||||
|
||||
func (s *Server) indexOf(rel string) int {
|
||||
for i, img := range s.Images {
|
||||
func indexOf(images []gallery.Image, rel string) int {
|
||||
for i, img := range images {
|
||||
if img.RelPath == rel {
|
||||
return i
|
||||
}
|
||||
@@ -175,9 +170,15 @@ func (s *Server) indexOf(rel string) int {
|
||||
func (s *Server) RegisterRoutes(mux *http.ServeMux) {
|
||||
mux.HandleFunc("GET /{$}", s.index)
|
||||
mux.HandleFunc("POST /contact", s.contact)
|
||||
mux.HandleFunc("GET /gallery", s.galleryPartial)
|
||||
mux.HandleFunc("GET /gallery/{path...}", s.imageModal)
|
||||
staticHandler := middleware.CacheStatic(s.StaticDir, 7*24*time.Hour)
|
||||
mux.Handle("GET /static/", http.StripPrefix("/static/", staticHandler))
|
||||
mux.Handle("GET /images/", http.StripPrefix("/images/", middleware.CacheImages(s.ImagesDir)))
|
||||
|
||||
mux.HandleFunc("GET /admin/login", s.adminLogin)
|
||||
mux.HandleFunc("POST /admin/login", s.adminLoginPost)
|
||||
mux.HandleFunc("POST /admin/logout", s.adminLogout)
|
||||
mux.HandleFunc("GET /admin/", s.requireAdmin(s.adminIndex))
|
||||
mux.HandleFunc("POST /admin/upload", s.requireAdmin(s.adminUpload))
|
||||
mux.HandleFunc("POST /admin/delete", s.requireAdmin(s.adminDelete))
|
||||
}
|
||||
|
||||
@@ -3,7 +3,9 @@ package middleware
|
||||
import (
|
||||
"compress/gzip"
|
||||
"io"
|
||||
"mime"
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -21,7 +23,7 @@ func (w *gzipResponseWriter) Write(b []byte) (int, error) {
|
||||
// Gzip compresses responses when the client accepts it.
|
||||
func Gzip(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if !strings.Contains(r.Header.Get("Accept-Encoding"), "gzip") {
|
||||
if !strings.Contains(r.Header.Get("Accept-Encoding"), "gzip") || shouldSkipGzip(r) {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
@@ -59,3 +61,20 @@ func formatMaxAge(d time.Duration) string {
|
||||
return strconv.Itoa(int(d.Seconds()))
|
||||
}
|
||||
|
||||
func shouldSkipGzip(r *http.Request) bool {
|
||||
// Range requests must stay byte-accurate for media seeking/playback.
|
||||
if r.Header.Get("Range") != "" {
|
||||
return true
|
||||
}
|
||||
|
||||
ext := strings.ToLower(filepath.Ext(r.URL.Path))
|
||||
if ext == "" {
|
||||
return false
|
||||
}
|
||||
|
||||
ct := mime.TypeByExtension(ext)
|
||||
return strings.HasPrefix(ct, "image/") ||
|
||||
strings.HasPrefix(ct, "video/") ||
|
||||
strings.HasPrefix(ct, "audio/")
|
||||
}
|
||||
|
||||
|
||||
@@ -281,53 +281,6 @@ main {
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.gallery-controls {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 1.25rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.gallery-controls-collections {
|
||||
padding: 0.75rem 0 0;
|
||||
margin-bottom: 1.25rem;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.gallery-controls-label {
|
||||
font-size: 0.8rem;
|
||||
font-family: var(--mono);
|
||||
color: var(--text-muted);
|
||||
margin-right: 0.25rem;
|
||||
width: 100%;
|
||||
flex-basis: 100%;
|
||||
}
|
||||
|
||||
@media (min-width: 600px) {
|
||||
.gallery-controls-label {
|
||||
width: auto;
|
||||
flex-basis: auto;
|
||||
}
|
||||
}
|
||||
|
||||
.filter-btn {
|
||||
font: inherit;
|
||||
padding: 0.45rem 0.9rem;
|
||||
border-radius: 999px;
|
||||
border: 1px solid var(--border);
|
||||
background: transparent;
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.filter-btn:hover,
|
||||
.filter-btn.active {
|
||||
background: var(--accent-soft);
|
||||
color: var(--text);
|
||||
border-color: rgba(196, 123, 58, 0.45);
|
||||
}
|
||||
|
||||
.gallery-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
|
||||
@@ -358,6 +311,21 @@ main {
|
||||
background: var(--surface);
|
||||
}
|
||||
|
||||
.gallery-video-badge {
|
||||
position: absolute;
|
||||
left: 0.35rem;
|
||||
bottom: 0.35rem;
|
||||
padding: 0.15rem 0.4rem;
|
||||
border-radius: 3px;
|
||||
background: rgba(0, 0, 0, 0.65);
|
||||
color: var(--text);
|
||||
font-family: var(--mono);
|
||||
font-size: 0.65rem;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.gallery-item:hover img {
|
||||
transform: scale(1.04);
|
||||
}
|
||||
@@ -530,6 +498,7 @@ main {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: 1rem;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.modal-close {
|
||||
@@ -550,13 +519,19 @@ main {
|
||||
|
||||
.modal-figure {
|
||||
margin: 0;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
background: #000;
|
||||
}
|
||||
|
||||
.modal-figure img {
|
||||
.modal-figure img,
|
||||
.modal-figure video {
|
||||
display: block;
|
||||
width: 100%;
|
||||
max-height: 75vh;
|
||||
object-fit: contain;
|
||||
border-radius: 8px;
|
||||
background: #000;
|
||||
}
|
||||
|
||||
.modal-figure figcaption {
|
||||
@@ -675,11 +650,6 @@ main {
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.filter-btn {
|
||||
min-height: 2.5rem;
|
||||
padding: 0.5rem 1rem;
|
||||
}
|
||||
|
||||
.contact-form {
|
||||
max-width: none;
|
||||
}
|
||||
@@ -739,7 +709,8 @@ main {
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.modal-figure img {
|
||||
.modal-figure img,
|
||||
.modal-figure video {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
max-height: none;
|
||||
@@ -775,3 +746,116 @@ main {
|
||||
transform: scale(1.02);
|
||||
}
|
||||
}
|
||||
|
||||
/* --- Admin --- */
|
||||
.admin-body {
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.admin-login {
|
||||
max-width: 24rem;
|
||||
margin: 4rem auto;
|
||||
padding: 0 1rem;
|
||||
}
|
||||
|
||||
.admin-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
padding: 1rem clamp(1rem, 4vw, 2rem);
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.admin-header h1 {
|
||||
margin: 0;
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
|
||||
.admin-main {
|
||||
max-width: 1100px;
|
||||
margin: 0 auto;
|
||||
padding: 1.5rem clamp(1rem, 4vw, 2rem) 3rem;
|
||||
}
|
||||
|
||||
.admin-panel {
|
||||
margin-bottom: 2rem;
|
||||
padding: 1.25rem;
|
||||
background: var(--bg-elevated);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
}
|
||||
|
||||
.admin-panel h2 {
|
||||
margin: 0 0 1rem;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.admin-back {
|
||||
color: var(--text-muted);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.admin-table-wrap {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.admin-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.admin-table th,
|
||||
.admin-table td {
|
||||
padding: 0.6rem 0.5rem;
|
||||
border-bottom: 1px solid var(--border);
|
||||
text-align: left;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.admin-table code {
|
||||
font-family: var(--mono);
|
||||
font-size: 0.8rem;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.admin-thumb {
|
||||
width: 4rem;
|
||||
}
|
||||
|
||||
.admin-thumb img {
|
||||
width: 4rem;
|
||||
height: 4rem;
|
||||
object-fit: cover;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.admin-delete {
|
||||
color: #e8a090;
|
||||
}
|
||||
|
||||
.admin-delete:hover {
|
||||
background: rgba(120, 40, 40, 0.25);
|
||||
}
|
||||
|
||||
#admin-flash:empty {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.contact-form select {
|
||||
display: block;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
font: inherit;
|
||||
padding: 0.65rem 0.85rem;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--bg-elevated);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.contact-form input[type="file"] {
|
||||
font-size: 0.9rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
156
app/templates/admin.templ
Normal file
156
app/templates/admin.templ
Normal file
@@ -0,0 +1,156 @@
|
||||
package templates
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"technical.kiwi/website/internal/gallery"
|
||||
)
|
||||
|
||||
templ AdminLogin(errMsg string) {
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8"/>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1"/>
|
||||
<title>Admin login — Technical Kiwi</title>
|
||||
<link rel="stylesheet" href="/static/style.css"/>
|
||||
</head>
|
||||
<body class="admin-body">
|
||||
<main class="admin-login">
|
||||
<h1>Gallery admin</h1>
|
||||
if errMsg != "" {
|
||||
<div class="alert alert-error" role="alert"><p>{ errMsg }</p></div>
|
||||
}
|
||||
<form method="post" action="/admin/login" class="contact-form">
|
||||
<div class="form-row">
|
||||
<label for="username">Username</label>
|
||||
<input type="text" id="username" name="username" required autocomplete="username"/>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label for="password">Password</label>
|
||||
<input type="password" id="password" name="password" required autocomplete="current-password"/>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary">Sign in</button>
|
||||
</form>
|
||||
<p class="admin-back"><a href="/">← Back to site</a></p>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
}
|
||||
|
||||
templ AdminDashboard(images []gallery.Image, albums []string, flash string) {
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8"/>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1"/>
|
||||
<title>Gallery admin — Technical Kiwi</title>
|
||||
<link rel="stylesheet" href="/static/style.css"/>
|
||||
<script src="/static/htmx.min.js" defer></script>
|
||||
</head>
|
||||
<body class="admin-body" hx-boost="false">
|
||||
<header class="admin-header">
|
||||
<h1>Gallery admin</h1>
|
||||
<form method="post" action="/admin/logout">
|
||||
<button type="submit" class="btn btn-ghost">Sign out</button>
|
||||
</form>
|
||||
</header>
|
||||
<main class="admin-main">
|
||||
<div id="admin-flash">
|
||||
if flash != "" {
|
||||
@AdminFlash(flash)
|
||||
}
|
||||
</div>
|
||||
<section class="admin-panel">
|
||||
<h2>Upload image</h2>
|
||||
<form
|
||||
class="contact-form"
|
||||
method="post"
|
||||
action="/admin/upload"
|
||||
enctype="multipart/form-data"
|
||||
hx-post="/admin/upload"
|
||||
hx-target="#admin-images"
|
||||
hx-swap="innerHTML"
|
||||
hx-encoding="multipart/form-data"
|
||||
>
|
||||
<div class="form-row">
|
||||
<label for="album">Album</label>
|
||||
<select id="album" name="album" required>
|
||||
for _, a := range albums {
|
||||
<option value={ a }>{ gallery.AlbumLabel(a) }</option>
|
||||
}
|
||||
<option value="new">+ New album</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label for="album_new">New album name</label>
|
||||
<input type="text" id="album_new" name="album_new" placeholder="e.g. my-project" pattern="[a-z0-9][a-z0-9_-]{0,63}"/>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label for="image">JPEG image</label>
|
||||
<input type="file" id="image" name="image" accept="image/jpeg,.jpg,.jpeg" required/>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary">Upload</button>
|
||||
</form>
|
||||
</section>
|
||||
<section class="admin-panel">
|
||||
<h2>Images ({ fmt.Sprintf("%d", len(images)) })</h2>
|
||||
<div id="admin-images">
|
||||
@AdminImageTable(images)
|
||||
</div>
|
||||
</section>
|
||||
<p class="admin-back"><a href="/">← View public site</a></p>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
}
|
||||
|
||||
templ AdminFlash(msg string) {
|
||||
if msg != "" {
|
||||
<div class="alert alert-error" role="alert"><p>{ msg }</p></div>
|
||||
}
|
||||
}
|
||||
|
||||
templ AdminImageTable(images []gallery.Image) {
|
||||
if len(images) == 0 {
|
||||
<p class="gallery-empty">No images yet.</p>
|
||||
} else {
|
||||
<div class="admin-table-wrap">
|
||||
<table class="admin-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Preview</th>
|
||||
<th>Path</th>
|
||||
<th>Album</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
for _, img := range images {
|
||||
<tr>
|
||||
<td class="admin-thumb">
|
||||
if img.ThumbURL != "" {
|
||||
<img src={ img.ThumbURL } alt="" loading="lazy"/>
|
||||
}
|
||||
</td>
|
||||
<td><code>{ img.RelPath }</code></td>
|
||||
<td>{ gallery.AlbumLabel(img.Album) }</td>
|
||||
<td>
|
||||
<form
|
||||
method="post"
|
||||
action="/admin/delete"
|
||||
hx-post="/admin/delete"
|
||||
hx-target="#admin-images"
|
||||
hx-swap="innerHTML"
|
||||
hx-confirm="Delete this image and its thumbnails?"
|
||||
>
|
||||
<input type="hidden" name="path" value={ img.RelPath }/>
|
||||
<button type="submit" class="btn btn-ghost admin-delete">Delete</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
@@ -31,6 +31,9 @@ templ GalleryGrid(images []gallery.Image) {
|
||||
} else {
|
||||
<span class="gallery-placeholder" aria-hidden="true"></span>
|
||||
}
|
||||
if img.IsVideo {
|
||||
<span class="gallery-video-badge" aria-hidden="true">Video</span>
|
||||
}
|
||||
if img.Collection != "" {
|
||||
<span class="gallery-album">{ img.Collection }</span>
|
||||
} else if img.Album != "" {
|
||||
@@ -49,7 +52,7 @@ templ ImageModal(img gallery.Image, prevPath, nextPath string) {
|
||||
class="modal-backdrop"
|
||||
hx-on:click="if (event.target === this) this.remove()"
|
||||
>
|
||||
<div class="modal" role="dialog" aria-modal="true" aria-label="Image viewer">
|
||||
<div class="modal" role="dialog" aria-modal="true" aria-label={ modalAriaLabel(img) }>
|
||||
<button
|
||||
type="button"
|
||||
class="modal-close"
|
||||
@@ -59,7 +62,11 @@ templ ImageModal(img gallery.Image, prevPath, nextPath string) {
|
||||
×
|
||||
</button>
|
||||
<figure class="modal-figure">
|
||||
<img src={ img.URL } alt={ img.Filename }/>
|
||||
if img.IsVideo {
|
||||
<video src={ img.URL } controls playsinline preload="metadata"></video>
|
||||
} else {
|
||||
<img src={ img.URL } alt={ img.Filename }/>
|
||||
}
|
||||
if !img.Date.IsZero() {
|
||||
<figcaption>{ modalCaption(img) }</figcaption>
|
||||
}
|
||||
@@ -98,7 +105,14 @@ func formatDate(t time.Time) string {
|
||||
if t.IsZero() {
|
||||
return ""
|
||||
}
|
||||
return t.Format("2 Jan 2006")
|
||||
return t.Format("Jan 2006")
|
||||
}
|
||||
|
||||
func modalAriaLabel(img gallery.Image) string {
|
||||
if img.IsVideo {
|
||||
return "Video viewer"
|
||||
}
|
||||
return "Image viewer"
|
||||
}
|
||||
|
||||
func modalCaption(img gallery.Image) string {
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
package templates
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"technical.kiwi/website/internal/gallery"
|
||||
)
|
||||
import "technical.kiwi/website/internal/gallery"
|
||||
|
||||
templ Home(images []gallery.Image, hero gallery.Image, hasHero bool, contactEnabled bool) {
|
||||
@Layout("Technical Kiwi Limited", heroPreload(hero, hasHero), homeContent(images, hero, hasHero, contactEnabled))
|
||||
@@ -20,10 +17,10 @@ templ homeContent(images []gallery.Image, hero gallery.Image, hasHero bool, cont
|
||||
<section class="hero">
|
||||
<div class="hero-copy">
|
||||
<p class="eyebrow">Technical Kiwi Limited</p>
|
||||
<h1>Electronic engineering, DevOps, and interactive art</h1>
|
||||
<h1>Electronic engineering and interactive art</h1>
|
||||
<p class="lead">
|
||||
We design and build lighting installations, embedded systems, and the infrastructure
|
||||
that keeps creative technology running — from workshop bench to stage.
|
||||
We design and build lighting installations, embedded systems, and interactive
|
||||
experiences — from workshop bench to stage.
|
||||
</p>
|
||||
<div class="hero-actions">
|
||||
<a href="#gallery" class="btn btn-primary">View our work</a>
|
||||
@@ -49,105 +46,20 @@ templ homeContent(images []gallery.Image, hero gallery.Image, hasHero bool, cont
|
||||
<h3>Electronic engineering</h3>
|
||||
<p>Custom LED systems, control electronics, PCB design, and fabrication for installations and products.</p>
|
||||
</article>
|
||||
<article class="service-card">
|
||||
<h3>DevOps & infrastructure</h3>
|
||||
<p>Servers, CI/CD, monitoring, and reliable deployments — so your systems stay up when the show goes live.</p>
|
||||
</article>
|
||||
<article class="service-card">
|
||||
<h3>Interactive art</h3>
|
||||
<p>Lighting, sound-reactive visuals, and experiential tech for events, venues, and public spaces.</p>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
<section id="code" class="code-section">
|
||||
<div class="section-head">
|
||||
<h2>Code</h2>
|
||||
<p>
|
||||
All repositories on
|
||||
<a href="https://git.technical.kiwi/" rel="noopener noreferrer" target="_blank">git.technical.kiwi</a>
|
||||
are written and maintained by Technical Kiwi — firmware, lighting control, web services,
|
||||
and the infrastructure behind them.
|
||||
</p>
|
||||
</div>
|
||||
<a
|
||||
href="https://git.technical.kiwi/"
|
||||
class="code-card"
|
||||
rel="noopener noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
<div class="code-card-copy">
|
||||
<h3>git.technical.kiwi</h3>
|
||||
<p>
|
||||
Source for installations, internal tools, and open components we ship or deploy
|
||||
{ "for" } clients. Browse projects, history, and releases in one place.
|
||||
</p>
|
||||
</div>
|
||||
<span class="btn btn-primary">Browse repositories</span>
|
||||
</a>
|
||||
</section>
|
||||
<section id="gallery" class="gallery-section">
|
||||
<div class="section-head">
|
||||
<h2>Gallery</h2>
|
||||
<p>Installations, prototypes, and behind-the-scenes work.</p>
|
||||
</div>
|
||||
@GalleryControls(images)
|
||||
@GalleryCollectionControls(images)
|
||||
<div id="gallery-grid" class="gallery-grid">
|
||||
@GalleryGrid(images)
|
||||
</div>
|
||||
</section>
|
||||
@ContactForm(contactEnabled)
|
||||
}
|
||||
|
||||
templ GalleryControls(images []gallery.Image) {
|
||||
<div class="gallery-controls">
|
||||
<button
|
||||
type="button"
|
||||
class="filter-btn active"
|
||||
hx-get="/gallery"
|
||||
hx-target="#gallery-grid"
|
||||
hx-swap="innerHTML"
|
||||
>
|
||||
All
|
||||
</button>
|
||||
for _, album := range gallery.Albums(images) {
|
||||
<button
|
||||
type="button"
|
||||
class="filter-btn"
|
||||
hx-get={ fmt.Sprintf("/gallery?album=%s", album) }
|
||||
hx-target="#gallery-grid"
|
||||
hx-swap="innerHTML"
|
||||
>
|
||||
{ gallery.AlbumLabel(album) }
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
templ GalleryCollectionControls(images []gallery.Image) {
|
||||
if len(gallery.Collections(images, "templeoftechno")) > 0 {
|
||||
<div class="gallery-controls gallery-controls-collections">
|
||||
<span class="gallery-controls-label">Temple of techno</span>
|
||||
<button
|
||||
type="button"
|
||||
class="filter-btn"
|
||||
hx-get="/gallery?album=templeoftechno"
|
||||
hx-target="#gallery-grid"
|
||||
hx-swap="innerHTML"
|
||||
>
|
||||
All events
|
||||
</button>
|
||||
for _, key := range gallery.Collections(images, "templeoftechno") {
|
||||
<button
|
||||
type="button"
|
||||
class="filter-btn"
|
||||
hx-get={ fmt.Sprintf("/gallery?album=templeoftechno&collection=%s", key) }
|
||||
hx-target="#gallery-grid"
|
||||
hx-swap="innerHTML"
|
||||
>
|
||||
{ gallery.CollectionLabel(key, images) }
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ templ Layout(title string, preloadImage string, content templ.Component) {
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover"/>
|
||||
<meta name="theme-color" content="#14100c"/>
|
||||
<title>{ title }</title>
|
||||
<meta name="description" content="Technical Kiwi Limited — electronic engineering, DevOps, and interactive art from New Zealand."/>
|
||||
<meta name="description" content="Technical Kiwi Limited — electronic engineering and interactive art from New Zealand."/>
|
||||
<link rel="stylesheet" href="/static/style.css"/>
|
||||
if preloadImage != "" {
|
||||
<link rel="preload" as="image" href={ preloadImage } fetchpriority="high"/>
|
||||
@@ -23,7 +23,6 @@ templ Layout(title string, preloadImage string, content templ.Component) {
|
||||
<nav class="site-nav">
|
||||
<a href="#services">Services</a>
|
||||
<a href="https://git.technical.kiwi/" rel="noopener noreferrer" target="_blank">Code</a>
|
||||
<a href="#gallery">Gallery</a>
|
||||
<a href="#contact">Contact</a>
|
||||
</nav>
|
||||
</header>
|
||||
@@ -31,10 +30,7 @@ templ Layout(title string, preloadImage string, content templ.Component) {
|
||||
@content
|
||||
</main>
|
||||
<footer class="site-footer">
|
||||
<p>
|
||||
© { currentYear() } Technical Kiwi Limited. New Zealand.
|
||||
<a href="https://git.technical.kiwi/" rel="noopener noreferrer" target="_blank">git.technical.kiwi</a>
|
||||
</p>
|
||||
<p>© { currentYear() } Technical Kiwi Limited. New Zealand.</p>
|
||||
</footer>
|
||||
<div id="modal-root"></div>
|
||||
<script src="/static/htmx.min.js" defer></script>
|
||||
|
||||
Reference in New Issue
Block a user