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.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