1import {
2 createProjectFaviconCache,
3 createProjectFaviconImageLoader,
4 PROJECT_FAVICON_MAX_DATA_URL_LENGTH,
5 PROJECT_FAVICON_THUMBNAIL_SIZE,
6} from "@t3tools/client-runtime/project-favicon-cache";
7
8const DATABASE_NAME = "t3code:project-favicons";
9const DATABASE_VERSION = 2;
10const STORE_NAME = "images";
11let database: Promise<IDBDatabase> | undefined;
12
13function openDatabase() {
14 return (database ??= new Promise<IDBDatabase>((resolve, reject) => {
15 const request = indexedDB.open(DATABASE_NAME, DATABASE_VERSION);
16 request.addEventListener("upgradeneeded", () => {
17 for (const name of request.result.objectStoreNames) {
18 if (name !== STORE_NAME) request.result.deleteObjectStore(name);
19 }
20 if (!request.result.objectStoreNames.contains(STORE_NAME)) {
21 request.result.createObjectStore(STORE_NAME);
22 }
23 });
24 request.addEventListener("success", () => resolve(request.result));
25 request.addEventListener("error", () => reject(request.error));
26 request.addEventListener("blocked", () => reject(new Error("Project icon cache is blocked.")));
27 }));
28}
29
30function completed(transaction: IDBTransaction) {
31 return new Promise<void>((resolve, reject) => {
32 transaction.addEventListener("complete", () => resolve());
33 transaction.addEventListener("abort", () => reject(transaction.error));
34 transaction.addEventListener("error", () => reject(transaction.error));
35 });
36}
37
38async function withStore<A>(
39 mode: IDBTransactionMode,
40 use: (store: IDBObjectStore) => IDBRequest<A> | void,
41) {
42 const transaction = (await openDatabase()).transaction(STORE_NAME, mode);
43 const request = use(transaction.objectStore(STORE_NAME));
44 await completed(transaction);
45 return request?.result;
46}
47
48/** Rasterizes a bitmap that is too large to inline, retrying at half size. */
49async function downscaleProjectFavicon(
50 image: { readonly mimeType: string; readonly bytes: Uint8Array<ArrayBuffer> },
51 signal: AbortSignal,
52) {
53 const bitmap = await createImageBitmap(new Blob([image.bytes], { type: image.mimeType }));
54 try {
55 signal.throwIfAborted();
56 const canvas = document.createElement("canvas");
57 for (const size of [PROJECT_FAVICON_THUMBNAIL_SIZE, PROJECT_FAVICON_THUMBNAIL_SIZE / 2]) {
58 const scale = Math.min(1, size / bitmap.width, size / bitmap.height);
59 canvas.width = Math.max(1, Math.round(bitmap.width * scale));
60 canvas.height = Math.max(1, Math.round(bitmap.height * scale));
61 const context = canvas.getContext("2d");
62 if (!context) throw new Error("Canvas is unavailable.");
63 context.clearRect(0, 0, canvas.width, canvas.height);
64 context.drawImage(bitmap, 0, 0, canvas.width, canvas.height);
65 const dataUrl = canvas.toDataURL("image/webp", 0.85);
66 if (dataUrl.length <= PROJECT_FAVICON_MAX_DATA_URL_LENGTH) return dataUrl;
67 }
68 throw new Error("Project icon thumbnail exceeds the cache limit.");
69 } finally {
70 bitmap.close();
71 }
72}
73
74export const projectFaviconCache = createProjectFaviconCache({
75 storage: {
76 list: async () => (await withStore("readonly", (store) => store.getAll())) ?? [],
77 put: async (key, entry) => {
78 await withStore("readwrite", (store) => store.put(entry, key));
79 },
80 remove: async (key) => {
81 await withStore("readwrite", (store) => store.delete(key));
82 },
83 },
84 load: createProjectFaviconImageLoader({ downscale: downscaleProjectFavicon }),
85});