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:
@@ -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>
|
||||
|
||||
@@ -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">×</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">‹</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">›</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
80
js/lightbox.js
Normal 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();
|
||||
},
|
||||
}));
|
||||
});
|
||||
Reference in New Issue
Block a user