Using IndexedDB
This commit is contained in:
@@ -1,7 +1,10 @@
|
||||
import { SvelteSet } from 'svelte/reactivity';
|
||||
import type { Item, Manifest, Asset, AssetStore, Viewport } from './types';
|
||||
|
||||
const STORAGE_KEY = 'buboard';
|
||||
const DB_NAME = 'buboard';
|
||||
const DB_VERSION = 1;
|
||||
const STORE_NAME = 'state';
|
||||
const LEGACY_STORAGE_KEY = 'buboard';
|
||||
|
||||
const DEFAULT_SHARED_CSS = `* {
|
||||
box-sizing: border-box;
|
||||
@@ -39,30 +42,22 @@ const DEFAULT_APP_CSS = `:root {
|
||||
--cm-operator: #56b6c2;
|
||||
}`;
|
||||
|
||||
interface StoredAsset {
|
||||
dataUrl: string;
|
||||
interface StoredAssetIndexedDB {
|
||||
blob: Blob;
|
||||
filename: string;
|
||||
}
|
||||
|
||||
interface StoredState {
|
||||
manifest: Manifest;
|
||||
assets: Record<string, StoredAsset>;
|
||||
}
|
||||
|
||||
async function blobToDataUrl(blob: Blob): Promise<string> {
|
||||
function openDb(): Promise<IDBDatabase> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => resolve(reader.result as string);
|
||||
reader.onerror = reject;
|
||||
reader.readAsDataURL(blob);
|
||||
const request = indexedDB.open(DB_NAME, DB_VERSION);
|
||||
request.onerror = () => reject(request.error);
|
||||
request.onsuccess = () => resolve(request.result);
|
||||
request.onupgradeneeded = () => {
|
||||
request.result.createObjectStore(STORE_NAME);
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async function dataUrlToBlob(dataUrl: string): Promise<Blob> {
|
||||
const res = await fetch(dataUrl);
|
||||
return res.blob();
|
||||
}
|
||||
|
||||
function createState() {
|
||||
let manifest = $state<Manifest>({
|
||||
version: 1,
|
||||
@@ -81,6 +76,7 @@ function createState() {
|
||||
let clipboard = $state<Omit<Item, 'id'>[]>([]);
|
||||
let locked = $state<boolean>(false);
|
||||
let snapToGrid = $state<boolean>(false);
|
||||
let persistenceError = $state<string | null>(null);
|
||||
|
||||
const GRID_SIZE = 20;
|
||||
|
||||
@@ -98,18 +94,25 @@ function createState() {
|
||||
clearTimeout(saveTimeout);
|
||||
saveTimeout = null;
|
||||
}
|
||||
const storedAssets: Record<string, StoredAsset> = {};
|
||||
for (const [id, asset] of Object.entries(assets)) {
|
||||
storedAssets[id] = {
|
||||
dataUrl: await blobToDataUrl(asset.blob),
|
||||
filename: asset.filename,
|
||||
};
|
||||
}
|
||||
const stored: StoredState = { manifest, assets: storedAssets };
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(stored));
|
||||
} catch {
|
||||
// localStorage full or unavailable
|
||||
const storedAssets: Record<string, StoredAssetIndexedDB> = {};
|
||||
for (const [id, asset] of Object.entries(assets)) {
|
||||
storedAssets[id] = { blob: asset.blob, filename: asset.filename };
|
||||
}
|
||||
const db = await openDb();
|
||||
const tx = db.transaction(STORE_NAME, 'readwrite');
|
||||
const store = tx.objectStore(STORE_NAME);
|
||||
store.put($state.snapshot(manifest), 'manifest');
|
||||
store.put(storedAssets, 'assets');
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
tx.oncomplete = () => resolve();
|
||||
tx.onerror = () => reject(tx.error);
|
||||
});
|
||||
db.close();
|
||||
persistenceError = null;
|
||||
} catch (e) {
|
||||
persistenceError = e instanceof Error ? e.message : 'Failed to save';
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -122,29 +125,78 @@ function createState() {
|
||||
|
||||
async function restore() {
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEY);
|
||||
const db = await openDb();
|
||||
|
||||
const loadFromDb = (): Promise<{ manifest: Manifest | null; assets: Record<string, StoredAssetIndexedDB> | null }> => {
|
||||
return new Promise((resolve) => {
|
||||
const tx = db.transaction(STORE_NAME, 'readonly');
|
||||
const store = tx.objectStore(STORE_NAME);
|
||||
const manifestReq = store.get('manifest');
|
||||
const assetsReq = store.get('assets');
|
||||
tx.oncomplete = () => {
|
||||
resolve({ manifest: manifestReq.result ?? null, assets: assetsReq.result ?? null });
|
||||
};
|
||||
tx.onerror = () => resolve({ manifest: null, assets: null });
|
||||
});
|
||||
};
|
||||
|
||||
const { manifest: storedManifest, assets: storedAssets } = await loadFromDb();
|
||||
db.close();
|
||||
|
||||
if (storedManifest && storedManifest.version === 1) {
|
||||
const restoredAssets: AssetStore = {};
|
||||
if (storedAssets) {
|
||||
for (const [id, asset] of Object.entries(storedAssets)) {
|
||||
const url = URL.createObjectURL(asset.blob);
|
||||
restoredAssets[id] = { blob: asset.blob, url, filename: asset.filename };
|
||||
}
|
||||
}
|
||||
for (const item of storedManifest.items) {
|
||||
if (item.assetId && restoredAssets[item.assetId]) {
|
||||
const newUrl = restoredAssets[item.assetId].url;
|
||||
item.html = item.html.replace(/src="[^"]*"/g, `src="${newUrl}"`);
|
||||
}
|
||||
}
|
||||
manifest = { ...storedManifest, flags: storedManifest.flags ?? {} };
|
||||
assets = restoredAssets;
|
||||
return;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to restore from IndexedDB:', e);
|
||||
}
|
||||
|
||||
// Migration from localStorage
|
||||
try {
|
||||
const raw = localStorage.getItem(LEGACY_STORAGE_KEY);
|
||||
if (!raw) return;
|
||||
const stored: StoredState = JSON.parse(raw);
|
||||
if (stored.manifest.version !== 1) return;
|
||||
|
||||
interface LegacyStoredAsset { dataUrl: string; filename: string; }
|
||||
interface LegacyStoredState { manifest: Manifest; assets: Record<string, LegacyStoredAsset>; }
|
||||
|
||||
const legacy: LegacyStoredState = JSON.parse(raw);
|
||||
if (legacy.manifest.version !== 1) return;
|
||||
|
||||
const restoredAssets: AssetStore = {};
|
||||
for (const [id, storedAsset] of Object.entries(stored.assets)) {
|
||||
const blob = await dataUrlToBlob(storedAsset.dataUrl);
|
||||
for (const [id, legacyAsset] of Object.entries(legacy.assets)) {
|
||||
const blob = await (await fetch(legacyAsset.dataUrl)).blob();
|
||||
const url = URL.createObjectURL(blob);
|
||||
restoredAssets[id] = { blob, url, filename: storedAsset.filename };
|
||||
restoredAssets[id] = { blob, url, filename: legacyAsset.filename };
|
||||
}
|
||||
|
||||
for (const item of stored.manifest.items) {
|
||||
for (const item of legacy.manifest.items) {
|
||||
if (item.assetId && restoredAssets[item.assetId]) {
|
||||
const newUrl = restoredAssets[item.assetId].url;
|
||||
item.html = item.html.replace(/src="[^"]*"/g, `src="${newUrl}"`);
|
||||
}
|
||||
}
|
||||
|
||||
manifest = { ...stored.manifest, flags: stored.manifest.flags ?? {} };
|
||||
manifest = { ...legacy.manifest, flags: legacy.manifest.flags ?? {} };
|
||||
assets = restoredAssets;
|
||||
} catch {
|
||||
// corrupted or missing data
|
||||
|
||||
await saveNow();
|
||||
localStorage.removeItem(LEGACY_STORAGE_KEY);
|
||||
} catch (e) {
|
||||
console.error('Failed to migrate from localStorage:', e);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -428,7 +480,7 @@ function createState() {
|
||||
editingId = null;
|
||||
editingGlobal = false;
|
||||
focusedId = null;
|
||||
localStorage.removeItem(STORAGE_KEY);
|
||||
indexedDB.deleteDatabase(DB_NAME);
|
||||
}
|
||||
|
||||
async function load(newManifest: Manifest, newAssets: AssetStore): Promise<void> {
|
||||
@@ -481,6 +533,9 @@ function createState() {
|
||||
get snapToGrid() {
|
||||
return snapToGrid;
|
||||
},
|
||||
get persistenceError() {
|
||||
return persistenceError;
|
||||
},
|
||||
get maxZIndex() {
|
||||
return maxZIndex;
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user