feat: gallery lightbox with Alpine.js

Hugo pre-computes a JSON array of {img, video} entries per gallery item
(WebP 1200w src + optional mp4 src). Alpine reads the array and manages
lightbox state (open/idx/prev/next/close).

Features:
- Click any card → opens fullscreen dark overlay (bg-void/95 + backdrop-blur)
- Videos: controls, autoplay, x-effect reloads src on navigate
- Images: full-res WebP centred with object-contain
- Keyboard: Escape closes, ←/→ navigate
- Dot indicators: active dot expands (heat colour)
- Prev/next arrow buttons (hidden when only 1 item)
- Grid cards: cursor-zoom-in, pointer-events-none on media so click
  always hits the <figure>
- Teleported to <body> via x-teleport to avoid stacking context issues
- Gradient-line accent top and bottom of overlay

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-11 18:24:33 +02:00
parent 72d24f5f4d
commit 1998820dec
+130 -17
View File
@@ -101,26 +101,139 @@
<div class="max-w-5xl mx-auto"> <div class="max-w-5xl mx-auto">
{{- if $bundleImages -}} {{- if $bundleImages -}}
<div class="flex items-center gap-5 mb-10"> {{- /* Pre-compute lightbox items so Alpine gets a static JSON array */ -}}
<span class="badge badge-gradient">Gallery</span> {{- $lbItems := slice -}}
<span class="label text-fog">{{ len $bundleImages }} {{ if eq (len $bundleImages) 1 }}work{{ else }}works{{ end }}</span> {{- range $bundleImages -}}
<div class="flex-1 gradient-line ml-2"></div>
</div>
<!-- Numbered png+mp4 pairs from page bundle -->
<div class="columns-1 sm:columns-2 lg:columns-3 gap-5 space-y-5">
{{- range $bundleImages -}}
{{- $stem := .Name | strings.TrimSuffix ".png" -}} {{- $stem := .Name | strings.TrimSuffix ".png" -}}
{{- $video := $.Resources.GetMatch (printf "%s.mp4" $stem) -}} {{- $video := $.Resources.GetMatch (printf "%s.mp4" $stem) -}}
<figure class="break-inside-avoid group card-comic"> {{- $webp := .Resize "1200x webp" -}}
<div class="card-media min-h-48"> {{- $entry := dict "img" $webp.RelPermalink "video" "" -}}
{{- if $video -}} {{- if $video -}}{{- $entry = dict "img" $webp.RelPermalink "video" $video.RelPermalink -}}{{- end -}}
<video class="w-full" src="{{ $video.RelPermalink }}" poster="{{ .RelPermalink }}" autoplay loop muted playsinline></video> {{- $lbItems = $lbItems | append $entry -}}
{{- else -}} {{- end -}}
{{- partial "img.html" (dict "res" . "widths" (slice 800 1200) "sizes" "(max-width: 640px) 100vw, (max-width: 1024px) 50vw, 33vw" "class" "w-full") -}}
{{- end -}} <div
x-data="{
open: false,
idx: 0,
items: {{ $lbItems | jsonify }},
show(i) { this.idx = i; this.open = true },
close() { this.open = false },
prev() { this.idx = (this.idx - 1 + this.items.length) % this.items.length },
next() { this.idx = (this.idx + 1) % this.items.length }
}"
@keydown.escape.window="open && close()"
@keydown.arrow-left.window="open && prev()"
@keydown.arrow-right.window="open && next()"
>
<div class="flex items-center gap-5 mb-10">
<span class="badge badge-gradient">Gallery</span>
<span class="label text-fog">{{ len $bundleImages }} {{ if eq (len $bundleImages) 1 }}work{{ else }}works{{ end }}</span>
<div class="flex-1 gradient-line ml-2"></div>
</div>
<!-- Numbered png+mp4 pairs from page bundle -->
<div class="columns-1 sm:columns-2 lg:columns-3 gap-5 space-y-5">
{{- range $i, $img := $bundleImages -}}
{{- $stem := $img.Name | strings.TrimSuffix ".png" -}}
{{- $video := $.Resources.GetMatch (printf "%s.mp4" $stem) -}}
<figure class="break-inside-avoid group card-comic cursor-zoom-in" @click="show({{ $i }})">
<div class="card-media min-h-48">
{{- if $video -}}
<video class="w-full pointer-events-none" src="{{ $video.RelPermalink }}" poster="{{ $img.RelPermalink }}" autoplay loop muted playsinline></video>
{{- else -}}
{{- partial "img.html" (dict "res" $img "widths" (slice 800 1200) "sizes" "(max-width: 640px) 100vw, (max-width: 1024px) 50vw, 33vw" "class" "w-full pointer-events-none") -}}
{{- end -}}
</div>
</figure>
{{- end -}}
</div>
<!-- Lightbox — teleported to <body> to escape stacking context -->
<template x-teleport="body">
<div
x-show="open"
x-cloak
x-transition:enter="transition duration-200 ease-out"
x-transition:enter-start="opacity-0"
x-transition:enter-end="opacity-100"
x-transition:leave="transition duration-150 ease-in"
x-transition:leave-start="opacity-100"
x-transition:leave-end="opacity-0"
class="fixed inset-0 z-50 flex items-center justify-center bg-void/95 backdrop-blur-sm"
@click.self="close()"
role="dialog"
aria-modal="true"
aria-label="Media lightbox"
>
<!-- Gradient accent line top -->
<div class="absolute top-0 inset-x-0 h-px gradient-line"></div>
<!-- Counter -->
<div class="absolute top-5 left-1/2 -translate-x-1/2 label text-fog tabular-nums"
x-text="`${String(idx + 1).padStart(2,'0')} / ${String(items.length).padStart(2,'0')}`"></div>
<!-- Close -->
<button
@click="close()"
class="absolute top-3 right-4 w-10 h-10 flex items-center justify-center label text-fog hover:text-chalk transition-colors"
aria-label="Close"
></button>
<!-- Prev -->
<button
@click="prev()"
x-show="items.length > 1"
class="absolute left-3 top-1/2 -translate-y-1/2 w-10 h-10 md:w-12 md:h-12 flex items-center justify-center text-fog hover:text-heat transition-colors text-xl"
aria-label="Previous"
>{{ partial "icon.html" "arrow-left" }}</button>
<!-- Media -->
<div class="w-full h-full flex items-center justify-center px-14 md:px-20 py-14">
<!-- Video -->
<video
x-show="items[idx] && items[idx].video"
:src="items[idx] && items[idx].video ? items[idx].video : ''"
:poster="items[idx] ? items[idx].img : ''"
x-effect="if (open && items[idx] && items[idx].video) { $el.load(); $el.play() }"
class="max-w-full max-h-full object-contain"
controls loop muted playsinline
></video>
<!-- Image -->
<img
x-show="items[idx] && !items[idx].video"
:src="items[idx] && !items[idx].video ? items[idx].img : ''"
alt=""
class="max-w-full max-h-full object-contain"
>
</div>
<!-- Next -->
<button
@click="next()"
x-show="items.length > 1"
class="absolute right-3 top-1/2 -translate-y-1/2 w-10 h-10 md:w-12 md:h-12 flex items-center justify-center text-fog hover:text-heat transition-colors text-xl"
aria-label="Next"
>{{ partial "icon.html" "arrow-right" }}</button>
<!-- Dot indicators -->
<div x-show="items.length > 1" class="absolute bottom-6 left-1/2 -translate-x-1/2 flex items-center gap-2">
<template x-for="(item, i) in items" :key="i">
<button
@click="idx = i"
class="h-1.5 rounded-full transition-all duration-300"
:class="i === idx ? 'w-5 bg-heat' : 'w-1.5 bg-fog hover:bg-chalk'"
:aria-label="`Go to item ${i + 1}`"
></button>
</template>
</div>
<!-- Gradient accent line bottom -->
<div class="absolute bottom-0 inset-x-0 h-px gradient-line"></div>
</div> </div>
</figure> </template>
{{- end -}}
</div> </div>
{{- else -}} {{- else -}}