diff --git a/package.json b/package.json
index 8d0ddba..7228980 100644
--- a/package.json
+++ b/package.json
@@ -37,6 +37,7 @@
"globals": "^15.14.0",
"heic2any": "^0.0.4",
"kysely-ctl": "^0.10.1",
+ "lru-cache": "^11.1.0",
"mime": "^4.0.6",
"p-limit": "^6.2.0",
"prettier": "^3.4.2",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 9ed4442..be3e935 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -96,6 +96,9 @@ importers:
kysely-ctl:
specifier: ^0.10.1
version: 0.10.1(kysely@0.27.5)
+ lru-cache:
+ specifier: ^11.1.0
+ version: 11.1.0
mime:
specifier: ^4.0.6
version: 4.0.6
@@ -1414,6 +1417,10 @@ packages:
lru-cache@10.4.3:
resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==}
+ lru-cache@11.1.0:
+ resolution: {integrity: sha512-QIXZUBJUx+2zHUdQujWejBkcD9+cs94tLn0+YL8UrCh+D5sCXZ4c7LaEH48pNwRY3MLDgqUFyhlCyjJPf1WP0A==}
+ engines: {node: 20 || >=22}
+
luxon@3.5.0:
resolution: {integrity: sha512-rh+Zjr6DNfUYR3bPwJEnuwDdqMbxZW7LOQfUN4B54+Cl+0o5zaU9RJ6bcidfDtC1cWCZXQ+nvX8bf6bAji37QQ==}
engines: {node: '>=12'}
@@ -3369,6 +3376,8 @@ snapshots:
lru-cache@10.4.3: {}
+ lru-cache@11.1.0: {}
+
luxon@3.5.0: {}
magic-string@0.30.17:
diff --git a/src/lib/components/molecules/labels/DirectoryEntryLabel.svelte b/src/lib/components/molecules/labels/DirectoryEntryLabel.svelte
index 9878e26..e38b348 100644
--- a/src/lib/components/molecules/labels/DirectoryEntryLabel.svelte
+++ b/src/lib/components/molecules/labels/DirectoryEntryLabel.svelte
@@ -10,7 +10,7 @@
name: string;
subtext?: string;
textClass?: ClassValue;
- thumbnail?: ArrayBuffer;
+ thumbnail?: string;
type: "directory" | "file";
}
@@ -22,19 +22,12 @@
thumbnail,
type,
}: Props = $props();
-
- let thumbnailUrl: string | undefined = $state();
-
- $effect(() => {
- thumbnailUrl = thumbnail && URL.createObjectURL(new Blob([thumbnail]));
- return () => thumbnailUrl && URL.revokeObjectURL(thumbnailUrl);
- });
{#snippet iconSnippet()}
- {#if thumbnailUrl}
-

+ {#if thumbnail}
+

{:else if type === "directory"}
{:else}
diff --git a/src/lib/modules/file/index.ts b/src/lib/modules/file/index.ts
index 42a5613..dc708ac 100644
--- a/src/lib/modules/file/index.ts
+++ b/src/lib/modules/file/index.ts
@@ -1,3 +1,4 @@
export * from "./cache";
export * from "./download";
+export * from "./thumbnail";
export * from "./upload";
diff --git a/src/lib/modules/file/thumbnail.ts b/src/lib/modules/file/thumbnail.ts
new file mode 100644
index 0000000..e78786c
--- /dev/null
+++ b/src/lib/modules/file/thumbnail.ts
@@ -0,0 +1,29 @@
+import { LRUCache } from "lru-cache";
+import { readFile, writeFile, deleteFile } from "$lib/modules/opfs";
+import { getThumbnailUrl } from "$lib/modules/thumbnail";
+
+const loadedThumbnails = new LRUCache
({ max: 100 });
+
+export const getFileThumbnail = async (fileId: number) => {
+ const thumbnail = loadedThumbnails.get(fileId);
+ if (thumbnail) {
+ return thumbnail;
+ }
+
+ const thumbnailBuffer = await readFile(`/thumbnails/${fileId}`);
+ if (!thumbnailBuffer) return null;
+
+ const thumbnailUrl = getThumbnailUrl(thumbnailBuffer);
+ loadedThumbnails.set(fileId, thumbnailUrl);
+ return thumbnailUrl;
+};
+
+export const storeFileThumbnail = async (fileId: number, thumbnailBuffer: ArrayBuffer) => {
+ await writeFile(`/thumbnails/${fileId}`, thumbnailBuffer);
+ loadedThumbnails.set(fileId, getThumbnailUrl(thumbnailBuffer));
+};
+
+export const deleteFileThumbnail = async (fileId: number) => {
+ loadedThumbnails.delete(fileId);
+ await deleteFile(`/thumbnails/${fileId}`);
+};
diff --git a/src/lib/modules/file/upload.ts b/src/lib/modules/file/upload.ts
index ac03e47..b56375f 100644
--- a/src/lib/modules/file/upload.ts
+++ b/src/lib/modules/file/upload.ts
@@ -130,9 +130,8 @@ const encryptFile = limitFunction(
const lastModifiedAtEncrypted = await encryptString(file.lastModified.toString(), dataKey);
const thumbnail = await generateThumbnail(file, fileType);
- const thumbnailEncrypted = thumbnail
- ? await encryptData(await thumbnail.arrayBuffer(), dataKey)
- : null;
+ const thumbnailBuffer = await thumbnail?.arrayBuffer();
+ const thumbnailEncrypted = thumbnailBuffer ? await encryptData(thumbnailBuffer, dataKey) : null;
status.update((value) => {
value.status = "upload-pending";
@@ -148,7 +147,8 @@ const encryptFile = limitFunction(
nameEncrypted,
createdAtEncrypted,
lastModifiedAtEncrypted,
- thumbnailEncrypted,
+ thumbnail: thumbnail &&
+ thumbnailEncrypted && { plaintext: thumbnailBuffer, ...thumbnailEncrypted },
};
},
{ concurrency: 4 },
@@ -198,7 +198,9 @@ export const uploadFile = async (
hmacSecret: HmacSecret,
masterKey: MasterKey,
onDuplicate: () => Promise,
-): Promise<{ fileId: number; fileBuffer: ArrayBuffer } | undefined> => {
+): Promise<
+ { fileId: number; fileBuffer: ArrayBuffer; thumbnailBuffer?: ArrayBuffer } | undefined
+> => {
const status = writable({
name: file.name,
parentId,
@@ -236,7 +238,7 @@ export const uploadFile = async (
nameEncrypted,
createdAtEncrypted,
lastModifiedAtEncrypted,
- thumbnailEncrypted,
+ thumbnail,
} = await encryptFile(status, file, fileBuffer, masterKey);
const form = new FormData();
@@ -263,20 +265,20 @@ export const uploadFile = async (
form.set("checksum", fileEncryptedHash);
let thumbnailForm = null;
- if (thumbnailEncrypted) {
+ if (thumbnail) {
thumbnailForm = new FormData();
thumbnailForm.set(
"metadata",
JSON.stringify({
dekVersion: dataKeyVersion.toISOString(),
- contentIv: thumbnailEncrypted.iv,
+ contentIv: thumbnail.iv,
} satisfies FileThumbnailUploadRequest),
);
- thumbnailForm.set("content", new Blob([thumbnailEncrypted.ciphertext]));
+ thumbnailForm.set("content", new Blob([thumbnail.ciphertext]));
}
const { fileId } = await requestFileUpload(status, form, thumbnailForm);
- return { fileId, fileBuffer };
+ return { fileId, fileBuffer, thumbnailBuffer: thumbnail?.plaintext };
} catch (e) {
status.update((value) => {
value.status = "error";
diff --git a/src/lib/modules/thumbnail.ts b/src/lib/modules/thumbnail.ts
index 30e931e..2352c65 100644
--- a/src/lib/modules/thumbnail.ts
+++ b/src/lib/modules/thumbnail.ts
@@ -1,3 +1,5 @@
+import { encodeToBase64 } from "$lib/modules/crypto";
+
const scaleSize = (width: number, height: number, targetSize: number) => {
if (width <= targetSize || height <= targetSize) {
return { width, height };
@@ -74,3 +76,7 @@ export const generateVideoThumbnail = (videoUrl: string, time = 0) => {
video.src = videoUrl;
});
};
+
+export const getThumbnailUrl = (thumbnailBuffer: ArrayBuffer) => {
+ return `data:image/webp;base64,${encodeToBase64(thumbnailBuffer)}`;
+};
diff --git a/src/routes/(fullscreen)/settings/thumbnails/service.ts b/src/routes/(fullscreen)/settings/thumbnails/service.ts
index a064078..ad24954 100644
--- a/src/routes/(fullscreen)/settings/thumbnails/service.ts
+++ b/src/routes/(fullscreen)/settings/thumbnails/service.ts
@@ -1,6 +1,6 @@
import { limitFunction } from "p-limit";
import { encryptData } from "$lib/modules/crypto";
-import { getFileCache, storeFileCache, downloadFile } from "$lib/modules/file";
+import { getFileCache, storeFileCache, downloadFile, storeFileThumbnail } from "$lib/modules/file";
import { generateImageThumbnail, generateVideoThumbnail } from "$lib/modules/thumbnail";
import type { FileThumbnailUploadRequest } from "$lib/server/schemas";
@@ -55,7 +55,10 @@ export const requestThumbnailUpload = limitFunction(
form.set("content", new Blob([thumbnailEncrypted.ciphertext]));
const res = await fetch(`/api/file/${fileId}/thumbnail/upload`, { method: "POST", body: form });
- return res.ok;
+ if (!res.ok) return false;
+
+ storeFileThumbnail(fileId, thumbnail); // Intended
+ return true;
},
- { concurrency: 1 },
+ { concurrency: 4 },
);
diff --git a/src/routes/(main)/directory/[[id]]/DirectoryEntries/File.svelte b/src/routes/(main)/directory/[[id]]/DirectoryEntries/File.svelte
index 22870e6..4245898 100644
--- a/src/routes/(main)/directory/[[id]]/DirectoryEntries/File.svelte
+++ b/src/routes/(main)/directory/[[id]]/DirectoryEntries/File.svelte
@@ -2,9 +2,10 @@
import type { Writable } from "svelte/store";
import { ActionEntryButton } from "$lib/components/atoms";
import { DirectoryEntryLabel } from "$lib/components/molecules";
+ import { getFileThumbnail } from "$lib/modules/file";
import type { FileInfo } from "$lib/modules/filesystem";
import { formatDateTime } from "$lib/modules/util";
- import { getFileThumbnail } from "./service";
+ import { requestFileThumbnailDownload } from "./service";
import type { SelectedEntry } from "../service.svelte";
import IconMoreVert from "~icons/material-symbols/more-vert";
@@ -17,7 +18,7 @@
let { info, onclick, onOpenMenuClick }: Props = $props();
- let thumbnail: ArrayBuffer | undefined = $state();
+ let thumbnail: string | undefined = $state();
const openFile = () => {
const { id, dataKey, dataKeyVersion, name } = $info!;
@@ -35,12 +36,15 @@
$effect(() => {
if ($info?.dataKey) {
- getFileThumbnail($info.id, $info.dataKey)
- .then((thumbnailData) => {
- thumbnail = thumbnailData ?? undefined;
+ getFileThumbnail($info.id)
+ .then(
+ (thumbnailUrl) => thumbnailUrl || requestFileThumbnailDownload($info.id, $info.dataKey!),
+ )
+ .then((thumbnailUrl) => {
+ thumbnail = thumbnailUrl ?? undefined;
})
.catch(() => {
- // TODO: Error handling
+ // TODO: Error Handling
thumbnail = undefined;
});
} else {
diff --git a/src/routes/(main)/directory/[[id]]/DirectoryEntries/service.ts b/src/routes/(main)/directory/[[id]]/DirectoryEntries/service.ts
index a14a866..70d8887 100644
--- a/src/routes/(main)/directory/[[id]]/DirectoryEntries/service.ts
+++ b/src/routes/(main)/directory/[[id]]/DirectoryEntries/service.ts
@@ -1,8 +1,10 @@
import { callGetApi } from "$lib/hooks";
import { decryptData } from "$lib/modules/crypto";
+import { storeFileThumbnail } from "$lib/modules/file";
+import { getThumbnailUrl } from "$lib/modules/thumbnail";
import type { FileThumbnailInfoResponse } from "$lib/server/schemas";
-export const getFileThumbnail = async (fileId: number, dataKey: CryptoKey) => {
+export const requestFileThumbnailDownload = async (fileId: number, dataKey: CryptoKey) => {
let res = await callGetApi(`/api/file/${fileId}/thumbnail`);
if (!res.ok) return null;
@@ -13,5 +15,7 @@ export const getFileThumbnail = async (fileId: number, dataKey: CryptoKey) => {
const thumbnailEncrypted = await res.arrayBuffer();
const thumbnail = await decryptData(thumbnailEncrypted, thumbnailEncryptedIv, dataKey);
- return thumbnail;
+
+ storeFileThumbnail(fileId, thumbnail); // Intended
+ return getThumbnailUrl(thumbnail);
};
diff --git a/src/routes/(main)/directory/[[id]]/service.svelte.ts b/src/routes/(main)/directory/[[id]]/service.svelte.ts
index 3c5f689..d4a0556 100644
--- a/src/routes/(main)/directory/[[id]]/service.svelte.ts
+++ b/src/routes/(main)/directory/[[id]]/service.svelte.ts
@@ -2,7 +2,7 @@ import { getContext, setContext } from "svelte";
import { callGetApi, callPostApi } from "$lib/hooks";
import { storeHmacSecrets } from "$lib/indexedDB";
import { generateDataKey, wrapDataKey, unwrapHmacSecret, encryptString } from "$lib/modules/crypto";
-import { storeFileCache, deleteFileCache, uploadFile } from "$lib/modules/file";
+import { storeFileCache, deleteFileCache, storeFileThumbnail, uploadFile } from "$lib/modules/file";
import type {
DirectoryRenameRequest,
DirectoryCreateRequest,
@@ -81,6 +81,10 @@ export const requestFileUpload = async (
if (!res) return false;
storeFileCache(res.fileId, res.fileBuffer); // Intended
+ if (res.thumbnailBuffer) {
+ storeFileThumbnail(res.fileId, res.thumbnailBuffer); // Intended
+ }
+
return true;
};