Merge pull request #562 from EdouardVanbelle/fix/front-from-legacy
restore frontend features from legacy
This commit is contained in:
+10
-1
@@ -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%
|
||||
<!--
|
||||
|
||||
@@ -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>
|
||||
|
||||
Vendored
+21
File diff suppressed because one or more lines are too long
+21
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user