diff --git a/src/lib/state.svelte.ts b/src/lib/state.svelte.ts index 7c23ff2..50c4a6c 100644 --- a/src/lib/state.svelte.ts +++ b/src/lib/state.svelte.ts @@ -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; -} - -async function blobToDataUrl(blob: Blob): Promise { +function openDb(): Promise { 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 { - const res = await fetch(dataUrl); - return res.blob(); -} - function createState() { let manifest = $state({ version: 1, @@ -81,6 +76,7 @@ function createState() { let clipboard = $state[]>([]); let locked = $state(false); let snapToGrid = $state(false); + let persistenceError = $state(null); const GRID_SIZE = 20; @@ -98,18 +94,25 @@ function createState() { clearTimeout(saveTimeout); saveTimeout = null; } - const storedAssets: Record = {}; - 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 = {}; + 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((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 | 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; } + + 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 { @@ -481,6 +533,9 @@ function createState() { get snapToGrid() { return snapToGrid; }, + get persistenceError() { + return persistenceError; + }, get maxZIndex() { return maxZIndex; },