Add full-image zoom overlay to single post pages

A "View full image" button appears in the single-post hero alongside
the return link. Clicking the lightbox main image also opens the same
overlay from any page. The overlay is pure image on a near-black
backdrop; click backdrop, close button, or Escape to dismiss.

Escape is layered: closes zoom first, then lightbox if zoom is not open.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-07 14:46:24 +02:00
parent 81e7417a1d
commit 3b359d2b37
4 changed files with 95 additions and 3 deletions
+10
View File
@@ -198,6 +198,16 @@ input[type="search"]::-webkit-search-cancel-button {
opacity: 1;
}
/* ── full-image zoom overlay ── */
.img-zoom {
position: fixed; inset: 0; z-index: 400;
background: rgba(8,6,4,.97);
display: none; place-items: center;
cursor: zoom-out;
}
.img-zoom[data-open="true"] { display: grid; }
.lb-img { cursor: zoom-in; }
/* ── scrollbar ── */
::-webkit-scrollbar { width: 10px; height: 10px; }
::-webkit-scrollbar-thumb { background: var(--rule); border-radius: 0; }
+24 -2
View File
@@ -1,15 +1,37 @@
{{ define "main" }}
{{- $issue := index (.Params.issues | default (slice "01")) 0 }}
{{- $issueURL := printf "/issues/%s/" $issue }}
{{- $img := .Resources.GetMatch "*.png" }}
{{- $cardSrc := "" }}
{{- if $img }}{{- $c := $img.Resize "900x1350 webp" }}{{- $cardSrc = $c.RelPermalink }}{{- end }}
<section id="hero" class="px-[var(--pad)] pt-[clamp(36px,5vw,64px)] pb-[clamp(28px,3.5vw,48px)] border-b border-[var(--rule)]">
<div class="font-sans font-medium text-[10px] leading-none tracking-[.22em] uppercase text-ink-soft mb-4">
№ {{ $issue }} · {{ index (.Params.categories | default (slice "Plate")) 0 }}
</div>
<h1 class="font-display font-normal text-[clamp(48px,7vw,96px)] leading-[0.94] m-0 mb-4"><em>{{ .Title }}</em></h1>
<p class="font-serif italic text-[clamp(13px,1vw,16px)] leading-[1.65] text-ink-2 max-w-[55ch] m-0">
<p class="font-serif italic text-[clamp(13px,1vw,16px)] leading-[1.65] text-ink-2 max-w-[55ch] m-0 mb-5">
{{ .Params.description }}
<a href="{{ $issueURL }}" class="border-b border-current ml-1">Return to issue →</a>
</p>
<div class="flex items-center gap-5 flex-wrap">
<a href="{{ $issueURL }}"
class="font-sans font-medium text-[11px] leading-none tracking-[.16em] uppercase text-ink-soft
border-b border-[var(--rule)] pb-px transition-colors duration-200 hover:text-ink hover:border-ink">
← Return to issue
</a>
{{- if $cardSrc }}
<button id="viewFull" data-src="{{ $cardSrc }}"
class="inline-flex items-center gap-[7px]
font-sans font-medium text-[11px] leading-none tracking-[.16em] uppercase text-ink-soft
border-b border-[var(--rule)] pb-px transition-colors duration-200 hover:text-ink hover:border-ink">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor"
stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M15 3h6v6M9 21H3v-6M21 3l-7 7M3 21l7-7"/>
</svg>
View full image
</button>
{{- end }}
</div>
</section>
<section id="grid" data-density="default"
+17
View File
@@ -59,6 +59,23 @@
{{- $posts | jsonify | safeJS }}
</script>
{{/* Pure image zoom — opened by hero button or clicking the lightbox main image */}}
<div id="imgZoom" class="img-zoom" role="dialog" aria-modal="true" aria-label="Full size image">
<button id="imgZoomClose"
class="absolute top-4 right-4 z-10
w-10 h-10 grid place-items-center
border border-white/20 rounded-full
text-white/60 hover:text-white hover:bg-white/10
transition-colors duration-200"
aria-label="Close full image">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor"
stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M18 6 6 18"/><path d="m6 6 12 12"/>
</svg>
</button>
<img id="imgZoomImg" src="" alt="" class="max-w-[96vw] max-h-[96vh] object-contain select-none" />
</div>
<script src="/js/app.js" defer></script>
</body>
</html>
+44 -1
View File
@@ -211,6 +211,7 @@
let lbIdx = -1; // index into lbList
let lbBuilt = false;
let lbReferrer = null; // URL to return to on close
let imgZoomOpen = false;
function lbOpen(slug, scopedList) {
lbList = scopedList || POSTS;
@@ -348,7 +349,7 @@
});
document.addEventListener('keydown', e => {
if (lb.dataset.open !== 'true') return;
if (e.key === 'Escape') { lbClose(); }
if (e.key === 'Escape') { if (imgZoomOpen) closeImgZoom(); else lbClose(); }
if (e.key === 'ArrowLeft') { e.preventDefault(); goToSlide(lbIdx - 1); }
if (e.key === 'ArrowRight') { e.preventDefault(); goToSlide(lbIdx + 1); }
});
@@ -469,4 +470,46 @@
lbOpen(window.__ROUX_OPEN_SLUG, scoped.length ? scoped : POSTS);
}
// ── Full-image zoom overlay
const imgZoom = document.getElementById('imgZoom');
const imgZoomImg = document.getElementById('imgZoomImg');
function openImgZoom(src) {
if (!imgZoom || !imgZoomImg || !src) return;
imgZoomImg.src = src;
imgZoom.dataset.open = 'true';
imgZoomOpen = true;
document.body.style.overflow = 'hidden';
}
function closeImgZoom() {
if (!imgZoom) return;
imgZoom.dataset.open = 'false';
imgZoomOpen = false;
if (!lb || lb.dataset.open !== 'true') document.body.style.overflow = '';
}
// Hero "View full image" button
const viewFullBtn = document.getElementById('viewFull');
if (viewFullBtn) {
viewFullBtn.addEventListener('click', () => openImgZoom(viewFullBtn.dataset.src));
}
// Click on the lightbox main image → zoom
document.addEventListener('click', e => {
const img = e.target.closest('.lb-img');
if (img) openImgZoom(img.src);
});
// Close on backdrop click or close button
if (imgZoom) {
imgZoom.addEventListener('click', e => { if (e.target === imgZoom) closeImgZoom(); });
document.getElementById('imgZoomClose')?.addEventListener('click', closeImgZoom);
}
// Escape when lightbox is NOT open (standalone zoom)
document.addEventListener('keydown', e => {
if (e.key === 'Escape' && imgZoomOpen && (!lb || lb.dataset.open !== 'true')) closeImgZoom();
});
})();