Skip to content

Commit f029774

Browse files
antontranelisclaude
andcommitted
feat: migrate from localStorage to IndexedDB for persistence
- Add IndexedDB storage adapter for Zustand persist middleware - Update billStore to use IndexedDB instead of localStorage - Add skipHydration: true for Next.js SSR compatibility - Export initializeBillStore() for manual hydration - Remove old localStorage helper functions - Bump version to 1.0.88 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1 parent 2c760d1 commit f029774

5 files changed

Lines changed: 139 additions & 151 deletions

File tree

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@antontranelis/money-printer",
3-
"version": "1.0.87",
3+
"version": "1.0.88",
44
"description": "Create personalized time vouchers that look like real currency. React components for voucher generation with PDF export.",
55
"type": "module",
66
"license": "MIT",

src/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ export {
1313
} from './components';
1414

1515
// Store
16-
export { useBillStore } from './stores';
16+
export { useBillStore, initializeBillStore } from './stores';
1717

1818
// Services
1919
export {

src/stores/billStore.ts

Lines changed: 22 additions & 148 deletions
Original file line numberDiff line numberDiff line change
@@ -1,96 +1,9 @@
11
import { create } from 'zustand';
2-
import { persist } from 'zustand/middleware';
2+
import { persist, createJSONStorage } from 'zustand/middleware';
33
import type { BillState, PersonalInfo, VoucherConfig, BillSide, HourValue, Language } from '../types/bill';
4-
import { resizeImageForStorage } from '../services/imageEffects';
54
import { clearHueShiftedCache } from '../services/canvasRenderer';
65
import { clearCompositorCache } from '../services/templateCompositor';
7-
8-
// Storage keys for portrait images (separate from main store to handle size limits)
9-
const PORTRAIT_STORAGE_KEY = 'money-generator-portrait';
10-
const BG_REMOVED_STORAGE_KEY = 'money-generator-bg-removed';
11-
12-
/**
13-
* Save portrait to localStorage with fallback
14-
* Clears old data before saving to prevent overflow
15-
*/
16-
async function savePortraitToStorage(imageDataUrl: string | null): Promise<void> {
17-
try {
18-
// Always clear old portrait first to prevent overflow
19-
localStorage.removeItem(PORTRAIT_STORAGE_KEY);
20-
21-
if (!imageDataUrl) return;
22-
23-
// Resize for storage (1200px max, PNG for transparency)
24-
const resized = await resizeImageForStorage(imageDataUrl);
25-
26-
try {
27-
localStorage.setItem(PORTRAIT_STORAGE_KEY, resized);
28-
} catch (e) {
29-
// Storage quota exceeded - silently fail, image will only be in memory
30-
console.warn('Could not persist portrait to localStorage:', e);
31-
}
32-
} catch (e) {
33-
console.warn('Error processing portrait for storage:', e);
34-
}
35-
}
36-
37-
/**
38-
* Save background-removed image to localStorage
39-
*/
40-
async function saveBgRemovedToStorage(imageDataUrl: string | null): Promise<void> {
41-
try {
42-
// Always clear old image first to prevent overflow
43-
localStorage.removeItem(BG_REMOVED_STORAGE_KEY);
44-
45-
if (!imageDataUrl) return;
46-
47-
// Resize for storage (1200px max, PNG for transparency)
48-
const resized = await resizeImageForStorage(imageDataUrl);
49-
50-
try {
51-
localStorage.setItem(BG_REMOVED_STORAGE_KEY, resized);
52-
} catch (e) {
53-
// Storage quota exceeded - silently fail, image will only be in memory
54-
console.warn('Could not persist bg-removed image to localStorage:', e);
55-
}
56-
} catch (e) {
57-
console.warn('Error processing bg-removed image for storage:', e);
58-
}
59-
}
60-
61-
/**
62-
* Load portrait from localStorage
63-
*/
64-
function loadPortraitFromStorage(): string | null {
65-
try {
66-
return localStorage.getItem(PORTRAIT_STORAGE_KEY);
67-
} catch {
68-
return null;
69-
}
70-
}
71-
72-
/**
73-
* Load background-removed image from localStorage
74-
*/
75-
function loadBgRemovedFromStorage(): string | null {
76-
try {
77-
return localStorage.getItem(BG_REMOVED_STORAGE_KEY);
78-
} catch {
79-
return null;
80-
}
81-
}
82-
83-
/**
84-
* Clear all portrait data from localStorage
85-
*/
86-
function clearPortraitFromStorage(): void {
87-
try {
88-
localStorage.removeItem(PORTRAIT_STORAGE_KEY);
89-
localStorage.removeItem(BG_REMOVED_STORAGE_KEY);
90-
} catch {
91-
// Ignore errors
92-
}
93-
}
6+
import { indexedDBStorage } from './indexedDBStorage';
947

958
// Detect browser language and return 'de' or 'en'
969
function getBrowserLanguage(): Language {
@@ -173,10 +86,6 @@ export const useBillStore = create<BillState & BillActions>()(
17386
})),
17487

17588
setPortrait: (original, enhanced = null) => {
176-
// NOTE: Do NOT save processed images to localStorage here!
177-
// Only rawImage should be persisted (via setPortraitRawImage)
178-
// portrait.original is computed on-the-fly from rawImage + effects
179-
18089
return set((state) => {
18190
// Keep zoom/pan if we already have a portrait (original OR rawImage)
18291
// This preserves settings during reload when original is null but rawImage exists
@@ -231,9 +140,6 @@ export const useBillStore = create<BillState & BillActions>()(
231140
})),
232141

233142
setPortraitRawImage: (rawImage) => {
234-
// Save rawImage to localStorage (this is the TRUE original, never processed)
235-
savePortraitToStorage(rawImage);
236-
237143
return set((state) => ({
238144
portrait: {
239145
...state.portrait,
@@ -243,11 +149,6 @@ export const useBillStore = create<BillState & BillActions>()(
243149
},
244150

245151
setPortraitBgRemoved: (bgRemoved, bgRemovedImage) => {
246-
// Save bg-removed image to localStorage asynchronously
247-
if (bgRemovedImage !== undefined) {
248-
saveBgRemovedToStorage(bgRemovedImage);
249-
}
250-
251152
return set((state) => ({
252153
portrait: {
253154
...state.portrait,
@@ -314,12 +215,16 @@ export const useBillStore = create<BillState & BillActions>()(
314215
},
315216

316217
reset: () => {
317-
clearPortraitFromStorage();
318218
return set(initialState);
319219
},
320220
}),
321221
{
322222
name: 'money-generator-storage',
223+
// Use IndexedDB for storage - handles large images without localStorage limits
224+
storage: createJSONStorage(() => indexedDBStorage),
225+
// Skip automatic hydration - consumer must call useBillStore.persist.rehydrate()
226+
// This prevents SSR/client mismatch issues with Next.js
227+
skipHydration: true,
323228
// Migrate old storage - preserve user settings
324229
migrate: (persistedState: unknown) => {
325230
const state = persistedState as BillState;
@@ -336,25 +241,21 @@ export const useBillStore = create<BillState & BillActions>()(
336241
}
337242
return state;
338243
},
339-
version: 2, // Bump version to trigger migration
244+
version: 3, // Bump version for IndexedDB migration
245+
// Store everything in IndexedDB - no size limits!
340246
partialize: (state): BillState => ({
341247
personalInfo: state.personalInfo,
342-
voucherConfig: {
343-
...state.voucherConfig,
344-
// Persist all voucher settings including templateHue
345-
},
346-
appLanguage: state.appLanguage, // Persist app language selection
347-
// Only persist small settings, NOT image data (too large for localStorage)
248+
voucherConfig: state.voucherConfig,
249+
appLanguage: state.appLanguage,
348250
portrait: {
349-
original: null, // Don't persist - too large
350-
enhanced: null, // Don't persist - too large
251+
original: null, // Don't persist processed image - recompute from rawImage
252+
enhanced: null, // Don't persist - recompute if needed
351253
useEnhanced: state.portrait.useEnhanced,
352254
zoom: state.portrait.zoom,
353255
panX: state.portrait.panX,
354256
panY: state.portrait.panY,
355-
rawImage: null, // Don't persist - too large
356-
bgRemovedImage: null, // Don't persist - too large
357-
// Persist background removal settings so UI shows correct controls after reload
257+
rawImage: state.portrait.rawImage, // Persist raw image in IndexedDB
258+
bgRemovedImage: state.portrait.bgRemovedImage, // Persist bg-removed in IndexedDB
358259
bgRemoved: state.portrait.bgRemoved,
359260
bgOpacity: state.portrait.bgOpacity,
360261
bgBlur: state.portrait.bgBlur,
@@ -368,39 +269,12 @@ export const useBillStore = create<BillState & BillActions>()(
368269
)
369270
);
370271

371-
// Initialize portrait from localStorage on app start
372-
// This runs once when the module is loaded
373-
if (typeof window !== 'undefined') {
374-
const storedRawImage = loadPortraitFromStorage();
375-
const storedBgRemoved = loadBgRemovedFromStorage();
376-
377-
if (storedRawImage) {
378-
// Use setTimeout to ensure store is fully initialized
379-
setTimeout(() => {
380-
const state = useBillStore.getState();
381-
// Only restore if no rawImage is currently set (avoid overwriting fresh uploads)
382-
if (!state.portrait.rawImage) {
383-
// Check if we have bgRemoved state but missing image - reset if so
384-
const hasBgRemovedState = state.portrait.bgRemoved;
385-
const hasBgRemovedImage = storedBgRemoved !== null;
386-
387-
useBillStore.setState({
388-
portrait: {
389-
...state.portrait,
390-
// Set rawImage from storage (this is the TRUE original)
391-
rawImage: storedRawImage,
392-
// DON'T set portrait.original here - it will be computed by PortraitUpload
393-
// when it detects rawImage exists but original is null
394-
original: null,
395-
// Restore bg-removed image if available
396-
bgRemovedImage: storedBgRemoved,
397-
// Only keep bgRemoved state if we have the image
398-
bgRemoved: hasBgRemovedState && hasBgRemovedImage,
399-
bgOpacity: hasBgRemovedState && hasBgRemovedImage ? state.portrait.bgOpacity : 0,
400-
bgBlur: hasBgRemovedState && hasBgRemovedImage ? state.portrait.bgBlur : 0,
401-
},
402-
});
403-
}
404-
}, 0);
272+
/**
273+
* Initialize the bill store - must be called on client-side
274+
* This triggers hydration from IndexedDB
275+
*/
276+
export function initializeBillStore(): void {
277+
if (typeof window !== 'undefined') {
278+
useBillStore.persist.rehydrate();
405279
}
406280
}

src/stores/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
export { useBillStore } from './billStore';
1+
export { useBillStore, initializeBillStore } from './billStore';

src/stores/indexedDBStorage.ts

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
/**
2+
* IndexedDB storage adapter for Zustand persist middleware
3+
* Handles large data (images) without localStorage size limits
4+
*/
5+
6+
const DB_NAME = 'money-generator-db';
7+
const STORE_NAME = 'zustand';
8+
const DB_VERSION = 1;
9+
10+
let dbPromise: Promise<IDBDatabase> | null = null;
11+
12+
function openDB(): Promise<IDBDatabase> {
13+
if (dbPromise) return dbPromise;
14+
15+
dbPromise = new Promise((resolve, reject) => {
16+
if (typeof window === 'undefined' || !window.indexedDB) {
17+
reject(new Error('IndexedDB not available'));
18+
return;
19+
}
20+
21+
const request = indexedDB.open(DB_NAME, DB_VERSION);
22+
23+
request.onerror = () => {
24+
dbPromise = null;
25+
reject(request.error);
26+
};
27+
28+
request.onsuccess = () => {
29+
resolve(request.result);
30+
};
31+
32+
request.onupgradeneeded = () => {
33+
const db = request.result;
34+
if (!db.objectStoreNames.contains(STORE_NAME)) {
35+
db.createObjectStore(STORE_NAME);
36+
}
37+
};
38+
});
39+
40+
return dbPromise;
41+
}
42+
43+
async function getItem(key: string): Promise<string | null> {
44+
try {
45+
const db = await openDB();
46+
return new Promise((resolve, reject) => {
47+
const tx = db.transaction(STORE_NAME, 'readonly');
48+
const store = tx.objectStore(STORE_NAME);
49+
const request = store.get(key);
50+
request.onerror = () => reject(request.error);
51+
request.onsuccess = () => resolve(request.result ?? null);
52+
});
53+
} catch (e) {
54+
console.error('[IndexedDB] Failed to get item:', e);
55+
return null;
56+
}
57+
}
58+
59+
async function setItem(key: string, value: string): Promise<void> {
60+
try {
61+
const db = await openDB();
62+
return new Promise((resolve, reject) => {
63+
const tx = db.transaction(STORE_NAME, 'readwrite');
64+
const store = tx.objectStore(STORE_NAME);
65+
const request = store.put(value, key);
66+
request.onerror = () => reject(request.error);
67+
request.onsuccess = () => resolve();
68+
});
69+
} catch (e) {
70+
console.error('[IndexedDB] Failed to set item:', e);
71+
}
72+
}
73+
74+
async function removeItem(key: string): Promise<void> {
75+
try {
76+
const db = await openDB();
77+
return new Promise((resolve, reject) => {
78+
const tx = db.transaction(STORE_NAME, 'readwrite');
79+
const store = tx.objectStore(STORE_NAME);
80+
const request = store.delete(key);
81+
request.onerror = () => reject(request.error);
82+
request.onsuccess = () => resolve();
83+
});
84+
} catch (e) {
85+
console.error('[IndexedDB] Failed to remove item:', e);
86+
}
87+
}
88+
89+
/**
90+
* Zustand-compatible async storage adapter for IndexedDB
91+
*/
92+
export const indexedDBStorage = {
93+
getItem,
94+
setItem,
95+
removeItem,
96+
};
97+
98+
/**
99+
* Clear all data from IndexedDB (useful for debugging/reset)
100+
*/
101+
export async function clearAllIndexedDB(): Promise<void> {
102+
try {
103+
const db = await openDB();
104+
return new Promise((resolve, reject) => {
105+
const tx = db.transaction(STORE_NAME, 'readwrite');
106+
const store = tx.objectStore(STORE_NAME);
107+
const request = store.clear();
108+
request.onerror = () => reject(request.error);
109+
request.onsuccess = () => resolve();
110+
});
111+
} catch (e) {
112+
console.error('[IndexedDB] Failed to clear:', e);
113+
}
114+
}

0 commit comments

Comments
 (0)