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:
+130
-17
@@ -101,26 +101,139 @@
|
||||
<div class="max-w-5xl mx-auto">
|
||||
|
||||
{{- if $bundleImages -}}
|
||||
<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 $bundleImages -}}
|
||||
{{- /* Pre-compute lightbox items so Alpine gets a static JSON array */ -}}
|
||||
{{- $lbItems := slice -}}
|
||||
{{- range $bundleImages -}}
|
||||
{{- $stem := .Name | strings.TrimSuffix ".png" -}}
|
||||
{{- $video := $.Resources.GetMatch (printf "%s.mp4" $stem) -}}
|
||||
<figure class="break-inside-avoid group card-comic">
|
||||
<div class="card-media min-h-48">
|
||||
{{- if $video -}}
|
||||
<video class="w-full" src="{{ $video.RelPermalink }}" poster="{{ .RelPermalink }}" autoplay loop muted playsinline></video>
|
||||
{{- else -}}
|
||||
{{- partial "img.html" (dict "res" . "widths" (slice 800 1200) "sizes" "(max-width: 640px) 100vw, (max-width: 1024px) 50vw, 33vw" "class" "w-full") -}}
|
||||
{{- end -}}
|
||||
{{- $webp := .Resize "1200x webp" -}}
|
||||
{{- $entry := dict "img" $webp.RelPermalink "video" "" -}}
|
||||
{{- if $video -}}{{- $entry = dict "img" $webp.RelPermalink "video" $video.RelPermalink -}}{{- end -}}
|
||||
{{- $lbItems = $lbItems | append $entry -}}
|
||||
{{- 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>
|
||||
</figure>
|
||||
{{- end -}}
|
||||
</template>
|
||||
|
||||
</div>
|
||||
|
||||
{{- else -}}
|
||||
|
||||
Reference in New Issue
Block a user