import { useEffect, useRef, useState, useCallback } from 'react'; import { useToast } from '@chakra-ui/react'; export type SaveStatus = 'idle' | 'saving' | 'saved' | 'error'; interface UseAutoSaveOptions { 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; 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>({ data, storageKey, onSave, onError, debounceMs = 2000, enabled = true, requiresId = false, }: UseAutoSaveOptions): UseAutoSaveReturn { const toast = useToast(); const [saveStatus, setSaveStatus] = useState('idle'); const [lastSaved, setLastSaved] = useState(null); const [hasDraft, setHasDraft] = useState(false); const [draftAge, setDraftAge] = useState(null); const saveTimerRef = useRef(); const lastLocalDataRef = useRef(''); const lastBackendDataRef = useRef(''); const isSavingRef = useRef(false); const lastDataObjRef = useRef(null); const localSaveTimerRef = useRef(); // 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(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; } }