feat: add media viewer with lightbox for file browser

Add interactive media viewer (lightbox) for images and videos in the file browser template:

- Images and videos now open in a modal overlay instead of navigating to a new page
- Navigation controls: left/right arrow buttons and keyboard shortcuts (←/→)
- Keyboard support: ESC to close, arrow keys to navigate
- Auto-play support for videos with playback controls
- Display file information and current position (e.g., "3 / 10")
- Responsive design for both desktop and mobile devices

Supported formats:
- Images: jpg, jpeg, png, gif, webp, tiff, bmp, heif, heic, svg, avif
- Videos: mp4, mov, m4v, mpeg, mpg, avi, ogg, webm, mkv, vob, gifv, 3gp

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
wang1zhen 2025-10-02 17:09:47 +09:00 committed by wang1zhen
parent afbdcec08b
commit 5c4923caea

View file

@ -1,6 +1,6 @@
{{ $nonce := uuidv4 -}}
{{ $nonceAttribute := print "nonce=" (quote $nonce) -}}
{{ $csp := printf "default-src 'none'; img-src 'self'; object-src 'none'; base-uri 'none'; script-src 'nonce-%s'; style-src 'nonce-%s'; frame-ancestors 'self'; form-action 'self';" $nonce $nonce -}}
{{ $csp := printf "default-src 'none'; img-src 'self'; media-src 'self'; object-src 'none'; base-uri 'none'; script-src 'nonce-%s'; style-src 'nonce-%s'; frame-ancestors 'self'; form-action 'self';" $nonce $nonce -}}
{{/* To disable the Content-Security-Policy, set this to false */}}{{ $enableCsp := true -}}
{{ if $enableCsp -}}
{{- .RespHeader.Set "Content-Security-Policy" $csp -}}
@ -777,6 +777,134 @@ footer {
}
}
/* Media Viewer Modal Styles */
.media-modal {
display: none;
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.95);
z-index: 10000;
justify-content: center;
align-items: center;
}
.media-modal.active {
display: flex;
}
.modal-content {
position: relative;
max-width: 95%;
max-height: 95%;
display: flex;
flex-direction: column;
align-items: center;
}
.modal-media-container {
max-width: 100%;
max-height: calc(95vh - 60px);
display: flex;
justify-content: center;
align-items: center;
}
.modal-media-container img,
.modal-media-container video {
max-width: 100%;
max-height: calc(95vh - 60px);
object-fit: contain;
}
.modal-close {
position: absolute;
top: 20px;
right: 30px;
color: #fff;
font-size: 40px;
font-weight: bold;
background: none;
border: none;
cursor: pointer;
z-index: 10001;
padding: 0;
width: 50px;
height: 50px;
line-height: 50px;
transition: opacity 0.2s;
}
.modal-close:hover,
.modal-close:focus {
opacity: 0.7;
}
.modal-nav {
position: absolute;
top: 50%;
transform: translateY(-50%);
color: #fff;
font-size: 60px;
font-weight: bold;
background: none;
border: none;
cursor: pointer;
padding: 20px;
z-index: 10001;
transition: opacity 0.2s;
user-select: none;
}
.modal-nav:hover,
.modal-nav:focus {
opacity: 0.7;
}
.modal-prev {
left: 20px;
}
.modal-next {
right: 20px;
}
.modal-info {
color: #fff;
text-align: center;
margin-top: 20px;
display: flex;
gap: 20px;
justify-content: center;
flex-wrap: wrap;
}
.modal-filename {
font-weight: bold;
}
.modal-counter {
opacity: 0.8;
}
@media (max-width: 768px) {
.modal-nav {
font-size: 40px;
padding: 10px;
}
.modal-close {
font-size: 30px;
width: 40px;
height: 40px;
line-height: 40px;
top: 10px;
right: 10px;
}
}
</style>
{{- if eq .Layout "grid"}}
<style {{ $nonceAttribute }}>.wrapper { max-width: none; } main { margin-top: 1px; }</style>
@ -1175,6 +1303,22 @@ footer {
</a>
</footer>
<!-- Media Viewer Modal -->
<div id="mediaModal" class="media-modal">
<button class="modal-close" aria-label="Close">&times;</button>
<button class="modal-nav modal-prev" aria-label="Previous">&lsaquo;</button>
<button class="modal-nav modal-next" aria-label="Next">&rsaquo;</button>
<div class="modal-content">
<div class="modal-media-container">
<!-- Media content will be injected here -->
</div>
<div class="modal-info">
<span class="modal-filename"></span>
<span class="modal-counter"></span>
</div>
</div>
</div>
<script {{ $nonceAttribute }}>
// @license magnet:?xt=urn:btih:8e4f440f4c65981c5bf93c76d35135ba5064d8b7&dn=apache-2.0.txt Apache-2.0
const filterEl = document.getElementById('filter');
@ -1266,6 +1410,165 @@ footer {
}
var timeList = Array.prototype.slice.call(document.getElementsByTagName("time"));
timeList.forEach(localizeDatetime);
// Media Viewer Functionality
const imageExtensions = ['.jpg', '.jpeg', '.png', '.gif', '.webp', '.tiff', '.bmp', '.heif', '.heic', '.svg', '.avif'];
const videoExtensions = ['.mp4', '.mov', '.m4v', '.mpeg', '.mpg', '.avi', '.ogg', '.webm', '.mkv', '.vob', '.gifv', '.3gp'];
let mediaItems = [];
let currentMediaIndex = 0;
function isMediaFile(filename) {
const lower = filename.toLowerCase();
return imageExtensions.some(ext => lower.endsWith(ext)) ||
videoExtensions.some(ext => lower.endsWith(ext));
}
function isImageFile(filename) {
const lower = filename.toLowerCase();
return imageExtensions.some(ext => lower.endsWith(ext));
}
function isVideoFile(filename) {
const lower = filename.toLowerCase();
return videoExtensions.some(ext => lower.endsWith(ext));
}
function collectMediaItems() {
mediaItems = [];
// Collect from both grid and list layouts
const allLinks = document.querySelectorAll('.entry a, tr.file a');
allLinks.forEach((link, index) => {
const href = link.getAttribute('href');
const nameEl = link.querySelector('.name');
const name = nameEl ? nameEl.textContent.trim() : href;
if (isMediaFile(name)) {
mediaItems.push({
url: href,
name: name,
element: link,
isImage: isImageFile(name),
isVideo: isVideoFile(name)
});
}
});
}
function openMediaViewer(index) {
if (index < 0 || index >= mediaItems.length) return;
currentMediaIndex = index;
const item = mediaItems[index];
const modal = document.getElementById('mediaModal');
const container = modal.querySelector('.modal-media-container');
const filenameEl = modal.querySelector('.modal-filename');
const counterEl = modal.querySelector('.modal-counter');
// Clear previous content
container.innerHTML = '';
// Create appropriate media element
if (item.isImage) {
const img = document.createElement('img');
img.src = item.url;
img.alt = item.name;
container.appendChild(img);
} else if (item.isVideo) {
const video = document.createElement('video');
video.src = item.url;
video.controls = true;
video.autoplay = true;
container.appendChild(video);
}
// Update info
filenameEl.textContent = item.name;
counterEl.textContent = `${index + 1} / ${mediaItems.length}`;
// Show modal
modal.classList.add('active');
document.body.style.overflow = 'hidden';
}
function closeMediaViewer() {
const modal = document.getElementById('mediaModal');
const container = modal.querySelector('.modal-media-container');
// Stop any playing video
const video = container.querySelector('video');
if (video) {
video.pause();
video.src = '';
}
modal.classList.remove('active');
document.body.style.overflow = '';
}
function navigateMedia(direction) {
let newIndex = currentMediaIndex + direction;
// Loop around
if (newIndex < 0) {
newIndex = mediaItems.length - 1;
} else if (newIndex >= mediaItems.length) {
newIndex = 0;
}
openMediaViewer(newIndex);
}
function initMediaViewer() {
collectMediaItems();
if (mediaItems.length === 0) return;
// Add click handlers to media links
mediaItems.forEach((item, index) => {
item.element.addEventListener('click', function(e) {
e.preventDefault();
openMediaViewer(index);
});
});
// Modal controls
const modal = document.getElementById('mediaModal');
const closeBtn = modal.querySelector('.modal-close');
const prevBtn = modal.querySelector('.modal-prev');
const nextBtn = modal.querySelector('.modal-next');
closeBtn.addEventListener('click', closeMediaViewer);
prevBtn.addEventListener('click', () => navigateMedia(-1));
nextBtn.addEventListener('click', () => navigateMedia(1));
// Click on background to close
modal.addEventListener('click', function(e) {
if (e.target === modal) {
closeMediaViewer();
}
});
// Keyboard navigation
document.addEventListener('keydown', function(e) {
if (!modal.classList.contains('active')) return;
switch(e.key) {
case 'Escape':
closeMediaViewer();
break;
case 'ArrowLeft':
navigateMedia(-1);
break;
case 'ArrowRight':
navigateMedia(1);
break;
}
});
}
// Initialize media viewer after page load
window.addEventListener('load', initMediaViewer);
// @license-end
</script>
</body>