feat: generate thumbnail from client is not found on server

- supported thumbnail filetype: image, pdf, video
- add play character if video's thumbnail is loaded
This commit is contained in:
Edouard Vanbelle
2026-04-29 00:06:44 +02:00
parent c6b1b14444
commit f6cd18e5e9
9 changed files with 269 additions and 25 deletions
+3
View File
@@ -425,4 +425,7 @@
--color-music-gradient: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
--color-music-background: var(--color-bg-surface);
--color-music-public-bg: rgba(74, 144, 217, 0.12);
--color-video-play: white;
--color-video-play-shadow: black;
}
+9
View File
@@ -51,6 +51,15 @@
color: var(--color-ft-video-text);
}
/* special case for vidao, add ▶ over the thumbnail (only if not hidden = thumb loaded) */
.video-icon:has(img:not(.hidden))::before {
content: "▶";
color: var(--color-video-play);
text-shadow: 0 0 3px var(--color-video-play-shadow);
z-index: 2; /* above the img */
font-weight: bold;
}
.code-icon {
background-color: var(--color-border);
}
+10 -5
View File
@@ -65,6 +65,14 @@
color: var(--color-badge-blue-text);
}
.file-item .file-icon > i,
.file-item .file-icon > svg {
position: absolute;
display: flex;
align-items: center;
justify-content: center;
}
/* ------------------File list View --------------------- */
.list-header {
@@ -145,7 +153,7 @@
align-items: center;
justify-content: center;
border-radius: 8px;
font-size: 20px;
font-size: 16px;
margin-bottom: 0;
flex-shrink: 0;
}
@@ -422,17 +430,14 @@
.files-grid-view .file-item .file-icon {
margin: auto;
margin-bottom: 10px;
font-size: 30px;
}
.files-grid-view .file-item .file-icon > i,
.files-grid-view .file-item .file-icon > svg {
position: absolute;
top: 5px;
width: 60px;
height: 60px;
display: flex;
align-items: center;
justify-content: center;
}
/* ----------------------- dragged items -----------*/
+13 -5
View File
@@ -16,6 +16,7 @@ import { wopiEditor } from '../features/files/wopiEditor.js';
import { favorites } from '../features/library/favorites.js';
import { recent } from '../features/library/recent.js';
import { fileSharing } from '../features/sharing/fileSharing.js';
import { thumbnail } from '../features/thumbnail.js';
import { sharedView } from '../views/shared/sharedView.js';
import { loadFiles } from './filesView.js';
import { updateHistory } from './main.js';
@@ -1302,6 +1303,7 @@ const ui = {
const formattedDate = formatDateTime(file.modified_at);
const isFav = favorites?.isFavorite(file.id, 'file');
const isShared = sharedView.isShared(file.id, 'file');
const canThumbnail = thumbnail.canHandle(iconSpecialClass);
const el = document.createElement('div');
el.className = 'file-item';
@@ -1315,7 +1317,7 @@ const ui = {
<div class="checkbox-cell"><input type="checkbox" class="item-checkbox"></div>
<div class="name-cell">
<div class="file-icon ${iconSpecialClass}">
${iconSpecialClass === 'image-icon' ? `<img class="file-thumb" src="/api/files/${file.id}/thumbnail/icon" loading="lazy" alt="">` : ''}
${canThumbnail ? `<img class="file-thumb" src="/api/files/${file.id}/thumbnail/icon" loading="lazy" alt="">` : ''}
<i class="${iconClass}"></i>
</div>
<span>${escapeHtml(file.name)}</span>
@@ -1332,11 +1334,17 @@ const ui = {
<button class="file-actions"><i class="fas fa-ellipsis-v"></i></button>
</div>
`;
var thumb = el.querySelector('.file-thumb');
if (thumb)
thumb.addEventListener('error', function () {
this.style.display = 'none';
var thumb = /** @type {HTMLImageElement} */ (el.querySelector('.file-thumb'));
if (thumb) {
thumb.addEventListener('error', () => {
console.log(`thumbnail not found for "${file.name}", try to generate it...`);
thumb.classList.add('hidden');
thumbnail.generate(file, (dataUrl) => {
thumb.src = dataUrl;
thumb.classList.remove('hidden');
});
});
}
this._bindStarClick(el);
return el;
},
+181
View File
@@ -0,0 +1,181 @@
import { getCsrfHeaders } from '../core/csrf.js';
/** @type {typeof import('../vendors/pdf.min.d.ts') | null} */
let _pdfjsLib = null;
/**
* Lazy-loads pdf.min.mjs on first use via dynamic import so it is never
* bundled into the IIFE (it uses top-level await which breaks IIFE wrapping).
* @returns {Promise<typeof import('../vendors/pdf.min.d.ts')>}
*/
async function getPdfjsLib() {
if (_pdfjsLib) return _pdfjsLib;
_pdfjsLib = await import('/js/vendors/pdf.min.mjs');
_pdfjsLib.GlobalWorkerOptions.workerSrc = '/js/vendors/pdf.worker.min.mjs';
return _pdfjsLib;
}
export const thumbnail = {
SUPPORTED_CLASS: ['image-icon', 'pdf-icon', 'video-icon'],
/**
*
* @param {String} iconSpecialClass
* @returns {boolean}
*/
canHandle(iconSpecialClass) {
return this.SUPPORTED_CLASS.includes(iconSpecialClass);
},
// TODO: use these informations from server ?
SIZES: {
icon: { width: 150, height: 150 },
preview: { width: 300, height: 300 },
large: { width: 900, height: 800 }
},
// note: server moved to jpeg q=80 for images
// FORMAT: 'image/webp',
// QUALITY: 0.85,
FORMAT: 'image/jpeg',
QUALITY: 0.8,
/**
* @typedef {Object} Size
* @property {number} width
* @property {number} height
*/
/**
*
* @param {number} srcWidth
* @param {number} srcHeight
* @param {number} targetWidth
* @param {number} targetHeight
* @returns {Size}
*/
computeSize(srcWidth, srcHeight, targetWidth, targetHeight) {
const srcRatio = srcWidth / srcHeight;
const targetRatio = targetWidth / targetHeight;
if (srcRatio > targetRatio) {
return { width: targetWidth, height: Math.round(targetWidth / srcRatio) };
} else {
return { width: Math.round(targetHeight * srcRatio), height: targetHeight };
}
},
/**
*
* @param {ImageBitmap} bitmap
* @param {number} targetWidth
* @param {number} targetHeight
* @param {ImageEncodeOptions} imageEncodeOptions
* @returns
*/
bitmapToBlob(bitmap, targetWidth, targetHeight, imageEncodeOptions) {
const { width, height } = this.computeSize(bitmap.width, bitmap.height, targetWidth, targetHeight);
const canvas = new OffscreenCanvas(width, height);
canvas.getContext('2d')?.drawImage(bitmap, 0, 0, width, height);
return canvas.convertToBlob(imageEncodeOptions);
},
/**
*
* @param {Blob} blob
* @returns
*/
blobToDataUrl(blob) {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(reader.result);
reader.onerror = reject;
reader.readAsDataURL(blob);
});
},
/**
*
* @param {Object} file
* @param {string} source
* @returns
*/
async sourceToBitmap(file, source) {
switch (file.icon_special_class) {
case 'image-icon': {
const response = await fetch(source);
if (!response.ok) throw new Error(`failed to fetch: ${response.status}`);
const blob = await response.blob();
return createImageBitmap(blob);
}
case 'pdf-icon': {
const pdfjsLib = await getPdfjsLib();
const pdf = await pdfjsLib.getDocument(source).promise;
const page = await pdf.getPage(1);
const viewport = page.getViewport({ scale: 1 });
const canvas = document.createElement('canvas');
canvas.width = viewport.width;
canvas.height = viewport.height;
await page.render({ canvasContext: canvas.getContext('2d'), viewport }).promise;
return createImageBitmap(canvas);
}
case 'video-icon': {
return new Promise((resolve, reject) => {
const video = document.createElement('video');
video.src = source;
video.muted = true;
video.preload = 'metadata';
video.onloadedmetadata = () => {
// seek to 1/3 of video to take snapshot
video.currentTime = video.duration / 3;
};
video.onseeked = async () => {
const bitmap = await createImageBitmap(video);
video.pause();
video.removeAttribute('src'); // hack to close network connection
video.load();
resolve(bitmap);
};
video.onerror = reject;
});
}
default:
throw new Error(`unknown type: ${file.icon_special_class} for file ${file.name}`);
}
},
/**
* generateThumbnail and update image
*
* @param {Object} file the source of the image
* @param {(dataURL: string) => void} [onIconGenerated] the callback once thumbnail is generated
*/
async generate(file, onIconGenerated) {
const source = `${window.location.origin}/api/files/${file.id}`;
const bitmap = await this.sourceToBitmap(file, source);
const [iconBlob, previewBlob, largeBlob] = await Promise.all(
Object.values(this.SIZES).map(({ width, height }) => this.bitmapToBlob(bitmap, width, height, { type: this.FORMAT, quality: this.QUALITY }))
);
if (onIconGenerated) {
onIconGenerated(await this.blobToDataUrl(iconBlob));
}
await Promise.all(
[
['icon', iconBlob],
['preview', previewBlob],
['large', largeBlob]
].map(([size, blob]) =>
fetch(`${window.location.origin}/api/files/${file.id}/thumbnail/${size}`, {
method: 'PUT',
headers: { ...getCsrfHeaders(), 'Content-Type': this.FORMAT },
body: blob
}).then((r) => console.log(`uploaded ${size} thumbnail of ${file.name}: ${r.status}`))
)
);
}
};
+21
View File
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long