Improve gallery video UX and add upload-to-publish media workflow.
Stage raw files in upload/, publish with make sync-media/publish, and polish the lightbox: autoplay, remembered volume, Escape to close, and image/video icons without poster or caption clutter. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
6
.gitignore
vendored
6
.gitignore
vendored
@@ -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
|
||||
|
||||
|
||||
@@ -23,5 +23,6 @@ COPY app/static ./static
|
||||
|
||||
EXPOSE 8080
|
||||
ENV ADDR=:8080
|
||||
ENV IMAGES_DIR=/app/images
|
||||
|
||||
CMD ["./server"]
|
||||
|
||||
18
Makefile
18
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
|
||||
|
||||
22
README.md
22
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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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" \
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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 {
|
||||
|
||||
3
app/scripts/convert-videos.sh
Executable file
3
app/scripts/convert-videos.sh
Executable file
@@ -0,0 +1,3 @@
|
||||
#!/usr/bin/env bash
|
||||
# Deprecated alias — use sync-media.sh (upload/ → app/images/).
|
||||
exec "$(dirname "$0")/sync-media.sh" "$@"
|
||||
193
app/scripts/sync-media.sh
Executable file
193
app/scripts/sync-media.sh
Executable file
@@ -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
|
||||
@@ -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;
|
||||
|
||||
275
app/static/video-test.html
Normal file
275
app/static/video-test.html
Normal file
@@ -0,0 +1,275 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Video playback test — Technical Kiwi</title>
|
||||
<style>
|
||||
:root {
|
||||
--bg: #14100c;
|
||||
--surface: #2a2219;
|
||||
--text: #f5efe6;
|
||||
--muted: #b5a090;
|
||||
--accent: #c47b3a;
|
||||
--border: rgba(245, 239, 230, 0.12);
|
||||
--mono: ui-monospace, "Cascadia Code", monospace;
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: system-ui, sans-serif;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
line-height: 1.5;
|
||||
}
|
||||
main {
|
||||
max-width: 960px;
|
||||
margin: 0 auto;
|
||||
padding: 1.5rem;
|
||||
}
|
||||
h1 { font-size: 1.35rem; margin: 0 0 0.5rem; }
|
||||
p.lead { color: var(--muted); margin: 0 0 1.5rem; }
|
||||
label { display: block; font-size: 0.85rem; color: var(--muted); margin-bottom: 0.35rem; }
|
||||
.row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
input[type="text"] {
|
||||
flex: 1 1 16rem;
|
||||
min-width: 0;
|
||||
padding: 0.55rem 0.75rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
background: var(--surface);
|
||||
color: var(--text);
|
||||
font: inherit;
|
||||
}
|
||||
button, .btn {
|
||||
padding: 0.55rem 1rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
background: var(--surface);
|
||||
color: var(--text);
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
button:hover, .btn:hover { border-color: var(--accent); }
|
||||
.panel {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
padding: 1rem;
|
||||
margin-bottom: 1rem;
|
||||
background: rgba(42, 34, 25, 0.5);
|
||||
}
|
||||
.panel h2 { font-size: 1rem; margin: 0 0 0.75rem; }
|
||||
video {
|
||||
display: block;
|
||||
width: 100%;
|
||||
max-height: 60vh;
|
||||
background: #000;
|
||||
border-radius: 8px;
|
||||
}
|
||||
.meta {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(12rem, 1fr));
|
||||
gap: 0.5rem 1rem;
|
||||
font-family: var(--mono);
|
||||
font-size: 0.8rem;
|
||||
margin-top: 0.75rem;
|
||||
}
|
||||
.meta dt { color: var(--muted); }
|
||||
.meta dd { margin: 0; word-break: break-all; }
|
||||
#log {
|
||||
font-family: var(--mono);
|
||||
font-size: 0.75rem;
|
||||
max-height: 14rem;
|
||||
overflow: auto;
|
||||
margin: 0;
|
||||
padding: 0.75rem;
|
||||
background: #0a0806;
|
||||
border-radius: 8px;
|
||||
list-style: none;
|
||||
}
|
||||
#log li { margin: 0.15rem 0; }
|
||||
#log .err { color: #f87171; }
|
||||
#log .ok { color: #86efac; }
|
||||
table.codec {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
table.codec th, table.codec td {
|
||||
text-align: left;
|
||||
padding: 0.35rem 0.5rem;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
table.codec th { color: var(--muted); font-weight: 500; }
|
||||
a { color: var(--accent); }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<h1>Video playback test</h1>
|
||||
<p class="lead">
|
||||
Served from <code>/static/video-test.html</code>.
|
||||
Use with <code>make dev</code> at
|
||||
<a href="http://localhost:7331/static/video-test.html">localhost:7331</a>
|
||||
or <a href="http://localhost:8080/static/video-test.html">8080</a>.
|
||||
Path is under <code>/images/…</code> (copy from gallery Network tab).
|
||||
</p>
|
||||
|
||||
<div class="panel">
|
||||
<label for="path">Video path (site-relative)</label>
|
||||
<div class="row">
|
||||
<input id="path" type="text" placeholder="/images/album/clip.mp4" spellcheck="false">
|
||||
<button type="button" id="load">Load</button>
|
||||
<a class="btn" id="open-tab" href="#" target="_blank" rel="noopener">Open URL</a>
|
||||
</div>
|
||||
<p style="margin:0;font-size:0.85rem;color:var(--muted)">
|
||||
Query link: <code>?path=/images/…</code> (encode slashes as <code>%2F</code> if needed)
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="panel">
|
||||
<h2>Player</h2>
|
||||
<video id="player" controls playsinline preload="auto"></video>
|
||||
<dl class="meta" id="stats">
|
||||
<div><dt>src</dt><dd id="stat-src">—</dd></div>
|
||||
<div><dt>readyState</dt><dd id="stat-ready">—</dd></div>
|
||||
<div><dt>dimensions</dt><dd id="stat-dim">—</dd></div>
|
||||
<div><dt>duration</dt><dd id="stat-dur">—</dd></div>
|
||||
<div><dt>networkState</dt><dd id="stat-net">—</dd></div>
|
||||
<div><dt>error</dt><dd id="stat-err">—</dd></div>
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
<div class="panel">
|
||||
<h2>Browser codec hints (canPlayType)</h2>
|
||||
<table class="codec" id="codec-table">
|
||||
<thead><tr><th>MIME</th><th>Result</th></tr></thead>
|
||||
<tbody></tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="panel">
|
||||
<h2>Events</h2>
|
||||
<ul id="log"></ul>
|
||||
</div>
|
||||
</main>
|
||||
<script>
|
||||
(function () {
|
||||
var pathInput = document.getElementById("path");
|
||||
var player = document.getElementById("player");
|
||||
var logEl = document.getElementById("log");
|
||||
var openTab = document.getElementById("open-tab");
|
||||
|
||||
var codecs = [
|
||||
'video/mp4; codecs="avc1.42E01E, mp4a.40.2"',
|
||||
'video/mp4; codecs="hvc1.1.6.L93.B0, mp4a.40.2"',
|
||||
'video/mp4; codecs="hev1.1.6.L93.B0, mp4a.40.2"',
|
||||
"video/mp4",
|
||||
"video/quicktime",
|
||||
"video/webm; codecs=\"vp9, opus\"",
|
||||
"video/webm",
|
||||
];
|
||||
|
||||
function log(msg, cls) {
|
||||
var li = document.createElement("li");
|
||||
if (cls) li.className = cls;
|
||||
li.textContent = new Date().toISOString().slice(11, 23) + " " + msg;
|
||||
logEl.prepend(li);
|
||||
}
|
||||
|
||||
function updateStats() {
|
||||
document.getElementById("stat-src").textContent = player.currentSrc || player.src || "—";
|
||||
document.getElementById("stat-ready").textContent = String(player.readyState);
|
||||
document.getElementById("stat-dim").textContent =
|
||||
player.videoWidth + "×" + player.videoHeight;
|
||||
document.getElementById("stat-dur").textContent = isFinite(player.duration)
|
||||
? player.duration.toFixed(2) + "s"
|
||||
: "—";
|
||||
document.getElementById("stat-net").textContent = String(player.networkState);
|
||||
var err = player.error;
|
||||
document.getElementById("stat-err").textContent = err
|
||||
? err.code + " " + (err.message || mediaErrorLabel(err.code))
|
||||
: "—";
|
||||
}
|
||||
|
||||
function mediaErrorLabel(code) {
|
||||
return (
|
||||
{ 1: "MEDIA_ERR_ABORTED", 2: "MEDIA_ERR_NETWORK", 3: "MEDIA_ERR_DECODE", 4: "MEDIA_ERR_SRC_NOT_SUPPORTED" }[code] || "?"
|
||||
);
|
||||
}
|
||||
|
||||
function normalizePath(raw) {
|
||||
raw = (raw || "").trim();
|
||||
if (!raw) return "";
|
||||
if (raw.startsWith("http://") || raw.startsWith("https://")) {
|
||||
try {
|
||||
var u = new URL(raw);
|
||||
return u.pathname;
|
||||
} catch (e) {
|
||||
return raw;
|
||||
}
|
||||
}
|
||||
return raw.startsWith("/") ? raw : "/" + raw;
|
||||
}
|
||||
|
||||
function loadFromInput() {
|
||||
var path = normalizePath(pathInput.value);
|
||||
if (!path) {
|
||||
log("No path entered", "err");
|
||||
return;
|
||||
}
|
||||
pathInput.value = path;
|
||||
logEl.innerHTML = "";
|
||||
log("Loading " + path);
|
||||
player.removeAttribute("poster");
|
||||
player.src = path;
|
||||
openTab.href = path;
|
||||
player.load();
|
||||
history.replaceState(null, "", "?path=" + encodeURIComponent(path));
|
||||
updateStats();
|
||||
}
|
||||
|
||||
[
|
||||
"loadstart", "loadedmetadata", "loadeddata", "canplay", "canplaythrough",
|
||||
"playing", "pause", "ended", "error", "stalled", "waiting", "suspend",
|
||||
].forEach(function (ev) {
|
||||
player.addEventListener(ev, function () {
|
||||
var cls = ev === "error" ? "err" : ev === "canplay" || ev === "playing" ? "ok" : "";
|
||||
log(ev + (ev === "error" && player.error ? " " + mediaErrorLabel(player.error.code) : ""));
|
||||
if (cls && ev !== "error") logEl.querySelector("li").className = cls;
|
||||
updateStats();
|
||||
});
|
||||
});
|
||||
|
||||
document.getElementById("load").addEventListener("click", loadFromInput);
|
||||
pathInput.addEventListener("keydown", function (e) {
|
||||
if (e.key === "Enter") loadFromInput();
|
||||
});
|
||||
|
||||
var tbody = document.querySelector("#codec-table tbody");
|
||||
var probe = document.createElement("video");
|
||||
codecs.forEach(function (mime) {
|
||||
var tr = document.createElement("tr");
|
||||
var tdM = document.createElement("td");
|
||||
var tdR = document.createElement("td");
|
||||
tdM.textContent = mime;
|
||||
tdR.textContent = probe.canPlayType(mime) || '""';
|
||||
tr.appendChild(tdM);
|
||||
tr.appendChild(tdR);
|
||||
tbody.appendChild(tr);
|
||||
});
|
||||
|
||||
var q = new URLSearchParams(location.search).get("path");
|
||||
if (q) {
|
||||
pathInput.value = q;
|
||||
loadFromInput();
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -33,9 +33,7 @@ templ GalleryGrid(images []gallery.Image) {
|
||||
} else {
|
||||
<span class="gallery-placeholder" aria-hidden="true"></span>
|
||||
}
|
||||
if img.IsVideo {
|
||||
<span class="gallery-video-badge" aria-hidden="true">Video</span>
|
||||
}
|
||||
@GalleryMediaIcon(img.IsVideo)
|
||||
if img.Collection != "" {
|
||||
<span class="gallery-album">{ img.Collection }</span>
|
||||
} else if img.Album != "" {
|
||||
@@ -49,6 +47,20 @@ templ GalleryGrid(images []gallery.Image) {
|
||||
}
|
||||
}
|
||||
|
||||
templ GalleryMediaIcon(isVideo bool) {
|
||||
<span class="gallery-media-icon" aria-hidden="true">
|
||||
if isVideo {
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M17 10.5V7c0-.55-.45-1-1-1H4c-.55 0-1 .45-1 1v10c0 .55.45 1 1 1h12c.55 0 1-.45 1-1v-3.5l4 4v-11l-4 4z"></path>
|
||||
</svg>
|
||||
} else {
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M21 19V5c0-1.1-.9-2-2-2H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2zM8.5 13.5l2.5 3.01L14.5 12l4.5 6H5l3.5-4.5z"></path>
|
||||
</svg>
|
||||
}
|
||||
</span>
|
||||
}
|
||||
|
||||
templ ImageModal(img gallery.Image, prevPath, nextPath string) {
|
||||
<div
|
||||
class="modal-backdrop"
|
||||
@@ -65,21 +77,12 @@ templ ImageModal(img gallery.Image, prevPath, nextPath string) {
|
||||
</button>
|
||||
<figure class="modal-figure">
|
||||
if img.IsVideo {
|
||||
<div class="modal-video">
|
||||
<video controls playsinline preload="auto" poster={ img.ThumbURL }>
|
||||
<source src={ img.URL } type={ videoMIME(img.Filename) }/>
|
||||
</video>
|
||||
<p class="video-unavailable" hidden>
|
||||
Playback is not supported in this browser.
|
||||
<a href={ img.URL } download={ img.Filename }>Download the video</a>
|
||||
</p>
|
||||
<div class="modal-video-stack">
|
||||
<video src={ img.URL } controls autoplay playsinline preload="auto"></video>
|
||||
</div>
|
||||
} else {
|
||||
<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 != "" {
|
||||
@@ -124,13 +127,3 @@ func modalAriaLabel(img gallery.Image) string {
|
||||
}
|
||||
return "Image viewer"
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -1,22 +1,7 @@
|
||||
package templates
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
import "time"
|
||||
|
||||
func currentYear() string {
|
||||
return time.Now().Format("2006")
|
||||
}
|
||||
|
||||
func videoMIME(filename string) string {
|
||||
switch strings.ToLower(filepath.Ext(filename)) {
|
||||
case ".webm":
|
||||
return "video/webm"
|
||||
case ".mov":
|
||||
return "video/quicktime"
|
||||
default:
|
||||
return "video/mp4"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -68,27 +68,63 @@ templ Layout(title string, preloadImage string, content templ.Component) {
|
||||
|
||||
document.addEventListener("DOMContentLoaded", function () {
|
||||
hydrateLazyThumbs(document);
|
||||
var modalRoot = document.getElementById("modal-root");
|
||||
if (!modalRoot) return;
|
||||
modalRoot.addEventListener("volumechange", function (event) {
|
||||
if (event.target.tagName !== "VIDEO") return;
|
||||
saveVideoVolume(event.target.volume);
|
||||
});
|
||||
});
|
||||
|
||||
function initModalVideos(root) {
|
||||
root.querySelectorAll(".modal-video video").forEach(function (video) {
|
||||
var wrap = video.closest(".modal-video");
|
||||
var notice = wrap && wrap.querySelector(".video-unavailable");
|
||||
video.addEventListener(
|
||||
"error",
|
||||
function () {
|
||||
video.classList.add("video-broken");
|
||||
if (notice) notice.hidden = false;
|
||||
},
|
||||
{ once: true },
|
||||
);
|
||||
video.load();
|
||||
});
|
||||
function closeModal() {
|
||||
var modal = document.getElementById("modal-root");
|
||||
if (modal) modal.innerHTML = "";
|
||||
}
|
||||
|
||||
var galleryVideoVolumeKey = "gallery-video-volume";
|
||||
|
||||
function getSavedVideoVolume() {
|
||||
try {
|
||||
var stored = localStorage.getItem(galleryVideoVolumeKey);
|
||||
if (stored === null) return null;
|
||||
var v = parseFloat(stored);
|
||||
if (!isFinite(v) || v < 0 || v > 1) return null;
|
||||
return v;
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function saveVideoVolume(volume) {
|
||||
try {
|
||||
localStorage.setItem(galleryVideoVolumeKey, String(volume));
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
function playModalVideo() {
|
||||
var video = document.querySelector("#modal-root video");
|
||||
if (!video) return;
|
||||
var saved = getSavedVideoVolume();
|
||||
if (saved !== null) video.volume = saved;
|
||||
var p = video.play();
|
||||
if (p && typeof p.catch === "function") {
|
||||
p.catch(function () {});
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener("keydown", function (event) {
|
||||
if (event.key !== "Escape") return;
|
||||
var modal = document.getElementById("modal-root");
|
||||
if (!modal || !modal.firstElementChild) return;
|
||||
event.preventDefault();
|
||||
closeModal();
|
||||
});
|
||||
|
||||
document.body.addEventListener("htmx:afterSwap", function (event) {
|
||||
hydrateLazyThumbs(event.target);
|
||||
initModalVideos(event.target);
|
||||
if (event.detail && event.detail.target && event.detail.target.id === "modal-root") {
|
||||
playModalVideo();
|
||||
}
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
|
||||
@@ -9,6 +9,8 @@ services:
|
||||
- ./app/images:/app/images
|
||||
env_file:
|
||||
- .env
|
||||
environment:
|
||||
IMAGES_DIR: /app/images
|
||||
#networks:
|
||||
# - caddy
|
||||
labels:
|
||||
@@ -25,6 +27,7 @@ services:
|
||||
- "8080:8080" # app direct (no auto-reload)
|
||||
volumes:
|
||||
- ./app:/app
|
||||
- ./upload:/upload
|
||||
- ${GOMODCACHE}:/go/pkg/mod
|
||||
- ${GOCACHE}:/root/.cache/go-build
|
||||
env_file:
|
||||
|
||||
0
upload/.gitkeep
Normal file
0
upload/.gitkeep
Normal file
Reference in New Issue
Block a user