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">
|
||||
|
||||
@@ -0,0 +1,263 @@
|
||||
import { useEffect, useRef, useState, useCallback } from 'react';
|
||||
import { useToast } from '@chakra-ui/react';
|
||||
|
||||
export type SaveStatus = 'idle' | 'saving' | 'saved' | 'error';
|
||||
|
||||
interface UseAutoSaveOptions<T> {
|
||||
data: T;
|
||||
storageKey: string;
|
||||
onSave: (data: T) => Promise<{ id?: number | string; [key: string]: any }>;
|
||||
onError?: (error: any) => void;
|
||||
debounceMs?: number;
|
||||
enabled?: boolean;
|
||||
requiresId?: boolean; // If true, only saves to backend when item has an ID
|
||||
}
|
||||
|
||||
interface UseAutoSaveReturn {
|
||||
saveStatus: SaveStatus;
|
||||
lastSaved: Date | null;
|
||||
forceSave: () => Promise<void>;
|
||||
clearDraft: () => void;
|
||||
hasDraft: boolean;
|
||||
draftAge: number | null; // Age in minutes
|
||||
}
|
||||
|
||||
/**
|
||||
* Auto-save hook with dual-layer protection:
|
||||
* 1. Immediate localStorage save for offline protection
|
||||
* 2. Debounced backend save for persistence
|
||||
*
|
||||
* Usage:
|
||||
* ```tsx
|
||||
* const { saveStatus, lastSaved, forceSave, clearDraft } = useAutoSave({
|
||||
* data: formData,
|
||||
* storageKey: 'draft-article-123',
|
||||
* onSave: async (data) => {
|
||||
* if (data.id) {
|
||||
* return await updateArticle(data.id, data);
|
||||
* } else {
|
||||
* return await createArticle({ ...data, published: false });
|
||||
* }
|
||||
* },
|
||||
* debounceMs: 2000,
|
||||
* enabled: true
|
||||
* });
|
||||
* ```
|
||||
*/
|
||||
export function useAutoSave<T extends Record<string, any>>({
|
||||
data,
|
||||
storageKey,
|
||||
onSave,
|
||||
onError,
|
||||
debounceMs = 2000,
|
||||
enabled = true,
|
||||
requiresId = false,
|
||||
}: UseAutoSaveOptions<T>): UseAutoSaveReturn {
|
||||
const toast = useToast();
|
||||
const [saveStatus, setSaveStatus] = useState<SaveStatus>('idle');
|
||||
const [lastSaved, setLastSaved] = useState<Date | null>(null);
|
||||
const [hasDraft, setHasDraft] = useState(false);
|
||||
const [draftAge, setDraftAge] = useState<number | null>(null);
|
||||
|
||||
const saveTimerRef = useRef<NodeJS.Timeout>();
|
||||
const lastDataRef = useRef<string>('');
|
||||
const isSavingRef = useRef(false);
|
||||
|
||||
// Check for existing draft on mount
|
||||
useEffect(() => {
|
||||
try {
|
||||
const stored = localStorage.getItem(storageKey);
|
||||
if (stored) {
|
||||
const parsed = JSON.parse(stored);
|
||||
setHasDraft(true);
|
||||
if (parsed.timestamp) {
|
||||
const age = Math.floor((Date.now() - parsed.timestamp) / 60000); // minutes
|
||||
setDraftAge(age);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('Failed to check for draft:', err);
|
||||
}
|
||||
}, [storageKey]);
|
||||
|
||||
// Save to localStorage immediately (instant protection)
|
||||
const saveToLocalStorage = useCallback((data: T) => {
|
||||
try {
|
||||
const payload = {
|
||||
data,
|
||||
timestamp: Date.now(),
|
||||
version: 1,
|
||||
};
|
||||
localStorage.setItem(storageKey, JSON.stringify(payload));
|
||||
setHasDraft(true);
|
||||
setDraftAge(0);
|
||||
} catch (err) {
|
||||
console.error('Failed to save to localStorage:', err);
|
||||
}
|
||||
}, [storageKey]);
|
||||
|
||||
// Save to backend (debounced)
|
||||
const saveToBackend = useCallback(async (data: T) => {
|
||||
// If requiresId is true and no ID exists, skip backend save
|
||||
if (requiresId && !data.id) {
|
||||
console.log('Skipping backend save - no ID yet');
|
||||
return;
|
||||
}
|
||||
|
||||
if (isSavingRef.current) {
|
||||
console.log('Save already in progress, skipping...');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
isSavingRef.current = true;
|
||||
setSaveStatus('saving');
|
||||
|
||||
const result = await onSave(data);
|
||||
|
||||
setSaveStatus('saved');
|
||||
setLastSaved(new Date());
|
||||
|
||||
// If the save returned an ID, update the data reference
|
||||
if (result?.id && !data.id) {
|
||||
console.log('Draft saved with new ID:', result.id);
|
||||
}
|
||||
|
||||
setTimeout(() => setSaveStatus('idle'), 2000);
|
||||
} catch (error: any) {
|
||||
console.error('Auto-save error:', error);
|
||||
setSaveStatus('error');
|
||||
|
||||
if (onError) {
|
||||
onError(error);
|
||||
} else {
|
||||
// Only show error toast if it's not a 401/403 (auth issues)
|
||||
const status = error?.response?.status;
|
||||
if (status !== 401 && status !== 403) {
|
||||
toast({
|
||||
title: 'Automatické uložení selhalo',
|
||||
description: error?.response?.data?.error || error?.message || 'Koncept je uložen lokálně',
|
||||
status: 'warning',
|
||||
duration: 3000,
|
||||
isClosable: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
setTimeout(() => setSaveStatus('idle'), 3000);
|
||||
} finally {
|
||||
isSavingRef.current = false;
|
||||
}
|
||||
}, [onSave, onError, toast, requiresId]);
|
||||
|
||||
// Main auto-save effect
|
||||
useEffect(() => {
|
||||
if (!enabled) return;
|
||||
|
||||
const dataString = JSON.stringify(data);
|
||||
|
||||
// Skip if data hasn't changed
|
||||
if (dataString === lastDataRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
lastDataRef.current = dataString;
|
||||
|
||||
// Save to localStorage immediately
|
||||
saveToLocalStorage(data);
|
||||
|
||||
// Debounce backend save
|
||||
if (saveTimerRef.current) {
|
||||
clearTimeout(saveTimerRef.current);
|
||||
}
|
||||
|
||||
saveTimerRef.current = setTimeout(() => {
|
||||
saveToBackend(data);
|
||||
}, debounceMs);
|
||||
|
||||
return () => {
|
||||
if (saveTimerRef.current) {
|
||||
clearTimeout(saveTimerRef.current);
|
||||
}
|
||||
};
|
||||
}, [data, enabled, debounceMs, saveToLocalStorage, saveToBackend]);
|
||||
|
||||
// Force immediate save
|
||||
const forceSave = useCallback(async () => {
|
||||
if (saveTimerRef.current) {
|
||||
clearTimeout(saveTimerRef.current);
|
||||
}
|
||||
saveToLocalStorage(data);
|
||||
await saveToBackend(data);
|
||||
}, [data, saveToLocalStorage, saveToBackend]);
|
||||
|
||||
// Clear draft
|
||||
const clearDraft = useCallback(() => {
|
||||
try {
|
||||
localStorage.removeItem(storageKey);
|
||||
setHasDraft(false);
|
||||
setDraftAge(null);
|
||||
if (saveTimerRef.current) {
|
||||
clearTimeout(saveTimerRef.current);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to clear draft:', err);
|
||||
}
|
||||
}, [storageKey]);
|
||||
|
||||
// Cleanup on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (saveTimerRef.current) {
|
||||
clearTimeout(saveTimerRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
return {
|
||||
saveStatus,
|
||||
lastSaved,
|
||||
forceSave,
|
||||
clearDraft,
|
||||
hasDraft,
|
||||
draftAge,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Load draft from localStorage
|
||||
*/
|
||||
export function loadDraft<T>(storageKey: string): T | null {
|
||||
try {
|
||||
const stored = localStorage.getItem(storageKey);
|
||||
if (!stored) return null;
|
||||
|
||||
const parsed = JSON.parse(stored);
|
||||
return parsed.data || null;
|
||||
} catch (err) {
|
||||
console.error('Failed to load draft:', err);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get draft metadata
|
||||
*/
|
||||
export function getDraftMetadata(storageKey: string): { timestamp: number; age: number } | null {
|
||||
try {
|
||||
const stored = localStorage.getItem(storageKey);
|
||||
if (!stored) return null;
|
||||
|
||||
const parsed = JSON.parse(stored);
|
||||
if (!parsed.timestamp) return null;
|
||||
|
||||
const age = Math.floor((Date.now() - parsed.timestamp) / 60000); // minutes
|
||||
return {
|
||||
timestamp: parsed.timestamp,
|
||||
age,
|
||||
};
|
||||
} catch (err) {
|
||||
console.error('Failed to get draft metadata:', err);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -42,13 +42,16 @@ export const useAllPageElementConfigs = (pageType: string) => {
|
||||
const [styles, setStyles] = useState<Record<string, Record<string, any>>>({});
|
||||
const [elementOrder, setElementOrder] = useState<string[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [refreshKey, setRefreshKey] = useState<number>(0);
|
||||
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
|
||||
// Helper function to apply DOM order
|
||||
const applyDOMOrder = (order: string[]) => {
|
||||
const container = document.querySelector('.container');
|
||||
// Check if MyUIbrix viewport wrapper is active
|
||||
const viewportWrapper = document.querySelector('.myuibrix-viewport-wrapper');
|
||||
const container = viewportWrapper || document.querySelector('.container');
|
||||
if (!container) return;
|
||||
|
||||
const sections = Array.from(container.querySelectorAll('[data-element]')) as HTMLElement[];
|
||||
@@ -108,11 +111,13 @@ export const useAllPageElementConfigs = (pageType: string) => {
|
||||
|
||||
// Listen for live updates from MyUIbrix editor (ONLY in preview mode)
|
||||
const handleMyUIbrixChange = ((event: CustomEvent) => {
|
||||
const { elementName, variant, visible, previewMode } = event.detail;
|
||||
const { elementName, variant, visible, previewMode, timestamp } = event.detail;
|
||||
|
||||
// Only apply changes if in preview mode (editing)
|
||||
// This prevents production users from seeing draft changes
|
||||
if (previewMode) {
|
||||
console.log(`[usePageElementConfig] Variant change: ${elementName} -> ${variant}`);
|
||||
|
||||
setConfigs(prev => ({
|
||||
...prev,
|
||||
[elementName]: variant
|
||||
@@ -122,6 +127,9 @@ export const useAllPageElementConfigs = (pageType: string) => {
|
||||
...prev,
|
||||
[elementName]: visible
|
||||
}));
|
||||
|
||||
// Force React to re-render by incrementing refresh key
|
||||
setRefreshKey(prev => prev + 1);
|
||||
}
|
||||
}) as EventListener;
|
||||
|
||||
@@ -137,27 +145,12 @@ export const useAllPageElementConfigs = (pageType: string) => {
|
||||
const { elementName, styles: newStyles, previewMode } = event.detail;
|
||||
|
||||
if (previewMode) {
|
||||
// Only update state - let React apply the styles through component rendering
|
||||
// This prevents conflicts with React's virtual DOM
|
||||
setStyles(prev => ({
|
||||
...prev,
|
||||
[elementName]: newStyles
|
||||
}));
|
||||
|
||||
// Apply styles to DOM element immediately
|
||||
const element = document.querySelector(`[data-element="${elementName}"]`) as HTMLElement;
|
||||
if (element) {
|
||||
// Convert style object to CSS
|
||||
Object.keys(newStyles).forEach(key => {
|
||||
const cssKey = key.replace(/([A-Z])/g, '-$1').toLowerCase();
|
||||
let value = newStyles[key];
|
||||
|
||||
// Handle numeric values that need units
|
||||
if (typeof value === 'number' && !['fontWeight', 'lineHeight', 'opacity', 'zIndex'].includes(key)) {
|
||||
value = `${value}px`;
|
||||
}
|
||||
|
||||
element.style.setProperty(cssKey, String(value));
|
||||
});
|
||||
}
|
||||
}
|
||||
}) as EventListener;
|
||||
|
||||
@@ -185,5 +178,5 @@ export const useAllPageElementConfigs = (pageType: string) => {
|
||||
return styles[elementName];
|
||||
};
|
||||
|
||||
return { configs, visibility, styles, elementOrder, getVariant, isVisible, getStyles, loading };
|
||||
return { configs, visibility, styles, elementOrder, getVariant, isVisible, getStyles, loading, refreshKey };
|
||||
};
|
||||
|
||||
@@ -3,6 +3,11 @@ import ReactDOM from 'react-dom/client';
|
||||
import './index.css';
|
||||
import './styles/global-enhancements.css';
|
||||
import './styles/admin-enhancements.css';
|
||||
// Quill editor styles (MUST be imported globally) - CRITICAL for rich text editor
|
||||
import 'react-quill/dist/quill.snow.css';
|
||||
import 'react-image-crop/dist/ReactCrop.css';
|
||||
// Custom editor styles AFTER quill base styles to ensure proper override
|
||||
import './styles/custom-editor.css';
|
||||
import App, { theme } from './App';
|
||||
import { ColorModeScript } from '@chakra-ui/react';
|
||||
import reportWebVitals from './reportWebVitals';
|
||||
|
||||
@@ -533,9 +533,14 @@ const CalendarPage: React.FC = () => {
|
||||
// First try ID-based matching (most reliable)
|
||||
let ourIsHome = false;
|
||||
let ourIsAway = false;
|
||||
if (clubId && m.home_id && m.away_id) {
|
||||
ourIsHome = m.home_id === clubId;
|
||||
ourIsAway = m.away_id === clubId;
|
||||
if (clubId) {
|
||||
// Check each team ID individually - even if one is missing, we can still match the other
|
||||
if (m.home_id) {
|
||||
ourIsHome = m.home_id === clubId;
|
||||
}
|
||||
if (m.away_id) {
|
||||
ourIsAway = m.away_id === clubId;
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to name matching if IDs not available or no match
|
||||
|
||||
+143
-65
@@ -1,4 +1,4 @@
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import React, { useEffect, useRef, useState, useMemo } from 'react';
|
||||
import MainLayout from '../components/layout/MainLayout';
|
||||
import { FiArrowRight, FiCalendar, FiUsers, FiAward, FiChevronLeft, FiChevronRight } from 'react-icons/fi';
|
||||
import '../styles/theme.css';
|
||||
@@ -16,6 +16,7 @@ import { getArticles as apiGetArticles, Article as ApiArticle } from '../service
|
||||
import { getCompetitionAliasesPublic, CompetitionAlias } from '../services/competitionAliases';
|
||||
import NewsletterSubscribe from '../components/newsletter/NewsletterSubscribe';
|
||||
import MyUIbrixStyleEditor from '../components/editor/MyUIbrixEditor';
|
||||
import MyUIbrixErrorBoundary from '../components/editor/MyUIbrixErrorBoundary';
|
||||
import ClubModal from '../components/home/ClubModal';
|
||||
import MatchModal from '../components/home/MatchModal';
|
||||
import { useAllPageElementConfigs } from '../hooks/usePageElementConfig';
|
||||
@@ -105,7 +106,19 @@ const HomePage: React.FC = () => {
|
||||
const [isLoading, setIsLoading] = useState<boolean>(true);
|
||||
|
||||
// MyUIbrix element configuration hook for live preview
|
||||
const { getVariant, isVisible, loading: configLoading } = useAllPageElementConfigs('homepage');
|
||||
const { getVariant, isVisible, getStyles, loading: configLoading, refreshKey } = useAllPageElementConfigs('homepage');
|
||||
|
||||
const heroFallbackArticles = useMemo(() => featured.map((item, index) => ({
|
||||
id: typeof item.id === 'number' ? item.id : index,
|
||||
title: item.title,
|
||||
excerpt: item.excerpt,
|
||||
image: item.image,
|
||||
date: item.date,
|
||||
category: item.category
|
||||
? { id: index, name: item.category }
|
||||
: undefined,
|
||||
slug: item.slug,
|
||||
})), [featured]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
@@ -287,25 +300,28 @@ const HomePage: React.FC = () => {
|
||||
};
|
||||
})
|
||||
);
|
||||
// Sort by datetime and pick upcoming first
|
||||
// Sort by datetime and filter to 14-day range (14 days past + 14 days future)
|
||||
const parseDT = (d: string, t: string) => new Date(`${d}T${(t || '00:00')}:00`).getTime();
|
||||
const now = Date.now();
|
||||
const upcoming = allMatches
|
||||
const fourteenDaysInMs = 14 * 24 * 60 * 60 * 1000;
|
||||
const minDate = now - fourteenDaysInMs;
|
||||
const maxDate = now + fourteenDaysInMs;
|
||||
const filteredMatches = allMatches
|
||||
.map((m: any & { __ts?: number }) => ({
|
||||
...m,
|
||||
__ts: parseDT(m.date, m.time)
|
||||
}))
|
||||
.filter((m: { __ts?: number }) => typeof m.__ts === 'number' && !isNaN(m.__ts!) && (m.__ts as number) >= now)
|
||||
.filter((m: { __ts?: number }) => {
|
||||
const ts = m.__ts;
|
||||
return typeof ts === 'number' && !isNaN(ts) && ts >= minDate && ts <= maxDate;
|
||||
})
|
||||
.sort((a: { __ts?: number }, b: { __ts?: number }) => (a.__ts as number) - (b.__ts as number))
|
||||
.map(({ __ts, ...rest }: any & { __ts?: number }) => rest);
|
||||
const chosen = upcoming.length ? upcoming : allMatches;
|
||||
setMatches(chosen);
|
||||
setMatches(filteredMatches);
|
||||
|
||||
// Build competitions with their matches for slider
|
||||
const comps = (facrClubJSON.competitions || []).map((c: any) => ({
|
||||
name: (amap?.[c?.code]?.alias) || c.name || c.code || 'Soutěž',
|
||||
matches_link: c.matches_link,
|
||||
matches: (Array.isArray(c.matches) ? c.matches : []).map((m: any, idx: number) => {
|
||||
// Build competitions with their matches for slider (also filter to 14-day range)
|
||||
const comps = (facrClubJSON.competitions || []).map((c: any) => {
|
||||
const compMatches = (Array.isArray(c.matches) ? c.matches : []).map((m: any, idx: number) => {
|
||||
const dt: string = String(m.date_time || '');
|
||||
const [d, t] = dt.includes(' ') ? dt.split(' ') : [dt, ''];
|
||||
const [day, month, year] = (d || '').split('.');
|
||||
@@ -324,8 +340,18 @@ const HomePage: React.FC = () => {
|
||||
report_url: m.report_url,
|
||||
venue: m.venue || '',
|
||||
};
|
||||
})
|
||||
}));
|
||||
});
|
||||
// Filter to 14-day range
|
||||
const filtered = compMatches.filter((m: any) => {
|
||||
const ts = new Date(`${m.date}T${(m.time || '00:00')}:00`).getTime();
|
||||
return !isNaN(ts) && ts >= minDate && ts <= maxDate;
|
||||
});
|
||||
return {
|
||||
name: (amap?.[c?.code]?.alias) || c.name || c.code || 'Soutěž',
|
||||
matches_link: c.matches_link,
|
||||
matches: filtered
|
||||
};
|
||||
});
|
||||
setFacrCompetitions(comps);
|
||||
|
||||
// Compute closest match index per competition to current time
|
||||
@@ -345,7 +371,7 @@ const HomePage: React.FC = () => {
|
||||
setClosestIndexByComp(closestIdx);
|
||||
|
||||
// Next match FACR link
|
||||
const first = chosen?.[0];
|
||||
const first = filteredMatches?.[0];
|
||||
setNextMatchLink((first && (first.facr_link || first.report_url)) || comps?.[0]?.matches_link || facrClubJSON?.url);
|
||||
} else {
|
||||
setMatches(mapMatches(matchesJSON));
|
||||
@@ -464,7 +490,8 @@ const HomePage: React.FC = () => {
|
||||
table: (c.table?.overall || []).map((r: any, idx: number) => ({
|
||||
position: Number(r.rank || idx + 1),
|
||||
team: r.team || r.team_name || '-',
|
||||
team_logo_url: r.team_logo_url,
|
||||
team_logo_url: getOverrideLogo(r.team || r.team_name, r.team_logo_url),
|
||||
team_id: r.team_id,
|
||||
points: Number(r.points || r.pts || 0),
|
||||
played: Number(r.played || 0),
|
||||
wins: Number(r.wins || 0),
|
||||
@@ -1367,7 +1394,7 @@ const HomePage: React.FC = () => {
|
||||
|
||||
{/* Hero section: variant controlled by MyUIbrix (getVariant) or fallback to settings.hero_style */}
|
||||
{getVariant('hero', heroStyle) === 'grid' && isVisible('hero', true) && (
|
||||
<section data-element="hero" className="hero-grid">
|
||||
<section key={`hero-grid-${refreshKey}`} data-element="hero" className="hero-grid" style={{ position: 'relative', ...getStyles('hero') }}>
|
||||
{featured[0] ? (
|
||||
<a href={`/news/${featured[0].slug || featured[0].id}`} className="hero-card big" style={{ textDecoration: 'none' }}>
|
||||
<div className="bg" style={{ backgroundImage: `url(${assetUrl(featured[0].image) || '/images/news/placeholder.jpg'})` }} />
|
||||
@@ -1409,7 +1436,7 @@ const HomePage: React.FC = () => {
|
||||
)}
|
||||
{/* Banner: homepage_middle */}
|
||||
{(banners || []).some(b => b.placement === 'homepage_middle') && isVisible('banner', true) && (
|
||||
<section data-element="banner" className="banner banner-middle" style={{ margin: '24px 0', textAlign: 'center' }}>
|
||||
<section data-element="banner" className="banner banner-middle" style={{ margin: '24px 0', textAlign: 'center', ...getStyles('banner') }}>
|
||||
{(banners || []).filter(b => b.placement === 'homepage_middle').map((b) => (
|
||||
<a key={b.id} href={b.url || '#'} target={b.url ? '_blank' : undefined} rel={b.url ? 'noopener noreferrer' : undefined} style={{ display: 'inline-block', margin: 8 }}>
|
||||
{/* eslint-disable-next-line jsx-a11y/alt-text */}
|
||||
@@ -1423,7 +1450,7 @@ const HomePage: React.FC = () => {
|
||||
|
||||
{/* Sidebar banners (homepage_sidebar) */}
|
||||
{(banners || []).some(b => b.placement === 'homepage_sidebar') && (
|
||||
<section data-element="sidebar" className="banner banner-sidebar" style={{ margin: '24px 0' }}>
|
||||
<section data-element="sidebar" className="banner banner-sidebar" style={{ margin: '24px 0', ...getStyles('sidebar') }}>
|
||||
{/* Simple responsive behavior: stack on mobile, sticky right rail on desktop */}
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end' }}>
|
||||
<div style={{ width: 320, maxWidth: '100%', position: 'sticky' as const, top: 96 }}>
|
||||
@@ -1438,13 +1465,14 @@ const HomePage: React.FC = () => {
|
||||
</section>
|
||||
)}
|
||||
{getVariant('hero', heroStyle) === 'scroller' && isVisible('hero', true) && (
|
||||
<section data-element="hero">
|
||||
<section key={`hero-scroller-${refreshKey}`} data-element="hero" style={{ position: 'relative', ...getStyles('hero') }}>
|
||||
<BlogCardsScroller />
|
||||
</section>
|
||||
)}
|
||||
{(getVariant('hero', heroStyle) === 'swiper' || getVariant('hero', heroStyle) === 'swiper_full') && isVisible('hero', true) && (
|
||||
<section data-element="hero" style={getVariant('hero', heroStyle) === 'swiper_full' ? { marginLeft: 'calc(50% - 50vw)', marginRight: 'calc(50% - 50vw)' } : undefined}>
|
||||
<BlogSwiper />
|
||||
<section key={`hero-swiper-${refreshKey}`} data-element="hero" style={getVariant('hero', heroStyle) === 'swiper_full' ? { position: 'relative', marginLeft: 'calc(50% - 50vw)', marginRight: 'calc(50% - 50vw)', ...getStyles('hero') } : { position: 'relative', ...getStyles('hero') }}>
|
||||
<BlogSwiper fallbackArticles={heroFallbackArticles}
|
||||
/>
|
||||
</section>
|
||||
)}
|
||||
|
||||
@@ -1470,7 +1498,7 @@ const HomePage: React.FC = () => {
|
||||
}
|
||||
};
|
||||
return (
|
||||
<section data-element="matches" className="next-match" onClick={handleNextMatchClick} style={{ cursor: 'pointer' }}>
|
||||
<section data-element="matches" className="next-match" onClick={handleNextMatchClick} style={{ cursor: 'pointer', position: 'relative', ...getStyles('matches') }}>
|
||||
<button
|
||||
aria-label="Předchozí soutěž"
|
||||
onClick={(e) => { e.stopPropagation(); setMatchesTab((i) => (i - 1 + facrCompetitions.length) % facrCompetitions.length); }}
|
||||
@@ -1504,7 +1532,7 @@ const HomePage: React.FC = () => {
|
||||
);
|
||||
})()
|
||||
) : isVisible('matches', true) ? (
|
||||
<section data-element="matches" className="next-match">
|
||||
<section data-element="matches" className="next-match" style={{ position: 'relative', ...getStyles('matches') }}>
|
||||
<div className="team">
|
||||
<img className="logo" src={assetUrl(matches[0]?.homeLogoURL) || assetUrl(clubLogo) || '/images/club-logo.png'} alt="Domácí" />
|
||||
<div>{sanitizeClubName(matches[0]?.homeTeam || clubName)}</div>
|
||||
@@ -1527,7 +1555,7 @@ const HomePage: React.FC = () => {
|
||||
|
||||
{/* Matches slider with scores by competition */}
|
||||
{facrCompetitions.length > 0 && (
|
||||
<section className="matches-slider">
|
||||
<section data-element="matches-slider" className="matches-slider" style={{ position: 'relative', ...getStyles('matches-slider') }}>
|
||||
<div className="section-head" style={{ marginTop: 16, marginBottom: 16 }}>
|
||||
<h3>Zápasy</h3>
|
||||
<a href="/kalendar" className="see-all">Všechny zápasy <FiArrowRight /></a>
|
||||
@@ -1603,7 +1631,7 @@ const HomePage: React.FC = () => {
|
||||
data-element="table"
|
||||
className="standings"
|
||||
data-variant={hasStandingsForCurrentTab ? undefined : 'standard'}
|
||||
style={{ marginTop: 32 }}
|
||||
style={{ marginTop: 32, ...getStyles('table') }}
|
||||
>
|
||||
<div>
|
||||
<div className="section-head" style={{ marginTop: 0 }}>
|
||||
@@ -1637,37 +1665,84 @@ const HomePage: React.FC = () => {
|
||||
<h3>Tabulky</h3>
|
||||
<a href="/tabulky" className="see-all" style={{ fontSize: '0.85rem' }}>Zobrazit vše <FiArrowRight size={14} /></a>
|
||||
</div>
|
||||
<div className="standings">
|
||||
{(matchingStanding?.table || matchingStanding?.rows || []).slice(0,8).map((row: any, idx: number) => {
|
||||
const handleClick = () => {
|
||||
const clubData = {
|
||||
team: row.team?.name ?? row.team ?? row.club ?? '-',
|
||||
team_id: row.team_id || '',
|
||||
team_logo_url: row.team_logo_url,
|
||||
rank: row.position ?? row.pos ?? row.rank ?? idx+1,
|
||||
played: row.played ?? row.matches ?? '-',
|
||||
wins: row.wins ?? row.win ?? '-',
|
||||
draws: row.draws ?? row.draw ?? '-',
|
||||
losses: row.losses ?? row.loss ?? '-',
|
||||
score: row.score ?? '-',
|
||||
points: row.points ?? row.pts ?? '-',
|
||||
};
|
||||
setSelectedClub(clubData);
|
||||
setIsModalOpen(true);
|
||||
};
|
||||
return (
|
||||
<div key={idx} className="standing-row" onClick={handleClick}>
|
||||
<div className="pos">#{row.position ?? row.pos ?? row.rank ?? idx+1}</div>
|
||||
<div className="team">
|
||||
{row.team_logo_url && (
|
||||
<img src={assetUrl(row.team_logo_url)} alt={row.team?.name ?? row.team ?? row.club ?? '-'} />
|
||||
)}
|
||||
<span className="name">{row.team?.name ?? row.team ?? row.club ?? '-'}</span>
|
||||
</div>
|
||||
<div className="pts">{row.points ?? row.pts ?? '-'}</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<div className="standings-table-wrapper" style={{ overflowX: 'auto' }}>
|
||||
<table className="standings-table-compact" style={{ width: '100%', borderCollapse: 'separate', borderSpacing: '0 4px' }}>
|
||||
<thead>
|
||||
<tr style={{ fontSize: '0.75rem', color: 'var(--dark-gray)', textTransform: 'uppercase' }}>
|
||||
<th style={{ padding: '6px 8px', textAlign: 'left', fontWeight: 600 }}>#</th>
|
||||
<th style={{ padding: '6px 8px', textAlign: 'left', fontWeight: 600 }}>Tým</th>
|
||||
<th style={{ padding: '6px 4px', textAlign: 'center', fontWeight: 600 }}>Z</th>
|
||||
<th style={{ padding: '6px 4px', textAlign: 'center', fontWeight: 600 }}>V</th>
|
||||
<th style={{ padding: '6px 4px', textAlign: 'center', fontWeight: 600 }}>R</th>
|
||||
<th style={{ padding: '6px 4px', textAlign: 'center', fontWeight: 600 }}>P</th>
|
||||
<th style={{ padding: '6px 4px', textAlign: 'center', fontWeight: 600, display: 'none' }} className="hide-mobile">Skóre</th>
|
||||
<th style={{ padding: '6px 8px', textAlign: 'center', fontWeight: 600 }}>Body</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{(matchingStanding?.table || matchingStanding?.rows || []).slice(0,8).map((row: any, idx: number) => {
|
||||
const handleClick = () => {
|
||||
const clubData = {
|
||||
team: row.team?.name ?? row.team ?? row.club ?? '-',
|
||||
team_id: row.team_id || '',
|
||||
team_logo_url: row.team_logo_url,
|
||||
rank: row.position ?? row.pos ?? row.rank ?? idx+1,
|
||||
played: row.played ?? row.matches ?? '-',
|
||||
wins: row.wins ?? row.win ?? '-',
|
||||
draws: row.draws ?? row.draw ?? '-',
|
||||
losses: row.losses ?? row.loss ?? '-',
|
||||
score: row.score ?? '-',
|
||||
points: row.points ?? row.pts ?? '-',
|
||||
};
|
||||
setSelectedClub(clubData);
|
||||
setIsModalOpen(true);
|
||||
};
|
||||
return (
|
||||
<tr
|
||||
key={idx}
|
||||
onClick={handleClick}
|
||||
style={{
|
||||
cursor: 'pointer',
|
||||
background: 'var(--card-bg)',
|
||||
border: '1px solid var(--card-border)',
|
||||
borderRadius: '8px',
|
||||
transition: 'all 0.2s ease',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.transform = 'translateX(2px)';
|
||||
e.currentTarget.style.boxShadow = '0 4px 12px rgba(0,0,0,0.08)';
|
||||
e.currentTarget.style.borderColor = 'var(--primary)';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.transform = 'translateX(0)';
|
||||
e.currentTarget.style.boxShadow = 'none';
|
||||
e.currentTarget.style.borderColor = 'var(--card-border)';
|
||||
}}
|
||||
>
|
||||
<td style={{ padding: '10px 8px', fontWeight: 700, color: 'var(--primary)', fontSize: '0.9rem' }}>#{row.position ?? row.pos ?? row.rank ?? idx+1}</td>
|
||||
<td style={{ padding: '10px 8px' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', minWidth: 0 }}>
|
||||
{row.team_logo_url && (
|
||||
<img
|
||||
src={assetUrl(row.team_logo_url)}
|
||||
alt={row.team?.name ?? row.team ?? row.club ?? '-'}
|
||||
style={{ width: '24px', height: '24px', borderRadius: '50%', objectFit: 'cover', background: 'var(--bg-soft)', border: '1px solid var(--card-border)', flexShrink: 0 }}
|
||||
/>
|
||||
)}
|
||||
<span style={{ fontWeight: 600, color: 'var(--text)', fontSize: '0.9rem', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{row.team?.name ?? row.team ?? row.club ?? '-'}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td style={{ padding: '10px 4px', textAlign: 'center', fontSize: '0.85rem', color: 'var(--text)' }}>{row.played ?? row.matches ?? '-'}</td>
|
||||
<td style={{ padding: '10px 4px', textAlign: 'center', fontSize: '0.85rem', color: 'var(--text)' }}>{row.wins ?? row.win ?? '-'}</td>
|
||||
<td style={{ padding: '10px 4px', textAlign: 'center', fontSize: '0.85rem', color: 'var(--text)' }}>{row.draws ?? row.draw ?? '-'}</td>
|
||||
<td style={{ padding: '10px 4px', textAlign: 'center', fontSize: '0.85rem', color: 'var(--text)' }}>{row.losses ?? row.loss ?? '-'}</td>
|
||||
<td style={{ padding: '10px 4px', textAlign: 'center', fontSize: '0.85rem', color: 'var(--text)', display: 'none' }} className="hide-mobile">{row.score ?? '-'}</td>
|
||||
<td style={{ padding: '10px 8px', textAlign: 'center', fontWeight: 700, color: 'var(--secondary)', fontSize: '1rem' }}>{row.points ?? row.pts ?? '-'}</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1678,7 +1753,7 @@ const HomePage: React.FC = () => {
|
||||
|
||||
{/* Players scroller (optional) */}
|
||||
{players.length > 0 && isVisible('team', false) && (
|
||||
<section data-element="team" className="players-scroller" style={{ marginTop: 32 }}>
|
||||
<section data-element="team" className="players-scroller" style={{ marginTop: 32, position: 'relative', ...getStyles('team') }}>
|
||||
<div className="section-head">
|
||||
<h3>Hráči</h3>
|
||||
<a href="/players" className="see-all">Zobrazit vše <FiArrowRight /></a>
|
||||
@@ -1713,7 +1788,7 @@ const HomePage: React.FC = () => {
|
||||
|
||||
{/* Gallery */}
|
||||
{isVisible('gallery', false) && (
|
||||
<section data-element="gallery" style={{ marginTop: 32, marginBottom: 32 }}>
|
||||
<section data-element="gallery" style={{ marginTop: 32, marginBottom: 32, position: 'relative', ...getStyles('gallery') }}>
|
||||
<div style={{ maxWidth: 1200, margin: '0 auto', padding: '0 12px' }}>
|
||||
<GallerySection />
|
||||
</div>
|
||||
@@ -1722,7 +1797,7 @@ const HomePage: React.FC = () => {
|
||||
|
||||
{/* Videos */}
|
||||
{isVisible('videos', false) && (
|
||||
<section data-element="videos" style={{ marginTop: 32, marginBottom: 32 }}>
|
||||
<section data-element="videos" style={{ marginTop: 32, marginBottom: 32, position: 'relative', ...getStyles('videos') }}>
|
||||
<div style={{ maxWidth: 1200, margin: '0 auto', padding: '0 12px' }}>
|
||||
<VideosSection />
|
||||
</div>
|
||||
@@ -1730,7 +1805,7 @@ const HomePage: React.FC = () => {
|
||||
)}
|
||||
|
||||
{isVisible('merch', true) && (
|
||||
<section data-element="merch" style={{ marginTop: 24, marginBottom: 24 }}>
|
||||
<section data-element="merch" style={{ marginTop: 24, marginBottom: 24, position: 'relative', ...getStyles('merch') }}>
|
||||
<div style={{ maxWidth: 1200, margin: '0 auto', padding: '0 12px' }}>
|
||||
<MerchSection />
|
||||
</div>
|
||||
@@ -1739,7 +1814,7 @@ const HomePage: React.FC = () => {
|
||||
|
||||
{/* Newsletter subscription CTA */}
|
||||
{isVisible('newsletter', false) && (
|
||||
<section data-element="newsletter" className="newsletter-cta" style={{ marginTop: 24, marginBottom: 24 }}>
|
||||
<section data-element="newsletter" className="newsletter-cta" style={{ marginTop: 24, marginBottom: 24, position: 'relative', ...getStyles('newsletter') }}>
|
||||
<div className="card" style={{ maxWidth: 960, margin: '0 auto' }}>
|
||||
<NewsletterSubscribe />
|
||||
</div>
|
||||
@@ -1748,7 +1823,7 @@ const HomePage: React.FC = () => {
|
||||
|
||||
{/* Banner: homepage_top */}
|
||||
{(banners || []).some(b => b.placement === 'homepage_top') && (
|
||||
<section data-element="banner" className="banner banner-top" style={{ margin: '24px 0', textAlign: 'center' }}>
|
||||
<section data-element="banner" className="banner banner-top" style={{ margin: '24px 0', textAlign: 'center', ...getStyles('banner') }}>
|
||||
{(banners || []).filter(b => b.placement === 'homepage_top').map((b) => (
|
||||
<a key={b.id} href={b.url || '#'} target={b.url ? '_blank' : undefined} rel={b.url ? 'noopener noreferrer' : undefined} style={{ display: 'inline-block', margin: 8 }}>
|
||||
{/* eslint-disable-next-line jsx-a11y/alt-text */}
|
||||
@@ -1760,7 +1835,7 @@ const HomePage: React.FC = () => {
|
||||
|
||||
{/* Banner: homepage_footer */}
|
||||
{(banners || []).some(b => b.placement === 'homepage_footer') && (
|
||||
<section data-element="banner" className="banner banner-footer" style={{ margin: '24px 0', textAlign: 'center' }}>
|
||||
<section data-element="banner" className="banner banner-footer" style={{ margin: '24px 0', textAlign: 'center', ...getStyles('banner') }}>
|
||||
{(banners || []).filter(b => b.placement === 'homepage_footer').map((b) => (
|
||||
<a key={b.id} href={b.url || '#'} target={b.url ? '_blank' : undefined} rel={b.url ? 'noopener noreferrer' : undefined} style={{ display: 'inline-block', margin: 8 }}>
|
||||
{/* eslint-disable-next-line jsx-a11y/alt-text */}
|
||||
@@ -1784,6 +1859,7 @@ const HomePage: React.FC = () => {
|
||||
paddingLeft: 'max(16px, calc((100vw - 1200px) / 2))',
|
||||
paddingRight: 'max(16px, calc((100vw - 1200px) / 2))',
|
||||
boxSizing: 'border-box',
|
||||
...getStyles('sponsors')
|
||||
}}
|
||||
>
|
||||
<div className="section-head">
|
||||
@@ -1842,7 +1918,9 @@ const HomePage: React.FC = () => {
|
||||
console.log('Team clicked:', teamName);
|
||||
}}
|
||||
/>
|
||||
<MyUIbrixStyleEditor pageType="homepage" />
|
||||
<MyUIbrixErrorBoundary>
|
||||
<MyUIbrixStyleEditor pageType="homepage" />
|
||||
</MyUIbrixErrorBoundary>
|
||||
</MainLayout>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -121,9 +121,14 @@ const MatchesPage: React.FC = () => {
|
||||
// First try ID-based matching (most reliable)
|
||||
let ourIsHome = false;
|
||||
let ourIsAway = false;
|
||||
if (clubId && m.home_id && m.away_id) {
|
||||
ourIsHome = m.home_id === clubId;
|
||||
ourIsAway = m.away_id === clubId;
|
||||
if (clubId) {
|
||||
// Check each team ID individually - even if one is missing, we can still match the other
|
||||
if (m.home_id) {
|
||||
ourIsHome = m.home_id === clubId;
|
||||
}
|
||||
if (m.away_id) {
|
||||
ourIsAway = m.away_id === clubId;
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to name matching if IDs not available or no match
|
||||
|
||||
@@ -57,6 +57,9 @@ import { MapCoordinates } from '../../utils/mapUrlParser';
|
||||
import ContactMap from '../../components/home/ContactMap';
|
||||
import RichTextEditor from '../../components/common/RichTextEditor';
|
||||
import { getCachedYouTube, YouTubeVideo } from '../../services/youtube';
|
||||
import SaveStatusIndicator from '../../components/common/SaveStatusIndicator';
|
||||
import DraftRecoveryModal from '../../components/common/DraftRecoveryModal';
|
||||
import { useAutoSave, loadDraft, getDraftMetadata } from '../../hooks/useAutoSave';
|
||||
import { FiVideo, FiYoutube, FiLink } from 'react-icons/fi';
|
||||
import ThumbnailPreview from '../../components/common/ThumbnailPreview';
|
||||
import { assetUrl } from '../../utils/url';
|
||||
@@ -77,6 +80,8 @@ const AdminActivitiesPage: React.FC = () => {
|
||||
const qc = useQueryClient();
|
||||
const { isOpen, onOpen, onClose } = useDisclosure();
|
||||
const [editing, setEditing] = useState<Partial<Event> | null>(null);
|
||||
const [showDraftRecovery, setShowDraftRecovery] = useState(false);
|
||||
const [draftKey, setDraftKey] = useState<string>('');
|
||||
const [aiPrompt, setAiPrompt] = useState<string>('');
|
||||
const [aiLoading, setAiLoading] = useState<boolean>(false);
|
||||
const [aiTone, setAiTone] = useState<'informative'|'friendly'|'formal'>('friendly');
|
||||
@@ -88,6 +93,31 @@ const AdminActivitiesPage: React.FC = () => {
|
||||
const [clubVideos, setClubVideos] = useState<YouTubeVideo[]>([]);
|
||||
const [youtubeTab, setYoutubeTab] = useState<'club' | 'custom'>('club');
|
||||
|
||||
// Auto-save hook - saves draft automatically
|
||||
const { saveStatus, lastSaved, forceSave, clearDraft } = useAutoSave({
|
||||
data: editing || {},
|
||||
storageKey: draftKey,
|
||||
onSave: async (data) => {
|
||||
// If event has ID, update it
|
||||
if (data.id) {
|
||||
return await updateEvent(data.id, data);
|
||||
}
|
||||
// If no ID and has title, create as draft
|
||||
if (data.title?.trim() && data.start_time) {
|
||||
const created = await createEvent(data);
|
||||
// Update editing state with new ID
|
||||
if (created?.id) {
|
||||
setEditing(prev => ({ ...prev, id: created.id } as any));
|
||||
}
|
||||
return created;
|
||||
}
|
||||
// Don't save if no title or start time
|
||||
return {};
|
||||
},
|
||||
debounceMs: 2000,
|
||||
enabled: isOpen && editing !== null,
|
||||
});
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ['admin-events'],
|
||||
queryFn: () => getEvents(),
|
||||
@@ -115,14 +145,28 @@ const AdminActivitiesPage: React.FC = () => {
|
||||
staleTime: 5 * 60_000,
|
||||
});
|
||||
|
||||
const openCreate = () => {
|
||||
setEditing({ title: '', description: '', type: 'other', is_public: true } as any);
|
||||
setLocationLat(undefined);
|
||||
setLocationLng(undefined);
|
||||
onOpen();
|
||||
const openCreate = () => {
|
||||
// Check for existing draft
|
||||
const key = 'draft-activity-new';
|
||||
setDraftKey(key);
|
||||
const metadata = getDraftMetadata(key);
|
||||
if (metadata && metadata.age < 1440) {
|
||||
// Show recovery modal
|
||||
setShowDraftRecovery(true);
|
||||
} else {
|
||||
// No draft, start fresh
|
||||
setEditing({ title: '', description: '', type: 'other', is_public: true } as any);
|
||||
setLocationLat(undefined);
|
||||
setLocationLng(undefined);
|
||||
onOpen();
|
||||
}
|
||||
};
|
||||
const openEdit = (ev: Event) => {
|
||||
setEditing({ ...ev });
|
||||
const openEdit = (ev: Event) => {
|
||||
// Set unique draft key for this event
|
||||
const key = `draft-activity-${ev.id}`;
|
||||
setDraftKey(key);
|
||||
|
||||
setEditing({ ...ev });
|
||||
// Initialize map coordinates from event
|
||||
if ((ev as any).latitude && (ev as any).longitude) {
|
||||
setLocationLat((ev as any).latitude);
|
||||
@@ -131,13 +175,43 @@ const AdminActivitiesPage: React.FC = () => {
|
||||
setLocationLat(undefined);
|
||||
setLocationLng(undefined);
|
||||
}
|
||||
onOpen();
|
||||
onOpen();
|
||||
};
|
||||
const closeModal = () => {
|
||||
setEditing(null);
|
||||
const closeModal = () => {
|
||||
setEditing(null);
|
||||
setLocationLat(undefined);
|
||||
setLocationLng(undefined);
|
||||
onClose();
|
||||
onClose();
|
||||
};
|
||||
|
||||
// Draft recovery handlers
|
||||
const handleRecoverDraft = () => {
|
||||
const draft = loadDraft<Partial<Event>>(draftKey);
|
||||
if (draft) {
|
||||
setEditing(draft);
|
||||
// Restore location if present
|
||||
if ((draft as any)?.latitude && (draft as any)?.longitude) {
|
||||
setLocationLat((draft as any).latitude);
|
||||
setLocationLng((draft as any).longitude);
|
||||
}
|
||||
onOpen();
|
||||
}
|
||||
setShowDraftRecovery(false);
|
||||
};
|
||||
|
||||
const handleDiscardDraft = () => {
|
||||
clearDraft();
|
||||
setEditing({ title: '', description: '', type: 'other', is_public: true } as any);
|
||||
setLocationLat(undefined);
|
||||
setLocationLng(undefined);
|
||||
setShowDraftRecovery(false);
|
||||
onOpen();
|
||||
};
|
||||
|
||||
const handleDeleteOnly = () => {
|
||||
clearDraft();
|
||||
setShowDraftRecovery(false);
|
||||
// Don't open the modal - just delete and close
|
||||
};
|
||||
|
||||
const createMut = useMutation({
|
||||
@@ -433,10 +507,13 @@ const AdminActivitiesPage: React.FC = () => {
|
||||
<ModalOverlay backdropFilter="blur(3px)" />
|
||||
<ModalContent maxW={{ base: '96vw', md: '920px' }} maxH={{ base: '90vh', md: '86vh' }} borderRadius="2xl" overflow="hidden" boxShadow="2xl">
|
||||
<ModalHeader>
|
||||
<VStack align="start" spacing={1}>
|
||||
<Heading size="md">{(editing as any)?.id ? 'Upravit aktivitu' : 'Nová aktivita'}</Heading>
|
||||
<Text fontSize="sm" color="gray.500">Plánujte klubové akce, sdílejte s fanoušky a týmem.</Text>
|
||||
</VStack>
|
||||
<HStack justify="space-between" align="start" w="full" pr={8}>
|
||||
<VStack align="start" spacing={1} flex={1}>
|
||||
<Heading size="md">{(editing as any)?.id ? 'Upravit aktivitu' : 'Nová aktivita'}</Heading>
|
||||
<Text fontSize="sm" color="gray.500">Plánujte klubové akce, sdílejte s fanoušky a týmem.</Text>
|
||||
</VStack>
|
||||
<SaveStatusIndicator status={saveStatus} lastSaved={lastSaved} compact />
|
||||
</HStack>
|
||||
</ModalHeader>
|
||||
<ModalCloseButton />
|
||||
<ModalBody overflowY="auto" maxH={{ base: '76vh', md: '70vh' }}>
|
||||
@@ -946,6 +1023,17 @@ const AdminActivitiesPage: React.FC = () => {
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
|
||||
{/* Draft Recovery Modal */}
|
||||
<DraftRecoveryModal
|
||||
isOpen={showDraftRecovery}
|
||||
onClose={() => setShowDraftRecovery(false)}
|
||||
onRecover={handleRecoverDraft}
|
||||
onDiscard={handleDiscardDraft}
|
||||
onDeleteOnly={handleDeleteOnly}
|
||||
draftAge={getDraftMetadata(draftKey)?.age || null}
|
||||
entityType="aktivitu"
|
||||
/>
|
||||
</Box>
|
||||
</AdminLayout>
|
||||
);
|
||||
|
||||
@@ -24,6 +24,9 @@ import AlbumPhotoPicker from '../../components/admin/AlbumPhotoPicker';
|
||||
import PollLinker from '../../components/admin/PollLinker';
|
||||
import ThumbnailPreview from '../../components/common/ThumbnailPreview';
|
||||
import FilePreview from '../../components/common/FilePreview';
|
||||
import SaveStatusIndicator from '../../components/common/SaveStatusIndicator';
|
||||
import DraftRecoveryModal from '../../components/common/DraftRecoveryModal';
|
||||
import { useAutoSave, loadDraft, getDraftMetadata } from '../../hooks/useAutoSave';
|
||||
|
||||
import { getCachedYouTube, YouTubeVideo } from '../../services/youtube';
|
||||
|
||||
@@ -173,6 +176,8 @@ const ArticlesAdminPage = () => {
|
||||
}
|
||||
|
||||
const [editing, setEditing] = useState<EditingArticle | null>(null);
|
||||
const [showDraftRecovery, setShowDraftRecovery] = useState(false);
|
||||
const [draftKey, setDraftKey] = useState<string>('');
|
||||
|
||||
|
||||
|
||||
@@ -254,6 +259,53 @@ const ArticlesAdminPage = () => {
|
||||
const [youtubeManualInput, setYoutubeManualInput] = useState<string>('');
|
||||
const { isOpen: isYouTubeModalOpen, onOpen: onYouTubeModalOpen, onClose: onYouTubeModalClose } = useDisclosure();
|
||||
|
||||
// Auto-save hook - saves draft automatically
|
||||
const { saveStatus, lastSaved, forceSave, clearDraft } = useAutoSave({
|
||||
data: editing || {},
|
||||
storageKey: draftKey,
|
||||
onSave: async (data) => {
|
||||
// If article has ID, update it as draft
|
||||
if (data.id) {
|
||||
return await updateArticle(data.id, { ...data as any, published: false });
|
||||
}
|
||||
// If no ID, create as draft
|
||||
if (data.title?.trim()) {
|
||||
const payload: CreateArticlePayload = {
|
||||
title: data.title || 'Koncept článku',
|
||||
content: data.content || '',
|
||||
image_url: data.image_url || '',
|
||||
category_name: data.category_name,
|
||||
published: false, // Always save as draft
|
||||
slug: data.slug || '',
|
||||
seo_title: data.seo_title || '',
|
||||
seo_description: data.seo_description || '',
|
||||
og_image_url: data.og_image_url || '',
|
||||
featured: data.featured || false,
|
||||
};
|
||||
const created = await createArticle(payload);
|
||||
// Update editing state with new ID
|
||||
if (created?.id) {
|
||||
setEditing(prev => ({ ...prev, id: created.id } as any));
|
||||
}
|
||||
return created;
|
||||
}
|
||||
// Don't save if no title
|
||||
return {};
|
||||
},
|
||||
debounceMs: 2000,
|
||||
enabled: isOpen && editing !== null,
|
||||
});
|
||||
|
||||
// Check for draft on component mount
|
||||
React.useEffect(() => {
|
||||
const key = 'draft-article-new';
|
||||
setDraftKey(key);
|
||||
const metadata = getDraftMetadata(key);
|
||||
if (metadata && metadata.age < 1440) { // Less than 24 hours old
|
||||
setShowDraftRecovery(true);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Fetch cached Zonerama gallery from prefetch
|
||||
const fetchCachedGallery = useCallback(async () => {
|
||||
try {
|
||||
@@ -616,13 +668,27 @@ const ArticlesAdminPage = () => {
|
||||
});
|
||||
|
||||
const openCreate = () => {
|
||||
setEditing({ title: '', content: '', featured: false, published: true } as any);
|
||||
setActiveTabIndex(0); // Start on AI tab for new articles
|
||||
setAiPrompt(''); // Clear AI prompt
|
||||
onOpen();
|
||||
// Check for existing draft
|
||||
const key = 'draft-article-new';
|
||||
setDraftKey(key);
|
||||
const metadata = getDraftMetadata(key);
|
||||
if (metadata && metadata.age < 1440) {
|
||||
// Show recovery modal
|
||||
setShowDraftRecovery(true);
|
||||
} else {
|
||||
// No draft, start fresh
|
||||
setEditing({ title: '', content: '', featured: false, published: false } as any);
|
||||
setActiveTabIndex(0); // Start on AI tab for new articles
|
||||
setAiPrompt(''); // Clear AI prompt
|
||||
onOpen();
|
||||
}
|
||||
};
|
||||
|
||||
const openEdit = (a: Article) => {
|
||||
// Set unique draft key for this article
|
||||
const key = `draft-article-${a.id}`;
|
||||
setDraftKey(key);
|
||||
|
||||
setEditing({
|
||||
...a,
|
||||
category_name: a.category?.name || a.category_name || ''
|
||||
@@ -652,6 +718,31 @@ const ArticlesAdminPage = () => {
|
||||
onClose();
|
||||
};
|
||||
|
||||
// Draft recovery handlers
|
||||
const handleRecoverDraft = () => {
|
||||
const draft = loadDraft<EditingArticle>(draftKey);
|
||||
if (draft) {
|
||||
setEditing(draft);
|
||||
setActiveTabIndex(1); // Go to Základní tab
|
||||
onOpen();
|
||||
}
|
||||
setShowDraftRecovery(false);
|
||||
};
|
||||
|
||||
const handleDiscardDraft = () => {
|
||||
clearDraft();
|
||||
setEditing({ title: '', content: '', featured: false, published: false } as any);
|
||||
setActiveTabIndex(0);
|
||||
setShowDraftRecovery(false);
|
||||
onOpen();
|
||||
};
|
||||
|
||||
const handleDeleteOnly = () => {
|
||||
clearDraft();
|
||||
setShowDraftRecovery(false);
|
||||
// Don't open the modal - just delete and close
|
||||
};
|
||||
|
||||
const createMut = useMutation({
|
||||
mutationFn: (payload: CreateArticlePayload) =>
|
||||
// Forward the payload as-is so new fields (youtube, gallery) are persisted
|
||||
@@ -1189,7 +1280,12 @@ const ArticlesAdminPage = () => {
|
||||
<Modal isOpen={isOpen} onClose={closeModal} size="xl" isCentered>
|
||||
<ModalOverlay />
|
||||
<ModalContent maxW="90vw" maxH="90vh">
|
||||
<ModalHeader>{(editing as any)?.id ? 'Upravit článek' : 'Nový článek'}</ModalHeader>
|
||||
<ModalHeader>
|
||||
<HStack justify="space-between" align="center" w="full" pr={8}>
|
||||
<Text>{(editing as any)?.id ? 'Upravit článek' : 'Nový článek'}</Text>
|
||||
<SaveStatusIndicator status={saveStatus} lastSaved={lastSaved} />
|
||||
</HStack>
|
||||
</ModalHeader>
|
||||
<ModalCloseButton />
|
||||
<ModalBody maxH="calc(90vh - 120px)" overflowY="auto">
|
||||
<Tabs variant="enclosed" colorScheme="blue" isFitted index={activeTabIndex} onChange={(index) => setActiveTabIndex(index)}>
|
||||
@@ -1839,20 +1935,28 @@ const ArticlesAdminPage = () => {
|
||||
qc.invalidateQueries({ queryKey: ['linked-polls'] });
|
||||
}} />
|
||||
) : (
|
||||
<Alert status="warning" borderRadius="md">
|
||||
<Alert status="info" borderRadius="md">
|
||||
<AlertIcon />
|
||||
<VStack align="start" spacing={2}>
|
||||
<Text fontWeight="semibold">Článek ještě není uložen</Text>
|
||||
<Text fontSize="sm">
|
||||
Pro propojení anket s článkem musíte nejprve článek uložit. Klikněte na "Uložit" níže - článek se uloží jako koncept a poté budete moci přidat ankety.
|
||||
<Text fontWeight="semibold">
|
||||
{saveStatus === 'saving' ? 'Ukládání článku...' : 'Článek se ukládá automaticky'}
|
||||
</Text>
|
||||
<Text fontSize="sm">
|
||||
Začněte psát článek na záložkách výše. Systém automaticky ukládá každou změnu jako koncept. Jakmile bude článek uložen (v záhlaví se zobrazí "Uloženo"), budete moci přidat ankety.
|
||||
</Text>
|
||||
{saveStatus === 'saving' && <Spinner size="sm" color="blue.500" />}
|
||||
{saveStatus === 'idle' && (
|
||||
<Text fontSize="xs" color="gray.600">
|
||||
💡 Vyplňte název článku pro aktivaci automatického ukládání
|
||||
</Text>
|
||||
)}
|
||||
<Button
|
||||
size="sm"
|
||||
colorScheme="blue"
|
||||
onClick={async () => {
|
||||
// Save article as draft first, keep modal open
|
||||
// Force save if needed
|
||||
try {
|
||||
await onSubmit({ keepOpen: true });
|
||||
await forceSave();
|
||||
// Switch to poll tab after save
|
||||
setActiveTabIndex(5); // Poll tab is index 5
|
||||
} catch (error) {
|
||||
@@ -2164,6 +2268,17 @@ const ArticlesAdminPage = () => {
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
|
||||
{/* Draft Recovery Modal */}
|
||||
<DraftRecoveryModal
|
||||
isOpen={showDraftRecovery}
|
||||
onClose={() => setShowDraftRecovery(false)}
|
||||
onRecover={handleRecoverDraft}
|
||||
onDiscard={handleDiscardDraft}
|
||||
onDeleteOnly={handleDeleteOnly}
|
||||
draftAge={getDraftMetadata(draftKey)?.age || null}
|
||||
entityType="článek"
|
||||
/>
|
||||
</AdminLayout>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -518,6 +518,34 @@ html {
|
||||
.table-card .standing-row .team .name { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; font-weight: 600; color: var(--text); }
|
||||
.table-card .standing-row .pts { text-align: right; font-weight: 800; color: var(--secondary); font-size: 1.1rem; }
|
||||
|
||||
/* Compact standings table with full statistics */
|
||||
.standings-table-compact {
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.standings-table-compact thead th {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
background: var(--bg);
|
||||
z-index: 1;
|
||||
border-bottom: 2px solid var(--card-border);
|
||||
}
|
||||
|
||||
.standings-table-compact tbody tr {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.standings-table-compact tbody tr + tr {
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
/* Hide score column on smaller screens to maintain compact layout */
|
||||
@media (max-width: 1200px) {
|
||||
.standings-table-compact .hide-mobile {
|
||||
display: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* Enriched standings table (logos, colors, motion) */
|
||||
.card.tables .table.enriched { margin-top: 8px; }
|
||||
.card.tables .table.enriched .tr {
|
||||
|
||||
@@ -0,0 +1,325 @@
|
||||
/**
|
||||
* MyUIbrix Editor Controller
|
||||
* Manages editor state, variant changes, and real-time preview
|
||||
*/
|
||||
|
||||
export interface ElementConfig {
|
||||
element_name: string;
|
||||
variant: string;
|
||||
visible: boolean;
|
||||
display_order: number;
|
||||
custom_styles?: Record<string, any>;
|
||||
}
|
||||
|
||||
export interface EditorState {
|
||||
isEditing: boolean;
|
||||
selectedElement: string | null;
|
||||
configs: Record<string, ElementConfig>;
|
||||
isDirty: boolean;
|
||||
lastSaved: Date | null;
|
||||
}
|
||||
|
||||
export class EditorController {
|
||||
private state: EditorState;
|
||||
private listeners: Set<(state: EditorState) => void>;
|
||||
private saveTimeout: NodeJS.Timeout | null = null;
|
||||
|
||||
constructor() {
|
||||
this.state = {
|
||||
isEditing: false,
|
||||
selectedElement: null,
|
||||
configs: {},
|
||||
isDirty: false,
|
||||
lastSaved: null,
|
||||
};
|
||||
this.listeners = new Set();
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe to state changes
|
||||
*/
|
||||
subscribe(listener: (state: EditorState) => void): () => void {
|
||||
this.listeners.add(listener);
|
||||
return () => this.listeners.delete(listener);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current state
|
||||
*/
|
||||
getState(): EditorState {
|
||||
return { ...this.state };
|
||||
}
|
||||
|
||||
/**
|
||||
* Update variant for an element with immediate visual feedback
|
||||
*/
|
||||
updateVariant(elementName: string, variant: string): void {
|
||||
console.log(`[EditorController] Updating variant: ${elementName} -> ${variant}`);
|
||||
|
||||
const config = this.state.configs[elementName] || {
|
||||
element_name: elementName,
|
||||
variant: 'default',
|
||||
visible: true,
|
||||
display_order: 0,
|
||||
};
|
||||
|
||||
this.state = {
|
||||
...this.state,
|
||||
configs: {
|
||||
...this.state.configs,
|
||||
[elementName]: {
|
||||
...config,
|
||||
variant,
|
||||
},
|
||||
},
|
||||
isDirty: true,
|
||||
};
|
||||
|
||||
this.notifyListeners();
|
||||
this.dispatchVariantChange(elementName, variant);
|
||||
this.scheduleAutoSave();
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle element visibility
|
||||
*/
|
||||
toggleVisibility(elementName: string): void {
|
||||
const config = this.state.configs[elementName];
|
||||
if (!config) return;
|
||||
|
||||
const newVisible = !config.visible;
|
||||
|
||||
this.state = {
|
||||
...this.state,
|
||||
configs: {
|
||||
...this.state.configs,
|
||||
[elementName]: {
|
||||
...config,
|
||||
visible: newVisible,
|
||||
},
|
||||
},
|
||||
isDirty: true,
|
||||
};
|
||||
|
||||
this.notifyListeners();
|
||||
this.dispatchVisibilityChange(elementName, newVisible);
|
||||
this.scheduleAutoSave();
|
||||
}
|
||||
|
||||
/**
|
||||
* Reorder elements
|
||||
*/
|
||||
reorderElements(newOrder: string[]): void {
|
||||
console.log('[EditorController] Reordering elements:', newOrder);
|
||||
|
||||
const updatedConfigs = { ...this.state.configs };
|
||||
newOrder.forEach((elementName, index) => {
|
||||
if (updatedConfigs[elementName]) {
|
||||
updatedConfigs[elementName] = {
|
||||
...updatedConfigs[elementName],
|
||||
display_order: index,
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
this.state = {
|
||||
...this.state,
|
||||
configs: updatedConfigs,
|
||||
isDirty: true,
|
||||
};
|
||||
|
||||
this.notifyListeners();
|
||||
this.dispatchReorderChange(newOrder);
|
||||
this.scheduleAutoSave();
|
||||
}
|
||||
|
||||
/**
|
||||
* Update custom styles for an element
|
||||
*/
|
||||
updateStyles(elementName: string, styles: Record<string, any>): void {
|
||||
const config = this.state.configs[elementName];
|
||||
if (!config) return;
|
||||
|
||||
this.state = {
|
||||
...this.state,
|
||||
configs: {
|
||||
...this.state.configs,
|
||||
[elementName]: {
|
||||
...config,
|
||||
custom_styles: styles,
|
||||
},
|
||||
},
|
||||
isDirty: true,
|
||||
};
|
||||
|
||||
this.notifyListeners();
|
||||
this.dispatchStyleChange(elementName, styles);
|
||||
this.scheduleAutoSave();
|
||||
}
|
||||
|
||||
/**
|
||||
* Set selected element
|
||||
*/
|
||||
selectElement(elementName: string | null): void {
|
||||
this.state = {
|
||||
...this.state,
|
||||
selectedElement: elementName,
|
||||
};
|
||||
this.notifyListeners();
|
||||
}
|
||||
|
||||
/**
|
||||
* Enter edit mode
|
||||
*/
|
||||
startEditing(initialConfigs: Record<string, ElementConfig>): void {
|
||||
console.log('[EditorController] Starting edit mode');
|
||||
this.state = {
|
||||
...this.state,
|
||||
isEditing: true,
|
||||
configs: initialConfigs,
|
||||
isDirty: false,
|
||||
};
|
||||
this.notifyListeners();
|
||||
}
|
||||
|
||||
/**
|
||||
* Exit edit mode
|
||||
*/
|
||||
stopEditing(): void {
|
||||
console.log('[EditorController] Stopping edit mode');
|
||||
if (this.saveTimeout) {
|
||||
clearTimeout(this.saveTimeout);
|
||||
this.saveTimeout = null;
|
||||
}
|
||||
|
||||
this.state = {
|
||||
...this.state,
|
||||
isEditing: false,
|
||||
selectedElement: null,
|
||||
};
|
||||
this.notifyListeners();
|
||||
}
|
||||
|
||||
/**
|
||||
* Save current state
|
||||
*/
|
||||
async save(): Promise<boolean> {
|
||||
if (!this.state.isDirty) {
|
||||
console.log('[EditorController] No changes to save');
|
||||
return true;
|
||||
}
|
||||
|
||||
try {
|
||||
console.log('[EditorController] Saving changes...');
|
||||
const configs = Object.values(this.state.configs);
|
||||
|
||||
// TODO: Replace with actual API call
|
||||
const response = await fetch('/api/v1/admin/page-elements/batch', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({ configs }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Save failed');
|
||||
}
|
||||
|
||||
this.state = {
|
||||
...this.state,
|
||||
isDirty: false,
|
||||
lastSaved: new Date(),
|
||||
};
|
||||
this.notifyListeners();
|
||||
|
||||
console.log('[EditorController] Changes saved successfully');
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('[EditorController] Save failed:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Force refresh of all elements
|
||||
*/
|
||||
forceRefresh(): void {
|
||||
console.log('[EditorController] Force refreshing all elements');
|
||||
window.dispatchEvent(new CustomEvent('myuibrix-force-refresh', {
|
||||
detail: { timestamp: Date.now() }
|
||||
}));
|
||||
}
|
||||
|
||||
// Private methods
|
||||
|
||||
private notifyListeners(): void {
|
||||
this.listeners.forEach(listener => {
|
||||
try {
|
||||
listener(this.getState());
|
||||
} catch (error) {
|
||||
console.error('[EditorController] Listener error:', error);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private dispatchVariantChange(elementName: string, variant: string): void {
|
||||
const config = this.state.configs[elementName];
|
||||
window.dispatchEvent(new CustomEvent('myuibrix-change', {
|
||||
detail: {
|
||||
elementName,
|
||||
variant,
|
||||
visible: config?.visible ?? true,
|
||||
previewMode: true,
|
||||
timestamp: Date.now(),
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
private dispatchVisibilityChange(elementName: string, visible: boolean): void {
|
||||
const config = this.state.configs[elementName];
|
||||
window.dispatchEvent(new CustomEvent('myuibrix-change', {
|
||||
detail: {
|
||||
elementName,
|
||||
variant: config?.variant ?? 'default',
|
||||
visible,
|
||||
previewMode: true,
|
||||
timestamp: Date.now(),
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
private dispatchReorderChange(order: string[]): void {
|
||||
window.dispatchEvent(new CustomEvent('myuibrix-reorder', {
|
||||
detail: {
|
||||
order,
|
||||
previewMode: true,
|
||||
timestamp: Date.now(),
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
private dispatchStyleChange(elementName: string, styles: Record<string, any>): void {
|
||||
window.dispatchEvent(new CustomEvent('myuibrix-style-change', {
|
||||
detail: {
|
||||
elementName,
|
||||
styles,
|
||||
previewMode: true,
|
||||
timestamp: Date.now(),
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
private scheduleAutoSave(): void {
|
||||
if (this.saveTimeout) {
|
||||
clearTimeout(this.saveTimeout);
|
||||
}
|
||||
|
||||
// Auto-save after 2 seconds of inactivity
|
||||
this.saveTimeout = setTimeout(() => {
|
||||
this.save();
|
||||
}, 2000);
|
||||
}
|
||||
}
|
||||
|
||||
// Singleton instance
|
||||
export const editorController = new EditorController();
|
||||
@@ -0,0 +1,203 @@
|
||||
/**
|
||||
* MyUIbrix Backend API Service
|
||||
* Provides validation and optimization for MyUIbrix editor
|
||||
*/
|
||||
|
||||
import api from './api';
|
||||
|
||||
export interface ElementConfig {
|
||||
page_type: string;
|
||||
element_name: string;
|
||||
variant: string;
|
||||
visible: boolean;
|
||||
display_order: number;
|
||||
custom_styles?: Record<string, any>;
|
||||
}
|
||||
|
||||
export interface ValidationResult {
|
||||
valid: boolean;
|
||||
optimized_styles?: Record<string, any>;
|
||||
suggestions?: string[];
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface OptimizationResult {
|
||||
current_layout: any[];
|
||||
suggestions: string[];
|
||||
performance_score: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a single element configuration
|
||||
*/
|
||||
export const validateElementConfig = async (config: ElementConfig): Promise<ValidationResult> => {
|
||||
try {
|
||||
const response = await api.post('/admin/myuibrix/validate', config);
|
||||
return response.data;
|
||||
} catch (error: any) {
|
||||
console.error('Failed to validate element config:', error);
|
||||
return {
|
||||
valid: false,
|
||||
error: error.response?.data?.error || 'Validation failed'
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Validate multiple element configurations at once
|
||||
*/
|
||||
export const batchValidateConfigs = async (configs: ElementConfig[]): Promise<any> => {
|
||||
try {
|
||||
const response = await api.post('/admin/myuibrix/validate-batch', configs);
|
||||
return response.data;
|
||||
} catch (error: any) {
|
||||
console.error('Failed to batch validate configs:', error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Get element preview metadata
|
||||
*/
|
||||
export const getElementPreview = async (
|
||||
element: string,
|
||||
variant: string,
|
||||
viewport: 'desktop' | 'tablet' | 'mobile' = 'desktop'
|
||||
): Promise<any> => {
|
||||
try {
|
||||
const response = await api.get('/admin/myuibrix/preview', {
|
||||
params: { element, variant, viewport }
|
||||
});
|
||||
return response.data;
|
||||
} catch (error: any) {
|
||||
console.error('Failed to get element preview:', error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Get layout optimization suggestions
|
||||
*/
|
||||
export const optimizePageLayout = async (pageType: string = 'homepage'): Promise<OptimizationResult> => {
|
||||
try {
|
||||
const response = await api.get('/admin/myuibrix/optimize-layout', {
|
||||
params: { page_type: pageType }
|
||||
});
|
||||
return response.data;
|
||||
} catch (error: any) {
|
||||
console.error('Failed to optimize layout:', error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Debounce helper for style changes
|
||||
*/
|
||||
export const debounce = <T extends (...args: any[]) => any>(
|
||||
func: T,
|
||||
wait: number
|
||||
): ((...args: Parameters<T>) => void) => {
|
||||
let timeout: NodeJS.Timeout | null = null;
|
||||
|
||||
return (...args: Parameters<T>) => {
|
||||
if (timeout) clearTimeout(timeout);
|
||||
timeout = setTimeout(() => func(...args), wait);
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Safe DOM manipulation wrapper
|
||||
*/
|
||||
export const safeDOM = {
|
||||
/**
|
||||
* Safely append child element
|
||||
*/
|
||||
appendChild: (parent: Element, child: Element): boolean => {
|
||||
try {
|
||||
if (!parent.contains(child)) {
|
||||
parent.appendChild(child);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
} catch (e) {
|
||||
console.warn('Failed to append child:', e);
|
||||
return false;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Safely remove child element
|
||||
*/
|
||||
removeChild: (parent: Element, child: Element): boolean => {
|
||||
try {
|
||||
if (parent.contains(child)) {
|
||||
parent.removeChild(child);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
} catch (e) {
|
||||
console.warn('Failed to remove child:', e);
|
||||
return false;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Safely replace child element
|
||||
*/
|
||||
replaceChild: (parent: Element, newChild: Element, oldChild: Element): boolean => {
|
||||
try {
|
||||
if (parent.contains(oldChild)) {
|
||||
parent.replaceChild(newChild, oldChild);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
} catch (e) {
|
||||
console.warn('Failed to replace child:', e);
|
||||
return false;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Safely query selector
|
||||
*/
|
||||
querySelector: (selector: string): Element | null => {
|
||||
try {
|
||||
return document.querySelector(selector);
|
||||
} catch (e) {
|
||||
console.warn('Invalid selector:', selector, e);
|
||||
return null;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Safely query selector all
|
||||
*/
|
||||
querySelectorAll: (selector: string): Element[] => {
|
||||
try {
|
||||
return Array.from(document.querySelectorAll(selector));
|
||||
} catch (e) {
|
||||
console.warn('Invalid selector:', selector, e);
|
||||
return [];
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Safely insert before another element
|
||||
*/
|
||||
insertBefore: (parent: Element, newChild: Element, referenceChild: Node | null): boolean => {
|
||||
try {
|
||||
if (referenceChild && !parent.contains(referenceChild)) {
|
||||
console.warn('Reference child is not a child of parent');
|
||||
return false;
|
||||
}
|
||||
if (!parent.contains(newChild)) {
|
||||
parent.insertBefore(newChild, referenceChild);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
} catch (e) {
|
||||
console.warn('Failed to insert before:', e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,42 @@
|
||||
const { createProxyMiddleware } = require('http-proxy-middleware');
|
||||
|
||||
module.exports = function(app) {
|
||||
// Proxy /uploads requests to backend
|
||||
app.use(
|
||||
'/uploads',
|
||||
createProxyMiddleware({
|
||||
target: process.env.REACT_APP_API_BASE_URL || 'http://localhost:8080',
|
||||
changeOrigin: true,
|
||||
logLevel: 'debug',
|
||||
onError: (err, req, res) => {
|
||||
console.error('Proxy error for /uploads:', err);
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
// Proxy /static requests to backend (for any static assets served by Go)
|
||||
app.use(
|
||||
'/static',
|
||||
createProxyMiddleware({
|
||||
target: process.env.REACT_APP_API_BASE_URL || 'http://localhost:8080',
|
||||
changeOrigin: true,
|
||||
logLevel: 'debug',
|
||||
onError: (err, req, res) => {
|
||||
console.error('Proxy error for /static:', err);
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
// Proxy /cache requests to backend (for FACR cache files, etc.)
|
||||
app.use(
|
||||
'/cache',
|
||||
createProxyMiddleware({
|
||||
target: process.env.REACT_APP_API_BASE_URL || 'http://localhost:8080',
|
||||
changeOrigin: true,
|
||||
logLevel: 'debug',
|
||||
onError: (err, req, res) => {
|
||||
console.error('Proxy error for /cache:', err);
|
||||
},
|
||||
})
|
||||
);
|
||||
};
|
||||
@@ -1,5 +1,58 @@
|
||||
/* Custom Rich Editor Enhancements */
|
||||
|
||||
/* ============================================
|
||||
FORCE QUILL VISIBILITY - CRITICAL FIX
|
||||
============================================ */
|
||||
/* Ensure Quill editor is ALWAYS visible - override ALL CSS */
|
||||
.quill,
|
||||
.ql-toolbar,
|
||||
.ql-toolbar.ql-snow,
|
||||
.ql-container,
|
||||
.ql-container.ql-snow,
|
||||
.ql-editor {
|
||||
display: block !important;
|
||||
visibility: visible !important;
|
||||
opacity: 1 !important;
|
||||
position: relative !important;
|
||||
width: 100% !important;
|
||||
}
|
||||
|
||||
.ql-toolbar.ql-snow {
|
||||
min-height: 42px !important;
|
||||
height: auto !important;
|
||||
}
|
||||
|
||||
.ql-container.ql-snow {
|
||||
min-height: 200px !important;
|
||||
height: auto !important;
|
||||
}
|
||||
|
||||
.ql-editor {
|
||||
min-height: 200px !important;
|
||||
height: auto !important;
|
||||
overflow-y: auto !important;
|
||||
}
|
||||
|
||||
/* Override Chakra UI potential conflicts - ALL wrapper classes */
|
||||
.chakra-ui-light .ql-container,
|
||||
.chakra-ui-light .ql-editor,
|
||||
.css-8opgp6 .quill,
|
||||
.css-8opgp6 .ql-toolbar,
|
||||
.css-8opgp6 .ql-container,
|
||||
.css-8opgp6 .ql-editor,
|
||||
.css-ele4hk .quill,
|
||||
.css-ele4hk .ql-toolbar,
|
||||
.css-ele4hk .ql-container,
|
||||
.css-ele4hk .ql-editor,
|
||||
[class^="css-"] .quill,
|
||||
[class^="css-"] .ql-toolbar,
|
||||
[class^="css-"] .ql-container,
|
||||
[class^="css-"] .ql-editor {
|
||||
display: block !important;
|
||||
visibility: visible !important;
|
||||
opacity: 1 !important;
|
||||
}
|
||||
|
||||
/* Quill Toolbar Styling */
|
||||
.ql-toolbar.ql-snow {
|
||||
background: linear-gradient(to bottom, #fafafa 0%, #f5f5f5 100%);
|
||||
|
||||
Reference in New Issue
Block a user