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:
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user