mirror of
https://github.com/Dvorinka/MyClubServer.git
synced 2026-06-04 02:32:57 +00:00
dev day #67
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user