diff --git a/.dockerignore b/.dockerignore
index 4bdce95..52048c4 100644
--- a/.dockerignore
+++ b/.dockerignore
@@ -1,3 +1,7 @@
# Gallery photos are mounted at runtime, not baked into the image
app/images/**
!app/images/.gitkeep
+
+# Raw uploads stay on the host only
+upload/**
+!upload/.gitkeep
diff --git a/.env.example b/.env.example
index 40c2d57..dd1b20d 100644
--- a/.env.example
+++ b/.env.example
@@ -12,7 +12,7 @@ SMTP_TLS=auto
# HTTP listen address
ADDR=:8080
-# Homepage hero (path under app/images/)
+# Homepage hero (path under app/images/, e.g. connectionmachine/photo.jpg)
HERO_IMAGE=connectionmachine/20220723_231556.jpg
# Gallery admin at /admin/ (both required to enable)
diff --git a/.gitignore b/.gitignore
index e24d5f8..3af679e 100644
--- a/.gitignore
+++ b/.gitignore
@@ -25,7 +25,11 @@ go.work
/app/tmp/
/app/bin/
-# Gallery photos and generated derivatives (not in git)
+# Raw media staging (sync to app/images/ with make sync-media)
+/upload/**
+!/upload/.gitkeep
+
+# Published gallery (served at /images/; not in git)
/app/images/**
!/app/images/.gitkeep
diff --git a/Dockerfile b/Dockerfile
index bbdceb0..889458d 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -23,5 +23,6 @@ COPY app/static ./static
EXPOSE 8080
ENV ADDR=:8080
+ENV IMAGES_DIR=/app/images
CMD ["./server"]
diff --git a/Makefile b/Makefile
index 126dd61..0a8386a 100644
--- a/Makefile
+++ b/Makefile
@@ -6,9 +6,9 @@ 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
+DEV_RUN := $(COMPOSE) run --rm --no-deps -T dev
-.PHONY: dev build up down generate tidy logs
+.PHONY: dev build up down generate tidy logs thumbs sync-media publish convert-videos
.DEFAULT_GOAL := dev
@@ -34,3 +34,17 @@ tidy:
logs:
$(COMPOSE) logs -f dev
+
+# Pre-build gallery thumbnails and video poster JPEGs (ffmpeg in dev image).
+thumbs:
+ $(DEV_RUN) go run ./cmd/server -thumbs
+
+# Copy photos and convert videos from upload/ into app/images/.
+sync-media:
+ $(DEV_RUN) sh -c 'UPLOAD_DIR=/upload IMAGES_DIR=/app/images ./scripts/sync-media.sh $(ARGS)'
+
+# sync-media then rebuild thumbs/hero derivatives under app/images/.
+publish: sync-media thumbs
+
+# Alias for sync-media (videos are converted as part of sync).
+convert-videos: sync-media
diff --git a/README.md b/README.md
index 2187087..48af4e3 100644
--- a/README.md
+++ b/README.md
@@ -24,6 +24,9 @@ make dev # live reload → http://localhost:7331
| `make generate` | Run `templ generate` in dev container |
| `make tidy` | Run `go mod tidy` in dev container |
| `make logs` | Follow dev container logs |
+| `make sync-media` | Copy photos and convert videos from `upload/` → `app/images/` |
+| `make thumbs` | Generate gallery thumbnails and video posters under `app/images/` |
+| `make publish` | `sync-media` then `thumbs` |
## Features
@@ -31,7 +34,7 @@ make dev # live reload → http://localhost:7331
- Gallery loads via HTMX (`hx-get="/gallery"`)
- Lightbox modal with previous/next navigation
- Contact form with SMTP relay (HTMX submit, no page reload)
-- Serves photos from `app/images/` (not in git — mount or copy locally)
+- Serves gallery media from `app/images/` (published copy; not in git)
- Password-protected gallery admin at `/admin/`
## Gallery admin
@@ -60,13 +63,13 @@ 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`.
+`make dev` mounts `./app` and `./upload` (raw media staging) plus 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` mounts `./app/images` into the container (photos stay on the host). It joins the external `caddy` Docker network for reverse proxy labels.
+Production `website` mounts `./app/images` into the container. It joins the external `caddy` Docker network for reverse proxy labels.
## Project layout
@@ -79,17 +82,22 @@ app/
internal/handlers/ Routes and HTMX partials
templates/ templ components (.templ → generated Go)
static/ CSS
- images/ Gallery photos (served at /images/)
+ images/ Published gallery (served at /images/)
+upload/ Raw media — run `make sync-media` to publish into app/images/
Dockerfile Production image
Dockerfile.dev Dev image (Go + templ + Air)
docker-compose.yaml
.env.example
```
-## Gallery images
+## Gallery media
-On startup the server generates missing derivatives under `app/images/thumbs/` (and `hero/` for JPEGs only). Photos get resized JPEG thumbs; videos (`.mp4`, `.webm`, `.mov`) get a poster frame JPEG via `ffmpeg` when installed. The lightbox plays full videos or shows full-resolution photos. Derivatives are gitignored and rebuilt when source files are newer.
+1. Drop photos and videos into repo-root **`upload/`** (album folders, same layout as the gallery).
+2. Run **`make sync-media`** — JPEGs are copied; videos are converted to H.264 MP4 in **`app/images/`**.
+3. Run **`make thumbs`** (or **`make publish`** for both steps) — builds `thumbs/` and `hero/` derivatives under `app/images/`.
+
+The site serves files from `app/images/` at `/images/…`. Thumbnails and hero images are gitignored and rebuilt when sources are newer.
## Deploy notes
-First request after deploy may take a moment while thumbnails are generated under the mounted `app/images/` directory.
+After adding media, run `make publish` on the host before or after deploy. First request may take a moment while any missing thumbnails are generated.
diff --git a/app/cmd/server/main.go b/app/cmd/server/main.go
index e9c970c..56a6926 100644
--- a/app/cmd/server/main.go
+++ b/app/cmd/server/main.go
@@ -22,7 +22,7 @@ func main() {
log.Fatal(err)
}
- imagesDir := filepath.Join(root, "images")
+ imagesDir := envOr("IMAGES_DIR", filepath.Join(root, "images"))
staticDir := filepath.Join(root, "static")
if *thumbsOnly {
@@ -54,6 +54,7 @@ func main() {
srv.RegisterRoutes(mux)
addr := envOr("ADDR", ":8080")
+ log.Printf("gallery images: %s", imagesDir)
log.Printf("Technical Kiwi website listening on %s", addr)
if err := http.ListenAndServe(addr, middleware.Gzip(mux)); err != nil {
log.Fatal(err)
diff --git a/app/dev.sh b/app/dev.sh
index 320595d..0c54689 100755
--- a/app/dev.sh
+++ b/app/dev.sh
@@ -2,7 +2,7 @@
set -e
# Browser: http://localhost:7331 (proxy injects auto-reload)
-# App: http://127.0.0.1:8080 (internal, via proxy)
+# App: http://127.0.0.1:8080 (direct, same app)
exec templ generate --watch \
--proxy="http://127.0.0.1:8080" \
--proxybind="0.0.0.0" \
diff --git a/app/images/.gitkeep b/app/images/.gitkeep
index 8b13789..e69de29 100644
--- a/app/images/.gitkeep
+++ b/app/images/.gitkeep
@@ -1 +0,0 @@
-
diff --git a/app/internal/gallery/gallery.go b/app/internal/gallery/gallery.go
index 6272ac7..fe1cbde 100644
--- a/app/internal/gallery/gallery.go
+++ b/app/internal/gallery/gallery.go
@@ -16,7 +16,7 @@ type Image struct {
Collection string // event subfolder label, e.g. Anniversary
CollectionKey string // slug for filters
Filename string // basename
- URL string
+ URL string // served file URL (/images/…)
ThumbURL string
HeroURL string
IsVideo bool
diff --git a/app/internal/gallery/media.go b/app/internal/gallery/media.go
index c8f21d3..712e104 100644
--- a/app/internal/gallery/media.go
+++ b/app/internal/gallery/media.go
@@ -15,6 +15,9 @@ func isVideo(name string) bool {
}
func isGalleryMedia(name string) bool {
+ if isWebDerivative(name) {
+ return false
+ }
return isJPEG(name) || isVideo(name)
}
@@ -34,3 +37,7 @@ func videoPosterRel(rel string) string {
}
return strings.TrimSuffix(rel, ext) + ".jpg"
}
+
+func isWebDerivative(name string) bool {
+ return strings.HasSuffix(strings.ToLower(name), ".web.mp4")
+}
diff --git a/app/internal/gallery/media_test.go b/app/internal/gallery/media_test.go
index a39b7f7..0b84d5e 100644
--- a/app/internal/gallery/media_test.go
+++ b/app/internal/gallery/media_test.go
@@ -12,6 +12,15 @@ func TestVideoPosterRel(t *testing.T) {
}
}
+func TestIsWebDerivative(t *testing.T) {
+ if !isWebDerivative("clip.web.mp4") {
+ t.Fatal("expected web derivative")
+ }
+ if isGalleryMedia("clip.web.mp4") {
+ t.Fatal("web derivative must not be listed")
+ }
+}
+
func TestList_includesVideo(t *testing.T) {
dir := t.TempDir()
album := filepath.Join(dir, "portal")
diff --git a/app/internal/handlers/handlers.go b/app/internal/handlers/handlers.go
index 987d7dc..ebaae43 100644
--- a/app/internal/handlers/handlers.go
+++ b/app/internal/handlers/handlers.go
@@ -73,8 +73,6 @@ func New(imagesDir, staticDir string, mailCfg *mail.Config) (*Server, error) {
} else {
log.Print("gallery thumbnails: ready")
}
- // Thumbnail URLs are resolved during gallery.List. Re-list after derivative
- // generation so newly-created thumbs appear immediately on first load.
if refreshed, err := gallery.List(imagesDir); err != nil {
log.Printf("gallery refresh after thumbnails: %v", err)
} else {
diff --git a/app/scripts/convert-videos.sh b/app/scripts/convert-videos.sh
new file mode 100755
index 0000000..898d974
--- /dev/null
+++ b/app/scripts/convert-videos.sh
@@ -0,0 +1,3 @@
+#!/usr/bin/env bash
+# Deprecated alias — use sync-media.sh (upload/ → app/images/).
+exec "$(dirname "$0")/sync-media.sh" "$@"
diff --git a/app/scripts/sync-media.sh b/app/scripts/sync-media.sh
new file mode 100755
index 0000000..d6aa48e
--- /dev/null
+++ b/app/scripts/sync-media.sh
@@ -0,0 +1,193 @@
+#!/usr/bin/env bash
+# Copy photos and convert videos from upload/ into app/images/ (gallery source).
+# Videos are always re-encoded for browser playback (H.264 + AAC, yuv420p, faststart).
+#
+# Usage (from app/):
+# ./scripts/sync-media.sh
+# UPLOAD_DIR=/upload IMAGES_DIR=/app/images ./scripts/sync-media.sh
+#
+# Requires: ffmpeg (for videos)
+
+set -euo pipefail
+
+upload_dir="${UPLOAD_DIR:-../upload}"
+images_dir="${IMAGES_DIR:-images}"
+dry_run=0
+force=0
+max_width=1920
+crf=23
+preset=medium
+
+usage() {
+ sed -n '2,8p' "$0"
+ echo ""
+ echo "Options:"
+ echo " --upload-dir DIR Raw media source (default: ../upload)"
+ echo " --images-dir DIR Gallery output (default: images)"
+ echo " --dry-run Print actions only"
+ echo " --force Re-copy / re-encode even if output looks up to date"
+ echo " --max-width N Scale down wide video (default: 1920)"
+ echo " --crf N H.264 quality (default: 23)"
+ echo " -h, --help Show this help"
+}
+
+while [[ $# -gt 0 ]]; do
+ case "$1" in
+ --upload-dir) upload_dir=$2; shift 2 ;;
+ --images-dir) images_dir=$2; shift 2 ;;
+ --dry-run) dry_run=1; shift ;;
+ --force) force=1; shift ;;
+ --max-width) max_width=$2; shift 2 ;;
+ --crf) crf=$2; shift 2 ;;
+ -h | --help) usage; exit 0 ;;
+ *) echo "Unknown option: $1" >&2; usage; exit 1 ;;
+ esac
+done
+
+if ! command -v ffmpeg >/dev/null 2>&1; then
+ echo "ffmpeg is required." >&2
+ exit 1
+fi
+
+if [[ ! -d "$upload_dir" ]]; then
+ echo "Upload directory not found: $upload_dir" >&2
+ exit 1
+fi
+
+mkdir -p "$images_dir"
+
+is_video_ext() {
+ case "${1,,}" in
+ .mp4 | .mov | .webm) return 0 ;;
+ *) return 1 ;;
+ esac
+}
+
+is_jpeg_ext() {
+ case "${1,,}" in
+ .jpg | .jpeg) return 0 ;;
+ *) return 1 ;;
+ esac
+}
+
+skip_name() {
+ local base=${1##*/}
+ [[ "$base" == .* ]] && return 0
+ [[ "$base" == *.web.mp4 ]] && return 0
+ [[ "$base" == *.converting.mp4 ]] && return 0
+ [[ "$base" == *.part ]] && return 0
+ return 1
+}
+
+skip_path() {
+ case "$1" in
+ */thumbs/* | */hero/*) return 0 ;;
+ esac
+ return 1
+}
+
+needs_convert() {
+ local src=$1 dst=$2
+ if [[ -f "$dst" ]] && [[ "$force" -eq 0 ]] && [[ "$dst" -nt "$src" ]]; then
+ return 1
+ fi
+ return 0
+}
+
+copy_one() {
+ local src=$1 dst=$2 rel
+ rel=${src#"$upload_dir"/}
+ rel=${rel#/}
+ echo "→ copy $rel"
+
+ if [[ "$dry_run" -eq 1 ]]; then
+ echo " would write: ${dst#"$images_dir"/}"
+ return 0
+ fi
+
+ mkdir -p "$(dirname "$dst")"
+ cp -p "$src" "$dst"
+ echo " wrote: ${dst#"$images_dir"/}"
+}
+
+convert_one() {
+ local src=$1 dst=$2
+ local dir name tmp rel
+ dir=$(dirname "$dst")
+ name=$(basename "$dst" .mp4)
+ tmp="$dir/.${name}.converting.mp4"
+ rel=${src#"$upload_dir"/}
+ rel=${rel#/}
+
+ echo "→ convert $rel"
+
+ if [[ "$dry_run" -eq 1 ]]; then
+ echo " would write: ${dst#"$images_dir"/}"
+ return 0
+ fi
+
+ mkdir -p "$dir"
+ rm -f "$tmp"
+ trap 'rm -f "$tmp"' RETURN
+
+ ffmpeg -nostdin -hide_banner -loglevel error -y \
+ -i "$src" \
+ -vf "scale=min(${max_width}\\,iw):-2" \
+ -c:v libx264 -profile:v high -level:v 4.0 -pix_fmt yuv420p \
+ -crf "$crf" -preset "$preset" \
+ -c:a aac -b:a 128k -ac 2 \
+ -movflags +faststart \
+ -f mp4 \
+ "$tmp"
+
+ mv -f "$tmp" "$dst"
+ trap - RETURN
+ echo " wrote: ${dst#"$images_dir"/}"
+}
+
+copied=0
+converted=0
+skipped=0
+
+while IFS= read -r -d '' src; do
+ base=$(basename "$src")
+ ext=".${base##*.}"
+
+ if skip_name "$base" || skip_path "$src"; then
+ continue
+ fi
+
+ rel=${src#"$upload_dir"/}
+ rel=${rel#/}
+ dst="$images_dir/$rel"
+
+ if is_jpeg_ext "$ext"; then
+ if [[ -f "$dst" ]] && [[ "$force" -eq 0 ]] && [[ "$dst" -nt "$src" ]]; then
+ ((skipped++)) || true
+ continue
+ fi
+ copy_one "$src" "$dst"
+ ((copied++)) || true
+ continue
+ fi
+
+ if ! is_video_ext "$ext"; then
+ continue
+ fi
+
+ dst="$images_dir/${rel%.*}.mp4"
+
+ if ! needs_convert "$src" "$dst"; then
+ ((skipped++)) || true
+ continue
+ fi
+
+ convert_one "$src" "$dst"
+ ((converted++)) || true
+done < <(find "$upload_dir" -type f \( -iname '*.jpg' -o -iname '*.jpeg' -o -iname '*.mov' -o -iname '*.mp4' -o -iname '*.webm' \) -print0)
+
+echo ""
+echo "Done. Copied: $copied, converted: $converted, skipped: $skipped."
+if [[ "$dry_run" -eq 1 ]]; then
+ echo "(dry run — no files changed)"
+fi
diff --git a/app/static/style.css b/app/static/style.css
index 38a6d44..608351e 100644
--- a/app/static/style.css
+++ b/app/static/style.css
@@ -311,21 +311,26 @@ main {
background: var(--surface);
}
-.gallery-video-badge {
+.gallery-media-icon {
position: absolute;
- left: 0.35rem;
- bottom: 0.35rem;
- padding: 0.15rem 0.4rem;
- border-radius: 3px;
- background: rgba(0, 0, 0, 0.65);
+ top: 0.5rem;
+ right: 0.5rem;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ width: 1.75rem;
+ height: 1.75rem;
+ border-radius: 4px;
+ background: rgba(20, 16, 12, 0.8);
color: var(--text);
- font-family: var(--mono);
- font-size: 0.65rem;
- letter-spacing: 0.04em;
- text-transform: uppercase;
pointer-events: none;
}
+.gallery-media-icon svg {
+ width: 1rem;
+ height: 1rem;
+}
+
.gallery-item:hover img {
transform: scale(1.04);
}
@@ -524,54 +529,30 @@ main {
background: #000;
}
-.modal-video {
- position: relative;
- width: 100%;
-}
-
-.modal-figure img,
-.modal-figure video {
+.modal-figure > img {
display: block;
width: 100%;
max-height: 75vh;
- min-height: 12rem;
object-fit: contain;
border-radius: 8px;
background: #000;
}
-.modal-figure video.video-broken {
- display: none;
-}
-
-.video-unavailable {
- margin: 0;
- padding: 1.25rem;
- text-align: center;
- color: var(--text-muted);
- background: #000;
- border-radius: 8px;
- min-height: 12rem;
+.modal-video-stack {
display: flex;
flex-direction: column;
- align-items: center;
- justify-content: center;
gap: 0.75rem;
+ width: 100%;
}
-.video-unavailable[hidden] {
- display: none !important;
-}
-
-.video-unavailable a {
- color: var(--accent);
-}
-
-.modal-figure figcaption {
- margin-top: 0.75rem;
- color: var(--text-muted);
- font-family: var(--mono);
- font-size: 0.85rem;
+.modal-video-stack video {
+ display: block;
+ width: 100%;
+ max-height: 75vh;
+ min-height: 3.5rem;
+ object-fit: contain;
+ border-radius: 8px;
+ background: #000;
}
.modal-nav {
@@ -742,14 +723,25 @@ main {
min-height: 0;
}
- .modal-figure img,
- .modal-figure video {
+ .modal-figure > img {
flex: 1;
min-height: 0;
max-height: none;
object-fit: contain;
}
+ .modal-video-stack {
+ flex: 1;
+ min-height: 0;
+ justify-content: center;
+ }
+
+ .modal-video-stack video {
+ flex: 1;
+ min-height: 0;
+ max-height: none;
+ }
+
.modal-nav {
flex-shrink: 0;
gap: 0.5rem;
diff --git a/app/static/video-test.html b/app/static/video-test.html
new file mode 100644
index 0000000..d2a8947
--- /dev/null
+++ b/app/static/video-test.html
@@ -0,0 +1,275 @@
+
+
+
+
+
+ Video playback test — Technical Kiwi
+
+
+
+
+ Video playback test
+
+ Served from /static/video-test.html.
+ Use with make dev at
+ localhost:7331
+ or 8080.
+ Path is under /images/… (copy from gallery Network tab).
+
+
+
+
+
+
+ Query link: ?path=/images/… (encode slashes as %2F if needed)
+
+
+
+
+
Player
+
+
+ - src
- —
+ - readyState
- —
+ - dimensions
- —
+ - duration
- —
+ - networkState
- —
+ - error
- —
+
+
+
+
+
Browser codec hints (canPlayType)
+
+
+
+
+
+
+
+
diff --git a/app/templates/gallery.templ b/app/templates/gallery.templ
index 70adca6..702f695 100644
--- a/app/templates/gallery.templ
+++ b/app/templates/gallery.templ
@@ -33,9 +33,7 @@ templ GalleryGrid(images []gallery.Image) {
} else {
}
- if img.IsVideo {
- Video
- }
+ @GalleryMediaIcon(img.IsVideo)
if img.Collection != "" {
{ img.Collection }
} else if img.Album != "" {
@@ -49,6 +47,20 @@ templ GalleryGrid(images []gallery.Image) {
}
}
+templ GalleryMediaIcon(isVideo bool) {
+
+ if isVideo {
+
+ } else {
+
+ }
+
+}
+
templ ImageModal(img gallery.Image, prevPath, nextPath string) {
if img.IsVideo {
-
-
-
- Playback is not supported in this browser.
- Download the video
-
+
+
} else {

}
- if !img.Date.IsZero() {
-
{ modalCaption(img) }
- }