mirror of
https://github.com/Dvorinka/MyClubServer.git
synced 2026-06-04 10:42: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;
|
||||
}
|
||||
}
|
||||
@@ -42,13 +42,16 @@ export const useAllPageElementConfigs = (pageType: string) => {
|
||||
const [styles, setStyles] = useState<Record<string, Record<string, any>>>({});
|
||||
const [elementOrder, setElementOrder] = useState<string[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [refreshKey, setRefreshKey] = useState<number>(0);
|
||||
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
|
||||
// Helper function to apply DOM order
|
||||
const applyDOMOrder = (order: string[]) => {
|
||||
const container = document.querySelector('.container');
|
||||
// Check if MyUIbrix viewport wrapper is active
|
||||
const viewportWrapper = document.querySelector('.myuibrix-viewport-wrapper');
|
||||
const container = viewportWrapper || document.querySelector('.container');
|
||||
if (!container) return;
|
||||
|
||||
const sections = Array.from(container.querySelectorAll('[data-element]')) as HTMLElement[];
|
||||
@@ -108,11 +111,13 @@ export const useAllPageElementConfigs = (pageType: string) => {
|
||||
|
||||
// Listen for live updates from MyUIbrix editor (ONLY in preview mode)
|
||||
const handleMyUIbrixChange = ((event: CustomEvent) => {
|
||||
const { elementName, variant, visible, previewMode } = event.detail;
|
||||
const { elementName, variant, visible, previewMode, timestamp } = event.detail;
|
||||
|
||||
// Only apply changes if in preview mode (editing)
|
||||
// This prevents production users from seeing draft changes
|
||||
if (previewMode) {
|
||||
console.log(`[usePageElementConfig] Variant change: ${elementName} -> ${variant}`);
|
||||
|
||||
setConfigs(prev => ({
|
||||
...prev,
|
||||
[elementName]: variant
|
||||
@@ -122,6 +127,9 @@ export const useAllPageElementConfigs = (pageType: string) => {
|
||||
...prev,
|
||||
[elementName]: visible
|
||||
}));
|
||||
|
||||
// Force React to re-render by incrementing refresh key
|
||||
setRefreshKey(prev => prev + 1);
|
||||
}
|
||||
}) as EventListener;
|
||||
|
||||
@@ -137,27 +145,12 @@ export const useAllPageElementConfigs = (pageType: string) => {
|
||||
const { elementName, styles: newStyles, previewMode } = event.detail;
|
||||
|
||||
if (previewMode) {
|
||||
// Only update state - let React apply the styles through component rendering
|
||||
// This prevents conflicts with React's virtual DOM
|
||||
setStyles(prev => ({
|
||||
...prev,
|
||||
[elementName]: newStyles
|
||||
}));
|
||||
|
||||
// Apply styles to DOM element immediately
|
||||
const element = document.querySelector(`[data-element="${elementName}"]`) as HTMLElement;
|
||||
if (element) {
|
||||
// Convert style object to CSS
|
||||
Object.keys(newStyles).forEach(key => {
|
||||
const cssKey = key.replace(/([A-Z])/g, '-$1').toLowerCase();
|
||||
let value = newStyles[key];
|
||||
|
||||
// Handle numeric values that need units
|
||||
if (typeof value === 'number' && !['fontWeight', 'lineHeight', 'opacity', 'zIndex'].includes(key)) {
|
||||
value = `${value}px`;
|
||||
}
|
||||
|
||||
element.style.setProperty(cssKey, String(value));
|
||||
});
|
||||
}
|
||||
}
|
||||
}) as EventListener;
|
||||
|
||||
@@ -185,5 +178,5 @@ export const useAllPageElementConfigs = (pageType: string) => {
|
||||
return styles[elementName];
|
||||
};
|
||||
|
||||
return { configs, visibility, styles, elementOrder, getVariant, isVisible, getStyles, loading };
|
||||
return { configs, visibility, styles, elementOrder, getVariant, isVisible, getStyles, loading, refreshKey };
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user