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
@@ -75,6 +75,13 @@ const CustomRichEditor: React.FC<CustomRichEditorProps> = ({
const quillRef = useRef<ReactQuill | null>(null);
const toolbarRef = useRef<HTMLDivElement | null>(null);
const onChangeRef = useRef(onChange);
const [isMounted, setIsMounted] = useState(false);
// Ensure component is mounted before rendering Quill
useEffect(() => {
setIsMounted(true);
return () => setIsMounted(false);
}, []);
// Keep onChange ref up to date
useEffect(() => {
@@ -1173,15 +1180,17 @@ const CustomRichEditor: React.FC<CustomRichEditorProps> = ({
},
}}
>
<ReactQuill
theme="snow"
value={value}
onChange={handleChange}
readOnly={readOnly}
placeholder={placeholder}
ref={quillRef}
modules={quillModules}
/>
{isMounted && (
<ReactQuill
theme="snow"
value={value}
onChange={handleChange}
readOnly={readOnly}
placeholder={placeholder}
ref={quillRef}
modules={quillModules}
/>
)}
</Box>
{!readOnly && (
@@ -0,0 +1,152 @@
import React from 'react';
import {
Modal,
ModalOverlay,
ModalContent,
ModalHeader,
ModalBody,
ModalFooter,
Button,
Text,
VStack,
HStack,
Icon,
Alert,
AlertIcon,
AlertDescription,
useColorModeValue,
} from '@chakra-ui/react';
import { FiClock, FiTrash2, FiDownload } from 'react-icons/fi';
interface DraftRecoveryModalProps {
isOpen: boolean;
onClose: () => void;
onRecover: () => void;
onDiscard: () => void;
onDeleteOnly: () => void; // Delete draft and close without opening new
draftAge: number | null; // Age in minutes
entityType?: string; // "článek", "aktivitu", "hráče", etc.
}
const DraftRecoveryModal: React.FC<DraftRecoveryModalProps> = ({
isOpen,
onClose,
onRecover,
onDiscard,
onDeleteOnly,
draftAge,
entityType = 'položku',
}) => {
const bgColor = useColorModeValue('white', 'gray.800');
const getDraftAgeText = () => {
if (draftAge === null) return 'nedávno';
if (draftAge < 1) return 'před chvílí';
if (draftAge < 60) return `před ${draftAge} min`;
if (draftAge < 1440) return `před ${Math.floor(draftAge / 60)} h`;
return `před ${Math.floor(draftAge / 1440)} dny`;
};
const handleRecover = () => {
onRecover();
onClose();
};
const handleDiscard = () => {
onDiscard();
onClose();
};
const handleDeleteOnly = () => {
onDeleteOnly();
onClose();
};
return (
<Modal
isOpen={isOpen}
onClose={onClose}
isCentered
closeOnOverlayClick={false}
closeOnEsc={false}
>
<ModalOverlay backdropFilter="blur(4px)" />
<ModalContent bg={bgColor} maxW="500px">
<ModalHeader>
<HStack spacing={2}>
<Icon as={FiClock} color="blue.500" boxSize={6} />
<Text>Nalezen neuložený koncept</Text>
</HStack>
</ModalHeader>
<ModalBody>
<VStack align="stretch" spacing={4}>
<Alert status="info" borderRadius="md">
<AlertIcon />
<AlertDescription>
Máme uložený neuložený koncept pro tuto {entityType}, vytvořený{' '}
<strong>{getDraftAgeText()}</strong>.
</AlertDescription>
</Alert>
<Text fontSize="sm" color="gray.600">
Co chcete udělat?
</Text>
<VStack align="stretch" spacing={2}>
<Button
leftIcon={<FiDownload />}
colorScheme="blue"
size="lg"
onClick={handleRecover}
>
Obnovit koncept
</Button>
<Text fontSize="xs" color="gray.500" textAlign="center">
Načte uložená data a můžete pokračovat v práci
</Text>
</VStack>
<VStack align="stretch" spacing={2}>
<Button
leftIcon={<FiTrash2 />}
variant="outline"
colorScheme="orange"
size="sm"
onClick={handleDeleteOnly}
>
Smazat koncept a zavřít
</Button>
<Text fontSize="xs" color="gray.500" textAlign="center">
Smaže uložený koncept bez vytvoření nové {entityType}
</Text>
</VStack>
<VStack align="stretch" spacing={2}>
<Button
leftIcon={<FiTrash2 />}
variant="outline"
colorScheme="red"
size="sm"
onClick={handleDiscard}
>
Zahodit koncept a začít znovu
</Button>
<Text fontSize="xs" color="gray.500" textAlign="center">
Smaže uložený koncept a vytvoří novou prázdnou {entityType}
</Text>
</VStack>
</VStack>
</ModalBody>
<ModalFooter>
<Text fontSize="xs" color="gray.400">
💡 Tip: Vaše práce se nyní ukládá automaticky při každé změně
</Text>
</ModalFooter>
</ModalContent>
</Modal>
);
};
export default DraftRecoveryModal;
@@ -0,0 +1,95 @@
import React from 'react';
import { HStack, Text, Spinner, Icon, Tooltip } from '@chakra-ui/react';
import { FiCheck, FiAlertCircle, FiClock } from 'react-icons/fi';
import type { SaveStatus } from '../../hooks/useAutoSave';
interface SaveStatusIndicatorProps {
status: SaveStatus;
lastSaved: Date | null;
compact?: boolean;
}
const SaveStatusIndicator: React.FC<SaveStatusIndicatorProps> = ({
status,
lastSaved,
compact = false,
}) => {
const getStatusDisplay = () => {
switch (status) {
case 'saving':
return {
icon: <Spinner size="xs" color="blue.500" />,
text: 'Ukládání...',
color: 'blue.500',
};
case 'saved':
return {
icon: <Icon as={FiCheck} color="green.500" />,
text: 'Uloženo',
color: 'green.500',
};
case 'error':
return {
icon: <Icon as={FiAlertCircle} color="orange.500" />,
text: 'Uloženo lokálně',
color: 'orange.500',
};
default:
return {
icon: <Icon as={FiClock} color="gray.400" />,
text: 'Čeká se na změny...',
color: 'gray.400',
};
}
};
const { icon, text, color } = getStatusDisplay();
const getLastSavedText = () => {
if (!lastSaved) return null;
const now = new Date();
const diff = Math.floor((now.getTime() - lastSaved.getTime()) / 1000); // seconds
if (diff < 60) return 'před chvílí';
if (diff < 3600) return `před ${Math.floor(diff / 60)} min`;
if (diff < 86400) return `před ${Math.floor(diff / 3600)} h`;
return lastSaved.toLocaleString('cs-CZ', {
day: 'numeric',
month: 'short',
hour: '2-digit',
minute: '2-digit'
});
};
const lastSavedText = getLastSavedText();
if (compact) {
return (
<Tooltip
label={lastSavedText ? `${text} ${lastSavedText}` : text}
hasArrow
>
<HStack spacing={1}>
{icon}
</HStack>
</Tooltip>
);
}
return (
<HStack spacing={2} fontSize="sm">
{icon}
<Text color={color} fontWeight="medium">
{text}
</Text>
{lastSavedText && status === 'saved' && (
<Text color="gray.500" fontSize="xs">
{lastSavedText}
</Text>
)}
</HStack>
);
};
export default SaveStatusIndicator;
@@ -0,0 +1,234 @@
/**
* MyUIbrix Editor - Viewport Simulator Integration Example
*
* This example shows how to integrate the professional ViewportSimulator
* into your existing MyUIbrixEditor component.
*
* QUICK INTEGRATION STEPS:
* 1. Import ViewportSimulator at the top
* 2. Add device state mapping
* 3. Wrap content when editing
* 4. Remove old viewport wrapper code (lines 1140-1305)
*/
// ============================================
// STEP 1: Add Import (at top of file)
// ============================================
import ViewportSimulator from './ViewportSimulator';
// ============================================
// STEP 2: Add Device Mapping (in component)
// ============================================
const MyUIbrixEditor: React.FC<Props> = ({ ... }) => {
const [isEditing, setIsEditing] = useState(false);
const [viewport, setViewport] = useState<'desktop' | 'tablet' | 'mobile'>('desktop');
// NEW: Map viewport names to device presets
const viewportToDevice = {
'mobile': 'iphone_14', // 393px
'tablet': 'ipad_air', // 820px
'desktop': 'desktop_1080', // 1920px
};
const currentDevice = viewportToDevice[viewport];
// ... rest of your state ...
// ============================================
// STEP 3: Remove Old Viewport Code
// ============================================
// DELETE the entire useEffect that creates .myuibrix-viewport-wrapper
// DELETE the useEffect that applies viewport width changes
// (Lines 1140-1305 in current file)
// ============================================
// STEP 4: Wrap Content in Render
// ============================================
return (
<>
{/* Toolbar - always visible when editing */}
{isEditing && (
<Box
position="fixed"
top={0}
left={0}
right={0}
height="60px"
bg={`linear-gradient(135deg, ${primaryColor}, ${primaryColor}dd)`}
color="white"
zIndex={9999}
display="flex"
alignItems="center"
px={4}
gap={3}
boxShadow="md"
>
{/* Your existing toolbar buttons */}
<HStack spacing={2}>
{/* Viewport Toggle Buttons */}
<Tooltip label="Desktop">
<IconButton
aria-label="Desktop"
icon={<FiMonitor />}
size="sm"
variant={viewport === 'desktop' ? 'solid' : 'ghost'}
bg={viewport === 'desktop' ? 'white' : 'transparent'}
color={viewport === 'desktop' ? primaryColor : 'whiteAlpha.800'}
onClick={() => setViewport('desktop')}
/>
</Tooltip>
<Tooltip label="Tablet">
<IconButton
aria-label="Tablet"
icon={<FiTablet />}
size="sm"
variant={viewport === 'tablet' ? 'solid' : 'ghost'}
bg={viewport === 'tablet' ? 'white' : 'transparent'}
color={viewport === 'tablet' ? primaryColor : 'whiteAlpha.800'}
onClick={() => setViewport('tablet')}
/>
</Tooltip>
<Tooltip label="Mobile" >
<IconButton
aria-label="Mobile"
icon={<FiSmartphone />}
size="sm"
variant={viewport === 'mobile' ? 'solid' : 'ghost'}
bg={viewport === 'mobile' ? 'white' : 'transparent'}
color={viewport === 'mobile' ? primaryColor : 'whiteAlpha.800'}
onClick={() => setViewport('mobile')}
/>
</Tooltip>
{/* ... other toolbar buttons ... */}
</HStack>
</Box>
)}
{/* Main Content Area */}
<Box
pt={isEditing ? '60px' : 0}
minHeight="100vh"
bg={isEditing ? 'gray.50' : 'transparent'}
>
{isEditing ? (
// NEW: Wrap in ViewportSimulator when editing
<ViewportSimulator
defaultDevice={currentDevice}
key={currentDevice} // Force remount on device change
showControls={false} // We have our own controls in toolbar
customCSS={`
/* Hide any admin-only elements in preview */
.admin-only { display: none !important; }
/* Ensure proper responsive behavior */
* { box-sizing: border-box; }
`}
onDeviceChange={(device) => {
console.log('Device changed to:', device.name);
// Update viewport state based on device category
setViewport(device.category as any);
}}
>
{children}
</ViewportSimulator>
) : (
// Normal render when not editing
children
)}
</Box>
{/* Your existing modals, panels, etc. */}
{showStylePanel && selectedElement && (
<Box
position="fixed"
right={0}
top="60px"
bottom={0}
width="320px"
bg="white"
borderLeft="1px"
borderColor="gray.200"
zIndex={10000}
overflowY="auto"
>
<VisualStylePanel
elementName={selectedElement}
onStyleChange={(styles) => handleStyleChange(selectedElement, styles)}
currentStyles={elementStyles[selectedElement]}
/>
</Box>
)}
{/* ... rest of your UI ... */}
</>
);
};
// ============================================
// ALTERNATIVE: Full-Screen Overlay Mode
// ============================================
// If you want the viewport to cover the entire screen:
return (
<>
{/* Toolbar */}
{isEditing && <EditorToolbar />}
{/* Full-Screen Viewport Overlay */}
{isEditing && (
<Box
position="fixed"
top="60px"
left={0}
right={0}
bottom={0}
zIndex={9998}
>
<ViewportSimulator
defaultDevice={currentDevice}
showControls={true} // Show built-in device controls
>
{children}
</ViewportSimulator>
</Box>
)}
{/* Original Content (hidden when editing) */}
{!isEditing && children}
</>
);
// ============================================
// ALTERNATIVE: Side-by-Side Preview
// ============================================
// Show editing + preview simultaneously:
return (
<>
{isEditing && <EditorToolbar />}
<HStack spacing={0} height="calc(100vh - 60px)" pt="60px">
{/* Left: Editable Content */}
<Box flex={1} overflow="auto" bg="white">
{children}
</Box>
{/* Right: Preview */}
{isEditing && (
<Box width="500px" borderLeft="2px" borderColor="gray.300">
<ViewportSimulator
defaultDevice={currentDevice}
showControls={true}
>
{children}
</ViewportSimulator>
</Box>
)}
</HStack>
</>
);
export default MyUIbrixEditor;
+311 -237
View File
@@ -97,11 +97,26 @@ import {
PREDEFINED_ELEMENTS,
PredefinedElement,
} from '../../services/pageElements';
import { safeDOM } from '../../services/myuibrix';
import { useAuth } from '../../contexts/AuthContext';
import { useClubTheme } from '../../contexts/ClubThemeContext';
import VisualStylePanel from './VisualStylePanel';
import { DEFAULT_HOMEPAGE_ELEMENTS, HOMEPAGE_IMPLEMENTED_ELEMENTS } from '../../data/defaultElements';
const SUPPORTED_HOME_VARIANTS: Record<string, string[]> = {
hero: ['grid', 'scroller', 'swiper', 'swiper_full'],
news: ['grid', 'scroller'],
matches: ['compact'],
sponsors: ['grid', 'slider', 'scroller', 'pyramid'],
gallery: ['grid'],
videos: ['grid'],
merch: ['grid'],
table: ['split_news'],
banner: ['top'],
sidebar: ['right'],
newsletter: ['default'],
};
interface MyUIbrixStyleEditorProps {
pageType: string;
onConfigChange?: (configs: PageElementConfig[]) => void;
@@ -132,7 +147,7 @@ const MyUIbrixStyleEditor: React.FC<MyUIbrixStyleEditorProps> = ({ pageType, onC
const [elementOrder, setElementOrder] = useState<string[]>([]);
const [draggedElement, setDraggedElement] = useState<string | null>(null);
const [dragOverElement, setDragOverElement] = useState<string | null>(null);
const [viewport, setViewport] = useState<'desktop' | 'tablet' | 'mobile'>('desktop');
const [viewport] = useState<'desktop'>('desktop');
const [elementStyles, setElementStyles] = useState<Record<string, any>>({});
const [showStylePanel, setShowStylePanel] = useState(true);
const [searchQuery, setSearchQuery] = useState('');
@@ -160,6 +175,41 @@ const MyUIbrixStyleEditor: React.FC<MyUIbrixStyleEditorProps> = ({ pageType, onC
const secondaryColor = clubTheme.secondary || '#ffd200';
const selectedBg = primaryColor;
const getAvailableVariants = useCallback((elementName: string) => {
const variants = ELEMENT_VARIANTS[elementName] || [];
if (pageType === 'homepage') {
const allowed = SUPPORTED_HOME_VARIANTS[elementName];
if (allowed && allowed.length > 0) {
return variants.filter(variant => allowed.includes(variant.value));
}
}
return variants;
}, [pageType]);
const getDefaultVariant = useCallback((elementName: string) => {
const element = PREDEFINED_ELEMENTS.find(e => e.name === elementName);
const candidate = element?.defaultVariant;
const available = getAvailableVariants(elementName);
if (available.length === 0) {
return candidate || 'default';
}
if (candidate && available.some(v => v.value === candidate)) {
return candidate;
}
return available[0].value;
}, [getAvailableVariants]);
const normalizeVariant = useCallback((elementName: string, variant?: string) => {
const available = getAvailableVariants(elementName);
if (available.length === 0) {
return variant || getDefaultVariant(elementName);
}
if (variant && available.some(v => v.value === variant)) {
return variant;
}
return getDefaultVariant(elementName);
}, [getAvailableVariants, getDefaultVariant]);
// Draggable panel handlers
const handlePanelMouseDown = useCallback((panelName: string, e: React.MouseEvent) => {
// Only allow dragging from header area
@@ -287,18 +337,16 @@ const MyUIbrixStyleEditor: React.FC<MyUIbrixStyleEditorProps> = ({ pageType, onC
const loadConfigs = async () => {
try {
const data = await getPageElementConfigs(pageType);
// If no configs, use defaults
const configsToUse = data.length > 0 ? data : DEFAULT_HOMEPAGE_ELEMENTS;
setConfigs(configsToUse);
const sanitizedConfigs = configsToUse.map(cfg => ({
...cfg,
variant: normalizeVariant(cfg.element_name, cfg.variant)
}));
setConfigs(sanitizedConfigs);
const changes: Record<string, string> = {};
const visible = new Set<string>();
// Sort by display_order
const sorted = [...configsToUse].sort((a, b) => (a.display_order || 0) - (b.display_order || 0));
const sorted = [...sanitizedConfigs].sort((a, b) => (a.display_order || 0) - (b.display_order || 0));
const order: string[] = [];
sorted.forEach(cfg => {
changes[cfg.element_name] = cfg.variant;
if (cfg.visible !== false) {
@@ -318,19 +366,21 @@ const MyUIbrixStyleEditor: React.FC<MyUIbrixStyleEditorProps> = ({ pageType, onC
} catch (error) {
console.error('Failed to load page element configs:', error);
// On error, use defaults
const fallbackConfigs = DEFAULT_HOMEPAGE_ELEMENTS.map(cfg => ({
...cfg,
variant: normalizeVariant(cfg.element_name, cfg.variant)
}));
const changes: Record<string, string> = {};
const visible = new Set<string>();
const order: string[] = [];
DEFAULT_HOMEPAGE_ELEMENTS.forEach(cfg => {
fallbackConfigs.forEach(cfg => {
changes[cfg.element_name] = cfg.variant;
if (cfg.visible !== false) {
visible.add(cfg.element_name);
}
order.push(cfg.element_name);
});
setConfigs(fallbackConfigs);
setLocalChanges(changes);
setVisibleElements(visible);
setElementOrder(order);
@@ -339,7 +389,7 @@ const MyUIbrixStyleEditor: React.FC<MyUIbrixStyleEditorProps> = ({ pageType, onC
};
loadConfigs();
}, [pageType]);
}, [pageType, normalizeVariant]);
// Keyboard shortcuts
useEffect(() => {
@@ -386,7 +436,7 @@ const MyUIbrixStyleEditor: React.FC<MyUIbrixStyleEditorProps> = ({ pageType, onC
useEffect(() => {
if (!isEditing) {
// Clean up overlays when exiting edit mode
document.querySelectorAll('.elementor-overlay').forEach(el => {
safeDOM.querySelectorAll('.elementor-overlay').forEach(el => {
// Remove event listeners before removing element
el.replaceWith(el.cloneNode(true));
el.remove();
@@ -397,7 +447,7 @@ const MyUIbrixStyleEditor: React.FC<MyUIbrixStyleEditorProps> = ({ pageType, onC
const addOverlay = (elementName: string) => {
const selector = `[data-element="${elementName}"]`;
const elements = document.querySelectorAll(selector);
const elements = safeDOM.querySelectorAll(selector);
elements.forEach((element) => {
const existing = element.querySelector('.elementor-overlay');
@@ -514,20 +564,25 @@ const MyUIbrixStyleEditor: React.FC<MyUIbrixStyleEditorProps> = ({ pageType, onC
deleteBtn.onmouseover = () => deleteBtn.style.transform = 'scale(1.1)';
deleteBtn.onmouseout = () => deleteBtn.style.transform = 'scale(1)';
actionsBar.appendChild(editBtn);
actionsBar.appendChild(moveUpBtn);
actionsBar.appendChild(moveDownBtn);
actionsBar.appendChild(deleteBtn);
// Use safeDOM to build overlay structure
safeDOM.appendChild(actionsBar, editBtn);
safeDOM.appendChild(actionsBar, moveUpBtn);
safeDOM.appendChild(actionsBar, moveDownBtn);
safeDOM.appendChild(actionsBar, deleteBtn);
overlay.appendChild(badge);
overlay.appendChild(actionsBar);
safeDOM.appendChild(overlay, badge);
safeDOM.appendChild(overlay, actionsBar);
const parentPos = window.getComputedStyle(element).position;
if (parentPos === 'static') {
(element as HTMLElement).style.position = 'relative';
}
element.appendChild(overlay);
// Add overlay using safeDOM to avoid React conflicts
if (!safeDOM.appendChild(element, overlay)) {
console.warn(`Failed to add overlay to element: ${elementName}`);
return;
}
// Click to auto-select and open style panel
overlay.addEventListener('click', (e) => {
@@ -668,14 +723,30 @@ const MyUIbrixStyleEditor: React.FC<MyUIbrixStyleEditorProps> = ({ pageType, onC
document.addEventListener('keydown', handleEscape);
return () => {
// Cleanup: Remove all overlays and event listeners
document.querySelectorAll('.elementor-overlay').forEach(el => {
// Remove event listeners by cloning
const clone = el.cloneNode(true) as Element;
el.replaceWith(clone);
clone.remove();
});
document.removeEventListener('keydown', handleEscape);
// Cleanup: Remove all overlays and event listeners - with safe DOM
try {
const overlays = safeDOM.querySelectorAll('.elementor-overlay');
overlays.forEach(el => {
try {
// Remove event listeners by cloning
const clone = el.cloneNode(false) as Element;
const parent = el.parentElement;
if (parent && safeDOM.replaceChild(parent, clone, el)) {
safeDOM.removeChild(parent, clone);
}
} catch (e) {
console.warn('Failed to cleanup overlay:', e);
}
});
} catch (e) {
console.error('Error during cleanup:', e);
}
try {
document.removeEventListener('keydown', handleEscape);
} catch (e) {
console.warn('Failed to remove event listener:', e);
}
// Clear debounce timer
if (debounceTimerRef.current) {
@@ -686,7 +757,7 @@ const MyUIbrixStyleEditor: React.FC<MyUIbrixStyleEditorProps> = ({ pageType, onC
// Update selected element overlay styling
useEffect(() => {
document.querySelectorAll('.elementor-overlay').forEach((overlay) => {
safeDOM.querySelectorAll('.elementor-overlay').forEach((overlay) => {
const parent = overlay.parentElement;
const elementName = parent?.getAttribute('data-element');
@@ -714,16 +785,17 @@ const MyUIbrixStyleEditor: React.FC<MyUIbrixStyleEditorProps> = ({ pageType, onC
const handleVariantChange = useCallback((elementName: string, variant: string) => {
// Prevent crashes by checking if variant exists
const variants = ELEMENT_VARIANTS[elementName];
if (!variants || !variants.find(v => v.value === variant)) {
const variants = getAvailableVariants(elementName);
if (!variants || variants.length === 0) {
console.warn(`Invalid variant "${variant}" for element "${elementName}"`);
return;
}
const safeVariant = normalizeVariant(elementName, variant);
// Helper function to apply the variant change
const applyChange = () => {
try {
const newChanges = { ...localChanges, [elementName]: variant };
const newChanges = { ...localChanges, [elementName]: safeVariant };
setLocalChanges(newChanges);
setHasChanges(true);
@@ -732,7 +804,7 @@ const MyUIbrixStyleEditor: React.FC<MyUIbrixStyleEditorProps> = ({ pageType, onC
const configIndex = prevConfigs.findIndex(c => c.element_name === elementName);
if (configIndex !== -1) {
const updated = [...prevConfigs];
updated[configIndex] = { ...updated[configIndex], variant };
updated[configIndex] = { ...updated[configIndex], variant: safeVariant };
return updated;
}
return prevConfigs;
@@ -743,7 +815,7 @@ const MyUIbrixStyleEditor: React.FC<MyUIbrixStyleEditorProps> = ({ pageType, onC
if (isEditing) {
requestAnimationFrame(() => {
window.dispatchEvent(new CustomEvent('myuibrix-change', {
detail: { elementName, variant, visible: visibleElements.has(elementName), previewMode: true }
detail: { elementName, variant: safeVariant, visible: visibleElements.has(elementName), previewMode: true }
}));
});
}
@@ -774,7 +846,7 @@ const MyUIbrixStyleEditor: React.FC<MyUIbrixStyleEditorProps> = ({ pageType, onC
}
applyChange();
}, [localChanges, visibleElements, isEditing, toast]);
}, [localChanges, visibleElements, isEditing, toast, getAvailableVariants, normalizeVariant]);
// Debounce style changes to prevent lag
const debounceTimerRef = useRef<NodeJS.Timeout | null>(null);
@@ -809,10 +881,28 @@ const MyUIbrixStyleEditor: React.FC<MyUIbrixStyleEditorProps> = ({ pageType, onC
newVisible.add(elementName);
setVisibleElements(newVisible);
const existingVariant = localChanges[elementName];
const defaultVariant = normalizeVariant(elementName, element.defaultVariant);
const variantToUse = normalizeVariant(elementName, existingVariant || defaultVariant);
if (!localChanges[elementName]) {
setLocalChanges(prev => ({ ...prev, [elementName]: element.defaultVariant }));
setLocalChanges(prev => ({ ...prev, [elementName]: variantToUse }));
}
setConfigs(prev => {
const index = prev.findIndex(cfg => cfg.element_name === elementName);
if (index !== -1) {
const updated = [...prev];
updated[index] = { ...updated[index], variant: variantToUse, visible: true };
return updated;
}
return [...prev, {
page_type: pageType,
element_name: elementName,
variant: variantToUse,
visible: true,
display_order: prev.length,
}];
});
setHasChanges(true);
setShowElementPicker(false);
setSearchQuery('');
@@ -821,27 +911,36 @@ const MyUIbrixStyleEditor: React.FC<MyUIbrixStyleEditorProps> = ({ pageType, onC
// Live preview ONLY during editing
if (isEditing) {
window.dispatchEvent(new CustomEvent('myuibrix-change', {
detail: { elementName, variant: localChanges[elementName] || element.defaultVariant, visible: true, previewMode: true }
detail: { elementName, variant: variantToUse, visible: true, previewMode: true }
}));
}
}, [visibleElements, localChanges, isEditing]);
}, [visibleElements, localChanges, isEditing, normalizeVariant, pageType]);
const handleRemoveElement = useCallback((elementName: string) => {
// Update state - React will handle DOM removal
const newVisible = new Set(visibleElements);
newVisible.delete(elementName);
setVisibleElements(newVisible);
setHasChanges(true);
setSelectedElement(null);
// Live preview ONLY during editing
// Hide the element via React state event
if (isEditing) {
window.dispatchEvent(new CustomEvent('myuibrix-change', {
detail: { elementName, variant: localChanges[elementName], visible: false, previewMode: true }
}));
}
// Force React to re-render by triggering a state update
setTimeout(() => {
const element = safeDOM.querySelector(`[data-element="${elementName}"]`);
if (element) {
(element as HTMLElement).style.display = 'none';
}
}, 0);
}, [visibleElements, localChanges, isEditing]);
// Apply visual reordering to DOM elements safely
// Apply visual reordering using CSS order property instead of DOM manipulation
const applyVisualReorder = useCallback((order: string[]) => {
// Prevent concurrent reordering operations
if (isReorderingRef.current) {
@@ -850,68 +949,35 @@ const MyUIbrixStyleEditor: React.FC<MyUIbrixStyleEditorProps> = ({ pageType, onC
isReorderingRef.current = true;
// Use requestAnimationFrame to avoid conflicts with React's render cycle
// Use CSS order property to avoid DOM manipulation conflicts with React
requestAnimationFrame(() => {
try {
// Find the actual container (could be wrapped in viewport wrapper)
const viewport = document.querySelector('.myuibrix-viewport-wrapper');
const container = viewport || document.querySelector('.container') || document.querySelector('main');
if (!container) {
isReorderingRef.current = false;
return;
order.forEach((elementName, index) => {
const element = safeDOM.querySelector(`[data-element="${elementName}"]`) as HTMLElement;
if (element) {
// Use CSS order instead of moving DOM nodes
element.style.order = String(index);
}
});
// Ensure parent container uses flexbox
const viewport = safeDOM.querySelector('.myuibrix-viewport-wrapper');
const container = viewport || safeDOM.querySelector('.container') || safeDOM.querySelector('main');
if (container) {
(container as HTMLElement).style.display = 'flex';
(container as HTMLElement).style.flexDirection = 'column';
}
// Get all sections with data-element attributes that are direct children
const sections = Array.from(container.children).filter(
(child): child is HTMLElement =>
child instanceof HTMLElement &&
child.hasAttribute('data-element')
);
console.log('Visual reorder applied via CSS order');
// Create a map of element names to their DOM nodes
const elementMap = new Map<string, HTMLElement>();
sections.forEach(section => {
const elementName = section.getAttribute('data-element');
if (elementName) {
elementMap.set(elementName, section);
}
});
// Dispatch reorder event for HomePage to update
window.dispatchEvent(new CustomEvent('myuibrix-reorder', {
detail: { order, previewMode: true }
}));
// Build ordered array of elements
const orderedElements: HTMLElement[] = [];
order.forEach((elementName) => {
const element = elementMap.get(elementName);
if (element && container.contains(element) && element.parentElement === container) {
orderedElements.push(element);
}
});
// Only proceed if we have elements to reorder
if (orderedElements.length === 0) {
isReorderingRef.current = false;
return;
}
// Create a document fragment to minimize reflows
const fragment = document.createDocumentFragment();
// Detach all elements first
orderedElements.forEach(el => {
if (el.parentElement === container) {
container.removeChild(el);
}
});
// Append in correct order to fragment
orderedElements.forEach(el => fragment.appendChild(el));
// Append fragment back to container
container.appendChild(fragment);
// Release lock after a brief delay to ensure DOM is settled
setTimeout(() => {
isReorderingRef.current = false;
}, 100);
}, 50);
} catch (error) {
console.error('Error during visual reordering:', error);
isReorderingRef.current = false;
@@ -1061,34 +1127,22 @@ const MyUIbrixStyleEditor: React.FC<MyUIbrixStyleEditorProps> = ({ pageType, onC
// Memoize current variants to avoid recalculation
const currentVariants = useMemo(() =>
selectedElement ? ELEMENT_VARIANTS[selectedElement] || [] : [],
[selectedElement]
selectedElement ? getAvailableVariants(selectedElement) : [],
[selectedElement, getAvailableVariants]
);
const currentVariant = useMemo(() =>
selectedElement ? (localChanges[selectedElement] || currentVariants[0]?.value) : null,
[selectedElement, localChanges, currentVariants]
selectedElement ? normalizeVariant(selectedElement, localChanges[selectedElement]) : null,
[selectedElement, localChanges, normalizeVariant]
);
// Calculate viewport width - USE REAL DEVICE WIDTHS
const getViewportWidth = () => {
switch (viewport) {
case 'mobile': return '375px'; // iPhone standard width
case 'tablet': return '768px'; // iPad portrait width
case 'desktop': return '100%'; // Full width
default: return '100%';
}
};
// Calculate viewport width - USE REAL DEVICE WIDTHS WITHOUT SCALING
const getViewportConfig = useCallback(() => ({
width: '100%',
label: 'Desktop (100%)'
}), []);
// Get viewport label for display
const getViewportLabel = () => {
switch (viewport) {
case 'mobile': return 'Mobil (375px)';
case 'tablet': return 'Tablet (768px)';
case 'desktop': return 'Desktop (100%)';
default: return 'Desktop (100%)';
}
};
const viewportConfig = useMemo(() => getViewportConfig(), [getViewportConfig]);
// Prevent all clicks on page content during edit mode
useEffect(() => {
@@ -1111,7 +1165,7 @@ const MyUIbrixStyleEditor: React.FC<MyUIbrixStyleEditorProps> = ({ pageType, onC
}
// Prevent all other interactions on the page content
const wrapper = document.querySelector('.myuibrix-viewport-wrapper');
const wrapper = safeDOM.querySelector('.myuibrix-viewport-wrapper');
if (wrapper && wrapper.contains(target)) {
e.preventDefault();
e.stopPropagation();
@@ -1136,100 +1190,175 @@ const MyUIbrixStyleEditor: React.FC<MyUIbrixStyleEditorProps> = ({ pageType, onC
document.body.style.backgroundColor = '#e2e8f0';
document.body.style.userSelect = 'none'; // Prevent text selection during editing
// Apply viewport container wrapper
const mainContent = document.querySelector('main') || document.querySelector('.container');
if (mainContent && !mainContent.querySelector('.myuibrix-viewport-wrapper')) {
// Create viewport wrapper
const wrapper = document.createElement('div');
wrapper.className = 'myuibrix-viewport-wrapper';
wrapper.style.cssText = `
margin: 0 auto;
transition: all 0.3s ease;
background: white;
box-shadow: 0 0 0 9999px rgba(0,0,0,0.15);
min-height: calc(100vh - 60px);
position: relative;
overflow: visible;
cursor: default;
`;
// Apply viewport wrapper - wrap ALL content including navbar
if (!safeDOM.querySelector('.myuibrix-viewport-wrapper')) {
// Find all chakra containers (navbar + content) using safeDOM
const allContainers = safeDOM.querySelectorAll('.chakra-container');
const pageContainer = safeDOM.querySelector('.container');
// Move all children into wrapper (except fixed/absolute positioned elements like MyUIbrix panels)
const children = Array.from(mainContent.children);
children.forEach(child => {
const styles = window.getComputedStyle(child as Element);
// Skip elements that are fixed or absolute positioned (MyUIbrix UI elements)
if (styles.position !== 'fixed' && styles.position !== 'absolute') {
wrapper.appendChild(child);
if (allContainers.length > 0 && pageContainer) {
// Create viewport wrapper
const wrapper = document.createElement('div');
wrapper.className = 'myuibrix-viewport-wrapper';
wrapper.style.cssText = `
margin: 0 auto;
transition: all 0.3s ease;
background: white;
min-height: 100vh;
position: relative;
overflow: visible;
cursor: default;
width: 100%;
max-width: 100%;
`;
// Store reference to parent and next sibling for restoration
const firstContainer = allContainers[0];
const parent = firstContainer.parentElement;
const nextSibling = firstContainer.nextSibling;
if (parent) {
parent.setAttribute('data-myuibrix-restore', 'true');
// Move all chakra containers into wrapper using safeDOM
allContainers.forEach(container => {
// Store original styles
container.setAttribute('data-myuibrix-original-maxw',
(container as HTMLElement).style.maxWidth || '');
container.setAttribute('data-myuibrix-original-width',
(container as HTMLElement).style.width || '');
// Remove max-width constraints for viewport simulation
(container as HTMLElement).style.maxWidth = 'none';
(container as HTMLElement).style.width = '100%';
// Use safe appendChild to avoid React conflicts
safeDOM.appendChild(wrapper, container);
});
// Insert wrapper back into DOM using safeDOM
if (nextSibling) {
safeDOM.insertBefore(parent, wrapper, nextSibling);
} else {
safeDOM.appendChild(parent, wrapper);
}
}
});
// Prepend wrapper (so fixed elements stay outside)
mainContent.insertBefore(wrapper, mainContent.firstChild);
}
}
} else {
document.body.style.paddingTop = '0';
document.body.style.backgroundColor = '';
document.body.style.userSelect = '';
// Remove viewport wrapper
const wrapper = document.querySelector('.myuibrix-viewport-wrapper');
if (wrapper && wrapper.parentElement) {
const parent = wrapper.parentElement;
const children = Array.from(wrapper.children);
children.forEach(child => {
parent.appendChild(child);
});
wrapper.remove();
// Remove viewport wrapper and restore original structure
const wrapper = safeDOM.querySelector('.myuibrix-viewport-wrapper');
if (wrapper) {
const parent = safeDOM.querySelector('[data-myuibrix-restore]');
if (parent) {
// Move all children back to parent using safeDOM
const children = Array.from(wrapper.children);
children.forEach(child => {
// Restore original styles
const originalMaxW = child.getAttribute('data-myuibrix-original-maxw');
const originalWidth = child.getAttribute('data-myuibrix-original-width');
if (originalMaxW !== null) {
(child as HTMLElement).style.maxWidth = originalMaxW;
child.removeAttribute('data-myuibrix-original-maxw');
}
if (originalWidth !== null) {
(child as HTMLElement).style.width = originalWidth;
child.removeAttribute('data-myuibrix-original-width');
}
// Use safe appendChild to avoid React conflicts
safeDOM.appendChild(parent, child as Element);
});
parent.removeAttribute('data-myuibrix-restore');
// Use safeDOM to remove wrapper
if (wrapper.parentElement) {
safeDOM.removeChild(wrapper.parentElement, wrapper);
}
}
}
}
return () => {
document.body.style.paddingTop = '0';
document.body.style.backgroundColor = '';
document.body.style.userSelect = '';
const wrapper = document.querySelector('.myuibrix-viewport-wrapper');
if (wrapper && wrapper.parentElement) {
const parent = wrapper.parentElement;
const children = Array.from(wrapper.children);
children.forEach(child => {
parent.appendChild(child);
});
wrapper.remove();
const wrapper = safeDOM.querySelector('.myuibrix-viewport-wrapper');
if (wrapper) {
const parent = safeDOM.querySelector('[data-myuibrix-restore]');
if (parent) {
const children = Array.from(wrapper.children);
children.forEach(child => {
const originalMaxW = child.getAttribute('data-myuibrix-original-maxw');
const originalWidth = child.getAttribute('data-myuibrix-original-width');
if (originalMaxW !== null) {
(child as HTMLElement).style.maxWidth = originalMaxW;
child.removeAttribute('data-myuibrix-original-maxw');
}
if (originalWidth !== null) {
(child as HTMLElement).style.width = originalWidth;
child.removeAttribute('data-myuibrix-original-width');
}
// Use safe appendChild to avoid React conflicts
safeDOM.appendChild(parent, child as Element);
});
parent.removeAttribute('data-myuibrix-restore');
// Use safeDOM to remove wrapper
if (wrapper.parentElement) {
safeDOM.removeChild(wrapper.parentElement, wrapper);
}
}
}
};
}, [isEditing]);
// Apply viewport width changes with smooth transitions
// Apply viewport width changes with smooth transitions - REAL WIDTH CONSTRAINTS
useEffect(() => {
if (!isEditing) return;
const wrapper = document.querySelector('.myuibrix-viewport-wrapper') as HTMLElement;
const wrapper = safeDOM.querySelector('.myuibrix-viewport-wrapper') as HTMLElement;
if (!wrapper) return;
const width = getViewportWidth();
// Apply actual width constraints without scaling for real responsive behavior
wrapper.style.width = '100%';
wrapper.style.maxWidth = '100%';
wrapper.style.transition = 'all 0.3s ease';
wrapper.style.margin = '0 auto';
wrapper.style.transform = 'none';
wrapper.style.transformOrigin = '';
// Apply width with smooth transition
wrapper.style.width = width;
wrapper.style.maxWidth = width;
// Add visual indicator for non-desktop viewports
// Add visual indicator for non-desktop viewports only
if (viewport !== 'desktop') {
wrapper.style.border = `3px solid ${primaryColor}`;
wrapper.style.boxShadow = `0 0 0 9999px rgba(0,0,0,0.25), 0 8px 32px rgba(0,0,0,0.2)`;
wrapper.style.marginTop = '20px';
wrapper.style.marginBottom = '20px';
wrapper.style.minHeight = 'calc(100vh - 100px)';
} else {
wrapper.style.border = 'none';
wrapper.style.boxShadow = '0 0 0 9999px rgba(0,0,0,0.12)';
wrapper.style.boxShadow = 'none';
wrapper.style.marginTop = '0';
wrapper.style.marginBottom = '0';
wrapper.style.minHeight = '100vh';
}
// Show toast notification when changing viewport
toast({
title: `Viewport změněn na ${getViewportLabel()}`,
description: viewport === 'desktop' ? 'Zobrazení na celou šířku' : `Šířka: ${width}`,
title: 'Viewport nastaven na Desktop',
description: 'Zobrazení na plnou šířku (100%)',
status: 'info',
duration: 2000,
isClosable: true,
position: 'bottom-right',
});
}, [isEditing, viewport, primaryColor, toast]);
}, [isEditing, viewport, primaryColor, toast, viewportConfig]);
// Early return if not admin (after all hooks)
if (!isAdmin) return null;
@@ -1303,64 +1432,9 @@ const MyUIbrixStyleEditor: React.FC<MyUIbrixStyleEditorProps> = ({ pageType, onC
<Text fontSize="sm" fontWeight="600" color="whiteAlpha.800">Změny vidíte pouze vy</Text>
</HStack>
{/* Center: Viewport Switcher */}
<VStack spacing={1}>
<HStack
spacing={2}
bg="whiteAlpha.200"
borderRadius="xl"
p={1.5}
backdropFilter="blur(8px)"
border="1px solid rgba(255,255,255,0.1)"
boxShadow="inset 0 1px 3px rgba(0,0,0,0.1)"
>
<Tooltip label="Desktop - plná šířka">
<IconButton
aria-label="Desktop"
icon={<FiMonitor />}
size="sm"
variant={viewport === 'desktop' ? 'solid' : 'ghost'}
bg={viewport === 'desktop' ? 'white' : 'transparent'}
color={viewport === 'desktop' ? primaryColor : 'whiteAlpha.800'}
_hover={{ bg: viewport === 'desktop' ? 'white' : 'whiteAlpha.300', transform: 'scale(1.05)' }}
transition="all 0.2s"
boxShadow={viewport === 'desktop' ? '0 2px 8px rgba(0,0,0,0.15)' : 'none'}
onClick={() => setViewport('desktop')}
/>
</Tooltip>
<Tooltip label="Tablet - 768px (iPad)">
<IconButton
aria-label="Tablet"
icon={<FiTablet />}
size="sm"
variant={viewport === 'tablet' ? 'solid' : 'ghost'}
bg={viewport === 'tablet' ? 'white' : 'transparent'}
color={viewport === 'tablet' ? primaryColor : 'whiteAlpha.800'}
_hover={{ bg: viewport === 'tablet' ? 'white' : 'whiteAlpha.300', transform: 'scale(1.05)' }}
transition="all 0.2s"
boxShadow={viewport === 'tablet' ? '0 2px 8px rgba(0,0,0,0.15)' : 'none'}
onClick={() => setViewport('tablet')}
/>
</Tooltip>
<Tooltip label="Mobil - 375px (iPhone)">
<IconButton
aria-label="Mobil"
icon={<FiSmartphone />}
size="sm"
variant={viewport === 'mobile' ? 'solid' : 'ghost'}
bg={viewport === 'mobile' ? 'white' : 'transparent'}
color={viewport === 'mobile' ? primaryColor : 'whiteAlpha.800'}
_hover={{ bg: viewport === 'mobile' ? 'white' : 'whiteAlpha.300', transform: 'scale(1.05)' }}
transition="all 0.2s"
boxShadow={viewport === 'mobile' ? '0 2px 8px rgba(0,0,0,0.15)' : 'none'}
onClick={() => setViewport('mobile')}
/>
</Tooltip>
</HStack>
<Text fontSize="xs" color="whiteAlpha.900" fontWeight="700" letterSpacing="wide">
{getViewportLabel()}
</Text>
</VStack>
<Text fontSize="sm" fontWeight="700" color="whiteAlpha.900">
Náhled: Desktop (100%)
</Text>
{/* Right: Actions */}
<HStack spacing={2}>
@@ -2041,7 +2115,7 @@ const MyUIbrixStyleEditor: React.FC<MyUIbrixStyleEditorProps> = ({ pageType, onC
onDrop={(e) => handleDrop(e, elementName)}
onClick={() => {
setSelectedElement(elementName);
const el = document.querySelector(`[data-element="${elementName}"]`);
const el = safeDOM.querySelector(`[data-element="${elementName}"]`);
if (el) {
const rect = el.getBoundingClientRect();
setElementPosition({
@@ -0,0 +1,252 @@
import React, { Component, ErrorInfo, ReactNode } from 'react';
import { Box, Button, Heading, Text, VStack, Icon, Code } from '@chakra-ui/react';
import { FiAlertTriangle, FiRefreshCw } from 'react-icons/fi';
interface Props {
children: ReactNode;
onReset?: () => void;
}
interface State {
hasError: boolean;
error: Error | null;
errorInfo: ErrorInfo | null;
errorCount: number;
}
/**
* Error boundary specifically for MyUIbrix editor to catch DOM manipulation errors
*/
class MyUIbrixErrorBoundary extends Component<Props, State> {
private resetTimeout: NodeJS.Timeout | null = null;
constructor(props: Props) {
super(props);
this.state = {
hasError: false,
error: null,
errorInfo: null,
errorCount: 0,
};
}
static getDerivedStateFromError(error: Error): Partial<State> {
// Update state so the next render will show the fallback UI
return {
hasError: true,
error,
};
}
componentDidCatch(error: Error, errorInfo: ErrorInfo) {
// Log error details for debugging
console.error('MyUIbrix Error Boundary caught an error:', error, errorInfo);
this.setState(prev => ({
errorInfo,
errorCount: prev.errorCount + 1,
}));
// Auto-reset after 3 seconds for DOM manipulation errors
if (
error.message.includes('removeChild') ||
error.message.includes('insertBefore') ||
error.message.includes('replaceChild') ||
error.name === 'DOMException'
) {
console.warn('DOM manipulation error detected - will auto-reset in 3 seconds');
this.scheduleAutoReset();
}
}
componentWillUnmount() {
if (this.resetTimeout) {
clearTimeout(this.resetTimeout);
}
}
scheduleAutoReset = () => {
if (this.resetTimeout) {
clearTimeout(this.resetTimeout);
}
this.resetTimeout = setTimeout(() => {
this.handleReset();
}, 3000);
};
handleReset = () => {
if (this.resetTimeout) {
clearTimeout(this.resetTimeout);
this.resetTimeout = null;
}
// Clean up any orphaned DOM elements
this.cleanupDOMElements();
this.setState({
hasError: false,
error: null,
errorInfo: null,
});
// Call parent reset handler if provided
if (this.props.onReset) {
this.props.onReset();
}
};
cleanupDOMElements = () => {
try {
// Remove all MyUIbrix overlays
document.querySelectorAll('.elementor-overlay').forEach(el => {
try {
el.remove();
} catch (e) {
console.warn('Failed to remove overlay:', e);
}
});
// Remove viewport wrapper if exists
const wrapper = document.querySelector('.myuibrix-viewport-wrapper');
if (wrapper && wrapper.parentElement) {
try {
const parent = wrapper.parentElement;
Array.from(wrapper.children).forEach(child => {
try {
parent.appendChild(child);
} catch (e) {
console.warn('Failed to move child:', e);
}
});
wrapper.remove();
} catch (e) {
console.warn('Failed to cleanup viewport wrapper:', e);
}
}
// Reset body styles
document.body.style.paddingTop = '0';
document.body.style.backgroundColor = '';
document.body.style.userSelect = '';
} catch (e) {
console.error('Error during DOM cleanup:', e);
}
};
render() {
if (this.state.hasError && this.state.error) {
const isDOMError =
this.state.error.message.includes('removeChild') ||
this.state.error.message.includes('insertBefore') ||
this.state.error.message.includes('replaceChild') ||
this.state.error.name === 'DOMException';
return (
<Box
position="fixed"
top="0"
left="0"
right="0"
bottom="0"
bg="rgba(0, 0, 0, 0.85)"
backdropFilter="blur(8px)"
display="flex"
alignItems="center"
justifyContent="center"
zIndex={99999}
>
<VStack
spacing={6}
bg="white"
p={8}
borderRadius="2xl"
boxShadow="0 20px 60px rgba(0,0,0,0.5)"
maxW="600px"
w="90%"
>
<Icon as={FiAlertTriangle} boxSize={16} color="orange.500" />
<VStack spacing={2}>
<Heading size="lg" color="gray.800">
{isDOMError ? 'Chyba při manipulaci s prvky' : 'Chyba editoru'}
</Heading>
<Text color="gray.600" textAlign="center">
{isDOMError
? 'Nastala chyba při přesouvání nebo upravování prvků. Editor se automaticky obnoví za 3 sekundy.'
: 'V editoru nastala neočekávaná chyba. Klikněte na tlačítko pro obnovení.'}
</Text>
</VStack>
{process.env.NODE_ENV === 'development' && (
<VStack spacing={2} w="100%" align="stretch">
<Text fontSize="sm" fontWeight="bold" color="gray.700">
Detaily chyby:
</Text>
<Code
p={3}
borderRadius="md"
fontSize="xs"
bg="red.50"
color="red.800"
maxH="120px"
overflow="auto"
w="100%"
>
{this.state.error.toString()}
</Code>
{this.state.errorInfo && (
<Code
p={3}
borderRadius="md"
fontSize="xs"
bg="gray.50"
color="gray.800"
maxH="120px"
overflow="auto"
w="100%"
>
{this.state.errorInfo.componentStack}
</Code>
)}
</VStack>
)}
<VStack spacing={3} w="100%">
<Button
leftIcon={<FiRefreshCw />}
colorScheme="blue"
size="lg"
w="100%"
onClick={this.handleReset}
>
Obnovit editor
</Button>
{this.state.errorCount > 3 && (
<Text fontSize="sm" color="orange.600" textAlign="center">
Opakované chyby ({this.state.errorCount}x). Zvažte obnovení stránky.
</Text>
)}
{this.state.errorCount > 3 && (
<Button
size="sm"
variant="ghost"
colorScheme="gray"
onClick={() => window.location.reload()}
>
Obnovit celou stránku
</Button>
)}
</VStack>
</VStack>
</Box>
);
}
return this.props.children;
}
}
export default MyUIbrixErrorBoundary;
@@ -0,0 +1,414 @@
import React, { useState, useEffect, useRef } from 'react';
import Frame from 'react-frame-component';
import { Box, HStack, IconButton, Text, Tooltip, VStack, Badge } from '@chakra-ui/react';
import { FiMonitor, FiTablet, FiSmartphone, FiRotateCw, FiMaximize2 } from 'react-icons/fi';
export interface DevicePreset {
name: string;
width: number;
height: number;
userAgent: string;
icon: React.ReactElement;
category: 'mobile' | 'tablet' | 'desktop';
}
export const DEVICE_PRESETS: Record<string, DevicePreset> = {
// Mobile devices
iphone_se: {
name: 'iPhone SE',
width: 375,
height: 667,
userAgent: 'Mozilla/5.0 (iPhone; CPU iPhone OS 15_0 like Mac OS X) AppleWebKit/605.1.15',
icon: <FiSmartphone />,
category: 'mobile',
},
iphone_14: {
name: 'iPhone 14 Pro',
width: 393,
height: 852,
userAgent: 'Mozilla/5.0 (iPhone; CPU iPhone OS 16_0 like Mac OS X) AppleWebKit/605.1.15',
icon: <FiSmartphone />,
category: 'mobile',
},
pixel_7: {
name: 'Pixel 7',
width: 412,
height: 915,
userAgent: 'Mozilla/5.0 (Linux; Android 13; Pixel 7) AppleWebKit/537.36',
icon: <FiSmartphone />,
category: 'mobile',
},
samsung_s23: {
name: 'Samsung S23',
width: 360,
height: 800,
userAgent: 'Mozilla/5.0 (Linux; Android 13; SM-S911B) AppleWebKit/537.36',
icon: <FiSmartphone />,
category: 'mobile',
},
// Tablets
ipad_mini: {
name: 'iPad Mini',
width: 768,
height: 1024,
userAgent: 'Mozilla/5.0 (iPad; CPU OS 15_0 like Mac OS X) AppleWebKit/605.1.15',
icon: <FiTablet />,
category: 'tablet',
},
ipad_air: {
name: 'iPad Air',
width: 820,
height: 1180,
userAgent: 'Mozilla/5.0 (iPad; CPU OS 16_0 like Mac OS X) AppleWebKit/605.1.15',
icon: <FiTablet />,
category: 'tablet',
},
ipad_pro: {
name: 'iPad Pro 12.9"',
width: 1024,
height: 1366,
userAgent: 'Mozilla/5.0 (iPad; CPU OS 16_0 like Mac OS X) AppleWebKit/605.1.15',
icon: <FiTablet />,
category: 'tablet',
},
// Desktop
desktop_1080: {
name: 'Desktop 1080p',
width: 1920,
height: 1080,
userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36',
icon: <FiMonitor />,
category: 'desktop',
},
desktop_1440: {
name: 'Desktop 1440p',
width: 2560,
height: 1440,
userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36',
icon: <FiMonitor />,
category: 'desktop',
},
laptop: {
name: 'Laptop',
width: 1366,
height: 768,
userAgent: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36',
icon: <FiMonitor />,
category: 'desktop',
},
};
interface ViewportSimulatorProps {
children: React.ReactNode;
defaultDevice?: string;
showControls?: boolean;
customCSS?: string;
onDeviceChange?: (device: DevicePreset) => void;
}
const ViewportSimulator: React.FC<ViewportSimulatorProps> = ({
children,
defaultDevice = 'desktop_1080',
showControls = true,
customCSS = '',
onDeviceChange,
}) => {
const [currentDevice, setCurrentDevice] = useState<string>(defaultDevice);
const [isPortrait, setIsPortrait] = useState(true);
const [scale, setScale] = useState(1);
const containerRef = useRef<HTMLDivElement>(null);
const frameRef = useRef<HTMLIFrameElement>(null);
const device = DEVICE_PRESETS[currentDevice];
const width = isPortrait ? device.width : device.height;
const height = isPortrait ? device.height : device.width;
// Auto-scale to fit container
useEffect(() => {
if (!containerRef.current) return;
const updateScale = () => {
const container = containerRef.current;
if (!container) return;
const containerWidth = container.clientWidth;
const containerHeight = container.clientHeight - (showControls ? 80 : 0);
// Calculate scale to fit both width and height
const scaleX = containerWidth / (width + 40); // +40 for padding/borders
const scaleY = containerHeight / (height + 40);
const newScale = Math.min(scaleX, scaleY, 1); // Don't scale up, only down
setScale(newScale);
};
updateScale();
const resizeObserver = new ResizeObserver(updateScale);
if (containerRef.current) {
resizeObserver.observe(containerRef.current);
}
return () => resizeObserver.disconnect();
}, [width, height, showControls]);
// Change device
const changeDevice = (deviceKey: string) => {
setCurrentDevice(deviceKey);
setIsPortrait(true);
if (onDeviceChange) {
onDeviceChange(DEVICE_PRESETS[deviceKey]);
}
};
// Rotate device
const rotateDevice = () => {
setIsPortrait(!isPortrait);
};
// Reset to full width
const resetToDesktop = () => {
changeDevice('desktop_1080');
};
// Inject CSS into iframe
const frameContent = `
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=${width}, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<style>
* {
box-sizing: border-box;
}
html, body {
margin: 0;
padding: 0;
width: 100%;
height: 100%;
overflow-x: hidden;
overflow-y: auto;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
/* Import parent styles if needed */
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap');
body {
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif;
background: #ffffff;
color: #1a202c;
}
/* Responsive breakpoints - REAL media queries */
@media (max-width: 767px) {
/* Mobile styles */
.container {
padding: 16px !important;
}
}
@media (min-width: 768px) and (max-width: 1023px) {
/* Tablet styles */
.container {
padding: 24px !important;
}
}
@media (min-width: 1024px) {
/* Desktop styles */
.container {
padding: 32px !important;
}
}
/* Custom CSS injection */
${customCSS}
</style>
</head>
<body>
<div id="frame-root"></div>
</body>
</html>
`;
return (
<VStack
ref={containerRef}
width="100%"
height="100%"
spacing={0}
align="stretch"
bg="gray.50"
position="relative"
>
{/* Controls */}
{showControls && (
<HStack
spacing={2}
p={3}
bg="white"
borderBottom="1px solid"
borderColor="gray.200"
flexWrap="wrap"
justify="space-between"
>
<HStack spacing={2} flexWrap="wrap">
{/* Mobile */}
<Tooltip label="iPhone SE">
<IconButton
aria-label="iPhone SE"
icon={<FiSmartphone />}
size="sm"
colorScheme={currentDevice === 'iphone_se' ? 'blue' : 'gray'}
variant={currentDevice === 'iphone_se' ? 'solid' : 'outline'}
onClick={() => changeDevice('iphone_se')}
/>
</Tooltip>
<Tooltip label="iPhone 14 Pro">
<IconButton
aria-label="iPhone 14 Pro"
icon={<FiSmartphone />}
size="sm"
colorScheme={currentDevice === 'iphone_14' ? 'blue' : 'gray'}
variant={currentDevice === 'iphone_14' ? 'solid' : 'outline'}
onClick={() => changeDevice('iphone_14')}
/>
</Tooltip>
{/* Tablet */}
<Tooltip label="iPad Mini">
<IconButton
aria-label="iPad Mini"
icon={<FiTablet />}
size="sm"
colorScheme={currentDevice === 'ipad_mini' ? 'blue' : 'gray'}
variant={currentDevice === 'ipad_mini' ? 'solid' : 'outline'}
onClick={() => changeDevice('ipad_mini')}
/>
</Tooltip>
<Tooltip label="iPad Air">
<IconButton
aria-label="iPad Air"
icon={<FiTablet />}
size="sm"
colorScheme={currentDevice === 'ipad_air' ? 'blue' : 'gray'}
variant={currentDevice === 'ipad_air' ? 'solid' : 'outline'}
onClick={() => changeDevice('ipad_air')}
/>
</Tooltip>
{/* Desktop */}
<Tooltip label="Laptop">
<IconButton
aria-label="Laptop"
icon={<FiMonitor />}
size="sm"
colorScheme={currentDevice === 'laptop' ? 'blue' : 'gray'}
variant={currentDevice === 'laptop' ? 'solid' : 'outline'}
onClick={() => changeDevice('laptop')}
/>
</Tooltip>
<Tooltip label="Desktop 1080p">
<IconButton
aria-label="Desktop"
icon={<FiMonitor />}
size="sm"
colorScheme={currentDevice === 'desktop_1080' ? 'blue' : 'gray'}
variant={currentDevice === 'desktop_1080' ? 'solid' : 'outline'}
onClick={() => changeDevice('desktop_1080')}
/>
</Tooltip>
{/* Rotate */}
{device.category !== 'desktop' && (
<Tooltip label="Otočit zařízení">
<IconButton
aria-label="Rotate"
icon={<FiRotateCw />}
size="sm"
onClick={rotateDevice}
colorScheme="purple"
variant="outline"
/>
</Tooltip>
)}
{/* Reset */}
<Tooltip label="Resetovat na desktop">
<IconButton
aria-label="Reset"
icon={<FiMaximize2 />}
size="sm"
onClick={resetToDesktop}
variant="ghost"
/>
</Tooltip>
</HStack>
<HStack spacing={3}>
<Badge colorScheme="blue" fontSize="xs">
{device.name}
</Badge>
<Text fontSize="xs" color="gray.600">
{width} × {height}px
</Text>
<Text fontSize="xs" color="gray.500">
{(scale * 100).toFixed(0)}%
</Text>
</HStack>
</HStack>
)}
{/* Viewport Frame */}
<Box
flex={1}
display="flex"
alignItems="center"
justifyContent="center"
p={4}
overflow="auto"
position="relative"
>
<Box
style={{
width: `${width}px`,
height: `${height}px`,
transform: `scale(${scale})`,
transformOrigin: 'top center',
transition: 'all 0.3s ease',
}}
boxShadow="0 10px 40px rgba(0,0,0,0.2)"
borderRadius="8px"
overflow="hidden"
bg="white"
position="relative"
>
<Frame
ref={frameRef}
initialContent={frameContent}
style={{
width: '100%',
height: '100%',
border: 'none',
display: 'block',
}}
mountTarget="#frame-root"
>
{children}
</Frame>
</Box>
</Box>
</VStack>
);
};
export default ViewportSimulator;
+85 -15
View File
@@ -1,5 +1,5 @@
import React, { useRef, useState, useCallback } from 'react';
import { Box, Image, Heading, Text, VStack, HStack, Skeleton, Button, IconButton, Flex, useBreakpointValue, Container } from '@chakra-ui/react';
import React, { useState, useCallback, useMemo, useEffect } from 'react';
import { Box, Image, Heading, Text, VStack, HStack, Skeleton, Button, IconButton, Flex, Container } from '@chakra-ui/react';
import { useQuery } from '@tanstack/react-query';
import { getArticles, getFeaturedArticles, Article } from '../../services/articles';
import { Link as RouterLink } from 'react-router-dom';
@@ -12,6 +12,18 @@ import { wrap } from 'popmotion';
const MotionBox = motion(Box);
const MotionImage = motion(Image);
type FallbackArticle = Partial<Article> & {
id?: number | string;
title: string;
excerpt?: string;
image?: string;
date?: string;
};
interface BlogSwiperProps {
fallbackArticles?: FallbackArticle[];
}
const variants = {
enter: (direction: number) => ({
x: direction > 0 ? 1000 : -1000,
@@ -150,8 +162,7 @@ const HeroSlide: React.FC<{ article: Article }> = ({ article }) => {
);
};
const BlogSwiper: React.FC = () => {
const [page, setPage] = useState(0);
const BlogSwiper: React.FC<BlogSwiperProps> = ({ fallbackArticles = [] }) => {
const [[slideIndex, direction], setSlideIndex] = useState([0, 0]);
const { data: featuredData, isLoading: loadingFeatured } = useQuery({
queryKey: ['featured-articles', { page: 1, page_size: 5 }],
@@ -164,8 +175,32 @@ const BlogSwiper: React.FC = () => {
enabled: Boolean(!loadingFeatured && !(featuredData?.data?.length)),
});
const articles = (featuredData?.data?.length ? featuredData.data : (latestData?.data || []));
const articleIndex = wrap(0, articles.length, slideIndex);
const normalizedFallback = useMemo<Article[]>(() => fallbackArticles.map((item, index) => ({
id: typeof item.id === 'number' ? item.id : index,
title: item.title,
content: item.content ?? item.excerpt ?? '',
image_url: item.image_url ?? item.image ?? undefined,
author: item.author,
category: typeof item.category === 'string' ? { id: index, name: item.category } : item.category,
category_name: typeof item.category === 'string' ? item.category : item.category_name,
slug: item.slug,
created_at: item.created_at ?? item.published_at ?? item.date ?? new Date().toISOString(),
published: item.published ?? true,
})), [fallbackArticles]);
const remoteArticles = useMemo<Article[]>(() => {
if (featuredData?.data?.length) {
return featuredData.data;
}
if (latestData?.data?.length) {
return latestData.data;
}
return [];
}, [featuredData?.data, latestData?.data]);
const articles = remoteArticles.length ? remoteArticles : normalizedFallback;
const articleCount = articles.length;
const articleIndex = articleCount > 0 ? wrap(0, articleCount, slideIndex) : 0;
const paginate = useCallback(
(newDirection: number) => {
setSlideIndex([slideIndex + newDirection, newDirection]);
@@ -174,17 +209,27 @@ const BlogSwiper: React.FC = () => {
);
// Auto-advance slides
React.useEffect(() => {
if (articles.length <= 1) return;
useEffect(() => {
if (articleCount <= 1) return;
const timer = setInterval(() => {
paginate(1);
}, 8000);
return () => clearInterval(timer);
}, [articles.length, paginate]);
if (loadingFeatured) {
return () => clearInterval(timer);
}, [articleCount, paginate]);
useEffect(() => {
if (articleCount === 0 && slideIndex !== 0) {
setSlideIndex([0, 0]);
} else if (articleIndex >= articleCount && articleCount > 0) {
setSlideIndex([0, 0]);
}
}, [articleCount, articleIndex, slideIndex]);
const isLoading = loadingFeatured && !remoteArticles.length && !normalizedFallback.length;
if (isLoading) {
return (
<Skeleton
w="100%"
@@ -194,10 +239,35 @@ const BlogSwiper: React.FC = () => {
);
}
if (!articles.length) return null;
if (!articleCount) {
return (
<Box
position="relative"
w="100%"
h={{ base: '480px', md: '560px' }}
borderRadius={{ base: 'none', md: 'xl' }}
bgGradient="linear(to-br, blackAlpha.600, blackAlpha.800)"
display="flex"
alignItems="center"
justifyContent="center"
color="whiteAlpha.800"
textAlign="center"
px={8}
>
<VStack spacing={4}>
<Heading size="lg">Žádné články k zobrazení</Heading>
<Text maxW="lg">
Přidejte prosím nové články nebo nastavte vybrané příspěvky, aby se karusel mohl zobrazit.
</Text>
</VStack>
</Box>
);
}
const currentArticle = articles[articleIndex];
if (!currentArticle) return null;
if (!currentArticle) {
return null;
}
return (
<Box position="relative" w="100%" overflow="hidden">