express-file-index
Version:
A simple file index middleware for Express.
589 lines (570 loc) • 24.3 kB
JavaScript
const closePreview = () => {
const elPreview = document.querySelector('#preview');
elPreview.classList.remove('visible');
setTimeout(() => {
elPreview.style.display = 'none';
const elPreviewContent = document.querySelector('#previewContent');
elPreviewContent.innerHTML = '';
}, 100);
// Remove query string
const url = new URL(window.location.href);
url.searchParams.delete('preview');
history.pushState({}, '', url.toString());
}
const openPreview = () => {
const elPreview = document.querySelector('#preview');
elPreview.style.display = '';
setTimeout(() => {
elPreview.classList.add('visible');
}, 10);
}
const previewFile = async data => {
// Get elements
const elPreviewName = document.querySelector('#previewFileName');
const elPreviewFileSize = document.querySelector('#previewFileSize');
const elPreviewFileModified = document.querySelector('#previewFileModified');
const elPreviewContent = document.querySelector('#previewContent');
const elPreviewDownload = document.querySelector('#previewDownload');
// Update elements
elPreviewName.innerText = data.name;
elPreviewName.title = data.name;
elPreviewFileSize.innerText = formatBytes(data.size);
elPreviewFileSize.title = data.size + ' bytes';
elPreviewFileModified.innerText = dayjs(data.modified).format(document.body.dataset.fileTimeFormat);
elPreviewFileModified.title = dayjs(data.modified).format('YYYY-MM-DD HH:mm:ss');
elPreviewDownload.href = data.pathTrue;
// Define all web browser supported file types
const maxTextSize = 1024 * 1024 * 1; // 1 MiB
const ext = data.name.split('.').pop().toLowerCase();
const types = {
image: [ 'jpg', 'jpeg', 'png', 'gif', 'webp', 'svg' ],
video: [ 'mp4', 'webm' ],
audio: [ 'mp3', 'wav', 'ogg', 'aac' ],
md: [ 'md', 'markdown' ],
html: [ 'html', 'htm' ]
}
// Show preview according to the file type
if (types.image.includes(ext)) {
// Show image preview
elPreviewContent.innerHTML = /*html*/`
<div class="card image">
<div class="body">
<img src="${data.pathTrue}" alt="${data.name}" />
</div>
</div>
`;
} else if (types.video.includes(ext)) {
// Show video preview
elPreviewContent.innerHTML = /*html*/`
<div class="card video">
<div class="body">
<video autoplay src="${data.pathTrue}"></video>
<div class="overlay visible">
<div class="controls">
<button class="btn secondary square playPause">
<span class="icon">play_arrow</span>
</button>
<div class="progress">
<div class="current">0:00</div>
<input type="range" min="0" max="100" value="0" step="0.001" style="--value: 0;" />
<div class="duration">0:00</div>
</div>
<button class="btn secondary square fullscreen">
<span class="icon">fullscreen</span>
</button>
<button class="btn secondary square menu">
<span class="icon">more_vert</span>
</button>
</div>
</div>
</div>
</div>
`;
const video = elPreviewContent.querySelector('video');
const btnPlayPause = elPreviewContent.querySelector('.playPause');
const btnFullscreen = elPreviewContent.querySelector('.fullscreen');
const btnMenu = elPreviewContent.querySelector('.menu');
const elProgress = elPreviewContent.querySelector('.progress input[type="range"]');
const elTsCurrent = elPreviewContent.querySelector('.progress .current');
const elTsDuration = elPreviewContent.querySelector('.progress .duration');
const overlay = elPreviewContent.querySelector('.overlay');
let controlsTimeout;
const showControls = () => {
overlay.classList.add('visible');
clearTimeout(controlsTimeout);
controlsTimeout = setTimeout(() => {
hideControls();
}, 3000);
};
const hideControls = () => {
if (!video.paused) {
overlay.classList.remove('visible');
} else {
showControls();
}
};
overlay.addEventListener('mousemove', showControls);
overlay.addEventListener('mouseleave', hideControls);
document.addEventListener('keydown', (e) => {
if (document.activeElement.tagName === 'INPUT') return; // Ignore if typing in input
switch (e.key) {
case ' ':
e.preventDefault();
if (video.paused) {
video.play();
} else {
video.pause();
}
break;
case 'ArrowRight':
video.currentTime = Math.min(video.duration, video.currentTime + 10);
break;
case 'ArrowLeft':
video.currentTime = Math.max(0, video.currentTime - 10);
break;
}
});
btnMenu.addEventListener('click', (e) => {
e.preventDefault();
const items = [
{
type: 'item',
label: '0.5x',
onClick: () => video.playbackRate = 0.5
},
{
type: 'item',
label: '1x (Normal)',
onClick: () => video.playbackRate = 1
},
{
type: 'item',
label: '1.5x',
onClick: () => video.playbackRate = 1.5
},
{
type: 'item',
label: '2x',
onClick: () => video.playbackRate = 2
},
];
showContextMenu({ items });
});
btnPlayPause.addEventListener('click', () => {
if (video.paused) {
video.play();
} else {
video.pause();
}
});
video.addEventListener('play', () => {
btnPlayPause.querySelector('.icon').innerText = 'pause';
});
video.addEventListener('pause', () => {
btnPlayPause.querySelector('.icon').innerText = 'play_arrow';
});
video.addEventListener('timeupdate', () => {
const percent = (video.currentTime / video.duration) * 100;
const tsCurrent = formatSecondsToTimestamp(video.currentTime);
const tsDuration = formatSecondsToTimestamp(video.duration);
elProgress.value = percent;
elProgress.style.setProperty('--value', percent);
elTsCurrent.innerText = tsCurrent;
elTsDuration.innerText = tsDuration;
});
elProgress.addEventListener('input', () => {
const percent = elProgress.value;
elProgress.style.setProperty('--value', percent);
video.currentTime = video.duration * (percent / 100);
});
btnFullscreen.addEventListener('click', () => {
const container = elPreviewContent.querySelector('.card.video .body');
if (container.requestFullscreen) {
container.requestFullscreen();
} else if (container.webkitRequestFullscreen) {
container.webkitRequestFullscreen();
} else if (container.msRequestFullscreen) {
container.msRequestFullscreen();
}
});
overlay.addEventListener('click', (e) => {
if (e.target === overlay) {
if (video.paused) {
video.play();
} else {
video.pause();
}
}
});
} else if (types.audio.includes(ext)) {
// Show audio preview
elPreviewContent.innerHTML = /*html*/`
<div class="card audio">
<div class="body">
<audio autoplay src="${data.pathTrue}" style="display: none"></audio>
<div class="times">
<div class="current">0:00</div>
<div class="grow"></div>
<div class="duration">0:00</div>
</div>
<div class="progress">
<input type="range" min="0" max="100" value="0" step="0.001" style="--value: 0;" />
</div>
<div class="controls">
<button class="btn secondary square backward">
<span class="icon">replay_10</span>
</button>
<button class="btn square playPause">
<span class="icon">play_arrow</span>
</button>
<button class="btn secondary square forward">
<span class="icon">forward_10</span>
</button>
</div>
</div>
</div>
`;
const audio = elPreviewContent.querySelector('audio');
const btnPlayPause = elPreviewContent.querySelector('.playPause');
const btnBackward = elPreviewContent.querySelector('.backward');
const btnForward = elPreviewContent.querySelector('.forward');
const elProgress = elPreviewContent.querySelector('.progress input[type="range"]');
const elTsCurrent = elPreviewContent.querySelector('.current');
const elTsDuration = elPreviewContent.querySelector('.duration');
btnPlayPause.addEventListener('click', () => {
if (audio.paused) {
audio.play();
} else {
audio.pause();
}
});
btnBackward.addEventListener('click', () => {
audio.currentTime = Math.max(0, audio.currentTime - 10);
});
btnForward.addEventListener('click', () => {
audio.currentTime = Math.min(audio.duration, audio.currentTime + 10);
});
audio.addEventListener('play', () => {
btnPlayPause.querySelector('.icon').innerText = 'pause';
});
audio.addEventListener('pause', () => {
btnPlayPause.querySelector('.icon').innerText = 'play_arrow';
});
audio.addEventListener('ended', () => {
btnPlayPause.querySelector('.icon').innerText = 'play_arrow';
});
audio.addEventListener('timeupdate', () => {
const percent = (audio.currentTime / audio.duration) * 100;
const tsCurrent = formatSecondsToTimestamp(audio.currentTime);
const tsDuration = formatSecondsToTimestamp(audio.duration);
elProgress.value = percent;
elProgress.style.setProperty('--value', percent);
elTsCurrent.innerText = tsCurrent;
elTsDuration.innerText = tsDuration;
});
elProgress.addEventListener('input', () => {
const percent = elProgress.value;
elProgress.style.setProperty('--value', percent);
audio.currentTime = audio.duration * (percent / 100);
});
} else {
// Attempt to download file as text
let text;
if (data.size < maxTextSize) {
text = await fetchTextFile(data.pathTrue);
}
// If the file was too big or not text, prompt to download
if (!text) {
elPreviewContent.innerHTML = /*html*/`
<div class="card column">
<div class="body">
<h3 style="text-align: center; margin: 0;">
This file can't be previewed.
</h3>
<a href="${data.pathTrue}" download class="btn" onClick="closePreview()">
<span class="icon">download</span>
Download file - ${formatBytes(data.size)}
</a>
</div>
</div>
`;
// Show markdown preview
} else if (types.md.includes(ext)) {
const html = markdownToSafeHTML(text);
elPreviewContent.innerHTML = /*html*/`
<div class="card html">
<div class="body">${html}</div>
</div>
`;
Prism.highlightAll(elPreviewContent);
// Show text preview
} else {
elPreviewContent.innerHTML = /*html*/`
<div class="card text">
<pre class="body"><code class="language-${ext}"></code></pre>
</div>
`;
const elCode = elPreviewContent.querySelector('code');
let highlightedHtml = text;
try {
highlightedHtml = Prism.highlight(text, Prism.languages[ext], ext);
} catch (error) {}
elCode.innerHTML = highlightedHtml;
}
}
// Set query string
const url = new URL(window.location.href);
url.searchParams.set('preview', data.name);
history.pushState({}, '', url.toString());
// Show preview dialog
openPreview();
}
document.addEventListener('keydown', (e) => {
if (e.key == 'Escape') {
closePreview();
}
});
document.addEventListener('DOMContentLoaded', async () => {
// Format dates
const fileTimes = document.querySelectorAll('[data-timestamp]');
for (const fileTime of fileTimes) {
if (fileTime.dataset.timestamp == '-')
continue;
const value = parseInt(fileTime.dataset.timestamp);
fileTime.title = dayjs(value).format('YYYY-MM-DD HH:mm:ss');
fileTime.innerText = dayjs(value).format(document.body.dataset.fileTimeFormat);
}
// Format sizes
const fileSizes = document.querySelectorAll('[data-bytes]');
for (const fileSize of fileSizes) {
if (fileSize.dataset.bytes == '-')
continue;
const value = parseInt(fileSize.dataset.bytes);
fileSize.title = value + ' bytes';
fileSize.innerText = formatBytes(value);
}
// Loop through files to add context menus, click actions, and find readme
const fileEntryElements = document.querySelectorAll('#fileEntries .entry');
const fileSelectAction = document.body.dataset.fileSelectAction;
let readmePath = null;
for (const entry of fileEntryElements) {
const data = JSON.parse(entry.dataset.json);
if (data.name.toLowerCase() == 'readme.md' && !readmePath) {
readmePath = data.pathTrue;
}
// Handle click actions
entry.addEventListener('click', (e) => {
switch (fileSelectAction) {
case 'preview': {
if (data.type != 'folder' && data.size !== '-') {
e.preventDefault();
previewFile(data);
}
break;
}
case 'download': {
if (data.type != 'folder') {
e.preventDefault();
const a = document.createElement('a');
a.href = data.pathTrue;
a.download = data.name;
a.click();
}
break;
}
}
});
// Handle context menus
entry.addEventListener('contextmenu', (e) => {
e.preventDefault();
const items = [];
if (data.type != 'folder' && data.size > 0) {
items.push({
type: 'item',
icon: 'visibility',
label: 'Preview file',
onClick: () => previewFile(data)
});
items.push({ type: 'separator' });
}
items.push({
type: 'item',
icon: 'open_in_new',
label: 'Open file',
onClick: () => window.location.href = entry.href
});
items.push({
type: 'item',
icon: 'open_in_new',
label: 'Open file in new tab...',
onClick: () => window.open(entry.href, '_blank')
});
items.push({ type: 'separator' });
const canFoldersDownload = document.body.dataset.canFoldersDownload === 'true';
const fileName = entry.querySelector('.name').title;
if (data.type == 'folder' && canFoldersDownload && fileName !== '..') {
items.push({
type: 'item',
icon: 'download',
label: `Download folder as zip`,
onClick: () => {
window.location.href = entry.href + '?format=zip';
}
});
items.push({ type: 'separator' });
} else if (data.type != 'folder') {
items.push({
type: 'item',
icon: 'download',
label: `Download file`,
onClick: () => {
const a = document.createElement('a');
a.href = entry.href;
a.download = fileName;
a.click();
}
});
items.push({ type: 'separator' });
}
if (data.path == data.pathAlias) {
items.push({
type: 'item',
icon: 'link',
label: 'Copy default file link',
onClick: () => {
navigator.clipboard.writeText(window.location.origin + data.pathTrue);
}
});
items.push({
type: 'item',
icon: 'link',
label: 'Copy clean file link',
onClick: () => {
navigator.clipboard.writeText(window.location.origin + data.pathAlias);
}
});
} else {
items.push({
type: 'item',
icon: 'link',
label: 'Copy link',
onClick: () => {
navigator.clipboard.writeText(entry.href);
}
});
}
showContextMenu({ items });
});
// Show preview if query string matches file name
const url = new URL(window.location.href);
const previewFileName = url.searchParams.get('preview');
if (previewFileName && (data.name == previewFileName || data.path.split('/').pop() == previewFileName)) {
previewFile(data);
}
}
// Add button listeners
const btnShareDir = document.getElementById('btnShareDir');
if (btnShareDir) {
btnShareDir.addEventListener('click', () => {
const urlWithoutQuery = window.location.origin + window.location.pathname;
navigator.clipboard.writeText(urlWithoutQuery);
});
}
const btnSort = document.getElementById('btnSort');
if (btnSort) {
btnSort.addEventListener('click', () => {
const url = new URL(window.location.href);
const currentSortType = url.searchParams.get('sortType') || 'name';
const currentSortOrder = url.searchParams.get('sortOrder') || 'asc';
const currentSort = `${currentSortType}-${currentSortOrder}`;
const items = [
{
type: 'item',
label: 'Sort by name A-Z',
icon: currentSort == 'name-asc' ? 'radio_button_checked' : 'radio_button_unchecked',
onClick: () => {
url.searchParams.delete('sortType');
url.searchParams.delete('sortOrder');
window.location.href = url.toString();
}
},
{
type: 'item',
label: 'Sort by name Z-A',
icon: currentSort == 'name-desc' ? 'radio_button_checked' : 'radio_button_unchecked',
onClick: () => {
url.searchParams.delete('sortType');
url.searchParams.set('sortOrder', 'desc');
window.location.href = url.toString();
}
},
{
type: 'item',
label: 'Sort oldest to newest',
icon: currentSort == 'modified-asc' ? 'radio_button_checked' : 'radio_button_unchecked',
onClick: () => {
url.searchParams.set('sortType', 'modified');
url.searchParams.delete('sortOrder');
window.location.href = url.toString();
}
},
{
type: 'item',
label: 'Sort newest to oldest',
icon: currentSort == 'modified-desc' ? 'radio_button_checked' : 'radio_button_unchecked',
onClick: () => {
url.searchParams.set('sortType', 'modified');
url.searchParams.set('sortOrder', 'desc');
window.location.href = url.toString();
}
},
{
type: 'item',
label: 'Sort smallest to largest',
icon: currentSort == 'size-asc' ? 'radio_button_checked' : 'radio_button_unchecked',
onClick: () => {
url.searchParams.set('sortType', 'size');
url.searchParams.delete('sortOrder');
window.location.href = url.toString();
}
},
{
type: 'item',
label: 'Sort largest to smallest',
icon: currentSort == 'size-desc' ? 'radio_button_checked' : 'radio_button_unchecked',
onClick: () => {
url.searchParams.set('sortType', 'size');
url.searchParams.set('sortOrder', 'desc');
window.location.href = url.toString();
}
}
];
showContextMenu({ items });
});
}
// Close preview when background is clicked
// But not when the buttons or the preview itself are clicked
const elPreview = document.querySelector('#preview');
const elPreviewTopbar = elPreview.querySelector('.topbar');
const elPreviewContent = document.querySelector('#previewContent');
elPreview.addEventListener('click', (e) => {
if ([ elPreview, elPreviewTopbar, elPreviewContent ].includes(e.target)) {
closePreview();
}
});
// Load readme
if (readmePath) {
const elReadme = document.querySelector('#readme');
const elReadmeBody = document.querySelector('#readme > .body');
elReadmeBody.innerHTML = '<p><em>Loading readme...</em></p>';
elReadme.style.display = '';
const res = await axios.get(readmePath);
const markdown = res.data;
const html = markdownToSafeHTML(markdown);
elReadmeBody.innerHTML = html;
Prism.highlightAll(elReadmeBody);
}
});
window.addEventListener('popstate', () => {
closePreview();
});