Files
MyClub/frontend/src/hooks/useAutoSave.ts
T
Tomas Dvorak f5b6f83974 dev day #99
2025-11-21 08:44:44 +01:00

289 lines
7.7 KiB
TypeScript

import { useEffect, useRef, useState, useCallback } from 'react';
import { useToast } from '@chakra-ui/react';
export type SaveStatus = 'idle' | 'saving' | 'saved' | 'error';
interface UseAutoSaveOptions<T> {
data: T;
storageKey: string;
onSave: (data: T) => Promise<{ id?: number | string; [key: string]: any }>;
onError?: (error: any) => void;
debounceMs?: number;
enabled?: boolean;
requiresId?: boolean; // If true, only saves to backend when item has an ID
}
interface UseAutoSaveReturn {
saveStatus: SaveStatus;
lastSaved: Date | null;
forceSave: () => Promise<void>;
clearDraft: () => void;
hasDraft: boolean;
draftAge: number | null; // Age in minutes
}
/**
* Auto-save hook with dual-layer protection:
* 1. Immediate localStorage save for offline protection
* 2. Debounced backend save for persistence
*
* Usage:
* ```tsx
* const { saveStatus, lastSaved, forceSave, clearDraft } = useAutoSave({
* data: formData,
* storageKey: 'draft-article-123',
* onSave: async (data) => {
* if (data.id) {
* return await updateArticle(data.id, data);
* } else {
* return await createArticle({ ...data, published: false });
* }
* },
* debounceMs: 2000,
* enabled: true
* });
* ```
*/
export function useAutoSave<T extends Record<string, any>>({
data,
storageKey,
onSave,
onError,
debounceMs = 2000,
enabled = true,
requiresId = false,
}: UseAutoSaveOptions<T>): UseAutoSaveReturn {
const toast = useToast();
const [saveStatus, setSaveStatus] = useState<SaveStatus>('idle');
const [lastSaved, setLastSaved] = useState<Date | null>(null);
const [hasDraft, setHasDraft] = useState(false);
const [draftAge, setDraftAge] = useState<number | null>(null);
const saveTimerRef = useRef<NodeJS.Timeout>();
const lastLocalDataRef = useRef<string>('');
const lastBackendDataRef = useRef<string>('');
const isSavingRef = useRef(false);
const lastDataObjRef = useRef<T | null>(null);
const localSaveTimerRef = useRef<NodeJS.Timeout>();
// Check for existing draft on mount
useEffect(() => {
try {
const stored = localStorage.getItem(storageKey);
if (stored) {
const parsed = JSON.parse(stored);
setHasDraft(true);
if (parsed.timestamp) {
const age = Math.floor((Date.now() - parsed.timestamp) / 60000); // minutes
setDraftAge(age);
}
}
} catch (err) {
console.warn('Failed to check for draft:', err);
}
}, [storageKey]);
// Save to localStorage immediately (instant protection)
const saveToLocalStorage = useCallback((data: T) => {
try {
const payload = {
data,
timestamp: Date.now(),
version: 1,
};
localStorage.setItem(storageKey, JSON.stringify(payload));
setHasDraft(true);
setDraftAge(0);
} catch (err) {
console.error('Failed to save to localStorage:', err);
}
}, [storageKey]);
// Save to backend (debounced)
const saveToBackend = useCallback(async (data: T) => {
// If requiresId is true and no ID exists, skip backend save
if (requiresId && !data.id) {
console.log('Skipping backend save - no ID yet');
return;
}
if (isSavingRef.current) {
console.log('Save already in progress, skipping...');
return;
}
try {
isSavingRef.current = true;
setSaveStatus('saving');
const result = await onSave(data);
setSaveStatus('saved');
setLastSaved(new Date());
// If the save returned an ID, update the data reference
if (result?.id && !data.id) {
console.log('Draft saved with new ID:', result.id);
}
setTimeout(() => setSaveStatus('idle'), 2000);
} catch (error: any) {
console.error('Auto-save error:', error);
setSaveStatus('error');
if (onError) {
onError(error);
} else {
// Only show error toast if it's not a 401/403 (auth issues)
const status = error?.response?.status;
if (status !== 401 && status !== 403) {
toast({
title: 'Automatické uložení selhalo',
description: error?.response?.data?.error || error?.message || 'Koncept je uložen lokálně',
status: 'warning',
duration: 3000,
isClosable: true,
});
}
}
setTimeout(() => setSaveStatus('idle'), 3000);
} finally {
isSavingRef.current = false;
}
}, [onSave, onError, toast, requiresId]);
// Main auto-save effect
useEffect(() => {
if (!enabled) return;
if (lastDataObjRef.current === data) {
return;
}
lastDataObjRef.current = data;
if (localSaveTimerRef.current) {
clearTimeout(localSaveTimerRef.current);
}
localSaveTimerRef.current = setTimeout(() => {
try {
const dataString = JSON.stringify(data);
if (dataString !== lastLocalDataRef.current) {
lastLocalDataRef.current = dataString;
saveToLocalStorage(data);
}
} catch (err) {
console.error('Local draft serialize error:', err);
}
}, 300);
// Debounce backend save
if (saveTimerRef.current) {
clearTimeout(saveTimerRef.current);
}
saveTimerRef.current = setTimeout(() => {
try {
const dataString = JSON.stringify(data);
if (dataString !== lastBackendDataRef.current) {
lastBackendDataRef.current = dataString;
saveToBackend(data);
}
} catch (err) {
console.error('Backend draft serialize error:', err);
}
}, debounceMs);
return () => {
if (saveTimerRef.current) {
clearTimeout(saveTimerRef.current);
}
if (localSaveTimerRef.current) {
clearTimeout(localSaveTimerRef.current);
}
};
}, [data, enabled, debounceMs, saveToLocalStorage, saveToBackend]);
// Force immediate save
const forceSave = useCallback(async () => {
if (saveTimerRef.current) {
clearTimeout(saveTimerRef.current);
}
saveToLocalStorage(data);
await saveToBackend(data);
}, [data, saveToLocalStorage, saveToBackend]);
// Clear draft
const clearDraft = useCallback(() => {
try {
localStorage.removeItem(storageKey);
setHasDraft(false);
setDraftAge(null);
if (saveTimerRef.current) {
clearTimeout(saveTimerRef.current);
}
} catch (err) {
console.error('Failed to clear draft:', err);
}
}, [storageKey]);
// Cleanup on unmount
useEffect(() => {
return () => {
if (saveTimerRef.current) {
clearTimeout(saveTimerRef.current);
}
if (localSaveTimerRef.current) {
clearTimeout(localSaveTimerRef.current);
}
};
}, []);
return {
saveStatus,
lastSaved,
forceSave,
clearDraft,
hasDraft,
draftAge,
};
}
/**
* Load draft from localStorage
*/
export function loadDraft<T>(storageKey: string): T | null {
try {
const stored = localStorage.getItem(storageKey);
if (!stored) return null;
const parsed = JSON.parse(stored);
return parsed.data || null;
} catch (err) {
console.error('Failed to load draft:', err);
return null;
}
}
/**
* Get draft metadata
*/
export function getDraftMetadata(storageKey: string): { timestamp: number; age: number } | null {
try {
const stored = localStorage.getItem(storageKey);
if (!stored) return null;
const parsed = JSON.parse(stored);
if (!parsed.timestamp) return null;
const age = Math.floor((Date.now() - parsed.timestamp) / 60000); // minutes
return {
timestamp: parsed.timestamp,
age,
};
} catch (err) {
console.error('Failed to get draft metadata:', err);
return null;
}
}