mirror of
https://github.com/Dvorinka/MyClubServer.git
synced 2026-06-03 18:22:57 +00:00
289 lines
7.7 KiB
TypeScript
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;
|
|
}
|
|
}
|