feat: Lightbox image viewer on batch page with arrow key navigation

Clicking an image thumbnail opens a full-size overlay instead of
navigating away. Left/right arrows and on-screen buttons cycle
through images. Click backdrop or press Escape to close. Includes
filename and download link in the lightbox footer.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-04-07 16:14:34 -04:00
parent ff62bbf5a9
commit 92c931d7da
6 changed files with 188 additions and 8 deletions

View File

@ -4,7 +4,7 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Password Required</title>
<link rel="stylesheet" href="/static/style.css?v=4">
<link rel="stylesheet" href="/static/style.css?v=5">
</head>
<body>
<div class="container">

View File

@ -4,7 +4,7 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{.Count}} files — upload.jeffemmett.com</title>
<link rel="stylesheet" href="/static/style.css?v=4">
<link rel="stylesheet" href="/static/style.css?v=5">
</head>
<body>
<div class="container batch-container">
@ -19,10 +19,13 @@
<div class="batch-grid">
{{range .Files}}
<div class="batch-card">
<a href="/f/{{.ID}}" class="batch-card-preview">
{{if eq .ThumbType "image"}}
{{if eq .ThumbType "image"}}
<a href="{{.PreviewURL}}" class="batch-card-preview" data-lightbox data-filename="{{.Filename}}" data-dl="/f/{{.ID}}/dl">
<img src="{{.PreviewURL}}" alt="{{.Filename}}" loading="lazy">
{{else if eq .ThumbType "video"}}
</a>
{{else}}
<a href="/f/{{.ID}}" class="batch-card-preview">
{{if eq .ThumbType "video"}}
<video src="{{.PreviewURL}}" preload="metadata" muted playsinline></video>
{{else if eq .ThumbType "audio"}}
<div class="batch-card-icon">
@ -49,6 +52,7 @@
</div>
{{end}}
</a>
{{end}}
<div class="batch-card-footer">
<a href="/f/{{.ID}}" class="batch-card-name" title="{{.Filename}}">{{.Filename}}</a>
<span class="batch-card-size">{{formatSize .Size}}</span>
@ -61,5 +65,78 @@
<a href="/">upload.jeffemmett.com</a>
</footer>
</div>
<!-- Lightbox -->
<div id="lightbox" class="lightbox hidden">
<div class="lightbox-backdrop"></div>
<button class="lightbox-close" title="Close">&times;</button>
<button class="lightbox-prev" title="Previous">&#8249;</button>
<button class="lightbox-next" title="Next">&#8250;</button>
<div class="lightbox-content">
<img id="lightbox-img" src="" alt="">
</div>
<div class="lightbox-footer">
<span id="lightbox-filename"></span>
<a id="lightbox-dl" href="" class="lightbox-dl-btn">Download</a>
</div>
</div>
<script>
(() => {
const lightbox = document.getElementById('lightbox');
const lbImg = document.getElementById('lightbox-img');
const lbName = document.getElementById('lightbox-filename');
const lbDl = document.getElementById('lightbox-dl');
const items = [...document.querySelectorAll('[data-lightbox]')];
let current = -1;
if (items.length === 0) return;
function open(index) {
const el = items[index];
current = index;
lbImg.src = el.href;
lbName.textContent = el.dataset.filename;
lbDl.href = el.dataset.dl;
lightbox.classList.remove('hidden');
document.body.style.overflow = 'hidden';
}
function close() {
lightbox.classList.add('hidden');
document.body.style.overflow = '';
current = -1;
}
function prev() {
if (current <= 0) return;
open(current - 1);
}
function next() {
if (current >= items.length - 1) return;
open(current + 1);
}
items.forEach((el, i) => {
el.addEventListener('click', (e) => {
e.preventDefault();
open(i);
});
});
lightbox.querySelector('.lightbox-backdrop').addEventListener('click', close);
lightbox.querySelector('.lightbox-close').addEventListener('click', close);
lightbox.querySelector('.lightbox-prev').addEventListener('click', prev);
lightbox.querySelector('.lightbox-next').addEventListener('click', next);
document.addEventListener('keydown', (e) => {
if (current === -1) return;
if (e.key === 'Escape') close();
else if (e.key === 'ArrowLeft') prev();
else if (e.key === 'ArrowRight') next();
});
})();
</script>
</body>
</html>

View File

@ -4,7 +4,7 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{.Filename}} — upload.jeffemmett.com</title>
<link rel="stylesheet" href="/static/style.css?v=4">
<link rel="stylesheet" href="/static/style.css?v=5">
</head>
<body>
<div class="container">

View File

@ -4,7 +4,7 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>upload.jeffemmett.com</title>
<link rel="stylesheet" href="/static/style.css?v=4">
<link rel="stylesheet" href="/static/style.css?v=5">
</head>
<body>
<div class="container">

View File

@ -4,7 +4,7 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Password Required</title>
<link rel="stylesheet" href="/static/style.css?v=4">
<link rel="stylesheet" href="/static/style.css?v=5">
</head>
<body>
<div class="container">

View File

@ -452,6 +452,109 @@ footer a:hover { color: var(--accent); }
color: var(--text-dim);
}
/* Lightbox */
.lightbox {
position: fixed;
inset: 0;
z-index: 1000;
display: flex;
align-items: center;
justify-content: center;
}
.lightbox-backdrop {
position: absolute;
inset: 0;
background: rgba(0, 0, 0, 0.9);
}
.lightbox-content {
position: relative;
max-width: 90vw;
max-height: 80vh;
display: flex;
align-items: center;
justify-content: center;
}
.lightbox-content img {
max-width: 90vw;
max-height: 80vh;
object-fit: contain;
border-radius: 4px;
user-select: none;
}
.lightbox-close,
.lightbox-prev,
.lightbox-next {
position: absolute;
z-index: 1001;
background: none;
border: none;
color: #fff;
cursor: pointer;
opacity: 0.7;
transition: opacity 0.2s;
padding: 0;
}
.lightbox-close:hover,
.lightbox-prev:hover,
.lightbox-next:hover {
opacity: 1;
}
.lightbox-close {
top: 1rem;
right: 1.25rem;
font-size: 2.5rem;
line-height: 1;
}
.lightbox-prev,
.lightbox-next {
top: 50%;
transform: translateY(-50%);
font-size: 3rem;
line-height: 1;
}
.lightbox-prev {
left: 1rem;
}
.lightbox-next {
right: 1rem;
}
.lightbox-footer {
position: absolute;
bottom: 1rem;
left: 50%;
transform: translateX(-50%);
z-index: 1001;
display: flex;
align-items: center;
gap: 1rem;
background: rgba(0, 0, 0, 0.6);
padding: 0.5rem 1rem;
border-radius: 6px;
font-size: 0.8125rem;
color: #fff;
white-space: nowrap;
}
.lightbox-dl-btn {
color: var(--accent);
text-decoration: none;
font-size: 0.8125rem;
}
.lightbox-dl-btn:hover {
text-decoration: underline;
}
/* Download page */
.file-card {
margin-top: 0;