feat: add fullscreen lightbox for article images

Alpine.js component that lets visitors click any image inside
article content to view it fullscreen with keyboard navigation
(arrow keys, Escape to close) and prev/next buttons.
This commit is contained in:
Ricardo
2026-02-27 10:14:35 +01:00
parent dbd2f72019
commit e2c40468b6
3 changed files with 99 additions and 1 deletions

View File

@@ -72,6 +72,7 @@
{# Alpine.js components — MUST load before Alpine core (Alpine.data() registration via alpine:init) #}
<script src="/js/comments.js?v={{ '/js/comments.js' | hash }}" defer></script>
<script src="/js/fediverse-interact.js?v={{ '/js/fediverse-interact.js' | hash }}" defer></script>
<script src="/js/lightbox.js?v={{ '/js/lightbox.js' | hash }}" defer></script>
<script defer src="/js/vendor/alpine-collapse.min.js?v={{ '/js/vendor/alpine-collapse.min.js' | hash }}"></script>
<script defer src="/js/vendor/alpine.min.js?v={{ '/js/vendor/alpine.min.js' | hash }}"></script>
<style>[x-cloak] { display: none !important; }</style>

View File

@@ -2,7 +2,7 @@
layout: layouts/base.njk
withBlogSidebar: true
---
<article class="h-entry post">
<article class="h-entry post" x-data="lightbox" @keydown.window="onKeydown($event)">
{% if title %}
<h1 class="p-name text-2xl sm:text-3xl font-bold text-surface-900 dark:text-surface-100 mb-3 sm:mb-4">{{ title }}</h1>
{% endif %}
@@ -245,6 +245,23 @@ withBlogSidebar: true
"image": ["{% if postImage.startsWith('http') %}{{ postImage }}{% elif '/' in postImage and postImage[0] == '/' %}{{ site.url }}{{ postImage }}{% else %}{{ site.url }}/{{ postImage }}{% endif %}"]{% endif %}
}
</script>
{# Lightbox overlay for article images #}
<template x-teleport="body">
<div x-show="open" x-transition.opacity.duration.200ms
class="fixed inset-0 z-[9999] flex items-center justify-center bg-black/90 backdrop-blur-sm"
@click.self="close()">
<button @click="close()" class="absolute top-4 right-4 text-white/70 hover:text-white text-3xl leading-none p-2 z-10" aria-label="Close">&times;</button>
<template x-if="images.length > 1">
<button @click="prev()" class="absolute left-4 top-1/2 -translate-y-1/2 text-white/70 hover:text-white text-4xl leading-none p-2 z-10" aria-label="Previous">&lsaquo;</button>
</template>
<template x-if="images.length > 1">
<button @click="next()" class="absolute right-4 top-1/2 -translate-y-1/2 text-white/70 hover:text-white text-4xl leading-none p-2 z-10" aria-label="Next">&rsaquo;</button>
</template>
<img :src="src" :alt="alt" class="max-h-[90vh] max-w-[90vw] object-contain" @click.stop>
<div x-show="alt" x-text="alt" class="absolute bottom-4 left-1/2 -translate-x-1/2 text-white/80 text-sm max-w-2xl text-center px-4 py-2 bg-black/50 rounded-lg"></div>
</div>
</template>
</article>
{# Comments section #}

80
js/lightbox.js Normal file
View File

@@ -0,0 +1,80 @@
/**
* Alpine.js lightbox component for article images.
* Registers via alpine:init so it's available before Alpine starts.
* Click any image inside .e-content to view fullscreen.
* Navigate with arrow keys, close with Escape or click outside.
*/
document.addEventListener("alpine:init", () => {
Alpine.data("lightbox", () => ({
open: false,
src: "",
alt: "",
images: [],
currentIndex: 0,
init() {
const container = this.$root;
const imgs = container.querySelectorAll(
".e-content img:not(.u-photo)"
);
this.images = Array.from(imgs);
this.images.forEach((img, i) => {
img.style.cursor = "zoom-in";
img.addEventListener("click", (e) => {
e.preventDefault();
this.show(i);
});
});
},
show(index) {
this.currentIndex = index;
const img = this.images[index];
// Use the largest source available
const picture = img.closest("picture");
if (picture) {
const source = picture.querySelector("source");
if (source) {
// Extract the URL from srcset (strip width descriptor)
const srcset = source.getAttribute("srcset") || "";
this.src = srcset.split(/\s+/)[0] || img.src;
} else {
this.src = img.src;
}
} else {
this.src = img.src;
}
this.alt = img.alt || "";
this.open = true;
document.body.style.overflow = "hidden";
},
close() {
this.open = false;
this.src = "";
document.body.style.overflow = "";
},
next() {
if (this.images.length > 1) {
this.show((this.currentIndex + 1) % this.images.length);
}
},
prev() {
if (this.images.length > 1) {
this.show(
(this.currentIndex - 1 + this.images.length) % this.images.length
);
}
},
onKeydown(e) {
if (!this.open) return;
if (e.key === "Escape") this.close();
if (e.key === "ArrowRight") this.next();
if (e.key === "ArrowLeft") this.prev();
},
}));
});