concept-collection / numbl-image-filter
numbl-image-filter / src / imageStore.ts
102 lines · 3.0 KBCodeBlameHistory
2 * Persistent storage for uploaded images, backed by IndexedDB.
3 *
4 * We store the original (compressed) file Blob plus a small thumbnail data URL
5 * and basic metadata. The full image is decoded from the Blob on demand when a
6 * stored upload is selected, so the database stays compact.
7 */
8import type { RawImage } from "./imageConvert.ts";
9import { loadImageFromFile, rawToThumbDataUrl } from "./imageLoad.ts";
11const DB_NAME = "numbl-image-filter";
12const DB_VERSION = 1;
13const STORE = "uploads";
15export interface UploadRecord {
16 id: string;
17 name: string;
18 width: number;
19 height: number;
20 /** Small data URL shown in the picker. */
21 thumb: string;
22 /** Original file bytes (compressed); decoded on demand. */
23 blob: Blob;
24 createdAt: number;
27function genId(): string {
28 if (typeof crypto !== "undefined" && crypto.randomUUID) {
29 return crypto.randomUUID();
30 }
31 return `up-${Date.now()}-${Math.random().toString(36).slice(2)}`;
34let dbPromise: Promise<IDBDatabase> | null = null;
35function getDb(): Promise<IDBDatabase> {
36 if (!dbPromise) {
37 dbPromise = new Promise((resolve, reject) => {
38 const req = indexedDB.open(DB_NAME, DB_VERSION);
39 req.onupgradeneeded = () => {
40 const db = req.result;
41 if (!db.objectStoreNames.contains(STORE)) {
42 db.createObjectStore(STORE, { keyPath: "id" });
43 }
44 };
45 req.onsuccess = () => resolve(req.result);
46 req.onerror = () =>
47 reject(req.error ?? new Error("Failed to open IndexedDB"));
48 });
49 }
50 return dbPromise;
53function runRequest<T>(
54 mode: IDBTransactionMode,
55 fn: (store: IDBObjectStore) => IDBRequest<T>
56): Promise<T> {
57 return getDb().then(
58 (db) =>
59 new Promise<T>((resolve, reject) => {
60 const t = db.transaction(STORE, mode);
61 const req = fn(t.objectStore(STORE));
62 req.onsuccess = () => resolve(req.result);
63 req.onerror = () => reject(req.error);
64 })
65 );
68/** All stored uploads, newest first. */
69export async function listUploads(): Promise<UploadRecord[]> {
70 const all = await runRequest<UploadRecord[]>(
71 "readonly",
72 (s) => s.getAll() as IDBRequest<UploadRecord[]>
73 );
74 return all.sort((a, b) => b.createdAt - a.createdAt);
77/** Decode + store an uploaded file. Returns the record and decoded image. */
78export async function addUpload(
79 file: File
80): Promise<{ record: UploadRecord; image: RawImage }> {
81 const image = await loadImageFromFile(file);
82 const record: UploadRecord = {
83 id: genId(),
84 name: file.name || "upload",
85 width: image.width,
86 height: image.height,
87 thumb: rawToThumbDataUrl(image, 72),
88 blob: file,
89 createdAt: Date.now(),
90 };
91 await runRequest("readwrite", (s) => s.put(record));
92 return { record, image };
95export async function deleteUpload(id: string): Promise<void> {
96 await runRequest("readwrite", (s) => s.delete(id));
99/** Decode the full image for a stored upload. */
100export function getUploadImage(rec: UploadRecord): Promise<RawImage> {
101 return loadImageFromFile(rec.blob);