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:
20
.env.example
Normal file
20
.env.example
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
# SMTP relay for the contact form (all three required to enable the form)
|
||||||
|
SMTP_HOST=smtp.example.com
|
||||||
|
SMTP_PORT=587
|
||||||
|
SMTP_USER=
|
||||||
|
SMTP_PASSWORD=
|
||||||
|
SMTP_FROM=website@technical.kiwi
|
||||||
|
SMTP_TO=hello@technical.kiwi
|
||||||
|
|
||||||
|
# TLS mode: auto (STARTTLS on 587), tls (465), plain (25/local relay)
|
||||||
|
SMTP_TLS=auto
|
||||||
|
|
||||||
|
# HTTP listen address
|
||||||
|
ADDR=:8080
|
||||||
|
|
||||||
|
# Homepage hero (path under app/images/)
|
||||||
|
HERO_IMAGE=connectionmachine/20220723_231556.jpg
|
||||||
|
|
||||||
|
# Host Go caches mounted into the dev container (make sets ~/go/pkg/mod and ~/.cache/go-build if unset)
|
||||||
|
# GOMODCACHE=/home/you/go/pkg/mod
|
||||||
|
# GOCACHE=/home/you/.cache/go-build
|
||||||
16
.gitignore
vendored
16
.gitignore
vendored
@@ -21,3 +21,19 @@
|
|||||||
# Go workspace file
|
# Go workspace file
|
||||||
go.work
|
go.work
|
||||||
|
|
||||||
|
# Air / local build artifacts in app/
|
||||||
|
/app/tmp/
|
||||||
|
/app/bin/
|
||||||
|
|
||||||
|
# Gallery photos and generated derivatives (not in git)
|
||||||
|
/app/images/**
|
||||||
|
!/app/images/.gitkeep
|
||||||
|
|
||||||
|
/bin/
|
||||||
|
|
||||||
|
# templ generated (regenerate with `templ generate`)
|
||||||
|
/app/templates/*_templ.go
|
||||||
|
|
||||||
|
# Local env (copy .env.example)
|
||||||
|
.env
|
||||||
|
|
||||||
|
|||||||
29
Dockerfile
Normal file
29
Dockerfile
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
ARG GO_VERSION=1.25
|
||||||
|
FROM golang:${GO_VERSION}-bookworm AS builder
|
||||||
|
|
||||||
|
RUN go install github.com/a-h/templ/cmd/templ@latest
|
||||||
|
|
||||||
|
WORKDIR /build
|
||||||
|
COPY app/go.mod app/go.sum ./
|
||||||
|
RUN go mod download
|
||||||
|
|
||||||
|
COPY app/ ./
|
||||||
|
RUN templ generate && CGO_ENABLED=0 go build -ldflags="-s -w" -o /server ./cmd/server
|
||||||
|
|
||||||
|
FROM debian:bookworm-slim
|
||||||
|
|
||||||
|
RUN apt-get update \
|
||||||
|
&& apt-get install -y --no-install-recommends ca-certificates \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY --from=builder /server ./server
|
||||||
|
COPY app/static ./static
|
||||||
|
COPY app/images ./images
|
||||||
|
RUN ./server -thumbs
|
||||||
|
|
||||||
|
EXPOSE 8080
|
||||||
|
ENV ADDR=:8080
|
||||||
|
|
||||||
|
CMD ["./server"]
|
||||||
18
Dockerfile.dev
Normal file
18
Dockerfile.dev
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
ARG GO_VERSION=1.25
|
||||||
|
FROM golang:${GO_VERSION}-bookworm
|
||||||
|
|
||||||
|
RUN go install github.com/a-h/templ/cmd/templ@latest \
|
||||||
|
&& go install github.com/air-verse/air@latest
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY app/go.mod app/go.sum ./
|
||||||
|
RUN go mod download
|
||||||
|
|
||||||
|
COPY app/.air.toml app/dev.sh ./
|
||||||
|
RUN chmod +x dev.sh
|
||||||
|
|
||||||
|
EXPOSE 8080 7331
|
||||||
|
ENV ADDR=:8080
|
||||||
|
|
||||||
|
CMD ["./dev.sh"]
|
||||||
36
Makefile
Normal file
36
Makefile
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
COMPOSE ?= docker compose
|
||||||
|
|
||||||
|
# Compose does not support nested ${VAR:-${HOME}/...}; set defaults here (override in .env).
|
||||||
|
-include .env
|
||||||
|
export GOMODCACHE ?= $(HOME)/go/pkg/mod
|
||||||
|
export GOCACHE ?= $(HOME)/.cache/go-build
|
||||||
|
|
||||||
|
# One-off commands in the dev image (same caches and ./app mount as `make dev`)
|
||||||
|
DEV_RUN := $(COMPOSE) run --rm --no-deps dev
|
||||||
|
|
||||||
|
.PHONY: dev build up down generate tidy logs
|
||||||
|
|
||||||
|
.DEFAULT_GOAL := dev
|
||||||
|
|
||||||
|
dev:
|
||||||
|
@test -f .env || cp -n .env.example .env
|
||||||
|
$(COMPOSE) up --build dev
|
||||||
|
|
||||||
|
build:
|
||||||
|
$(COMPOSE) build website
|
||||||
|
|
||||||
|
up:
|
||||||
|
@test -f .env || cp -n .env.example .env
|
||||||
|
$(COMPOSE) up --build website
|
||||||
|
|
||||||
|
down:
|
||||||
|
$(COMPOSE) down
|
||||||
|
|
||||||
|
generate:
|
||||||
|
$(DEV_RUN) sh -c "templ generate"
|
||||||
|
|
||||||
|
tidy:
|
||||||
|
$(DEV_RUN) go mod tidy
|
||||||
|
|
||||||
|
logs:
|
||||||
|
$(COMPOSE) logs -f dev
|
||||||
86
README.md
86
README.md
@@ -1,2 +1,86 @@
|
|||||||
# website
|
# Technical Kiwi Limited — Website
|
||||||
|
|
||||||
|
A small business site built with **Go**, [**templ**](https://templ.guide/) (server-side HTML templates), and [**HTMX**](https://htmx.org/) for gallery interactions.
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
- [Docker](https://docs.docker.com/get-docker/) and Docker Compose
|
||||||
|
|
||||||
|
## Quick start
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp .env.example .env # optional SMTP settings
|
||||||
|
make dev # live reload → http://localhost:7331
|
||||||
|
```
|
||||||
|
|
||||||
|
## Make targets
|
||||||
|
|
||||||
|
| Target | Description |
|
||||||
|
|--------|-------------|
|
||||||
|
| `make dev` | Dev with Air + templ browser live reload (default) |
|
||||||
|
| `make build` | Build production `website` image |
|
||||||
|
| `make up` | Run production `website` service |
|
||||||
|
| `make down` | Stop compose services |
|
||||||
|
| `make generate` | Run `templ generate` in dev container |
|
||||||
|
| `make tidy` | Run `go mod tidy` in dev container |
|
||||||
|
| `make logs` | Follow dev container logs |
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- Single-page layout: hero, services, gallery, contact
|
||||||
|
- Gallery loads via HTMX (`hx-get="/gallery"`)
|
||||||
|
- Year filters without full page reload
|
||||||
|
- Lightbox modal with previous/next navigation
|
||||||
|
- Contact form with SMTP relay (HTMX submit, no page reload)
|
||||||
|
- Serves photos from `app/images/`
|
||||||
|
|
||||||
|
## Contact form (SMTP)
|
||||||
|
|
||||||
|
Set relay details in `.env`:
|
||||||
|
|
||||||
|
| Variable | Description |
|
||||||
|
|----------|-------------|
|
||||||
|
| `SMTP_HOST` | Relay hostname (required) |
|
||||||
|
| `SMTP_PORT` | Port (default `587`) |
|
||||||
|
| `SMTP_USER` / `SMTP_PASSWORD` | Auth if your relay requires it |
|
||||||
|
| `SMTP_FROM` | Envelope/header From address (required) |
|
||||||
|
| `SMTP_TO` | Where contact messages are delivered (required) |
|
||||||
|
| `SMTP_TLS` | `auto`, `tls` (465), or `plain` (25/local relay) |
|
||||||
|
|
||||||
|
If SMTP is not configured, the page shows a mailto fallback instead of the form.
|
||||||
|
|
||||||
|
## Dev container
|
||||||
|
|
||||||
|
`make dev` mounts `./app` and your host Go caches (`~/go/pkg/mod`, `~/.cache/go-build` by default). Override with `GOMODCACHE` / `GOCACHE` in `.env`.
|
||||||
|
|
||||||
|
**Use [http://localhost:7331](http://localhost:7331)** in the browser — the templ proxy injects live reload on `.templ`, `.go`, and `.css` changes. Port 8080 hits the app directly without auto-reload.
|
||||||
|
|
||||||
|
Only run **`dev`** or **`website`** at a time if you map both to the same host ports.
|
||||||
|
|
||||||
|
Production `website` joins the external `caddy` Docker network for reverse proxy labels.
|
||||||
|
|
||||||
|
## Project layout
|
||||||
|
|
||||||
|
```
|
||||||
|
app/
|
||||||
|
cmd/server/ HTTP server entrypoint
|
||||||
|
internal/gallery/ Scan and sort images from disk
|
||||||
|
internal/contact/ Form validation
|
||||||
|
internal/mail/ SMTP relay
|
||||||
|
internal/handlers/ Routes and HTMX partials
|
||||||
|
templates/ templ components (.templ → generated Go)
|
||||||
|
static/ CSS
|
||||||
|
images/ Gallery photos (served at /images/)
|
||||||
|
Dockerfile Production image
|
||||||
|
Dockerfile.dev Dev image (Go + templ + Air)
|
||||||
|
docker-compose.yaml
|
||||||
|
.env.example
|
||||||
|
```
|
||||||
|
|
||||||
|
## Gallery images
|
||||||
|
|
||||||
|
On startup the server builds JPEG thumbnails in `app/images/thumbs/` (max 480px edge) for the grid and hero. The lightbox still loads full-resolution originals. Thumbnails are gitignored and regenerated when source photos change.
|
||||||
|
|
||||||
|
## Deploy notes
|
||||||
|
|
||||||
|
First request after deploy may take a moment while thumbnails are generated if they are not baked into the image yet.
|
||||||
|
|||||||
18
app/.air.toml
Normal file
18
app/.air.toml
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
root = "."
|
||||||
|
tmp_dir = "tmp"
|
||||||
|
|
||||||
|
[build]
|
||||||
|
cmd = "templ generate --notify-proxy && go build -o ./tmp/main ./cmd/server"
|
||||||
|
entrypoint = ["./tmp/main"]
|
||||||
|
delay = 300
|
||||||
|
exclude_dir = ["tmp", "bin", "images", "vendor", "testdata"]
|
||||||
|
exclude_regex = ["_templ.go"]
|
||||||
|
include_ext = ["go", "css"]
|
||||||
|
send_interrupt = true
|
||||||
|
stop_on_error = true
|
||||||
|
|
||||||
|
[log]
|
||||||
|
time = true
|
||||||
|
|
||||||
|
[misc]
|
||||||
|
clean_on_exit = true
|
||||||
68
app/cmd/server/main.go
Normal file
68
app/cmd/server/main.go
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"flag"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
|
||||||
|
"technical.kiwi/website/internal/gallery"
|
||||||
|
"technical.kiwi/website/internal/handlers"
|
||||||
|
"technical.kiwi/website/internal/mail"
|
||||||
|
"technical.kiwi/website/internal/middleware"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
thumbsOnly := flag.Bool("thumbs", false, "generate gallery thumbnails and exit")
|
||||||
|
flag.Parse()
|
||||||
|
|
||||||
|
root, err := os.Getwd()
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
imagesDir := filepath.Join(root, "images")
|
||||||
|
staticDir := filepath.Join(root, "static")
|
||||||
|
|
||||||
|
if *thumbsOnly {
|
||||||
|
if err := gallery.EnsureThumbnails(imagesDir); err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
log.Print("thumbnails generated")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var mailCfg *mail.Config
|
||||||
|
if mail.Enabled() {
|
||||||
|
cfg, err := mail.Load()
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("smtp config: %v", err)
|
||||||
|
}
|
||||||
|
mailCfg = &cfg
|
||||||
|
log.Printf("contact form: smtp relay %s:%d → %s", cfg.Host, cfg.Port, cfg.To)
|
||||||
|
} else {
|
||||||
|
log.Print("contact form: disabled (set SMTP_HOST, SMTP_FROM, SMTP_TO to enable)")
|
||||||
|
}
|
||||||
|
|
||||||
|
srv, err := handlers.New(imagesDir, staticDir, mailCfg)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
srv.RegisterRoutes(mux)
|
||||||
|
|
||||||
|
addr := envOr("ADDR", ":8080")
|
||||||
|
log.Printf("Technical Kiwi website listening on %s", addr)
|
||||||
|
if err := http.ListenAndServe(addr, middleware.Gzip(mux)); err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func envOr(key, fallback string) string {
|
||||||
|
if v := os.Getenv(key); v != "" {
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
11
app/dev.sh
Executable file
11
app/dev.sh
Executable file
@@ -0,0 +1,11 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
set -e
|
||||||
|
|
||||||
|
# Browser: http://localhost:7331 (proxy injects auto-reload)
|
||||||
|
# App: http://127.0.0.1:8080 (internal, via proxy)
|
||||||
|
exec templ generate --watch \
|
||||||
|
--proxy="http://127.0.0.1:8080" \
|
||||||
|
--proxybind="0.0.0.0" \
|
||||||
|
--proxyport="7331" \
|
||||||
|
--open-browser=false \
|
||||||
|
--cmd="air -c .air.toml"
|
||||||
7
app/go.mod
Normal file
7
app/go.mod
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
module technical.kiwi/website
|
||||||
|
|
||||||
|
go 1.25.0
|
||||||
|
|
||||||
|
require github.com/a-h/templ v0.3.1020
|
||||||
|
|
||||||
|
require golang.org/x/image v0.41.0
|
||||||
6
app/go.sum
Normal file
6
app/go.sum
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
github.com/a-h/templ v0.3.1020 h1:ypAT/L5ySWEnZ6Zft/5yfoWXYYkhFNvEFOeeqecg4tw=
|
||||||
|
github.com/a-h/templ v0.3.1020/go.mod h1:A2DlK61v+K+NRoGnhmYbNYVmtYHcFO5/AisMvBdDxTM=
|
||||||
|
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
|
||||||
|
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||||
|
golang.org/x/image v0.41.0 h1:8wS72eGJMJaBxK6okTzd4WaXumUlTVlb753MlsSvTCo=
|
||||||
|
golang.org/x/image v0.41.0/go.mod h1:uIc348UZMSvS5Z65CVZ7iDPaNobNFEPeJ4kbqTOszmA=
|
||||||
0
app/images/.gitkeep
Normal file
0
app/images/.gitkeep
Normal file
65
app/internal/contact/validate.go
Normal file
65
app/internal/contact/validate.go
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
package contact
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/mail"
|
||||||
|
"strings"
|
||||||
|
"unicode/utf8"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
maxNameLen = 120
|
||||||
|
maxEmailLen = 254
|
||||||
|
maxMessageLen = 8000
|
||||||
|
minMessageLen = 10
|
||||||
|
)
|
||||||
|
|
||||||
|
// Submission is validated contact form input.
|
||||||
|
type Submission struct {
|
||||||
|
Name string
|
||||||
|
Email string
|
||||||
|
Message string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Errors maps field names to user-facing messages.
|
||||||
|
type Errors map[string]string
|
||||||
|
|
||||||
|
func (e Errors) Any() bool {
|
||||||
|
return len(e) > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse reads and validates a contact form POST.
|
||||||
|
func Parse(name, email, message string) (Submission, Errors) {
|
||||||
|
errs := make(Errors)
|
||||||
|
|
||||||
|
name = strings.TrimSpace(name)
|
||||||
|
email = strings.TrimSpace(email)
|
||||||
|
message = strings.TrimSpace(message)
|
||||||
|
|
||||||
|
if name == "" {
|
||||||
|
errs["name"] = "Name is required."
|
||||||
|
} else if utf8.RuneCountInString(name) > maxNameLen {
|
||||||
|
errs["name"] = "Name is too long."
|
||||||
|
}
|
||||||
|
|
||||||
|
if email == "" {
|
||||||
|
errs["email"] = "Email is required."
|
||||||
|
} else if utf8.RuneCountInString(email) > maxEmailLen {
|
||||||
|
errs["email"] = "Email is too long."
|
||||||
|
} else if _, err := mail.ParseAddress(email); err != nil {
|
||||||
|
errs["email"] = "Enter a valid email address."
|
||||||
|
}
|
||||||
|
|
||||||
|
if message == "" {
|
||||||
|
errs["message"] = "Message is required."
|
||||||
|
} else if utf8.RuneCountInString(message) < minMessageLen {
|
||||||
|
errs["message"] = "Message must be at least 10 characters."
|
||||||
|
} else if utf8.RuneCountInString(message) > maxMessageLen {
|
||||||
|
errs["message"] = "Message is too long."
|
||||||
|
}
|
||||||
|
|
||||||
|
if errs.Any() {
|
||||||
|
return Submission{}, errs
|
||||||
|
}
|
||||||
|
|
||||||
|
return Submission{Name: name, Email: email, Message: message}, nil
|
||||||
|
}
|
||||||
27
app/internal/contact/validate_test.go
Normal file
27
app/internal/contact/validate_test.go
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
package contact
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestParse_valid(t *testing.T) {
|
||||||
|
sub, errs := Parse("Jimmy", "jim@example.com", "Hello there, this is a test message.")
|
||||||
|
if errs.Any() {
|
||||||
|
t.Fatalf("unexpected errors: %v", errs)
|
||||||
|
}
|
||||||
|
if sub.Name != "Jimmy" || sub.Email != "jim@example.com" {
|
||||||
|
t.Fatalf("got %+v", sub)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParse_shortMessage(t *testing.T) {
|
||||||
|
_, errs := Parse("Jimmy", "jim@example.com", "short")
|
||||||
|
if !errs.Any() {
|
||||||
|
t.Fatal("expected validation error")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParse_invalidEmail(t *testing.T) {
|
||||||
|
_, errs := Parse("Jimmy", "not-an-email", "This message is long enough to pass.")
|
||||||
|
if errs["email"] == "" {
|
||||||
|
t.Fatal("expected email error")
|
||||||
|
}
|
||||||
|
}
|
||||||
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
|
||||||
|
}
|
||||||
57
app/internal/gallery/gallery_test.go
Normal file
57
app/internal/gallery/gallery_test.go
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
package gallery
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestAlbumFromRel(t *testing.T) {
|
||||||
|
if albumFromRel("portal/IMG_20241012_115041.jpg") != "portal" {
|
||||||
|
t.Fatal("expected portal album")
|
||||||
|
}
|
||||||
|
if albumFromRel("IMG_20241012_115041.jpg") != "" {
|
||||||
|
t.Fatal("expected empty album for root file")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSafeRelPath(t *testing.T) {
|
||||||
|
if !safeRelPath("portal/foo.jpg") {
|
||||||
|
t.Fatal("valid path rejected")
|
||||||
|
}
|
||||||
|
if safeRelPath("../etc/passwd") {
|
||||||
|
t.Fatal("traversal allowed")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestList_subfolders(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
album := filepath.Join(dir, "portal")
|
||||||
|
if err := os.MkdirAll(album, 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
name := "IMG_20240101_120000.jpg"
|
||||||
|
if err := os.WriteFile(filepath.Join(album, name), []byte("x"), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
images, err := List(dir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(images) != 1 || images[0].Album != "portal" || images[0].RelPath != "portal/"+name {
|
||||||
|
t.Fatalf("got %+v", images)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCollections_nested(t *testing.T) {
|
||||||
|
images := []Image{
|
||||||
|
{Album: "templeoftechno", CollectionKey: "anniversary", Collection: "Anniversary"},
|
||||||
|
{Album: "templeoftechno", CollectionKey: "groovatory", Collection: "Groovatory"},
|
||||||
|
{Album: "portal", CollectionKey: "x", Collection: "X"},
|
||||||
|
}
|
||||||
|
keys := Collections(images, "templeoftechno")
|
||||||
|
if len(keys) != 2 {
|
||||||
|
t.Fatalf("got %v", keys)
|
||||||
|
}
|
||||||
|
}
|
||||||
34
app/internal/gallery/hero.go
Normal file
34
app/internal/gallery/hero.go
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
package gallery
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
const defaultHeroRel = "connectionmachine/20220723_231556.jpg"
|
||||||
|
|
||||||
|
// HeroRelPath returns the configured hero image path relative to images/.
|
||||||
|
func HeroRelPath() string {
|
||||||
|
if p := strings.TrimSpace(os.Getenv("HERO_IMAGE")); p != "" {
|
||||||
|
return filepathToSlash(p)
|
||||||
|
}
|
||||||
|
return defaultHeroRel
|
||||||
|
}
|
||||||
|
|
||||||
|
// SelectHero picks the homepage hero from the gallery list.
|
||||||
|
func SelectHero(images []Image) (Image, bool) {
|
||||||
|
want := HeroRelPath()
|
||||||
|
for _, img := range images {
|
||||||
|
if img.RelPath == want {
|
||||||
|
return img, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(images) > 0 {
|
||||||
|
return images[0], true
|
||||||
|
}
|
||||||
|
return Image{}, false
|
||||||
|
}
|
||||||
|
|
||||||
|
func filepathToSlash(p string) string {
|
||||||
|
return strings.ReplaceAll(p, "\\", "/")
|
||||||
|
}
|
||||||
22
app/internal/gallery/hero_test.go
Normal file
22
app/internal/gallery/hero_test.go
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
package gallery
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestSelectHero_configured(t *testing.T) {
|
||||||
|
t.Setenv("HERO_IMAGE", "connectionmachine/20220723_231556.jpg")
|
||||||
|
images := []Image{
|
||||||
|
{RelPath: "templeoftechno/IMG_20250817_020719.jpg"},
|
||||||
|
{RelPath: "connectionmachine/20220723_231556.jpg", HeroURL: "/images/hero/connectionmachine/20220723_231556.jpg"},
|
||||||
|
}
|
||||||
|
hero, ok := SelectHero(images)
|
||||||
|
if !ok || hero.RelPath != "connectionmachine/20220723_231556.jpg" {
|
||||||
|
t.Fatalf("got %+v ok=%v", hero, ok)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseDate_20220723(t *testing.T) {
|
||||||
|
tm, year := parseDate("20220723_231556.jpg")
|
||||||
|
if tm.IsZero() || year != 2022 {
|
||||||
|
t.Fatalf("got %v year=%d", tm, year)
|
||||||
|
}
|
||||||
|
}
|
||||||
59
app/internal/gallery/path.go
Normal file
59
app/internal/gallery/path.go
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
package gallery
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/url"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
var zipExportSuffix = regexp.MustCompile(`-\d{8}T\d{6}Z-1-\d+$`)
|
||||||
|
|
||||||
|
// URLPath escapes a relative image path for use in URL paths.
|
||||||
|
func URLPath(rel string) string {
|
||||||
|
parts := strings.Split(rel, "/")
|
||||||
|
for i, p := range parts {
|
||||||
|
parts[i] = url.PathEscape(p)
|
||||||
|
}
|
||||||
|
return strings.Join(parts, "/")
|
||||||
|
}
|
||||||
|
|
||||||
|
func collectionFromRel(rel string) (key, label string) {
|
||||||
|
parts := strings.Split(rel, "/")
|
||||||
|
if len(parts) <= 2 {
|
||||||
|
return "", ""
|
||||||
|
}
|
||||||
|
inner := strings.TrimSpace(parts[len(parts)-2])
|
||||||
|
label = cleanCollectionName(inner)
|
||||||
|
if label == "" {
|
||||||
|
return "", ""
|
||||||
|
}
|
||||||
|
return collectionKey(label), label
|
||||||
|
}
|
||||||
|
|
||||||
|
func cleanCollectionName(name string) string {
|
||||||
|
name = strings.TrimSpace(name)
|
||||||
|
name = zipExportSuffix.ReplaceAllString(name, "")
|
||||||
|
return strings.TrimSpace(name)
|
||||||
|
}
|
||||||
|
|
||||||
|
func collectionKey(label string) string {
|
||||||
|
key := strings.ToLower(label)
|
||||||
|
key = strings.ReplaceAll(key, " ", "-")
|
||||||
|
key = strings.Map(func(r rune) rune {
|
||||||
|
if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') || r == '-' {
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
return -1
|
||||||
|
}, key)
|
||||||
|
return key
|
||||||
|
}
|
||||||
|
|
||||||
|
// CollectionLabel returns display name for a collection filter key.
|
||||||
|
func CollectionLabel(key string, images []Image) string {
|
||||||
|
for _, img := range images {
|
||||||
|
if img.CollectionKey == key {
|
||||||
|
return img.Collection
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return key
|
||||||
|
}
|
||||||
22
app/internal/gallery/path_test.go
Normal file
22
app/internal/gallery/path_test.go
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
package gallery
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestCollectionFromRel(t *testing.T) {
|
||||||
|
rel := "templeoftechno/Anniversary-20251105T063538Z-1-001/Anniversary/photo.jpg"
|
||||||
|
key, label := collectionFromRel(rel)
|
||||||
|
if label != "Anniversary" {
|
||||||
|
t.Fatalf("label=%q", label)
|
||||||
|
}
|
||||||
|
if key != "anniversary" {
|
||||||
|
t.Fatalf("key=%q", key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestURLPath_spaces(t *testing.T) {
|
||||||
|
got := URLPath("templeoftechno/The Groovatory/foo.jpg")
|
||||||
|
want := "templeoftechno/The%20Groovatory/foo.jpg"
|
||||||
|
if got != want {
|
||||||
|
t.Fatalf("got %q want %q", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
151
app/internal/gallery/thumbs.go
Normal file
151
app/internal/gallery/thumbs.go
Normal file
@@ -0,0 +1,151 @@
|
|||||||
|
package gallery
|
||||||
|
|
||||||
|
import (
|
||||||
|
"image"
|
||||||
|
"image/jpeg"
|
||||||
|
"io/fs"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"golang.org/x/image/draw"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
gridThumbMaxEdge = 320
|
||||||
|
heroThumbMaxEdge = 640
|
||||||
|
gridThumbQuality = 75
|
||||||
|
heroThumbQuality = 78
|
||||||
|
thumbDirName = "thumbs"
|
||||||
|
heroDirName = "hero"
|
||||||
|
)
|
||||||
|
|
||||||
|
// EnsureThumbnails creates grid and hero JPEG derivatives mirroring album folders.
|
||||||
|
func EnsureThumbnails(imagesDir string) error {
|
||||||
|
if err := os.MkdirAll(filepath.Join(imagesDir, thumbDirName), 0o755); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := os.MkdirAll(filepath.Join(imagesDir, heroDirName), 0o755); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return filepath.WalkDir(imagesDir, 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(imagesDir, path)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
rel = filepath.ToSlash(rel)
|
||||||
|
if !safeRelPath(rel) {
|
||||||
|
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
|
||||||
|
}
|
||||||
|
if err := os.MkdirAll(filepath.Dir(heroDst), 0o755); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := writeThumb(path, thumbDst, gridThumbMaxEdge, gridThumbQuality); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return writeThumb(path, heroDst, heroThumbMaxEdge, heroThumbQuality)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// EnsurePriority generates thumbnails for one image before first page load.
|
||||||
|
func EnsurePriority(imagesDir, relPath string) error {
|
||||||
|
if !safeRelPath(relPath) || !isJPEG(filepath.Base(relPath)) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
src := filepath.Join(imagesDir, filepath.FromSlash(relPath))
|
||||||
|
thumbDst := filepath.Join(imagesDir, thumbDirName, filepath.FromSlash(relPath))
|
||||||
|
heroDst := filepath.Join(imagesDir, heroDirName, filepath.FromSlash(relPath))
|
||||||
|
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, heroDst, heroThumbMaxEdge, heroThumbQuality); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return writeThumb(src, thumbDst, gridThumbMaxEdge, gridThumbQuality)
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeThumb(src, dst string, maxEdge, quality 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
|
||||||
|
}
|
||||||
|
|
||||||
|
f, err := os.Open(src)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
|
||||||
|
img, err := jpeg.Decode(f)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
thumb := resizeThumb(img, maxEdge)
|
||||||
|
out, err := os.Create(dst)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer out.Close()
|
||||||
|
|
||||||
|
return jpeg.Encode(out, thumb, &jpeg.Options{Quality: quality})
|
||||||
|
}
|
||||||
|
|
||||||
|
func resizeThumb(src image.Image, maxEdge int) image.Image {
|
||||||
|
b := src.Bounds()
|
||||||
|
w, h := b.Dx(), b.Dy()
|
||||||
|
if w <= 0 || h <= 0 {
|
||||||
|
return src
|
||||||
|
}
|
||||||
|
|
||||||
|
scale := float64(maxEdge) / float64(w)
|
||||||
|
if h > w {
|
||||||
|
scale = float64(maxEdge) / float64(h)
|
||||||
|
}
|
||||||
|
nw := int(float64(w) * scale)
|
||||||
|
nh := int(float64(h) * scale)
|
||||||
|
if nw < 1 {
|
||||||
|
nw = 1
|
||||||
|
}
|
||||||
|
if nh < 1 {
|
||||||
|
nh = 1
|
||||||
|
}
|
||||||
|
if nw >= w && nh >= h {
|
||||||
|
return src
|
||||||
|
}
|
||||||
|
|
||||||
|
dst := image.NewRGBA(image.Rect(0, 0, nw, nh))
|
||||||
|
draw.CatmullRom.Scale(dst, dst.Bounds(), src, b, draw.Over, nil)
|
||||||
|
return dst
|
||||||
|
}
|
||||||
|
|
||||||
|
func isJPEG(name string) bool {
|
||||||
|
ext := strings.ToLower(filepath.Ext(name))
|
||||||
|
return ext == ".jpg" || ext == ".jpeg"
|
||||||
|
}
|
||||||
183
app/internal/handlers/handlers.go
Normal file
183
app/internal/handlers/handlers.go
Normal file
@@ -0,0 +1,183 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"technical.kiwi/website/internal/contact"
|
||||||
|
"technical.kiwi/website/internal/gallery"
|
||||||
|
"technical.kiwi/website/internal/mail"
|
||||||
|
"technical.kiwi/website/internal/middleware"
|
||||||
|
"technical.kiwi/website/templates"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Server holds shared state for HTTP handlers.
|
||||||
|
type Server struct {
|
||||||
|
ImagesDir string
|
||||||
|
StaticDir string
|
||||||
|
Images []gallery.Image
|
||||||
|
Hero gallery.Image
|
||||||
|
HasHero bool
|
||||||
|
Mail *mail.Config
|
||||||
|
ContactEnabled bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// New builds a Server after loading the image gallery.
|
||||||
|
func New(imagesDir, staticDir string, mailCfg *mail.Config) (*Server, error) {
|
||||||
|
images, err := gallery.List(imagesDir)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
hero, hasHero := gallery.SelectHero(images)
|
||||||
|
if hasHero {
|
||||||
|
if err := gallery.EnsurePriority(imagesDir, hero.RelPath); err != nil {
|
||||||
|
log.Printf("hero image: %v", err)
|
||||||
|
} else {
|
||||||
|
images, err = gallery.List(imagesDir)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
hero, hasHero = gallery.SelectHero(images)
|
||||||
|
}
|
||||||
|
log.Printf("hero image: %s", hero.RelPath)
|
||||||
|
}
|
||||||
|
srv := &Server{
|
||||||
|
ImagesDir: imagesDir,
|
||||||
|
StaticDir: staticDir,
|
||||||
|
Images: images,
|
||||||
|
Hero: hero,
|
||||||
|
HasHero: hasHero,
|
||||||
|
Mail: mailCfg,
|
||||||
|
ContactEnabled: mailCfg != nil,
|
||||||
|
}
|
||||||
|
go func() {
|
||||||
|
if err := gallery.EnsureThumbnails(imagesDir); err != nil {
|
||||||
|
log.Printf("gallery thumbnails: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
log.Print("gallery thumbnails: ready")
|
||||||
|
}()
|
||||||
|
return srv, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) contact(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodPost {
|
||||||
|
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := r.ParseForm(); err != nil {
|
||||||
|
http.Error(w, "bad request", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if strings.TrimSpace(r.FormValue("website")) != "" {
|
||||||
|
templates.ContactSuccess().Render(r.Context(), w)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
sub, errs := contact.Parse(
|
||||||
|
r.FormValue("name"),
|
||||||
|
r.FormValue("email"),
|
||||||
|
r.FormValue("message"),
|
||||||
|
)
|
||||||
|
if errs.Any() {
|
||||||
|
templates.ContactValidationAlert(errs).Render(r.Context(), w)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if s.Mail == nil {
|
||||||
|
templates.ContactSendError().Render(r.Context(), w)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.Mail.ContactEmail(sub.Name, sub.Email, sub.Message); err != nil {
|
||||||
|
log.Printf("contact mail: %v", err)
|
||||||
|
templates.ContactSendError().Render(r.Context(), w)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
if !ok {
|
||||||
|
http.NotFound(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
idx := s.indexOf(rel)
|
||||||
|
prev, next := "", ""
|
||||||
|
if idx > 0 {
|
||||||
|
prev = s.Images[idx-1].RelPath
|
||||||
|
}
|
||||||
|
if idx >= 0 && idx < len(s.Images)-1 {
|
||||||
|
next = s.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 {
|
||||||
|
if img.RelPath == rel {
|
||||||
|
return img, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return gallery.Image{}, false
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) indexOf(rel string) int {
|
||||||
|
for i, img := range s.Images {
|
||||||
|
if img.RelPath == rel {
|
||||||
|
return i
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
|
||||||
|
// RegisterRoutes attaches handlers to mux.
|
||||||
|
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)))
|
||||||
|
}
|
||||||
71
app/internal/mail/config.go
Normal file
71
app/internal/mail/config.go
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
package mail
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"strconv"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Config holds SMTP relay settings from the environment.
|
||||||
|
type Config struct {
|
||||||
|
Host string
|
||||||
|
Port int
|
||||||
|
User string
|
||||||
|
Password string
|
||||||
|
From string
|
||||||
|
To string
|
||||||
|
// TLS: "auto" (STARTTLS on 587, plain on 25), "tls" (implicit TLS, e.g. 465), "plain"
|
||||||
|
TLS string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load reads SMTP settings from environment variables.
|
||||||
|
//
|
||||||
|
// Required: SMTP_HOST, SMTP_FROM, SMTP_TO
|
||||||
|
// Optional: SMTP_PORT (587), SMTP_USER, SMTP_PASSWORD, SMTP_TLS (auto)
|
||||||
|
func Load() (Config, error) {
|
||||||
|
host := os.Getenv("SMTP_HOST")
|
||||||
|
if host == "" {
|
||||||
|
return Config{}, fmt.Errorf("SMTP_HOST is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
from := os.Getenv("SMTP_FROM")
|
||||||
|
if from == "" {
|
||||||
|
return Config{}, fmt.Errorf("SMTP_FROM is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
to := os.Getenv("SMTP_TO")
|
||||||
|
if to == "" {
|
||||||
|
return Config{}, fmt.Errorf("SMTP_TO is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
port := 587
|
||||||
|
if p := os.Getenv("SMTP_PORT"); p != "" {
|
||||||
|
n, err := strconv.Atoi(p)
|
||||||
|
if err != nil {
|
||||||
|
return Config{}, fmt.Errorf("SMTP_PORT: %w", err)
|
||||||
|
}
|
||||||
|
port = n
|
||||||
|
}
|
||||||
|
|
||||||
|
tls := os.Getenv("SMTP_TLS")
|
||||||
|
if tls == "" {
|
||||||
|
tls = "auto"
|
||||||
|
}
|
||||||
|
|
||||||
|
return Config{
|
||||||
|
Host: host,
|
||||||
|
Port: port,
|
||||||
|
User: os.Getenv("SMTP_USER"),
|
||||||
|
Password: os.Getenv("SMTP_PASSWORD"),
|
||||||
|
From: from,
|
||||||
|
To: to,
|
||||||
|
TLS: tls,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Enabled reports whether relay settings are present.
|
||||||
|
func Enabled() bool {
|
||||||
|
return os.Getenv("SMTP_HOST") != "" &&
|
||||||
|
os.Getenv("SMTP_FROM") != "" &&
|
||||||
|
os.Getenv("SMTP_TO") != ""
|
||||||
|
}
|
||||||
163
app/internal/mail/send.go
Normal file
163
app/internal/mail/send.go
Normal file
@@ -0,0 +1,163 @@
|
|||||||
|
package mail
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"crypto/tls"
|
||||||
|
"fmt"
|
||||||
|
"net"
|
||||||
|
"net/smtp"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ContactEmail sends a contact form message via the configured SMTP relay.
|
||||||
|
func (c Config) ContactEmail(name, replyTo, message string) error {
|
||||||
|
subject := fmt.Sprintf("Contact form: %s", name)
|
||||||
|
var body bytes.Buffer
|
||||||
|
body.WriteString(fmt.Sprintf("Name: %s\n", name))
|
||||||
|
body.WriteString(fmt.Sprintf("Email: %s\n\n", replyTo))
|
||||||
|
body.WriteString(message)
|
||||||
|
|
||||||
|
return c.send(subject, body.String(), replyTo)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c Config) send(subject, body, replyTo string) error {
|
||||||
|
addr := fmt.Sprintf("%s:%d", c.Host, c.Port)
|
||||||
|
|
||||||
|
var msg bytes.Buffer
|
||||||
|
msg.WriteString(fmt.Sprintf("From: %s\r\n", c.From))
|
||||||
|
msg.WriteString(fmt.Sprintf("To: %s\r\n", c.To))
|
||||||
|
if replyTo != "" {
|
||||||
|
msg.WriteString(fmt.Sprintf("Reply-To: %s\r\n", replyTo))
|
||||||
|
}
|
||||||
|
msg.WriteString(fmt.Sprintf("Subject: %s\r\n", subject))
|
||||||
|
msg.WriteString("MIME-Version: 1.0\r\n")
|
||||||
|
msg.WriteString("Content-Type: text/plain; charset=UTF-8\r\n")
|
||||||
|
msg.WriteString("\r\n")
|
||||||
|
msg.WriteString(body)
|
||||||
|
|
||||||
|
raw := msg.Bytes()
|
||||||
|
from := extractAddr(c.From)
|
||||||
|
|
||||||
|
var auth smtp.Auth
|
||||||
|
if c.User != "" {
|
||||||
|
auth = smtp.PlainAuth("", c.User, c.Password, c.Host)
|
||||||
|
}
|
||||||
|
|
||||||
|
switch c.tlsMode() {
|
||||||
|
case "plain":
|
||||||
|
return smtp.SendMail(addr, auth, from, []string{c.To}, raw)
|
||||||
|
case "tls":
|
||||||
|
return c.sendTLS(addr, auth, from, raw)
|
||||||
|
default:
|
||||||
|
return c.sendStartTLS(addr, auth, from, raw)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c Config) tlsMode() string {
|
||||||
|
switch strings.ToLower(c.TLS) {
|
||||||
|
case "plain", "tls":
|
||||||
|
return strings.ToLower(c.TLS)
|
||||||
|
}
|
||||||
|
if c.Port == 465 {
|
||||||
|
return "tls"
|
||||||
|
}
|
||||||
|
if c.Port == 25 {
|
||||||
|
return "plain"
|
||||||
|
}
|
||||||
|
return "starttls"
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c Config) sendStartTLS(addr string, auth smtp.Auth, from string, raw []byte) error {
|
||||||
|
conn, err := net.Dial("tcp", addr)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer conn.Close()
|
||||||
|
|
||||||
|
client, err := smtp.NewClient(conn, c.Host)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer client.Close()
|
||||||
|
|
||||||
|
if ok, _ := client.Extension("STARTTLS"); ok {
|
||||||
|
if err := client.StartTLS(&tls.Config{ServerName: c.Host}); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if auth != nil {
|
||||||
|
if ok, _ := client.Extension("AUTH"); ok {
|
||||||
|
if err := client.Auth(auth); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := client.Mail(from); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := client.Rcpt(c.To); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
w, err := client.Data()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if _, err := w.Write(raw); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := w.Close(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return client.Quit()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c Config) sendTLS(addr string, auth smtp.Auth, from string, raw []byte) error {
|
||||||
|
conn, err := tls.Dial("tcp", addr, &tls.Config{ServerName: c.Host})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer conn.Close()
|
||||||
|
|
||||||
|
client, err := smtp.NewClient(conn, c.Host)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer client.Close()
|
||||||
|
|
||||||
|
if auth != nil {
|
||||||
|
if ok, _ := client.Extension("AUTH"); ok {
|
||||||
|
if err := client.Auth(auth); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := client.Mail(from); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := client.Rcpt(c.To); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
w, err := client.Data()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if _, err := w.Write(raw); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := w.Close(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return client.Quit()
|
||||||
|
}
|
||||||
|
|
||||||
|
func extractAddr(from string) string {
|
||||||
|
if i := strings.Index(from, "<"); i >= 0 {
|
||||||
|
if j := strings.Index(from[i:], ">"); j > 0 {
|
||||||
|
return strings.TrimSpace(from[i+1 : i+j])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(from)
|
||||||
|
}
|
||||||
61
app/internal/middleware/middleware.go
Normal file
61
app/internal/middleware/middleware.go
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
package middleware
|
||||||
|
|
||||||
|
import (
|
||||||
|
"compress/gzip"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type gzipResponseWriter struct {
|
||||||
|
http.ResponseWriter
|
||||||
|
io.Writer
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *gzipResponseWriter) Write(b []byte) (int, error) {
|
||||||
|
return w.Writer.Write(b)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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") {
|
||||||
|
next.ServeHTTP(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.Header().Set("Vary", "Accept-Encoding")
|
||||||
|
w.Header().Set("Content-Encoding", "gzip")
|
||||||
|
gz := gzip.NewWriter(w)
|
||||||
|
defer gz.Close()
|
||||||
|
next.ServeHTTP(&gzipResponseWriter{ResponseWriter: w, Writer: gz}, r)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// CacheStatic wraps a file server with long-lived cache headers.
|
||||||
|
func CacheStatic(dir string, maxAge time.Duration) http.Handler {
|
||||||
|
fs := http.FileServer(http.Dir(dir))
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Cache-Control", "public, max-age="+formatMaxAge(maxAge)+", immutable")
|
||||||
|
fs.ServeHTTP(w, r)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// CacheImages wraps image serving; thumbs cache longer than originals.
|
||||||
|
func CacheImages(dir string) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
rel := strings.TrimPrefix(r.URL.Path, "/")
|
||||||
|
if strings.HasPrefix(rel, "thumbs/") || strings.HasPrefix(rel, "hero/") {
|
||||||
|
w.Header().Set("Cache-Control", "public, max-age=604800, immutable")
|
||||||
|
} else {
|
||||||
|
w.Header().Set("Cache-Control", "public, max-age=86400")
|
||||||
|
}
|
||||||
|
http.FileServer(http.Dir(dir)).ServeHTTP(w, r)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func formatMaxAge(d time.Duration) string {
|
||||||
|
return strconv.Itoa(int(d.Seconds()))
|
||||||
|
}
|
||||||
|
|
||||||
1
app/static/htmx.min.js
vendored
Normal file
1
app/static/htmx.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
777
app/static/style.css
Normal file
777
app/static/style.css
Normal file
@@ -0,0 +1,777 @@
|
|||||||
|
:root {
|
||||||
|
--bg: #14100c;
|
||||||
|
--bg-elevated: #1f1812;
|
||||||
|
--surface: #2a2219;
|
||||||
|
--text: #f5efe6;
|
||||||
|
--text-muted: #b5a090;
|
||||||
|
--accent: #c47b3a;
|
||||||
|
--accent-soft: rgba(196, 123, 58, 0.2);
|
||||||
|
--border: rgba(245, 239, 230, 0.1);
|
||||||
|
--radius: 12px;
|
||||||
|
--font: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
||||||
|
--mono: ui-monospace, "Cascadia Code", "Segoe UI Mono", monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
*,
|
||||||
|
*::before,
|
||||||
|
*::after {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
html {
|
||||||
|
scroll-behavior: smooth;
|
||||||
|
color-scheme: dark;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
font-family: var(--font);
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--text);
|
||||||
|
line-height: 1.6;
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
color: var(--accent);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
a:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
img {
|
||||||
|
max-width: 100%;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 1.5rem;
|
||||||
|
padding: 1.25rem clamp(1.25rem, 4vw, 3rem);
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
background: rgba(20, 16, 12, 0.92);
|
||||||
|
backdrop-filter: blur(10px);
|
||||||
|
z-index: 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
color: var(--text);
|
||||||
|
text-decoration: none;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo:hover {
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo-mark {
|
||||||
|
width: 2.25rem;
|
||||||
|
height: 2.25rem;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: radial-gradient(circle at 30% 30%, #e8c9a0, var(--accent) 55%, #6b3d1f);
|
||||||
|
color: #14100c;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
font-family: var(--mono);
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 1rem;
|
||||||
|
box-shadow: 0 0 24px var(--accent-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-nav {
|
||||||
|
display: flex;
|
||||||
|
gap: 1.25rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-nav a {
|
||||||
|
color: var(--text-muted);
|
||||||
|
text-decoration: none;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-nav a:hover {
|
||||||
|
color: var(--text);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
main {
|
||||||
|
padding-bottom: 4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1.1fr 0.9fr;
|
||||||
|
gap: 2.5rem;
|
||||||
|
align-items: center;
|
||||||
|
padding: clamp(2.5rem, 6vw, 5rem) clamp(1.25rem, 4vw, 3rem);
|
||||||
|
max-width: 1200px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 900px) {
|
||||||
|
.hero {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.eyebrow {
|
||||||
|
font-family: var(--mono);
|
||||||
|
font-size: 0.8rem;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--accent);
|
||||||
|
margin: 0 0 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero h1 {
|
||||||
|
font-size: clamp(2rem, 4.5vw, 3.25rem);
|
||||||
|
line-height: 1.15;
|
||||||
|
margin: 0 0 1rem;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lead {
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 1.1rem;
|
||||||
|
max-width: 38ch;
|
||||||
|
margin: 0 0 1.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.75rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0.7rem 1.25rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-weight: 600;
|
||||||
|
text-decoration: none;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary {
|
||||||
|
background: var(--accent);
|
||||||
|
color: #14100c;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary:hover {
|
||||||
|
filter: brightness(1.08);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-ghost {
|
||||||
|
border-color: var(--border);
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-ghost:hover {
|
||||||
|
background: var(--surface);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-visual {
|
||||||
|
margin: 0;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
overflow: hidden;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
box-shadow: 0 24px 60px rgba(0, 0, 0, 0.45), 0 0 80px var(--accent-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-visual img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
aspect-ratio: 4 / 5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.services,
|
||||||
|
.code-section,
|
||||||
|
.gallery-section,
|
||||||
|
.contact {
|
||||||
|
max-width: 1200px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 3rem clamp(1.25rem, 4vw, 3rem);
|
||||||
|
}
|
||||||
|
|
||||||
|
.services h2,
|
||||||
|
.code-section h2,
|
||||||
|
.gallery-section h2,
|
||||||
|
.contact h2 {
|
||||||
|
font-size: 1.75rem;
|
||||||
|
margin: 0 0 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.service-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
||||||
|
gap: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.service-card {
|
||||||
|
background: var(--bg-elevated);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
padding: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.service-card h3 {
|
||||||
|
margin: 0 0 0.5rem;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.service-card p {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 0.95rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-head p {
|
||||||
|
color: var(--text-muted);
|
||||||
|
margin: -0.75rem 0 1.5rem;
|
||||||
|
max-width: 60ch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.code-card {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 1.5rem;
|
||||||
|
padding: 1.75rem;
|
||||||
|
background: var(--bg-elevated);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
color: inherit;
|
||||||
|
text-decoration: none;
|
||||||
|
transition: border-color 0.2s ease, background 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.code-card:hover {
|
||||||
|
text-decoration: none;
|
||||||
|
border-color: rgba(196, 123, 58, 0.45);
|
||||||
|
background: var(--surface);
|
||||||
|
}
|
||||||
|
|
||||||
|
.code-card h3 {
|
||||||
|
margin: 0 0 0.5rem;
|
||||||
|
font-family: var(--mono);
|
||||||
|
font-size: 1.1rem;
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.code-card p {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--text-muted);
|
||||||
|
max-width: 50ch;
|
||||||
|
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));
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gallery-item {
|
||||||
|
position: relative;
|
||||||
|
padding: 0;
|
||||||
|
border: none;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
overflow: hidden;
|
||||||
|
cursor: pointer;
|
||||||
|
background: var(--surface);
|
||||||
|
aspect-ratio: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gallery-item img,
|
||||||
|
.gallery-placeholder {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
transition: transform 0.25s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gallery-placeholder {
|
||||||
|
display: block;
|
||||||
|
background: var(--surface);
|
||||||
|
}
|
||||||
|
|
||||||
|
.gallery-item:hover img {
|
||||||
|
transform: scale(1.04);
|
||||||
|
}
|
||||||
|
|
||||||
|
.gallery-album,
|
||||||
|
.gallery-date {
|
||||||
|
position: absolute;
|
||||||
|
left: 0.5rem;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
font-family: var(--mono);
|
||||||
|
background: rgba(20, 16, 12, 0.8);
|
||||||
|
padding: 0.2rem 0.45rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
max-width: calc(100% - 1rem);
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gallery-album {
|
||||||
|
top: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gallery-date {
|
||||||
|
bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gallery-loading,
|
||||||
|
.gallery-empty {
|
||||||
|
color: var(--text-muted);
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.contact-intro,
|
||||||
|
.contact-unavailable {
|
||||||
|
color: var(--text-muted);
|
||||||
|
max-width: 50ch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.contact-form {
|
||||||
|
max-width: 32rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1.25rem;
|
||||||
|
margin-top: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.contact-form .form-row {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: stretch;
|
||||||
|
gap: 0.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.contact-form .form-row label {
|
||||||
|
display: block;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.contact-form .form-row input,
|
||||||
|
.contact-form .form-row textarea {
|
||||||
|
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);
|
||||||
|
-webkit-appearance: none;
|
||||||
|
appearance: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.contact-form .form-row input:focus,
|
||||||
|
.contact-form .form-row textarea:focus {
|
||||||
|
outline: 2px solid var(--accent-soft);
|
||||||
|
border-color: rgba(196, 123, 58, 0.45);
|
||||||
|
}
|
||||||
|
|
||||||
|
.contact-form .form-row textarea {
|
||||||
|
resize: vertical;
|
||||||
|
min-height: 8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.contact-form .btn {
|
||||||
|
align-self: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hp-field {
|
||||||
|
position: absolute;
|
||||||
|
width: 1px;
|
||||||
|
height: 1px;
|
||||||
|
padding: 0;
|
||||||
|
margin: -1px;
|
||||||
|
overflow: hidden;
|
||||||
|
clip: rect(0, 0, 0, 0);
|
||||||
|
white-space: nowrap;
|
||||||
|
border: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.contact-result {
|
||||||
|
margin-top: 1rem;
|
||||||
|
max-width: 32rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.alert {
|
||||||
|
padding: 1rem 1.15rem;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.alert p {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.alert ul {
|
||||||
|
margin: 0.5rem 0 0;
|
||||||
|
padding-left: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.alert-success {
|
||||||
|
background: rgba(196, 123, 58, 0.12);
|
||||||
|
border-color: rgba(196, 123, 58, 0.35);
|
||||||
|
}
|
||||||
|
|
||||||
|
.alert-error {
|
||||||
|
background: rgba(120, 40, 40, 0.25);
|
||||||
|
border-color: rgba(180, 80, 80, 0.35);
|
||||||
|
}
|
||||||
|
|
||||||
|
.alert a {
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-footer {
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
padding: 1.5rem clamp(1.25rem, 4vw, 3rem);
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-footer p {
|
||||||
|
margin: 0;
|
||||||
|
max-width: 1200px;
|
||||||
|
margin-inline: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-footer a {
|
||||||
|
margin-left: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Modal */
|
||||||
|
.modal-backdrop {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.82);
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
padding: 1.5rem;
|
||||||
|
z-index: 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal {
|
||||||
|
position: relative;
|
||||||
|
max-width: min(960px, 100%);
|
||||||
|
width: 100%;
|
||||||
|
background: var(--bg-elevated);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-close {
|
||||||
|
position: absolute;
|
||||||
|
top: 0.5rem;
|
||||||
|
right: 0.5rem;
|
||||||
|
width: 2.25rem;
|
||||||
|
height: 2.25rem;
|
||||||
|
border: none;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--surface);
|
||||||
|
color: var(--text);
|
||||||
|
font-size: 1.4rem;
|
||||||
|
line-height: 1;
|
||||||
|
cursor: pointer;
|
||||||
|
z-index: 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-figure {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-figure img {
|
||||||
|
width: 100%;
|
||||||
|
max-height: 75vh;
|
||||||
|
object-fit: contain;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-figure figcaption {
|
||||||
|
margin-top: 0.75rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-family: var(--mono);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-nav {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 1rem;
|
||||||
|
margin-top: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-nav-btn {
|
||||||
|
font: inherit;
|
||||||
|
padding: 0.5rem 0.9rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-nav-btn:hover {
|
||||||
|
background: var(--accent-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.htmx-request.gallery-grid {
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Mobile --- */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.site-header {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.75rem;
|
||||||
|
padding: 0.85rem 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo-text {
|
||||||
|
font-size: 0.95rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-nav {
|
||||||
|
width: 100%;
|
||||||
|
gap: 0.5rem 1rem;
|
||||||
|
overflow-x: auto;
|
||||||
|
-webkit-overflow-scrolling: touch;
|
||||||
|
scrollbar-width: none;
|
||||||
|
padding-bottom: 0.15rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-nav::-webkit-scrollbar {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-nav a {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
white-space: nowrap;
|
||||||
|
padding: 0.35rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero {
|
||||||
|
gap: 1.5rem;
|
||||||
|
padding: 1.75rem 1rem 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero h1 {
|
||||||
|
font-size: clamp(1.65rem, 8vw, 2.25rem);
|
||||||
|
}
|
||||||
|
|
||||||
|
.lead {
|
||||||
|
font-size: 1rem;
|
||||||
|
max-width: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-actions {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-actions .btn {
|
||||||
|
justify-content: center;
|
||||||
|
min-height: 2.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.services,
|
||||||
|
.code-section,
|
||||||
|
.gallery-section,
|
||||||
|
.contact {
|
||||||
|
padding: 2rem 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.service-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.code-card {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: stretch;
|
||||||
|
padding: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.code-card .btn {
|
||||||
|
justify-content: center;
|
||||||
|
width: 100%;
|
||||||
|
min-height: 2.75rem;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gallery-grid {
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-btn {
|
||||||
|
min-height: 2.5rem;
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.contact-form {
|
||||||
|
max-width: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.contact-form .btn {
|
||||||
|
width: 100%;
|
||||||
|
justify-content: center;
|
||||||
|
min-height: 2.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-footer {
|
||||||
|
padding: 1.25rem 1rem;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-footer p {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.35rem 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-footer a {
|
||||||
|
margin-left: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-backdrop {
|
||||||
|
padding: 0;
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
max-width: none;
|
||||||
|
width: 100%;
|
||||||
|
min-height: 100dvh;
|
||||||
|
max-height: 100dvh;
|
||||||
|
border-radius: 0;
|
||||||
|
border: none;
|
||||||
|
padding: max(0.75rem, env(safe-area-inset-top))
|
||||||
|
max(0.75rem, env(safe-area-inset-right))
|
||||||
|
max(0.75rem, env(safe-area-inset-bottom))
|
||||||
|
max(0.75rem, env(safe-area-inset-left));
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-close {
|
||||||
|
top: max(0.5rem, env(safe-area-inset-top));
|
||||||
|
right: max(0.5rem, env(safe-area-inset-right));
|
||||||
|
width: 2.75rem;
|
||||||
|
height: 2.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-figure {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-figure img {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
max-height: none;
|
||||||
|
object-fit: contain;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-nav {
|
||||||
|
flex-shrink: 0;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-nav-btn {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 2.75rem;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 380px) {
|
||||||
|
.gallery-grid {
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (hover: hover) {
|
||||||
|
.gallery-item:hover img {
|
||||||
|
transform: scale(1.04);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (hover: none) {
|
||||||
|
.gallery-item:active img {
|
||||||
|
transform: scale(1.02);
|
||||||
|
}
|
||||||
|
}
|
||||||
76
app/templates/contact.templ
Normal file
76
app/templates/contact.templ
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
package templates
|
||||||
|
|
||||||
|
templ ContactForm(enabled bool) {
|
||||||
|
<section id="contact" class="contact">
|
||||||
|
<h2>Contact</h2>
|
||||||
|
<p class="contact-intro">
|
||||||
|
Interested in a project or collaboration? Send a message below.
|
||||||
|
</p>
|
||||||
|
if !enabled {
|
||||||
|
<p class="contact-unavailable">
|
||||||
|
The contact form is not configured yet. Email
|
||||||
|
<a href="mailto:hello@technical.kiwi">hello@technical.kiwi</a>
|
||||||
|
directly.
|
||||||
|
</p>
|
||||||
|
} else {
|
||||||
|
<form
|
||||||
|
class="contact-form"
|
||||||
|
method="post"
|
||||||
|
action="/contact"
|
||||||
|
hx-post="/contact"
|
||||||
|
hx-target="#contact-result"
|
||||||
|
hx-swap="innerHTML"
|
||||||
|
hx-boost="false"
|
||||||
|
hx-disabled-elt="find button[type=submit]"
|
||||||
|
>
|
||||||
|
<div class="form-row">
|
||||||
|
<label for="name">Name</label>
|
||||||
|
<input type="text" id="name" name="name" required autocomplete="name" maxlength="120"/>
|
||||||
|
</div>
|
||||||
|
<div class="form-row">
|
||||||
|
<label for="email">Email</label>
|
||||||
|
<input type="email" id="email" name="email" required autocomplete="email" maxlength="254"/>
|
||||||
|
</div>
|
||||||
|
<div class="form-row">
|
||||||
|
<label for="message">Message</label>
|
||||||
|
<textarea id="message" name="message" required rows="6" maxlength="8000"></textarea>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
class="hp-field"
|
||||||
|
type="text"
|
||||||
|
name="website"
|
||||||
|
id="website"
|
||||||
|
tabindex="-1"
|
||||||
|
autocomplete="off"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
<button type="submit" class="btn btn-primary">Send message</button>
|
||||||
|
</form>
|
||||||
|
}
|
||||||
|
<div id="contact-result" class="contact-result"></div>
|
||||||
|
</section>
|
||||||
|
}
|
||||||
|
|
||||||
|
templ ContactSuccess() {
|
||||||
|
<div class="alert alert-success" role="status">
|
||||||
|
<p>Thanks — your message has been sent. We will get back to you soon.</p>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
templ ContactValidationAlert(errs map[string]string) {
|
||||||
|
<div class="alert alert-error" role="alert">
|
||||||
|
<p>Please fix the following:</p>
|
||||||
|
<ul>
|
||||||
|
for _, msg := range errs {
|
||||||
|
<li>{ msg }</li>
|
||||||
|
}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
templ ContactSendError() {
|
||||||
|
<div class="alert alert-error" role="alert">
|
||||||
|
<p>Something went wrong sending your message. Please try again later or email
|
||||||
|
<a href="mailto:hello@technical.kiwi">hello@technical.kiwi</a>.</p>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
112
app/templates/gallery.templ
Normal file
112
app/templates/gallery.templ
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
package templates
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"technical.kiwi/website/internal/gallery"
|
||||||
|
)
|
||||||
|
|
||||||
|
templ GalleryGrid(images []gallery.Image) {
|
||||||
|
if len(images) == 0 {
|
||||||
|
<p class="gallery-empty">No images for this filter.</p>
|
||||||
|
} else {
|
||||||
|
for _, img := range images {
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="gallery-item"
|
||||||
|
hx-get={ fmt.Sprintf("/gallery/%s", gallery.URLPath(img.RelPath)) }
|
||||||
|
hx-target="#modal-root"
|
||||||
|
hx-swap="innerHTML"
|
||||||
|
aria-label={ fmt.Sprintf("Open %s", gallery.AlbumLabel(img.Album)) }
|
||||||
|
>
|
||||||
|
if img.ThumbURL != "" {
|
||||||
|
<img
|
||||||
|
src={ img.ThumbURL }
|
||||||
|
alt={ fmt.Sprintf("%s — %s", gallery.AlbumLabel(img.Album), formatDate(img.Date)) }
|
||||||
|
loading="lazy"
|
||||||
|
decoding="async"
|
||||||
|
sizes="(max-width: 480px) 45vw, 160px"
|
||||||
|
/>
|
||||||
|
} else {
|
||||||
|
<span class="gallery-placeholder" aria-hidden="true"></span>
|
||||||
|
}
|
||||||
|
if img.Collection != "" {
|
||||||
|
<span class="gallery-album">{ img.Collection }</span>
|
||||||
|
} else if img.Album != "" {
|
||||||
|
<span class="gallery-album">{ gallery.AlbumLabel(img.Album) }</span>
|
||||||
|
}
|
||||||
|
if !img.Date.IsZero() {
|
||||||
|
<span class="gallery-date">{ formatDate(img.Date) }</span>
|
||||||
|
}
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
templ ImageModal(img gallery.Image, prevPath, nextPath string) {
|
||||||
|
<div
|
||||||
|
class="modal-backdrop"
|
||||||
|
hx-on:click="if (event.target === this) this.remove()"
|
||||||
|
>
|
||||||
|
<div class="modal" role="dialog" aria-modal="true" aria-label="Image viewer">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="modal-close"
|
||||||
|
onclick="document.getElementById('modal-root').innerHTML = ''"
|
||||||
|
aria-label="Close"
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
<figure class="modal-figure">
|
||||||
|
<img src={ img.URL } alt={ img.Filename }/>
|
||||||
|
if !img.Date.IsZero() {
|
||||||
|
<figcaption>{ modalCaption(img) }</figcaption>
|
||||||
|
}
|
||||||
|
</figure>
|
||||||
|
<nav class="modal-nav" aria-label="Gallery navigation">
|
||||||
|
if prevPath != "" {
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="modal-nav-btn"
|
||||||
|
hx-get={ fmt.Sprintf("/gallery/%s", gallery.URLPath(prevPath)) }
|
||||||
|
hx-target="#modal-root"
|
||||||
|
hx-swap="innerHTML"
|
||||||
|
>
|
||||||
|
← Previous
|
||||||
|
</button>
|
||||||
|
} else {
|
||||||
|
<span></span>
|
||||||
|
}
|
||||||
|
if nextPath != "" {
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="modal-nav-btn"
|
||||||
|
hx-get={ fmt.Sprintf("/gallery/%s", gallery.URLPath(nextPath)) }
|
||||||
|
hx-target="#modal-root"
|
||||||
|
hx-swap="innerHTML"
|
||||||
|
>
|
||||||
|
Next →
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
func formatDate(t time.Time) string {
|
||||||
|
if t.IsZero() {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return t.Format("2 Jan 2006")
|
||||||
|
}
|
||||||
|
|
||||||
|
func modalCaption(img gallery.Image) string {
|
||||||
|
if img.Collection != "" {
|
||||||
|
return img.Collection + " — " + formatDate(img.Date)
|
||||||
|
}
|
||||||
|
if img.Album != "" {
|
||||||
|
return gallery.AlbumLabel(img.Album) + " — " + formatDate(img.Date)
|
||||||
|
}
|
||||||
|
return formatDate(img.Date)
|
||||||
|
}
|
||||||
7
app/templates/helpers.go
Normal file
7
app/templates/helpers.go
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
package templates
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
|
func currentYear() string {
|
||||||
|
return time.Now().Format("2006")
|
||||||
|
}
|
||||||
153
app/templates/home.templ
Normal file
153
app/templates/home.templ
Normal file
@@ -0,0 +1,153 @@
|
|||||||
|
package templates
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"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))
|
||||||
|
}
|
||||||
|
|
||||||
|
func heroPreload(hero gallery.Image, hasHero bool) string {
|
||||||
|
if !hasHero {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return hero.HeroURL
|
||||||
|
}
|
||||||
|
|
||||||
|
templ homeContent(images []gallery.Image, hero gallery.Image, hasHero bool, contactEnabled bool) {
|
||||||
|
<section class="hero">
|
||||||
|
<div class="hero-copy">
|
||||||
|
<p class="eyebrow">Technical Kiwi Limited</p>
|
||||||
|
<h1>Electronic engineering, DevOps, 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.
|
||||||
|
</p>
|
||||||
|
<div class="hero-actions">
|
||||||
|
<a href="#gallery" class="btn btn-primary">View our work</a>
|
||||||
|
<a href="#contact" class="btn btn-ghost">Get in touch</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
if hasHero && hero.HeroURL != "" {
|
||||||
|
<figure class="hero-visual">
|
||||||
|
<img
|
||||||
|
src={ hero.HeroURL }
|
||||||
|
alt="Technical Kiwi — Connection machine"
|
||||||
|
loading="eager"
|
||||||
|
decoding="sync"
|
||||||
|
fetchpriority="high"
|
||||||
|
/>
|
||||||
|
</figure>
|
||||||
|
}
|
||||||
|
</section>
|
||||||
|
<section id="services" class="services">
|
||||||
|
<h2>What we do</h2>
|
||||||
|
<div class="service-grid">
|
||||||
|
<article class="service-card">
|
||||||
|
<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>
|
||||||
|
}
|
||||||
|
}
|
||||||
43
app/templates/layout.templ
Normal file
43
app/templates/layout.templ
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
package templates
|
||||||
|
|
||||||
|
templ Layout(title string, preloadImage string, content templ.Component) {
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8"/>
|
||||||
|
<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."/>
|
||||||
|
<link rel="stylesheet" href="/static/style.css"/>
|
||||||
|
if preloadImage != "" {
|
||||||
|
<link rel="preload" as="image" href={ preloadImage } fetchpriority="high"/>
|
||||||
|
}
|
||||||
|
</head>
|
||||||
|
<body hx-boost="true">
|
||||||
|
<header class="site-header">
|
||||||
|
<a href="/" class="logo">
|
||||||
|
<span class="logo-mark" aria-hidden="true">T</span>
|
||||||
|
<span class="logo-text">Technical Kiwi</span>
|
||||||
|
</a>
|
||||||
|
<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>
|
||||||
|
<main>
|
||||||
|
@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>
|
||||||
|
</footer>
|
||||||
|
<div id="modal-root"></div>
|
||||||
|
<script src="/static/htmx.min.js" defer></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
}
|
||||||
39
docker-compose.yaml
Normal file
39
docker-compose.yaml
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
version: '3.9'
|
||||||
|
|
||||||
|
services:
|
||||||
|
website:
|
||||||
|
image: technical.kiwi/website
|
||||||
|
container_name: technicalkiwi
|
||||||
|
build: ./
|
||||||
|
env_file:
|
||||||
|
- .env
|
||||||
|
#networks:
|
||||||
|
# - caddy
|
||||||
|
labels:
|
||||||
|
caddy: technicalkiwi
|
||||||
|
caddy.reverse_proxy: "{{upstreams 8080}}"
|
||||||
|
|
||||||
|
dev:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile.dev
|
||||||
|
container_name: technicalkiwi-dev
|
||||||
|
ports:
|
||||||
|
- "7331:7331" # templ live-reload proxy — open this in the browser
|
||||||
|
- "8080:8080" # app direct (no auto-reload)
|
||||||
|
volumes:
|
||||||
|
- ./app:/app
|
||||||
|
- ${GOMODCACHE}:/go/pkg/mod
|
||||||
|
- ${GOCACHE}:/root/.cache/go-build
|
||||||
|
env_file:
|
||||||
|
- .env
|
||||||
|
environment:
|
||||||
|
ADDR: ":8080"
|
||||||
|
GOMODCACHE: /go/pkg/mod
|
||||||
|
GOCACHE: /root/.cache/go-build
|
||||||
|
working_dir: /app
|
||||||
|
|
||||||
|
#networks:
|
||||||
|
# caddy:
|
||||||
|
# external: true
|
||||||
|
# name: caddy
|
||||||
Reference in New Issue
Block a user