Using IndexedDB
This commit is contained in:
@@ -1,7 +1,10 @@
|
|||||||
import { SvelteSet } from 'svelte/reactivity';
|
import { SvelteSet } from 'svelte/reactivity';
|
||||||
import type { Item, Manifest, Asset, AssetStore, Viewport } from './types';
|
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 = `* {
|
const DEFAULT_SHARED_CSS = `* {
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
@@ -39,30 +42,22 @@ const DEFAULT_APP_CSS = `:root {
|
|||||||
--cm-operator: #56b6c2;
|
--cm-operator: #56b6c2;
|
||||||
}`;
|
}`;
|
||||||
|
|
||||||
interface StoredAsset {
|
interface StoredAssetIndexedDB {
|
||||||
dataUrl: string;
|
blob: Blob;
|
||||||
filename: string;
|
filename: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface StoredState {
|
function openDb(): Promise<IDBDatabase> {
|
||||||
manifest: Manifest;
|
|
||||||
assets: Record<string, StoredAsset>;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function blobToDataUrl(blob: Blob): Promise<string> {
|
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const reader = new FileReader();
|
const request = indexedDB.open(DB_NAME, DB_VERSION);
|
||||||
reader.onload = () => resolve(reader.result as string);
|
request.onerror = () => reject(request.error);
|
||||||
reader.onerror = reject;
|
request.onsuccess = () => resolve(request.result);
|
||||||
reader.readAsDataURL(blob);
|
request.onupgradeneeded = () => {
|
||||||
|
request.result.createObjectStore(STORE_NAME);
|
||||||
|
};
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function dataUrlToBlob(dataUrl: string): Promise<Blob> {
|
|
||||||
const res = await fetch(dataUrl);
|
|
||||||
return res.blob();
|
|
||||||
}
|
|
||||||
|
|
||||||
function createState() {
|
function createState() {
|
||||||
let manifest = $state<Manifest>({
|
let manifest = $state<Manifest>({
|
||||||
version: 1,
|
version: 1,
|
||||||
@@ -81,6 +76,7 @@ function createState() {
|
|||||||
let clipboard = $state<Omit<Item, 'id'>[]>([]);
|
let clipboard = $state<Omit<Item, 'id'>[]>([]);
|
||||||
let locked = $state<boolean>(false);
|
let locked = $state<boolean>(false);
|
||||||
let snapToGrid = $state<boolean>(false);
|
let snapToGrid = $state<boolean>(false);
|
||||||
|
let persistenceError = $state<string | null>(null);
|
||||||
|
|
||||||
const GRID_SIZE = 20;
|
const GRID_SIZE = 20;
|
||||||
|
|
||||||
@@ -98,18 +94,25 @@ function createState() {
|
|||||||
clearTimeout(saveTimeout);
|
clearTimeout(saveTimeout);
|
||||||
saveTimeout = null;
|
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 {
|
try {
|
||||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(stored));
|
const storedAssets: Record<string, StoredAssetIndexedDB> = {};
|
||||||
} catch {
|
for (const [id, asset] of Object.entries(assets)) {
|
||||||
// localStorage full or unavailable
|
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() {
|
async function restore() {
|
||||||
try {
|
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;
|
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 = {};
|
const restoredAssets: AssetStore = {};
|
||||||
for (const [id, storedAsset] of Object.entries(stored.assets)) {
|
for (const [id, legacyAsset] of Object.entries(legacy.assets)) {
|
||||||
const blob = await dataUrlToBlob(storedAsset.dataUrl);
|
const blob = await (await fetch(legacyAsset.dataUrl)).blob();
|
||||||
const url = URL.createObjectURL(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]) {
|
if (item.assetId && restoredAssets[item.assetId]) {
|
||||||
const newUrl = restoredAssets[item.assetId].url;
|
const newUrl = restoredAssets[item.assetId].url;
|
||||||
item.html = item.html.replace(/src="[^"]*"/g, `src="${newUrl}"`);
|
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;
|
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;
|
editingId = null;
|
||||||
editingGlobal = false;
|
editingGlobal = false;
|
||||||
focusedId = null;
|
focusedId = null;
|
||||||
localStorage.removeItem(STORAGE_KEY);
|
indexedDB.deleteDatabase(DB_NAME);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function load(newManifest: Manifest, newAssets: AssetStore): Promise<void> {
|
async function load(newManifest: Manifest, newAssets: AssetStore): Promise<void> {
|
||||||
@@ -481,6 +533,9 @@ function createState() {
|
|||||||
get snapToGrid() {
|
get snapToGrid() {
|
||||||
return snapToGrid;
|
return snapToGrid;
|
||||||
},
|
},
|
||||||
|
get persistenceError() {
|
||||||
|
return persistenceError;
|
||||||
|
},
|
||||||
get maxZIndex() {
|
get maxZIndex() {
|
||||||
return maxZIndex;
|
return maxZIndex;
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user