Merge pull request #562 from EdouardVanbelle/fix/front-from-legacy

restore frontend features from legacy
This commit is contained in:
Dionisio Pozo
2026-07-08 19:00:16 +02:00
committed by GitHub
5 changed files with 334 additions and 5 deletions
+10 -1
View File
@@ -3,7 +3,16 @@
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="icon" href="%sveltekit.assets%/favicon.ico" />
<!-- Icon set — mirrors the legacy static/index.html head. SVG is
the primary (modern browsers use it, scales at every DPI); the
.ico fallback covers old browsers + Windows taskbar shortcuts.
Apple touch icon: iOS home-screen. Safari mask icon: pinned
tabs. Manifest wires the PWA install flow. -->
<link rel="icon" type="image/svg+xml" href="%sveltekit.assets%/logo/logo-plain.svg" />
<link rel="icon" href="%sveltekit.assets%/favicon.ico" sizes="32x32" />
<link rel="apple-touch-icon" href="%sveltekit.assets%/logo/apple-touch-icon.png" />
<link rel="mask-icon" href="%sveltekit.assets%/logo/logo-plain.svg" color="#ff5e3a" />
<link rel="manifest" href="%sveltekit.assets%/manifest.webmanifest" />
<meta name="color-scheme" content="light dark" />
%sveltekit.head%
<!--
+245
View File
@@ -0,0 +1,245 @@
/**
* Client-side thumbnail generation + upload.
*
* Fallback path: when the server returns 404 for a file's thumbnail
* (the mime type isn't supported server-side, e.g. PDF, or the async
* server-side generator hasn't caught up yet), the client can generate
* the three canonical sizes from the file itself and PUT them back so
* subsequent viewers hit the server thumbnail.
*
* Ported from the legacy vanilla-JS `static/js/features/thumbnail.js`
* (retired in commit 54639d46). Same shape, same behaviour:
*
* * SUPPORTED_MIME_TYPE — image/*, application/pdf, video/*.
* * SIZES — icon 150×150, preview 300×300, large 900×800.
* * FORMAT / QUALITY — JPEG q=0.8 (matches the server's own encoder).
* * MAX_CONCURRENT — 3 parallel generations, excess queued.
*
* pdf.js lives at `/vendors/pdf.min.mjs` (+ worker) — dynamically
* imported on first PDF encounter so image/video-only sessions never
* pay the ~1 MB pdf.js download.
*/
import type { FileItem } from '$lib/api/types';
import { getCsrfHeaders } from '$lib/api/csrf';
const PDFJS_LIB_URL = '/vendors/pdf.min.mjs';
const PDFJS_WORKER_URL = '/vendors/pdf.worker.min.mjs';
// Anything that ships a runtime API surface too broad to type here without
// vendoring `@types/pdfjs-dist`; the two methods we call (`getDocument`,
// worker options) are stable across pdf.js 4.x.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let pdfjsLibPromise: Promise<any> | null = null;
let pdfWorkerWarmed = false;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function getPdfjsLib(): Promise<any> {
if (!pdfjsLibPromise) {
pdfjsLibPromise = import(/* @vite-ignore */ PDFJS_LIB_URL)
.then((lib) => {
lib.GlobalWorkerOptions.workerSrc = PDFJS_WORKER_URL;
return lib;
})
.catch((err) => {
pdfjsLibPromise = null; // allow retry on a later sighting
throw err;
});
}
return pdfjsLibPromise;
}
const SUPPORTED_MIME_TYPE = [/^image\//, /^application\/pdf$/, /^video\//];
const SIZES = {
icon: { width: 150, height: 150 },
preview: { width: 300, height: 300 },
large: { width: 900, height: 800 }
} as const;
const FORMAT = 'image/jpeg';
const QUALITY = 0.8;
const MAX_CONCURRENT = 3;
let activeGenerates = 0;
const generateQueue: Array<(release: void) => void> = [];
interface Size {
width: number;
height: number;
}
function computeSize(
srcWidth: number,
srcHeight: number,
targetWidth: number,
targetHeight: number
): Size {
const srcRatio = srcWidth / srcHeight;
const targetRatio = targetWidth / targetHeight;
if (srcRatio > targetRatio) {
return { width: targetWidth, height: Math.round(targetWidth / srcRatio) };
}
return { width: Math.round(targetHeight * srcRatio), height: targetHeight };
}
async function bitmapToBlob(
bitmap: ImageBitmap,
targetWidth: number,
targetHeight: number,
options: ImageEncodeOptions
): Promise<Blob> {
const { width, height } = 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(options);
}
function blobToDataUrl(blob: Blob): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(reader.result as string);
reader.onerror = reject;
reader.readAsDataURL(blob);
});
}
async function sourceToBitmap(file: FileItem, source: string): Promise<ImageBitmap> {
const mime = file.mime_type ?? '';
if (mime.startsWith('image/')) {
const response = await fetch(source);
if (!response.ok) throw new Error(`failed to fetch: ${response.status}`);
const blob = await response.blob();
return createImageBitmap(blob);
}
if (mime === 'application/pdf') {
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);
}
if (mime.startsWith('video/')) {
return new Promise<ImageBitmap>((resolve, reject) => {
const video = document.createElement('video');
video.src = source;
video.muted = true;
video.preload = 'metadata';
video.onloadedmetadata = () => {
// Snapshot at 1/3 duration — skips titles/logos, still in the
// meat of the content for most videos.
video.currentTime = video.duration / 3;
};
video.onseeked = async () => {
const bitmap = await createImageBitmap(video);
video.pause();
video.removeAttribute('src'); // close the pending HTTP body
video.load();
resolve(bitmap);
};
video.onerror = reject;
});
}
throw new Error(`unsupported mime type: ${mime} for file ${file.name}`);
}
async function generate(
file: FileItem,
onIconGenerated?: (dataUrl: string) => void,
onPreviewGenerated?: (dataUrl: string) => void
): Promise<void> {
const source = `${window.location.origin}/api/files/${file.id}`;
const bitmap = await sourceToBitmap(file, source);
const [iconBlob, previewBlob, largeBlob] = await Promise.all(
Object.values(SIZES).map(({ width, height }) =>
bitmapToBlob(bitmap, width, height, { type: FORMAT, quality: QUALITY })
)
);
if (onIconGenerated) onIconGenerated(await blobToDataUrl(iconBlob));
if (onPreviewGenerated) onPreviewGenerated(await blobToDataUrl(previewBlob));
await Promise.all(
(
[
['icon', iconBlob],
['preview', previewBlob],
['large', largeBlob]
] as const
).map(([size, blob]) =>
fetch(`${window.location.origin}/api/files/${file.id}/thumbnail/${size}`, {
method: 'PUT',
headers: { ...getCsrfHeaders(), 'Content-Type': FORMAT },
body: blob,
credentials: 'same-origin'
})
)
);
}
/**
* True when this file's mime type is one the client-side generator can
* handle. Callers use this to decide whether to install the fallback
* `onerror` handler on the `<img>` in the first place.
*/
export function canThumbnailClientSide(file: FileItem): boolean {
const mime = file.mime_type ?? '';
return SUPPORTED_MIME_TYPE.some((re) => re.test(mime));
}
/**
* Fire-and-forget warm-up of the pdf.js stack (module + worker script).
*
* Call the moment a PDF file appears in a listing so the ~1.3 MB library
* downloads in the background while the user is still looking at the
* list — instead of stalling the first thumbnail render on it.
* Idempotent; only folders that actually contain PDFs pay the download.
*/
export function preloadPdf(): void {
getPdfjsLib().catch(() => {
/* transient failure — first real use retries */
});
if (pdfWorkerWarmed) return;
pdfWorkerWarmed = true;
fetch(PDFJS_WORKER_URL)
.then((r) => (r.ok ? r.blob() : Promise.reject(new Error(`HTTP ${r.status}`))))
.catch(() => {
pdfWorkerWarmed = false; // allow retry on a later sighting
});
}
/**
* Concurrency-limited wrapper around `generate`. At most `MAX_CONCURRENT`
* generations run simultaneously; excess calls await a released slot.
*
* The optional callbacks receive the icon / preview data URLs the moment
* they encode locally — callers use them to paint the fallback
* immediately, before the server round-trip completes.
*/
export async function queueGenerate(
file: FileItem,
onIconGenerated?: (dataUrl: string) => void,
onPreviewGenerated?: (dataUrl: string) => void
): Promise<void> {
if (activeGenerates >= MAX_CONCURRENT) {
await new Promise<void>((resolve) => generateQueue.push(resolve));
}
activeGenerates++;
try {
await generate(file, onIconGenerated, onPreviewGenerated);
} catch (err) {
if (err instanceof Error) {
console.warn(`thumbnail generation failed for ${file.name}:`, err.message);
} else {
console.warn(`thumbnail generation failed for ${file.name}:`, err);
}
} finally {
activeGenerates--;
const next = generateQueue.shift();
if (next) next();
}
}
@@ -61,6 +61,11 @@
import { formatBytes } from '$lib/utils/format';
import { formatDate, iconNameFromClass, fileIconKindClass } from '$lib/utils/display';
import { gridColumns } from '$lib/utils/grid';
import {
canThumbnailClientSide,
preloadPdf,
queueGenerate as queueThumbnailGenerate
} from '$lib/utils/thumbnail';
// File preview and the WOPI editor are heavy and only appear on demand, so
// their modules load the first time the user opens one (see the effects that
@@ -731,7 +736,11 @@
*/
function canThumbnail(file: FileItem): boolean {
const m = file.mime_type ?? '';
return m.startsWith('image/') || m.startsWith('video/');
// PDF joins image/video: the client-side generator ported from
// the legacy vanilla frontend renders PDFs via pdf.js on the
// `<img onerror>` fallback path. Without this the img never
// mounts for PDFs and the fallback never fires.
return m.startsWith('image/') || m.startsWith('video/') || m === 'application/pdf';
}
// ── Multi-select + batch ────────────────────────────────────────────────
@@ -1431,10 +1440,21 @@
const SKELETON = [0, 1, 2, 3, 4, 5, 6, 7];
// Reload whenever the route path changes.
//
// `load()` reads several reactive signals in its sync phase
// (session.isExternalUser, session.homeFolderId, plus whatever
// its awaited callees touch). Naively calling `void load()` here
// tracks all of those as dependencies of this effect — and
// `session.loadHomeFolder()`'s own writes to `homeFolderId`
// during its resolution then re-trigger the effect, firing a
// second and third `load()` before the first has settled. Wrap
// in `untrack` so the ONLY dependency is `pathSegments` (route
// change is the sole legitimate re-trigger).
$effect(() => {
// reference pathSegments so the effect re-runs on navigation
void pathSegments;
void load();
untrack(() => {
void load();
});
});
// The command palette's "Upload files" action navigates here then dispatches
@@ -1907,7 +1927,20 @@
src={fileThumbnailUrl(file.id)}
alt=""
loading="lazy"
onerror={(e) => ((e.currentTarget as HTMLImageElement).style.display = 'none')}
onerror={(e) => {
// Server-side thumbnail is missing (404) — try client-side
// generation for image / PDF / video and PUT the result
// back so the next viewer gets the server thumbnail.
// Ported from the legacy static/js/features/thumbnail.js.
const img = e.currentTarget as HTMLImageElement;
img.style.display = 'none';
if (!canThumbnailClientSide(file)) return;
if (file.mime_type === 'application/pdf') preloadPdf();
void queueThumbnailGenerate(file, (dataUrl) => {
img.src = dataUrl;
img.style.display = '';
});
}}
/>
{/if}
</div>
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long