This commit is contained in:
Tomas Dvorak
2025-10-21 15:02:05 +02:00
parent 68e69e00cc
commit 63700eedb2
103 changed files with 12442 additions and 446 deletions
+263
View File
@@ -0,0 +1,263 @@
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 lastDataRef = useRef<string>('');
const isSavingRef = useRef(false);
// 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;
const dataString = JSON.stringify(data);
// Skip if data hasn't changed
if (dataString === lastDataRef.current) {
return;
}
lastDataRef.current = dataString;
// Save to localStorage immediately
saveToLocalStorage(data);
// Debounce backend save
if (saveTimerRef.current) {
clearTimeout(saveTimerRef.current);
}
saveTimerRef.current = setTimeout(() => {
saveToBackend(data);
}, debounceMs);
return () => {
if (saveTimerRef.current) {
clearTimeout(saveTimerRef.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);
}
};
}, []);
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;
}
}