This commit is contained in:
Tomas Dvorak
2025-10-23 22:26:50 +02:00
parent 63700eedb2
commit 70ea0c3c91
75 changed files with 3337 additions and 1160 deletions
+51 -4
View File
@@ -5,6 +5,7 @@ import { BrowserRouter as Router, Routes, Route, Navigate, Outlet } from 'react-
import './styles/custom-scrollbar.css';
import { AuthProvider, useAuth } from './contexts/AuthContext';
import AuthPage from './pages/AuthPage';
import RegisterPage from './pages/RegisterPage';
import DashboardPage from './pages/DashboardPage';
import ArticlesListPage from './pages/ArticlesListPage';
import HomePage from './pages/HomePage';
@@ -50,6 +51,7 @@ import AnalyticsAdminPage from './pages/admin/AnalyticsAdminPage';
import FilesAdminPage from './pages/admin/FilesAdminPage';
import ContactsAdminPage from './pages/admin/ContactsAdminPage';
import NavigationAdminPage from './pages/admin/NavigationAdminPage';
import SemiAdminPage from './pages/SemiAdminPage';
import PollsAdminPage from './pages/admin/PollsAdminPage';
// Admin pages render their own AdminLayout internally
import SetupPage from './pages/SetupPage';
@@ -261,7 +263,7 @@ const App: React.FC = () => {
// Public Route component - redirects to admin if already authenticated
const PublicRoute = ({ children }: { children: React.ReactNode }) => {
const { isAuthenticated, isLoading } = useAuth();
const { isAuthenticated, isLoading, user } = useAuth();
const [checkingSetup, setCheckingSetup] = useState(true);
const [requiresSetup, setRequiresSetup] = useState<boolean>(false);
@@ -285,6 +287,10 @@ const App: React.FC = () => {
}
if (isAuthenticated) {
const role = user?.role;
if (role === 'fan') {
return <Navigate to="/semiadmin" replace />;
}
return <Navigate to="/admin" replace />;
}
@@ -374,10 +380,26 @@ const App: React.FC = () => {
</PublicRoute>
}
/>
<Route
path="/register"
element={
<PublicRoute>
<RegisterPage />
</PublicRoute>
}
/>
<Route path="/forgot-password" element={<ForgotPasswordPage />} />
<Route path="/reset-password" element={<ResetPasswordPage />} />
<Route path="/newsletter/unsubscribe/:email" element={<NewsletterUnsubscribePage />} />
<Route path="/newsletter/preferences" element={<NewsletterPreferencesPage />} />
<Route
path="/semiadmin"
element={
<ProtectedRoute>
<SemiAdminPage />
</ProtectedRoute>
}
/>
<Route path="/403" element={<ForbiddenPage />} />
{/* Admin area (pages include AdminLayout themselves) */}
@@ -388,15 +410,14 @@ const App: React.FC = () => {
}>
<Route path="/admin" element={<AdminDashboardPage />} />
<Route path="/admin/docs" element={<AdminDocsPage />} />
<Route path="/admin/clanky" element={<ArticlesAdminPage />} />
{/* moved to editor-accessible routes below */}
<Route path="/admin/o-klubu" element={<AboutAdminPage />} />
<Route path="/admin/videa" element={<AdminVideosPage />} />
<Route path="/admin/galerie" element={<GalleryAdminPage />} />
<Route path="/admin/obleceni" element={<AdminMerchPage />} />
<Route path="/admin/aktivity" element={<AdminActivitiesPage />} />
<Route path="/admin/sponzori" element={<SponsorsAdminPage />} />
<Route path="/admin/kategorie" element={<CategoriesAdminPage />} />
<Route path="/admin/media" element={<MediaAdminPage />} />
{/* moved to editor-accessible routes below */}
<Route path="/admin/zapasy" element={<MatchesAdminPage />} />
<Route path="/admin/hraci" element={<PlayersAdminPage />} />
<Route path="/admin/tymy" element={<TeamsAdminPage />} />
@@ -455,6 +476,32 @@ const App: React.FC = () => {
}
/>
{/* Editor-accessible content pages (also allow admin) */}
<Route
path="/admin/clanky"
element={
<ProtectedRoute requiredRole="editor">
<ArticlesAdminPage />
</ProtectedRoute>
}
/>
<Route
path="/admin/aktivity"
element={
<ProtectedRoute requiredRole="editor">
<AdminActivitiesPage />
</ProtectedRoute>
}
/>
<Route
path="/admin/media"
element={
<ProtectedRoute requiredRole="editor">
<MediaAdminPage />
</ProtectedRoute>
}
/>
{/* Not found route */}
<Route path="*" element={<NotFoundPage />} />
</Routes>
+26 -4
View File
@@ -33,6 +33,7 @@ import {
InputGroup,
InputLeftElement,
Input,
useToast,
} from '@chakra-ui/react';
import { MoonIcon, SunIcon, HamburgerIcon, EditIcon, ChevronDownIcon } from '@chakra-ui/icons';
import { FaFacebook, FaInstagram, FaYoutube, FaPhotoVideo, FaExternalLinkAlt, FaShoppingBag, FaCamera, FaSearch } from 'react-icons/fa';
@@ -49,6 +50,7 @@ import { getPlayers } from '../services/public';
import { getArticles } from '../services/articles';
import { getCachedYouTube } from '../services/youtube';
import { getZoneramaManifestWithFallbacks } from '../services/zonerama';
import { getMyNewsletterToken } from '../services/public/newsletter';
type NavLink = { label: string; to?: string; items?: { label: string; to: string }[]; external?: boolean };
@@ -236,7 +238,7 @@ const MobileMenu = ({ isOpen, onClose, isAdmin, menuBg, dividerColor, settings,
</Drawer>
);
const Navbar = () => {
const Navbar: React.FC<{ fullWidth?: boolean }> = ({ fullWidth = false }) => {
const { colorMode, toggleColorMode } = useColorMode();
const { isAuthenticated, logout, user } = useAuth();
const { isOpen, onOpen, onClose } = useDisclosure();
@@ -246,12 +248,14 @@ const Navbar = () => {
const theme = useClubTheme();
const location = useLocation();
const navigate = useNavigate();
const toast = useToast();
const menuBg = useColorModeValue('white', '#0f1115');
const dividerColor = useColorModeValue('gray.600', 'gray.300');
const hoverBg = useColorModeValue('blackAlpha.100', 'whiteAlpha.200');
const activeBg = useColorModeValue('blackAlpha.50', 'whiteAlpha.100');
const activeTextColor = useColorModeValue('brand.primary', 'brand.accent');
const navTextColor = useColorModeValue('gray.700', 'gray.200');
const topBarBg = useColorModeValue('gray.50', 'blackAlpha.500');
const [scrolled, setScrolled] = useState(false);
const [hasTables, setHasTables] = useState<boolean | null>(null);
const [hasActivities, setHasActivities] = useState<boolean | null>(null);
@@ -261,6 +265,7 @@ const Navbar = () => {
const [hasGallery, setHasGallery] = useState<boolean | null>(null);
const [dynamicNavItems, setDynamicNavItems] = useState<NavigationItem[]>([]);
const [navLoading, setNavLoading] = useState(true);
const containerMaxW = fullWidth ? 'full' as const : '7xl' as const;
// Search modal state
const [query, setQuery] = useState('');
@@ -279,6 +284,21 @@ const Navbar = () => {
return () => window.removeEventListener('scroll', onScroll as any);
}, []);
// Open newsletter preferences for logged-in user (fetch token and redirect)
const openMyNewsletterPrefs = async () => {
try {
const { token } = await getMyNewsletterToken();
navigate(`/newsletter/preferences?token=${encodeURIComponent(token)}`);
} catch (err: any) {
toast({
title: 'Chyba',
description: 'Nelze načíst odkaz na emailové preference. Zkuste to prosím znovu.',
status: 'error',
duration: 4000,
});
}
};
// Also set document title to club name ASAP (SEO component will refine further)
useEffect(() => {
const name = settings?.club_name || theme.name;
@@ -607,8 +627,8 @@ const Navbar = () => {
<Box position="sticky" top={0} zIndex={1000}>
{/* Top bar with socials and quick external links */}
{(settings?.facebook_url || settings?.instagram_url || settings?.youtube_url || settings?.shop_url) && (
<Box bg={useColorModeValue('gray.50', 'blackAlpha.500')} borderBottomWidth="1px" borderColor="border.subtle" py={1}>
<Container maxW="7xl">
<Box bg={topBarBg} borderBottomWidth="1px" borderColor="border.subtle" py={1}>
<Container maxW={containerMaxW}>
<Flex align="center" justify="space-between" gap={2}>
<HStack spacing={2}>
{settings?.shop_url && (
@@ -643,7 +663,7 @@ const Navbar = () => {
transition="box-shadow 0.2s ease, background-color 0.2s ease, backdrop-filter 0.2s ease"
>
<MobileMenu isOpen={isOpen} onClose={onClose} isAdmin={isAdmin} menuBg={menuBg} dividerColor={dividerColor} settings={settings} categories={navCategories} galleryHref={galleryHref} galleryLabel={galleryLabel} hasTables={hasTables} hasActivities={hasActivities} hasPlayers={hasPlayers} hasArticles={hasArticles} hasVideos={hasVideos} hasGallery={hasGallery} dynamicNavItems={dynamicNavItems} navLoading={navLoading} />
<Container maxW="7xl">
<Container maxW={containerMaxW}>
<Flex h={16} alignItems="center" justifyContent="space-between">
<HStack spacing={4} alignItems="center">
{/* Club Logo only */}
@@ -768,6 +788,8 @@ const Navbar = () => {
</MenuButton>
<MenuList>
<MenuItem as={RouterLink} to="/admin/nastaveni">Můj účet</MenuItem>
<MenuItem onClick={openMyNewsletterPrefs}>Emailové preference</MenuItem>
<MenuItem as={RouterLink} to="/profil/nastaveni">Nastavení stránky</MenuItem>
{isAdmin && <MenuItem as={RouterLink} to="/admin">Administrace</MenuItem>}
<MenuItem onClick={logout}>Odhlásit se</MenuItem>
</MenuList>
@@ -443,6 +443,7 @@ const PollLinker: React.FC<PollLinkerProps> = ({ articleId, eventId, onPollsChan
>
<option value="single">Jedna odpověď</option>
<option value="multiple">Více odpovědí</option>
<option value="rating">Hodnocení</option>
</Select>
</FormControl>
@@ -36,6 +36,7 @@ import {
} from 'lucide-react';
import { Image as ChakraImage } from '@chakra-ui/react';
import { cropAndUpload, quickEditImage } from '../../services/imageProcessing';
import { assetUrl } from '../../utils/url';
interface ImageFilters {
brightness: number;
@@ -245,28 +246,36 @@ const CustomRichEditor: React.FC<CustomRichEditorProps> = ({
return;
}
// Calculate crop data in pixels
// Calculate crop data in natural image pixels (backend expects absolute pixels)
const img = imgRef.current;
const percToPx = (val: number, size: number) => (crop.unit === '%' ? (val / 100) * size : val);
const displayW = img.width;
const displayH = img.height;
const naturalW = img.naturalWidth || displayW;
const naturalH = img.naturalHeight || displayH;
const scaleX = naturalW / Math.max(1, displayW);
const scaleY = naturalH / Math.max(1, displayH);
const toDisplayPx = (val: number, size: number) => (crop.unit === '%' ? (val / 100) * size : val);
let cropData = undefined;
if (crop.width && crop.height && crop.width > 0 && crop.height > 0) {
const cropPx = {
x: Math.round(Math.max(0, percToPx(crop.x || 0, img.width))),
y: Math.round(Math.max(0, percToPx(crop.y || 0, img.height))),
width: Math.round(Math.min(img.width, percToPx(crop.width || img.width, img.width))),
height: Math.round(Math.min(img.height, percToPx(crop.height || img.height, img.height))),
};
// Adjust crop to fit image bounds
if (cropPx.x + cropPx.width > img.width) {
cropPx.width = img.width - cropPx.x;
}
if (cropPx.y + cropPx.height > img.height) {
cropPx.height = img.height - cropPx.y;
}
cropData = cropPx;
// Convert selection from displayed coordinates to natural pixel coordinates
const dispX = Math.max(0, toDisplayPx(crop.x || 0, displayW));
const dispY = Math.max(0, toDisplayPx(crop.y || 0, displayH));
const dispW = Math.min(displayW, toDisplayPx(crop.width || displayW, displayW));
const dispH = Math.min(displayH, toDisplayPx(crop.height || displayH, displayH));
let natX = Math.round(dispX * scaleX);
let natY = Math.round(dispY * scaleY);
let natW = Math.round(dispW * scaleX);
let natH = Math.round(dispH * scaleY);
// Clamp within natural bounds
if (natX + natW > naturalW) natW = naturalW - natX;
if (natY + natH > naturalH) natH = naturalH - natY;
natW = Math.max(1, natW);
natH = Math.max(1, natH);
cropData = { x: natX, y: natY, width: natW, height: natH };
}
toast({ title: 'Zpracování obrázku...', status: 'info', duration: 2000 });
@@ -290,16 +299,25 @@ const CustomRichEditor: React.FC<CustomRichEditorProps> = ({
const range = quill.getSelection();
const index = range ? range.index : quill.getLength();
// Insert the image
quill.insertEmbed(index, 'image', res.url, 'api');
// Move cursor after the image
quill.setSelection(index + 1, 0, 'api');
// Force content change to trigger re-render
onChangeRef.current(quill.root.innerHTML);
toast({ title: 'Obrázek vložen', status: 'success', duration: 2000 });
const absoluteUrl = assetUrl(res.url) || res.url;
const img = new Image();
img.onload = () => {
try {
quill.insertEmbed(index, 'image', absoluteUrl, 'api');
// Move cursor after the image
quill.setSelection(index + 1, 0, 'api');
// Force content change to trigger re-render
onChangeRef.current(quill.root.innerHTML);
toast({ title: 'Obrázek vložen', status: 'success', duration: 2000 });
} catch (e) {
console.error('Insert after preload error:', e);
toast({ title: 'Chyba při vkládání obrázku', description: String(e), status: 'error' });
}
};
img.onerror = () => {
toast({ title: 'Obrázek nelze načíst', description: absoluteUrl, status: 'error' });
};
img.src = absoluteUrl;
} catch (embedError) {
console.error('Error inserting image:', embedError);
toast({ title: 'Chyba při vkládání obrázku', description: String(embedError), status: 'error' });
@@ -716,6 +734,12 @@ const CustomRichEditor: React.FC<CustomRichEditorProps> = ({
// Delete selected image on Delete key
const handleKeyDown = (e: KeyboardEvent) => {
const target = e.target as HTMLElement | null;
const tag = target?.tagName;
// Do not act on Delete/Backspace if user is typing in an input, textarea, or contentEditable
if (tag === 'INPUT' || tag === 'TEXTAREA' || (target && (target as any).isContentEditable)) {
return;
}
if (selectedImage && (e.key === 'Delete' || e.key === 'Backspace')) {
e.preventDefault();
selectedImage.remove();
@@ -754,6 +778,9 @@ const CustomRichEditor: React.FC<CustomRichEditorProps> = ({
editor.root.addEventListener('scroll', handleScroll);
editor.root.addEventListener('dragstart', handleDragStart);
document.addEventListener('keydown', handleKeyDown);
// Also reposition on window resize and any document scroll (capture phase)
window.addEventListener('resize', handleScroll);
document.addEventListener('scroll', handleScroll, true);
return () => {
editor.root.removeEventListener('click', handleImageClick);
@@ -761,10 +788,12 @@ const CustomRichEditor: React.FC<CustomRichEditorProps> = ({
editor.root.removeEventListener('scroll', handleScroll);
editor.root.removeEventListener('dragstart', handleDragStart);
document.removeEventListener('keydown', handleKeyDown);
window.removeEventListener('resize', handleScroll);
document.removeEventListener('scroll', handleScroll, true);
removeResizeHandle();
deselectImage();
};
}, [readOnly, toast]);
}, [readOnly, toast, isMounted]);
// Apply filters to selected image
const applyFiltersToImage = useCallback((img: HTMLImageElement, filters: ImageFilters) => {
@@ -850,7 +879,8 @@ const CustomRichEditor: React.FC<CustomRichEditorProps> = ({
});
// Replace image src
selectedImageElement.src = res.url;
const absoluteUrl = assetUrl(res.url) || res.url;
selectedImageElement.src = absoluteUrl;
// Reset filters to default since they're now baked into the image
setImageFilters({
@@ -873,6 +903,8 @@ const CustomRichEditor: React.FC<CustomRichEditorProps> = ({
const editor = quillRef.current?.getEditor();
if (editor) {
onChangeRef.current(editor.root.innerHTML);
// Force overlay reposition
try { editor.root.dispatchEvent(new Event('scroll')); } catch {}
}
toast({ title: 'Filtry aplikovány', status: 'success', duration: 2000 });
@@ -904,6 +936,8 @@ const CustomRichEditor: React.FC<CustomRichEditorProps> = ({
const editor = quillRef.current?.getEditor();
if (editor) {
onChangeRef.current(editor.root.innerHTML);
// Force overlay reposition
try { editor.root.dispatchEvent(new Event('scroll')); } catch {}
}
toast({ title: `Obrázek zarovnán ${alignment === 'left' ? 'vlevo' : alignment === 'center' ? 'na střed' : 'vpravo'}`, status: 'success', duration: 1500 });
}
@@ -924,6 +958,8 @@ const CustomRichEditor: React.FC<CustomRichEditorProps> = ({
setManualWidth(finalWidth.toString());
if (editor) {
onChangeRef.current(editor.root.innerHTML);
// Force overlay reposition
try { editor.root.dispatchEvent(new Event('scroll')); } catch {}
}
toast({ title: 'Šířka nastavena', description: `${finalWidth}px`, status: 'success', duration: 1500 });
} else {
@@ -1218,9 +1254,9 @@ const CustomRichEditor: React.FC<CustomRichEditorProps> = ({
maxH="80vh"
overflowY="auto"
pointerEvents="auto"
onClick={(e) => { e.preventDefault(); e.stopPropagation(); }}
onMouseDown={(e) => { e.preventDefault(); e.stopPropagation(); }}
onMouseUp={(e) => { e.preventDefault(); e.stopPropagation(); }}
onClick={(e) => { e.stopPropagation(); }}
onMouseDown={(e) => { e.stopPropagation(); }}
onMouseUp={(e) => { e.stopPropagation(); }}
css={{
'&::-webkit-scrollbar': {
width: '6px',
@@ -1302,7 +1338,7 @@ const CustomRichEditor: React.FC<CustomRichEditorProps> = ({
type="number"
value={manualWidth}
onChange={(e) => setManualWidth(e.target.value)}
onKeyPress={(e) => e.key === 'Enter' && applyManualWidth()}
onKeyDown={(e) => { if (e.key === 'Enter') { e.stopPropagation(); applyManualWidth(); } }}
placeholder="Šířka v px"
min={50}
/>
+35 -307
View File
@@ -1,28 +1,15 @@
import React, { useState } from 'react';
import React from 'react';
import {
Box,
Button,
HStack,
Icon,
Link as ChakraLink,
Modal,
ModalOverlay,
ModalContent,
ModalHeader,
ModalBody,
ModalCloseButton,
ModalFooter,
Text,
useDisclosure,
Image,
VStack,
Badge,
useColorModeValue,
AspectRatio,
} from '@chakra-ui/react';
import {
FiDownload,
FiEye,
FiExternalLink,
FiFile,
FiFileText,
FiImage,
@@ -44,10 +31,7 @@ const FilePreview: React.FC<FilePreviewProps> = ({
name,
mimeType = '',
size,
showInline = false,
}) => {
const { isOpen, onOpen, onClose } = useDisclosure();
const [imageError, setImageError] = useState(false);
const fullUrl = assetUrl(url) || url;
const fileName = name || url.split('/').pop() || 'file';
@@ -56,7 +40,6 @@ const FilePreview: React.FC<FilePreviewProps> = ({
const borderColor = useColorModeValue('gray.200', 'gray.700');
const cardBg = useColorModeValue('white', 'gray.800');
const mutedText = useColorModeValue('gray.600', 'gray.300');
const linkColor = useColorModeValue('blue.600', 'blue.300');
// Determine file type and icon
const getFileInfo = () => {
@@ -89,297 +72,42 @@ const FilePreview: React.FC<FilePreviewProps> = ({
const sizeMB = sizeKB && sizeKB > 1024 ? (sizeKB / 1024).toFixed(1) : undefined;
const sizeStr = sizeMB ? `${sizeMB} MB` : sizeKB ? `${sizeKB} kB` : '';
// Render preview content based on file type
const renderPreviewContent = () => {
if (fileInfo.type === 'image') {
if (imageError) {
return (
<VStack spacing={4} py={10}>
<Icon as={FiImage} boxSize={12} color="gray.400" />
<Text color={mutedText}>Obrázek se nepodařilo načíst</Text>
<Button
as={ChakraLink}
href={fullUrl}
isExternal
leftIcon={<FiDownload />}
colorScheme="blue"
>
Stáhnout soubor
</Button>
</VStack>
);
}
return (
<Image
src={fullUrl}
alt={fileName}
maxW="100%"
maxH="70vh"
objectFit="contain"
onError={() => setImageError(true)}
/>
);
}
if (fileInfo.type === 'pdf') {
// Try multiple PDF viewing methods due to CSP restrictions
return (
<VStack spacing={4} w="100%" minH="70vh">
{/* Primary: Try direct iframe embed */}
<Box w="100%" h="70vh" borderWidth="1px" borderRadius="md" overflow="hidden">
<iframe
src={`${fullUrl}#view=FitH&toolbar=1`}
title={fileName}
style={{ border: 'none', width: '100%', height: '100%' }}
onError={(e) => {
console.error('PDF iframe load error:', e);
}}
/>
</Box>
{/* Fallback options */}
<VStack spacing={2} w="100%">
<Text fontSize="sm" color={mutedText} textAlign="center">
Pokud se PDF nezobrazuje, použijte jedno z tlačítek níže:
</Text>
<HStack spacing={3} flexWrap="wrap" justify="center">
<Button
as={ChakraLink}
href={fullUrl}
isExternal
leftIcon={<FiEye />}
colorScheme="blue"
size="sm"
>
Otevřít v novém okně
</Button>
<Button
as={ChakraLink}
href={`https://mozilla.github.io/pdf.js/web/viewer.html?file=${encodeURIComponent(fullUrl)}`}
isExternal
leftIcon={<FiEye />}
colorScheme="purple"
size="sm"
>
Zobrazit pomocí PDF.js
</Button>
<Button
as={ChakraLink}
href={`https://docs.google.com/viewer?url=${encodeURIComponent(fullUrl)}&embedded=true`}
isExternal
leftIcon={<FiEye />}
colorScheme="green"
size="sm"
>
Zobrazit přes Google
</Button>
<Button
as={ChakraLink}
href={fullUrl}
download
leftIcon={<FiDownload />}
colorScheme="gray"
size="sm"
>
Stáhnout PDF
</Button>
</HStack>
</VStack>
</VStack>
);
}
if (fileInfo.type === 'video') {
return (
<AspectRatio ratio={16 / 9} w="100%">
<video controls style={{ width: '100%', height: '100%' }}>
<source src={fullUrl} type={mime} />
Váš prohlížeč nepodporuje přehrávání videa.
</video>
</AspectRatio>
);
}
if (fileInfo.type === 'audio') {
return (
<VStack spacing={4} py={10}>
<Icon as={FiMusic} boxSize={12} color={fileInfo.color} />
<audio controls style={{ width: '100%', maxWidth: '500px' }}>
<source src={fullUrl} type={mime} />
Váš prohlížeč nepodporuje přehrávání zvuku.
</audio>
</VStack>
);
}
// For Office documents, show info and download option
return (
<VStack spacing={4} py={10}>
<Icon as={fileInfo.icon} boxSize={16} color={fileInfo.color} />
<VStack spacing={2}>
<Text fontSize="lg" fontWeight="medium">{fileName}</Text>
{sizeStr && <Badge colorScheme="gray">{sizeStr}</Badge>}
<Text color={mutedText} fontSize="sm" textAlign="center">
{fileInfo.type === 'presentation' && 'PowerPoint prezentace'}
{fileInfo.type === 'document' && 'Word dokument'}
{fileInfo.type === 'spreadsheet' && 'Excel tabulka'}
</Text>
</VStack>
<HStack spacing={3}>
<Button
as={ChakraLink}
href={fullUrl}
isExternal
leftIcon={<FiDownload />}
colorScheme="blue"
>
Stáhnout
</Button>
<Button
as={ChakraLink}
href={`https://view.officeapps.live.com/op/view.aspx?src=${encodeURIComponent(fullUrl)}`}
isExternal
leftIcon={<FiEye />}
variant="outline"
>
Zobrazit online
</Button>
</HStack>
<Text fontSize="xs" color={mutedText}>
Pro zobrazení .pptx, .docx, .xlsx můžete použít "Zobrazit online"
</Text>
</VStack>
);
};
// Inline preview for images
if (showInline && fileInfo.type === 'image') {
return (
<Box
borderWidth="1px"
borderColor={borderColor}
borderRadius="md"
overflow="hidden"
bg={cardBg}
>
<Image
src={fullUrl}
alt={fileName}
w="100%"
maxH="400px"
objectFit="cover"
cursor="pointer"
onClick={onOpen}
_hover={{ opacity: 0.9 }}
onError={() => setImageError(true)}
/>
{!imageError && (
<HStack justify="space-between" p={3} borderTopWidth="1px">
<Text fontSize="sm" color={mutedText} isTruncated maxW="60%">
{fileName}
</Text>
<HStack spacing={2}>
<Button size="sm" leftIcon={<FiEye />} onClick={onOpen}>
Náhled
</Button>
<Button
as={ChakraLink}
href={fullUrl}
isExternal
size="sm"
variant="ghost"
leftIcon={<FiDownload />}
>
Stáhnout
</Button>
</HStack>
</HStack>
)}
</Box>
);
}
// Compact button view
// Simplified preview: only provide an "Open in new window" action
return (
<>
<HStack
justify="space-between"
p={3}
borderWidth="1px"
borderColor={borderColor}
borderRadius="md"
bg={cardBg}
>
<HStack flex={1} minW={0}>
<Icon as={fileInfo.icon} color={fileInfo.color} flexShrink={0} />
<VStack align="start" spacing={0} flex={1} minW={0}>
<ChakraLink
href={fullUrl}
isExternal
color={linkColor}
fontWeight="medium"
isTruncated
maxW="100%"
_hover={{ textDecoration: 'underline' }}
>
{fileName}
</ChakraLink>
{sizeStr && <Text fontSize="xs" color={mutedText}>{sizeStr}</Text>}
</VStack>
</HStack>
<HStack spacing={2} flexShrink={0}>
{fileInfo.canPreview && (
<Button size="sm" leftIcon={<FiEye />} onClick={onOpen} variant="outline">
Náhled
</Button>
)}
<Button
as={ChakraLink}
href={fullUrl}
isExternal
size="sm"
leftIcon={<FiDownload />}
colorScheme="blue"
<HStack
justify="space-between"
p={3}
borderWidth="1px"
borderColor={borderColor}
borderRadius="md"
bg={cardBg}
>
<HStack flex={1} minW={0}>
<Icon as={fileInfo.icon} color={fileInfo.color} flexShrink={0} />
<VStack align="start" spacing={0} flex={1} minW={0}>
<Text
fontWeight="medium"
isTruncated
maxW="100%"
>
Stáhnout
</Button>
</HStack>
{fileName}
</Text>
{sizeStr && <Text fontSize="xs" color={mutedText}>{sizeStr}</Text>}
</VStack>
</HStack>
{/* Preview Modal */}
<Modal isOpen={isOpen} onClose={onClose} size="6xl" isCentered>
<ModalOverlay bg="blackAlpha.800" />
<ModalContent maxW="90vw" maxH="90vh">
<ModalHeader>
<HStack justify="space-between">
<VStack align="start" spacing={0}>
<Text>{fileName}</Text>
{sizeStr && <Text fontSize="sm" fontWeight="normal" color={mutedText}>{sizeStr}</Text>}
</VStack>
</HStack>
</ModalHeader>
<ModalCloseButton />
<ModalBody pb={6} overflow="auto">
{renderPreviewContent()}
</ModalBody>
<ModalFooter>
<Button
as={ChakraLink}
href={fullUrl}
isExternal
leftIcon={<FiDownload />}
colorScheme="blue"
mr={3}
>
Stáhnout
</Button>
<Button variant="ghost" onClick={onClose}>
Zavřít
</Button>
</ModalFooter>
</ModalContent>
</Modal>
</>
<HStack spacing={2} flexShrink={0}>
<Button
as={ChakraLink}
href={fullUrl}
isExternal
size="sm"
leftIcon={<FiExternalLink />}
colorScheme="blue"
>
Otevřít v novém okně
</Button>
</HStack>
</HStack>
);
};
@@ -83,7 +83,7 @@ const ContextualAdminLinks: React.FC<ContextualAdminLinksProps> = ({ elementName
};
return links[element] || [
{ label: 'Admin Dashboard', url: '/admin', icon: FiSettings, description: 'Go to admin panel' },
{ label: 'Administrace', url: '/admin', icon: FiSettings, description: 'Přejít do administrace' },
];
};
@@ -95,7 +95,7 @@ const ContextualAdminLinks: React.FC<ContextualAdminLinksProps> = ({ elementName
<HStack>
<Icon as={FiExternalLink} color="blue.500" />
<Text fontSize="xs" fontWeight="bold" color="gray.500" textTransform="uppercase">
Quick Admin Links
Rychlé odkazy administrace
</Text>
</HStack>
@@ -152,7 +152,7 @@ const ContextualAdminLinks: React.FC<ContextualAdminLinksProps> = ({ elementName
</Box>
<Text fontSize="xs" color="gray.500" textAlign="center">
💡 These links help you manage content for this section
💡 Tyto odkazy vám pomohou spravovat obsah této sekce
</Text>
</VStack>
</Box>
@@ -18,23 +18,32 @@ import {
AlertIcon,
useColorModeValue,
Divider,
Spinner,
} from '@chakra-ui/react';
import { FiCode, FiEye, FiSave, FiRefreshCw } from 'react-icons/fi';
import { FiCode, FiEye, FiSave, FiRefreshCw, FiZap } from 'react-icons/fi';
import { generateCSSAI, AIGenerateCSSReq } from '../../services/ai';
import { ELEMENT_TSX_CONTEXT } from './elementContext';
interface CustomCSSEditorProps {
elementName: string;
onCSSChange: (css: string) => void;
currentCSS?: string;
currentStyles?: Record<string, any>;
theme?: Record<string, string>;
}
const CustomCSSEditor: React.FC<CustomCSSEditorProps> = ({
elementName,
onCSSChange,
currentCSS = '',
currentStyles = {},
theme = {},
}) => {
const [css, setCSS] = useState(currentCSS);
const [isValid, setIsValid] = useState(true);
const [preview, setPreview] = useState(false);
const [aiPrompt, setAIPrompt] = useState('Zvýrazni tento blok: moderní vzhled, zaoblené rohy, stín, lepší hover efekt; respektuj klubové barvy a responzivitu.');
const [aiLoading, setAILoading] = useState(false);
const toast = useToast();
const bgColor = useColorModeValue('white', 'gray.800');
const borderColor = useColorModeValue('gray.200', 'gray.600');
@@ -58,6 +67,84 @@ const CustomCSSEditor: React.FC<CustomCSSEditorProps> = ({
}
};
// Collect rich AI context about the current element and page layout
const collectAIContext = (elName: string) => {
try {
const rootSelector = `[data-element="${elName}"]`;
const el = document.querySelector(rootSelector) as HTMLElement | null;
const container = (document.querySelector('.myuibrix-viewport-wrapper') as HTMLElement) || (document.querySelector('.container') as HTMLElement) || null;
const cssVars = (() => {
const style = getComputedStyle(document.documentElement);
const keys = ['--primary','--primary-light','--secondary','--text','--bg','--bg-soft','--club-primary','--club-text-on-primary'];
const out: Record<string,string> = {};
keys.forEach(k => { const v = style.getPropertyValue(k); if (v) out[k] = v.trim(); });
return out;
})();
const elComputed = el ? getComputedStyle(el) : null;
const computed = elComputed ? {
display: elComputed.display,
gridTemplateColumns: elComputed.gridTemplateColumns,
gridTemplateRows: elComputed.gridTemplateRows,
gap: elComputed.gap,
justifyItems: (elComputed as any).justifyItems,
alignItems: (elComputed as any).alignItems,
color: elComputed.color,
backgroundColor: elComputed.backgroundColor,
padding: `${elComputed.paddingTop} ${elComputed.paddingRight} ${elComputed.paddingBottom} ${elComputed.paddingLeft}`,
margin: `${elComputed.marginTop} ${elComputed.marginRight} ${elComputed.marginBottom} ${elComputed.marginLeft}`,
fontFamily: elComputed.fontFamily,
fontSize: elComputed.fontSize,
} : {};
const rect = el ? el.getBoundingClientRect() : null;
const neighborInfo = (() => {
try {
const nodes = Array.from((container || document).querySelectorAll('[data-element]')) as HTMLElement[];
const names = nodes.map(n => n.getAttribute('data-element')) as (string|null)[];
const index = names.findIndex(n => n === elName);
const prev = index > 0 ? names[index-1] : null;
const next = index >= 0 && index < names.length - 1 ? names[index+1] : null;
return { index, total: names.length, previous: prev, next };
} catch { return {}; }
})();
const containerComputed = container ? getComputedStyle(container) : null;
const containerInfo = containerComputed ? {
display: containerComputed.display,
gridTemplateColumns: containerComputed.gridTemplateColumns,
gridAutoFlow: containerComputed.gridAutoFlow,
gap: containerComputed.gap,
} : {};
// HTML snapshot (truncate to keep payload small)
const rootHtml = el ? el.outerHTML.slice(0, 6000) : '';
const tsxCtx = ELEMENT_TSX_CONTEXT[elName] || {};
return {
page_path: typeof window !== 'undefined' ? window.location.pathname : '',
element: {
name: elName,
variant: el?.getAttribute('data-variant') || null,
classList: el ? Array.from(el.classList) : [],
attributes: el ? Array.from(el.attributes).map(a => ({ name: a.name, value: a.value })) : [],
rect: rect ? { width: rect.width, height: rect.height } : undefined,
computed,
root_html_snapshot: rootHtml,
},
container: containerInfo,
neighbors: neighborInfo,
css_variables: cssVars,
tsx_snippet: tsxCtx.tsx || undefined,
known_selectors: tsxCtx.selectors || undefined,
design_notes: tsxCtx.notes || undefined,
};
} catch {
return {};
}
};
const handleCSSChange = (value: string) => {
setCSS(value);
const valid = validateCSS(value);
@@ -79,11 +166,29 @@ const CustomCSSEditor: React.FC<CustomCSSEditorProps> = ({
// Create new style element
const style = document.createElement('style');
style.id = `custom-css-${elementName}`;
style.textContent = `
[data-element="${elementName}"] {
${cssString}
}
`;
// If the CSS contains braces or at-rules, assume full CSS block already scoped
const hasBlocks = /\{[^}]*\}|@media|@keyframes/.test(cssString);
if (hasBlocks) {
style.textContent = cssString;
} else {
// Treat as declarations, scope under element
// Ensure each declaration is marked important to override theme CSS
const importantDecls = cssString
.split(';')
.map(s => s.trim())
.filter(Boolean)
.map(s => {
// Avoid double !important
return /!important\s*$/.test(s) ? s : `${s} !important`;
})
.join(';\n ');
style.textContent = `
[data-element="${elementName}"] {
${importantDecls};
}
`;
}
document.head.appendChild(style);
}
};
@@ -166,6 +271,12 @@ overflow: hidden;`,
<Text>Examples</Text>
</HStack>
</Tab>
<Tab>
<HStack spacing={2}>
<FiZap />
<Text>AI (beta)</Text>
</HStack>
</Tab>
</TabList>
<TabPanels>
@@ -288,6 +399,88 @@ border-radius: 10px;`}
))}
</VStack>
</TabPanel>
{/* AI Tab */}
<TabPanel>
<VStack align="stretch" spacing={3} p={4}>
<Text fontSize="xs" fontWeight="bold" color="gray.500" textTransform="uppercase">
Vygenerovat CSS pomocí AI
</Text>
<Textarea
value={aiPrompt}
onChange={(e) => setAIPrompt(e.target.value)}
placeholder="Popište, jak má daný blok vypadat (česky). Např.: Tmavé pozadí, světlý text, zaoblené rohy, 2-sloupcový layout na desktopu, jeden sloupec na mobilu."
fontSize="sm"
minHeight="120px"
bg={useColorModeValue('gray.50', 'gray.900')}
borderColor={borderColor}
/>
<HStack spacing={2} flexWrap="wrap">
{[
'Tmavé pozadí a světlý text',
'Skleněný efekt (glassmorphism)',
'Zaoblené rohy a měkký stín',
'Dvou-sloupcový grid, mobil 1 sloupec',
'Akcent klubových barev',
].map((t, idx) => (
<Button key={idx} size="xs" variant="outline" onClick={() => setAIPrompt(p => `${p} ${t}.`)}>
{t}
</Button>
))}
</HStack>
<HStack>
<Button
leftIcon={aiLoading ? <Spinner size="xs" /> : <FiZap />}
colorScheme="purple"
size="sm"
isLoading={aiLoading}
onClick={async () => {
try {
setAILoading(true);
const payload: AIGenerateCSSReq = {
prompt: aiPrompt,
element_name: elementName,
root_selector: `[data-element="${elementName}"]`,
current_css: css,
current_styles: currentStyles || {},
theme: (theme as any) || {},
breakpoints: [640, 960, 1200],
context: collectAIContext(elementName),
};
const res = await generateCSSAI(payload);
const next = (res?.css || '').trim();
if (!next) {
toast({ title: 'AI nevrátila CSS', status: 'warning', duration: 2500 });
return;
}
setCSS(next);
setIsValid(true);
setPreview(true);
applyCSS(next);
// Persist into parent editor state so it survives panel close
onCSSChange(next);
toast({ title: 'CSS vygenerováno', status: 'success', duration: 1500 });
} catch (e: any) {
toast({ title: 'Chyba při generování CSS', description: e?.message || 'Zkuste to znovu', status: 'error', duration: 3000 });
} finally {
setAILoading(false);
}
}}
>
Vygenerovat CSS
</Button>
<Button size="sm" variant="ghost" onClick={() => setAIPrompt('')}>Vymazat zadání</Button>
</HStack>
<Alert status="info" borderRadius="md" fontSize="xs">
<AlertIcon />
AI výstup je automaticky scope-nutý pod `[data-element="název"]`. Po vygenerování se CSS předvyplní do editoru a lze ho dál upravovat.
</Alert>
</VStack>
</TabPanel>
</TabPanels>
</Tabs>
</Box>
+609 -116
View File
@@ -150,11 +150,18 @@ const MyUIbrixStyleEditor: React.FC<MyUIbrixStyleEditorProps> = ({ pageType, onC
const [viewport] = useState<'desktop'>('desktop');
const [elementStyles, setElementStyles] = useState<Record<string, any>>({});
const [showStylePanel, setShowStylePanel] = useState(true);
const [stylePanelRight, setStylePanelRight] = useState(false);
const [searchQuery, setSearchQuery] = useState('');
const [selectedCategory, setSelectedCategory] = useState<string>('all');
const [showHelpHint, setShowHelpHint] = useState(true);
const [baseline, setBaseline] = useState<{ variants: Record<string, string>; visible: Set<string>; order: string[]; css: Record<string, string> }>({ variants: {}, visible: new Set<string>(), order: [], css: {} });
const overlayRef = useRef<HTMLDivElement>(null);
const isReorderingRef = useRef(false);
const [allowCrossContainer, setAllowCrossContainer] = useState(false);
const [pendingInsertIndex, setPendingInsertIndex] = useState<number | null>(null);
const [containerGridCols, setContainerGridCols] = useState<number>(0);
const elementOrderRef = useRef<string[]>([]);
useEffect(() => { elementOrderRef.current = elementOrder; }, [elementOrder]);
// Draggable panel states
const [panelPositions, setPanelPositions] = useState({
@@ -322,6 +329,83 @@ const MyUIbrixStyleEditor: React.FC<MyUIbrixStyleEditorProps> = ({ pageType, onC
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// Toggle body class for edit mode so other parts can detect reliably
useEffect(() => {
try {
if (isEditing) {
document.body.classList.add('myuibrix-edit-mode');
} else {
document.body.classList.remove('myuibrix-edit-mode');
}
} catch {}
return () => {
try {
document.body.classList.remove('myuibrix-edit-mode');
} catch {}
};
}, [isEditing]);
// Editor-only CSS: hide MyClub watermark and stabilize footer appearance in edit mode
useEffect(() => {
let styleEl: HTMLStyleElement | null = null;
try {
if (isEditing) {
styleEl = document.createElement('style');
styleEl.id = 'myuibrix-footer-editor-fixes';
styleEl.textContent = `
body.myuibrix-edit-mode [data-watermark="myclub"] { display: none !important; }
body.myuibrix-edit-mode [data-element="footer"] { position: relative; z-index: 0; }
`;
document.head.appendChild(styleEl);
}
} catch {}
return () => {
try { const n = document.getElementById('myuibrix-footer-editor-fixes'); if (n) n.remove(); } catch {}
};
}, [isEditing]);
// Toggle cross-container reorder mode class for global checks
useEffect(() => {
try {
if (allowCrossContainer) {
document.body.classList.add('myuibrix-cross-container-reorder');
} else {
document.body.classList.remove('myuibrix-cross-container-reorder');
}
} catch {}
return () => {
try { document.body.classList.remove('myuibrix-cross-container-reorder'); } catch {}
};
}, [allowCrossContainer]);
// Auto-open Layers panel on the left by default when entering edit mode
useEffect(() => {
if (isEditing) {
setShowLayersPanel(true);
}
}, [isEditing]);
// Detect grid columns on main container for grid insertion UI
useEffect(() => {
try {
const el = safeDOM.querySelector('.container') as HTMLElement | null;
if (!el) { setContainerGridCols(0); return; }
const cs = window.getComputedStyle(el);
if (cs.display !== 'grid') { setContainerGridCols(0); return; }
const gtc = cs.gridTemplateColumns || '';
let cols = 0;
const m = gtc.match(/repeat\((\d+)/);
if (m) cols = parseInt(m[1], 10);
if (!cols) {
// Fallback: naive split
cols = gtc.split(' ').filter(Boolean).length || 2;
}
setContainerGridCols(cols);
} catch {
setContainerGridCols(0);
}
}, [isEditing, elementStyles]);
// Auto-dismiss help hint after 5 seconds
useEffect(() => {
if (isEditing && showHelpHint) {
@@ -342,6 +426,12 @@ const MyUIbrixStyleEditor: React.FC<MyUIbrixStyleEditorProps> = ({ pageType, onC
...cfg,
variant: normalizeVariant(cfg.element_name, cfg.variant)
}));
// Load saved custom CSS from settings
const cssByElement: Record<string, string> = {};
sanitizedConfigs.forEach(cfg => {
const css = (cfg.settings && (cfg.settings as any).customCSS) || '';
if (css) cssByElement[cfg.element_name] = String(css);
});
setConfigs(sanitizedConfigs);
const changes: Record<string, string> = {};
const visible = new Set<string>();
@@ -358,10 +448,45 @@ const MyUIbrixStyleEditor: React.FC<MyUIbrixStyleEditorProps> = ({ pageType, onC
setLocalChanges(changes);
setVisibleElements(visible);
setElementOrder(order);
// Prime style state with saved custom CSS
if (Object.keys(cssByElement).length > 0) {
setElementStyles(prev => {
const next = { ...prev } as Record<string, any>;
Object.entries(cssByElement).forEach(([name, css]) => {
next[name] = { ...(next[name] || {}), customCSS: css };
});
return next;
});
// Inject saved CSS for preview (admin only)
Object.entries(cssByElement).forEach(([name, css]) => {
try {
const styleId = `custom-css-${name}`;
const existing = document.getElementById(styleId);
if (existing) existing.remove();
const style = document.createElement('style');
style.id = styleId;
const hasBlocks = /\{[^}]*\}|@media|@keyframes/.test(css);
if (hasBlocks) {
style.textContent = css;
} else {
const importantDecls = css
.split(';')
.map(s => s.trim())
.filter(Boolean)
.map(s => (/!important\s*$/.test(s) ? s : `${s} !important`))
.join(';\n ');
style.textContent = `\n [data-element="${name}"] {\n ${importantDecls};\n }\n `;
}
document.head.appendChild(style);
} catch {}
});
}
setBaseline({ variants: { ...changes }, visible: new Set<string>(visible), order: [...order], css: cssByElement });
// If using defaults and no data exists, mark as has changes so user can save
// If using defaults and no data exists, treat everything as unsaved
if (data.length === 0) {
setHasChanges(true);
setBaseline({ variants: {}, visible: new Set<string>(), order: [], css: {} });
}
} catch (error) {
console.error('Failed to load page element configs:', error);
@@ -385,12 +510,48 @@ const MyUIbrixStyleEditor: React.FC<MyUIbrixStyleEditorProps> = ({ pageType, onC
setVisibleElements(visible);
setElementOrder(order);
setHasChanges(true);
// Treat fallback like unsaved defaults so counter encourages save
setBaseline({ variants: {}, visible: new Set<string>(), order: [], css: {} });
}
};
loadConfigs();
}, [pageType, normalizeVariant]);
// Compute unsaved changes count by diffing against baseline
const unsavedCount = useMemo(() => {
try {
const saved = baseline || { variants: {}, visible: new Set<string>(), order: [], css: {} } as any;
const names = new Set<string>([
...Object.keys(localChanges || {}),
...Object.keys(saved.variants || {}),
...elementOrder,
...saved.order,
]);
const changedElements = new Set<string>();
names.forEach((name) => {
const curVar = normalizeVariant(name, localChanges[name]);
const savVar = normalizeVariant(name, saved.variants[name]);
if ((curVar || '') !== (savVar || '')) changedElements.add(name);
const curVis = visibleElements.has(name);
const savVis = saved.visible.has(name);
if (curVis !== savVis) changedElements.add(name);
const curCSS = String((elementStyles[name]?.customCSS || '')).trim();
const savCSS = String((saved.css?.[name] || '')).trim();
if (curCSS !== savCSS) changedElements.add(name);
});
const orderEqual = (elementOrder.length === saved.order.length) && elementOrder.every((n, i) => n === saved.order[i]);
return changedElements.size + (orderEqual ? 0 : 1);
} catch {
return 0;
}
}, [localChanges, visibleElements, elementOrder, baseline, normalizeVariant]);
// Keep hasChanges in sync with computed counter
useEffect(() => {
setHasChanges(unsavedCount > 0);
}, [unsavedCount]);
// Keyboard shortcuts
useEffect(() => {
if (!isEditing) return;
@@ -433,6 +594,7 @@ const MyUIbrixStyleEditor: React.FC<MyUIbrixStyleEditorProps> = ({ pageType, onC
}, [isEditing, selectedElement, showElementPicker, showLayersPanel, hasChanges]);
// Add element highlighting and click handlers when editing
// Also re-run when order/visibility changes so overlays are added for newly shown elements
useEffect(() => {
if (!isEditing) {
// Clean up overlays when exiting edit mode
@@ -466,6 +628,8 @@ const MyUIbrixStyleEditor: React.FC<MyUIbrixStyleEditorProps> = ({ pageType, onC
transition: all 0.2s;
z-index: 9998;
cursor: move;
user-select: none;
-webkit-user-select: none;
`;
const badge = document.createElement('div');
@@ -564,10 +728,64 @@ const MyUIbrixStyleEditor: React.FC<MyUIbrixStyleEditorProps> = ({ pageType, onC
deleteBtn.onmouseover = () => deleteBtn.style.transform = 'scale(1.1)';
deleteBtn.onmouseout = () => deleteBtn.style.transform = 'scale(1)';
// Add before button
const addBeforeBtn = document.createElement('button');
addBeforeBtn.innerHTML = '';
addBeforeBtn.title = 'Přidat před';
addBeforeBtn.style.cssText = editBtn.style.cssText;
addBeforeBtn.onmouseover = () => addBeforeBtn.style.transform = 'scale(1.1)';
addBeforeBtn.onmouseout = () => addBeforeBtn.style.transform = 'scale(1)';
// Add after button
const addAfterBtn = document.createElement('button');
addAfterBtn.innerHTML = '';
addAfterBtn.title = 'Přidat za';
addAfterBtn.style.cssText = editBtn.style.cssText;
addAfterBtn.onmouseover = () => addAfterBtn.style.transform = 'scale(1.1)';
addAfterBtn.onmouseout = () => addAfterBtn.style.transform = 'scale(1)';
// Grid column quick-insert buttons (add into specific grid column)
let colWrap: HTMLDivElement | null = null;
if (containerGridCols > 1) {
colWrap = document.createElement('div');
colWrap.className = 'elementor-col-picker';
colWrap.style.cssText = `
display: flex;
gap: 4px;
`;
for (let c = 0; c < containerGridCols; c++) {
const colBtn = document.createElement('button');
colBtn.innerHTML = '';
colBtn.title = `Přidat do sloupce ${c + 1}`;
colBtn.style.cssText = editBtn.style.cssText;
colBtn.onmouseover = () => (colBtn.style.transform = 'scale(1.1)');
colBtn.onmouseout = () => (colBtn.style.transform = 'scale(1)');
colBtn.addEventListener('click', (e) => {
e.stopPropagation();
try {
const cols = Math.max(1, containerGridCols || 1);
const L = elementOrderRef.current.length;
let countInCol = 0;
for (let i = 0; i < L; i++) {
if (i % cols === c) countInCol++;
}
const targetIndex = c + countInCol * cols;
setPendingInsertIndex(Math.min(targetIndex, elementOrderRef.current.length));
setShowElementPicker(true);
} catch {}
});
safeDOM.appendChild(colWrap!, colBtn);
}
}
// Use safeDOM to build overlay structure
safeDOM.appendChild(actionsBar, editBtn);
safeDOM.appendChild(actionsBar, moveUpBtn);
safeDOM.appendChild(actionsBar, moveDownBtn);
safeDOM.appendChild(actionsBar, addBeforeBtn);
safeDOM.appendChild(actionsBar, addAfterBtn);
if (containerGridCols > 1 && colWrap) {
safeDOM.appendChild(actionsBar, colWrap);
}
safeDOM.appendChild(actionsBar, deleteBtn);
safeDOM.appendChild(overlay, badge);
@@ -652,10 +870,35 @@ const MyUIbrixStyleEditor: React.FC<MyUIbrixStyleEditorProps> = ({ pageType, onC
}
});
// Add before/after handlers
addBeforeBtn.addEventListener('click', (e) => {
e.stopPropagation();
try {
const idx = elementOrderRef.current.indexOf(elementName);
if (idx >= 0) {
setPendingInsertIndex(idx);
setShowElementPicker(true);
}
} catch {}
});
addAfterBtn.addEventListener('click', (e) => {
e.stopPropagation();
try {
const idx = elementOrderRef.current.indexOf(elementName);
if (idx >= 0) {
setPendingInsertIndex(idx + 1);
setShowElementPicker(true);
}
} catch {}
});
// per-column insert handled by elementor-col-picker buttons above
// Make overlay draggable
overlay.draggable = true;
overlay.addEventListener('dragstart', (e) => {
e.stopPropagation();
try { (e as DragEvent).dataTransfer?.setData('text/plain', elementName); } catch {}
setDraggedElement(elementName);
overlay.style.opacity = '0.5';
});
@@ -669,6 +912,7 @@ const MyUIbrixStyleEditor: React.FC<MyUIbrixStyleEditorProps> = ({ pageType, onC
overlay.addEventListener('dragover', (e) => {
e.preventDefault();
e.stopPropagation();
try { (e as DragEvent).dataTransfer!.dropEffect = 'move'; } catch {}
if (draggedElement && draggedElement !== elementName) {
overlay.style.border = `3px solid ${secondaryColor}`;
setDragOverElement(elementName);
@@ -688,13 +932,13 @@ const MyUIbrixStyleEditor: React.FC<MyUIbrixStyleEditorProps> = ({ pageType, onC
e.stopPropagation();
if (draggedElement && draggedElement !== elementName) {
// Reorder elements
const newOrder = [...elementOrder];
const draggedIndex = newOrder.indexOf(draggedElement);
const newOrder = [...elementOrderRef.current];
const draggedIndex = newOrder.indexOf(draggedElement as string);
const targetIndex = newOrder.indexOf(elementName);
if (draggedIndex !== -1 && targetIndex !== -1) {
newOrder.splice(draggedIndex, 1);
newOrder.splice(targetIndex, 0, draggedElement);
newOrder.splice(targetIndex, 0, draggedElement as string);
setElementOrder(newOrder);
setHasChanges(true);
applyVisualReorder(newOrder);
@@ -706,13 +950,23 @@ const MyUIbrixStyleEditor: React.FC<MyUIbrixStyleEditorProps> = ({ pageType, onC
});
};
// Only add overlays for elements that are actually implemented on this page
const implementedElements = pageType === 'homepage' ? HOMEPAGE_IMPLEMENTED_ELEMENTS : Object.keys(ELEMENT_VARIANTS);
implementedElements.forEach((elementName) => {
if (ELEMENT_VARIANTS[elementName]) {
addOverlay(elementName);
}
});
// Add overlays for all present [data-element] nodes in DOM (dynamic)
try {
const nodes = Array.from(safeDOM.querySelectorAll('[data-element]')) as HTMLElement[];
const names = Array.from(new Set(nodes
.map(n => n.getAttribute('data-element'))
.filter((v): v is string => !!v && v !== 'container')
));
names.forEach(name => addOverlay(name));
} catch {
// fallback to implemented list if needed
const implementedElements = pageType === 'homepage' ? HOMEPAGE_IMPLEMENTED_ELEMENTS : Object.keys(ELEMENT_VARIANTS);
implementedElements.forEach((elementName) => {
if (ELEMENT_VARIANTS[elementName]) {
addOverlay(elementName);
}
});
}
// Close panel on escape
const handleEscape = (e: KeyboardEvent) => {
@@ -753,7 +1007,7 @@ const MyUIbrixStyleEditor: React.FC<MyUIbrixStyleEditorProps> = ({ pageType, onC
clearTimeout(debounceTimerRef.current);
}
};
}, [isEditing, selectedElement, pageType]);
}, [isEditing, selectedElement, pageType, elementOrder, visibleElements]);
// Update selected element overlay styling
useEffect(() => {
@@ -848,6 +1102,71 @@ const MyUIbrixStyleEditor: React.FC<MyUIbrixStyleEditorProps> = ({ pageType, onC
applyChange();
}, [localChanges, visibleElements, isEditing, toast, getAvailableVariants, normalizeVariant]);
// Apply visual reordering with optional cross-container moves
const applyVisualReorder = useCallback((order: string[]) => {
if (isReorderingRef.current) return;
isReorderingRef.current = true;
requestAnimationFrame(() => {
try {
const cross = allowCrossContainer || (typeof document !== 'undefined' && document.body?.classList?.contains('myuibrix-cross-container-reorder'));
if (cross) {
// Prefer the in-page content wrapper as the canonical parent
const primary = (safeDOM.querySelector('.container') as HTMLElement) ||
(safeDOM.querySelector('.myuibrix-viewport-wrapper') as HTMLElement) ||
(safeDOM.querySelector('main') as HTMLElement) || null;
if (primary) {
// Move any element not under the primary into it
order.forEach((name) => {
const el = safeDOM.querySelector(`[data-element="${name}"]`) as HTMLElement | null;
if (el && el.parentElement !== primary) {
safeDOM.appendChild(primary, el);
}
});
// Append in requested order
order.forEach((name) => {
const el = safeDOM.querySelector(`[data-element="${name}"]`) as HTMLElement | null;
if (el) {
try { el.style.order = ''; } catch {}
safeDOM.appendChild(primary, el);
}
});
}
} else {
// Reorder only within each element's existing parent
const parentMap = new Map<HTMLElement, HTMLElement[]>();
order.forEach((name) => {
const el = safeDOM.querySelector(`[data-element="${name}"]`) as HTMLElement | null;
if (!el || !el.parentElement) return;
const parent = el.parentElement as HTMLElement;
if (!parentMap.has(parent)) parentMap.set(parent, []);
parentMap.get(parent)!.push(el);
});
parentMap.forEach((els, parent) => {
els.forEach((el) => { try { el.style.order = ''; } catch {} });
order.forEach((name) => {
const el = safeDOM.querySelector(`[data-element="${name}"]`) as HTMLElement | null;
if (el && el.parentElement === parent) {
safeDOM.appendChild(parent, el);
}
});
});
}
// Notify listeners (HomePage hook) about the new order
window.dispatchEvent(new CustomEvent('myuibrix-reorder', {
detail: { order, previewMode: true }
}));
setTimeout(() => { isReorderingRef.current = false; }, 50);
} catch (error) {
console.error('Error during DOM reordering:', error);
isReorderingRef.current = false;
}
});
}, [allowCrossContainer]);
// Debounce style changes to prevent lag
const debounceTimerRef = useRef<NodeJS.Timeout | null>(null);
@@ -873,20 +1192,34 @@ const MyUIbrixStyleEditor: React.FC<MyUIbrixStyleEditorProps> = ({ pageType, onC
}, 100); // 100ms debounce
}, [isEditing]);
const handleAddElement = useCallback((elementName: string) => {
// Helper: compute insert index for a given grid column (append at end of that column)
const computeInsertIndexForColumn = useCallback((col: number) => {
const cols = Math.max(1, containerGridCols || 1);
const L = elementOrderRef.current.length;
let countInCol = 0;
for (let i = 0; i < L; i++) {
if (i % cols === col) countInCol++;
}
return col + countInCol * cols;
}, [containerGridCols]);
// Add element from picker and make it visible + ordered in preview
const handleAddElement = useCallback((elementName: string, insertAt?: number) => {
const element = PREDEFINED_ELEMENTS.find(e => e.name === elementName);
if (!element) return;
const newVisible = new Set(visibleElements);
newVisible.add(elementName);
setVisibleElements(newVisible);
const existingVariant = localChanges[elementName];
const defaultVariant = normalizeVariant(elementName, element.defaultVariant);
const defaultVariant = normalizeVariant(elementName, element?.defaultVariant || 'default');
const variantToUse = normalizeVariant(elementName, existingVariant || defaultVariant);
if (!localChanges[elementName]) {
setLocalChanges(prev => ({ ...prev, [elementName]: variantToUse }));
}
// Mark as visible in editor state
setVisibleElements(prev => {
const next = new Set(prev);
next.add(elementName);
return next;
});
// Ensure config entry exists and is visible
setConfigs(prev => {
const index = prev.findIndex(cfg => cfg.element_name === elementName);
if (index !== -1) {
@@ -894,27 +1227,104 @@ const MyUIbrixStyleEditor: React.FC<MyUIbrixStyleEditorProps> = ({ pageType, onC
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,
}];
return [
...prev,
{
page_type: pageType,
element_name: elementName,
variant: variantToUse,
visible: true,
display_order: prev.length,
}
];
});
// Close picker UI
setHasChanges(true);
setShowElementPicker(false);
setSearchQuery('');
setSelectedCategory('all');
// Live preview ONLY during editing
// Add into ordering at desired position and apply reordering
setElementOrder(prev => {
const targetIndex = (typeof insertAt === 'number') ? insertAt : (pendingInsertIndex != null ? pendingInsertIndex : undefined);
if (prev.includes(elementName)) {
try {
toast({
title: 'Duplicitní element',
description: 'Tento element již na stránce existuje. Přesouvám existující na zvolené místo.',
status: 'warning',
duration: 2500,
isClosable: true,
});
} catch {}
const existingIdx = prev.indexOf(elementName);
if (typeof targetIndex === 'number' && targetIndex !== existingIdx) {
const reordered = [...prev];
reordered.splice(existingIdx, 1);
reordered.splice(Math.min(targetIndex, reordered.length), 0, elementName);
if (isEditing) {
requestAnimationFrame(() => {
applyVisualReorder(reordered);
window.dispatchEvent(new CustomEvent('myuibrix-reorder', { detail: { order: reordered, previewMode: true } }));
});
}
return reordered;
}
if (isEditing) {
requestAnimationFrame(() => {
applyVisualReorder(prev);
window.dispatchEvent(new CustomEvent('myuibrix-reorder', { detail: { order: prev, previewMode: true } }));
});
}
return prev;
}
const newOrder = [...prev];
if (typeof targetIndex === 'number') {
newOrder.splice(Math.min(targetIndex, newOrder.length), 0, elementName);
} else {
newOrder.push(elementName);
}
if (isEditing) {
requestAnimationFrame(() => {
applyVisualReorder(newOrder);
window.dispatchEvent(new CustomEvent('myuibrix-reorder', {
detail: { order: newOrder, previewMode: true }
}));
});
}
// Ensure element is visible in DOM for editing
setTimeout(() => {
try {
const el = safeDOM.querySelector(`[data-element="${elementName}"]`);
if (el) {
(el as HTMLElement).style.display = '';
}
} catch {}
}, 0);
return newOrder;
});
// Notify preview consumers to render element
if (isEditing) {
window.dispatchEvent(new CustomEvent('myuibrix-change', {
detail: { elementName, variant: variantToUse, visible: true, previewMode: true }
}));
}
}, [visibleElements, localChanges, isEditing, normalizeVariant, pageType]);
setPendingInsertIndex(null);
// Auto-select and open style panel for the newly added element
try {
setSelectedElement(elementName);
setTimeout(() => {
const el = safeDOM.querySelector(`[data-element="${elementName}"]`) as HTMLElement | null;
if (el) {
const rect = el.getBoundingClientRect();
setElementPosition({ top: rect.top, left: rect.left, width: rect.width, height: rect.height });
setShowStylePanel(true);
}
}, 0);
} catch {}
}, [localChanges, isEditing, normalizeVariant, pageType, applyVisualReorder, pendingInsertIndex]);
const handleRemoveElement = useCallback((elementName: string) => {
// Update state - React will handle DOM removal
@@ -940,50 +1350,7 @@ const MyUIbrixStyleEditor: React.FC<MyUIbrixStyleEditorProps> = ({ pageType, onC
}, 0);
}, [visibleElements, localChanges, isEditing]);
// Apply visual reordering using CSS order property instead of DOM manipulation
const applyVisualReorder = useCallback((order: string[]) => {
// Prevent concurrent reordering operations
if (isReorderingRef.current) {
return;
}
isReorderingRef.current = true;
// Use CSS order property to avoid DOM manipulation conflicts with React
requestAnimationFrame(() => {
try {
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';
}
console.log('Visual reorder applied via CSS order');
// Dispatch reorder event for HomePage to update
window.dispatchEvent(new CustomEvent('myuibrix-reorder', {
detail: { order, previewMode: true }
}));
setTimeout(() => {
isReorderingRef.current = false;
}, 50);
} catch (error) {
console.error('Error during visual reordering:', error);
isReorderingRef.current = false;
}
});
}, []);
const handleMoveUp = useCallback((elementName: string) => {
const currentIndex = elementOrder.indexOf(elementName);
@@ -1087,6 +1454,57 @@ const MyUIbrixStyleEditor: React.FC<MyUIbrixStyleEditorProps> = ({ pageType, onC
}
}, [draggedElement, elementOrder, isEditing, applyVisualReorder]);
// Start with a blank layout: hide all elements and clear order
const handleStartBlank = useCallback(() => {
try {
if (!confirm('Začít s prázdným rozložením? Všechny sekce (kromě hlavičky a patičky) budou skryté.')) {
return;
}
} catch {}
const previouslyVisible = Array.from(visibleElements);
const keep = new Set(['header', 'footer']);
const toHide = previouslyVisible.filter(n => !keep.has(n));
const newVisible = new Set<string>();
previouslyVisible.forEach(n => { if (keep.has(n)) newVisible.add(n); });
setVisibleElements(newVisible);
setElementOrder(prev => prev.filter(n => keep.has(n)));
setHasChanges(true);
if (isEditing) {
toHide.forEach((elementName) => {
try {
window.dispatchEvent(new CustomEvent('myuibrix-change', {
detail: { elementName, variant: localChanges[elementName], visible: false, previewMode: true }
}));
const el = safeDOM.querySelector(`[data-element="${elementName}"]`);
if (el) {
(el as HTMLElement).style.display = 'none';
}
} catch {}
});
requestAnimationFrame(() => {
const order = Array.from(newVisible).filter(n => keep.has(n));
applyVisualReorder(order);
window.dispatchEvent(new CustomEvent('myuibrix-reorder', {
detail: { order, previewMode: true }
}));
});
}
try {
toast({
title: 'Prázdné rozložení',
description: 'Všechny sekce byly skryty. Můžete začít přidávat prvky.',
status: 'info',
duration: 2500,
isClosable: true,
});
} catch {}
setShowElementPicker(true);
}, [visibleElements, isEditing, localChanges, applyVisualReorder, toast]);
const handleSave = async () => {
try {
const configsToSave: PageElementConfig[] = elementOrder.map((elementName, index) => ({
@@ -1095,6 +1513,10 @@ const MyUIbrixStyleEditor: React.FC<MyUIbrixStyleEditorProps> = ({ pageType, onC
variant: localChanges[elementName] || 'default',
visible: visibleElements.has(elementName),
display_order: index,
settings: {
...(configs.find(c => c.element_name === elementName)?.settings || {}),
customCSS: (elementStyles[elementName]?.customCSS || ''),
},
}));
await batchUpdatePageElementConfigs(configsToSave);
@@ -1107,6 +1529,13 @@ const MyUIbrixStyleEditor: React.FC<MyUIbrixStyleEditorProps> = ({ pageType, onC
isClosable: true,
});
// Update baseline to current state so counter resets immediately
setBaseline({
variants: { ...localChanges },
visible: new Set<string>(visibleElements),
order: [...elementOrder],
css: Object.fromEntries(Object.entries(elementStyles).map(([k, v]) => [k, String((v as any)?.customCSS || '')])),
});
setHasChanges(false);
// Reload the page to apply changes in production view
@@ -1438,7 +1867,27 @@ const MyUIbrixStyleEditor: React.FC<MyUIbrixStyleEditorProps> = ({ pageType, onC
{/* Right: Actions */}
<HStack spacing={2}>
{hasChanges && (
<Button
leftIcon={<FaPaintBrush />}
size="sm"
variant={showStylePanel ? 'solid' : 'outline'}
colorScheme={showStylePanel ? 'blue' : 'whiteAlpha'}
onClick={() => setShowStylePanel(!showStylePanel)}
borderRadius="xl"
>
Vizuální styly
</Button>
<Tooltip label={stylePanelRight ? 'Ukotvit vlevo' : 'Ukotvit vpravo'}>
<IconButton
aria-label="Dock panel"
icon={stylePanelRight ? <FaAngleLeft /> : <FaAngleRight />}
size="sm"
variant="ghost"
colorScheme="whiteAlpha"
onClick={() => setStylePanelRight(!stylePanelRight)}
/>
</Tooltip>
{unsavedCount > 0 && (
<Badge
bg="yellow.400"
color="gray.900"
@@ -1457,7 +1906,7 @@ const MyUIbrixStyleEditor: React.FC<MyUIbrixStyleEditorProps> = ({ pageType, onC
animation: 'bounce 1s infinite'
}}
>
{Object.keys(localChanges).length} neuložených změn
{unsavedCount} neuložených změn
</Badge>
)}
<Button
@@ -1492,18 +1941,17 @@ const MyUIbrixStyleEditor: React.FC<MyUIbrixStyleEditorProps> = ({ pageType, onC
</HStack>
</Flex>
</Box>
{/* Left Visual Style Panel */}
{showStylePanel && selectedElement && (
{/* Visual Style Panel (anchored, non-movable) */}
{showStylePanel && (
<Box
className="myuibrix-panel"
position="fixed"
left={`${panelPositions.visualStylePanel.x}px`}
top={`${panelPositions.visualStylePanel.y}px`}
width={`${panelPositions.visualStylePanel.width}px`}
height={`${panelPositions.visualStylePanel.height}px`}
left={stylePanelRight ? undefined : 4}
right={stylePanelRight ? 4 : undefined}
top={64}
bottom={4}
width="380px"
zIndex={9998}
onMouseDown={(e) => handlePanelMouseDown('visualStylePanel', e)}
cursor={draggingPanel === 'visualStylePanel' ? 'grabbing' : 'default'}
overflow="hidden"
display="flex"
flexDirection="column"
@@ -1526,14 +1974,14 @@ const MyUIbrixStyleEditor: React.FC<MyUIbrixStyleEditorProps> = ({ pageType, onC
bgGradient={`linear(135deg, ${primaryColor}, ${primaryColor}dd)`}
color="white"
p={3}
cursor="move"
cursor="default"
display="flex"
alignItems="center"
justifyContent="space-between"
flexShrink={0}
borderTopRadius="2xl"
borderBottom="1px solid rgba(255,255,255,0.2)"
boxShadow="0 2px 8px rgba(0,0,0,0.1)"
borderBottom="1px solid rgba(255,255,255,0.2)"
>
<HStack>
<Icon as={FaPaintBrush} />
@@ -1549,29 +1997,19 @@ const MyUIbrixStyleEditor: React.FC<MyUIbrixStyleEditorProps> = ({ pageType, onC
/>
</Box>
<Box flex="1" overflow="auto">
<VisualStylePanel
elementName={selectedElement}
onStyleChange={(styles) => handleStyleChange(selectedElement, styles)}
currentStyles={elementStyles[selectedElement]}
/>
{selectedElement ? (
<VisualStylePanel
elementName={selectedElement}
onStyleChange={(styles) => handleStyleChange(selectedElement, styles)}
currentStyles={elementStyles[selectedElement]}
/>
) : (
<Box p={4} color="gray.600" fontSize="sm">
<Text fontWeight="bold" mb={2}>Vyberte sekci</Text>
<Text>Vyberte sekci na stránce pro úpravu vizuálních stylů. Klikněte na zvýrazněný překryv sekce nebo vyberte ze seznamu vrstev.</Text>
</Box>
)}
</Box>
{/* Resize handle */}
<Box
position="absolute"
bottom={0}
right={0}
width="24px"
height="24px"
cursor="nwse-resize"
bgGradient="linear(135deg, transparent, rgba(0,0,0,0.15))"
opacity={0.4}
_hover={{ opacity: 1 }}
onMouseDown={(e) => handleResizeStart('visualStylePanel', e)}
sx={{
clipPath: 'polygon(100% 0, 100% 100%, 0 100%)'
}}
transition="opacity 0.2s"
/>
</Box>
)}
</>
@@ -1579,6 +2017,7 @@ const MyUIbrixStyleEditor: React.FC<MyUIbrixStyleEditorProps> = ({ pageType, onC
{/* Floating Control Panel - Minimalist */}
<Box
className="myuibrix-toolbar"
position="fixed"
left={4}
bottom={4}
@@ -2016,6 +2455,34 @@ const MyUIbrixStyleEditor: React.FC<MyUIbrixStyleEditorProps> = ({ pageType, onC
</Box>
)}
{/* Grid insertion pickers */}
{containerGridCols > 1 && (
<Box p={4} borderBottom="1px" borderColor="gray.100" bg="white">
<VStack align="stretch" spacing={2}>
<Text fontSize="sm" fontWeight="bold" color="gray.600">Vložit do sloupce</Text>
<HStack spacing={2} flexWrap="wrap">
{Array.from({ length: containerGridCols }).map((_, col) => (
<Button
key={col}
size="sm"
variant={pendingInsertIndex != null && (pendingInsertIndex % containerGridCols) === col ? 'solid' : 'outline'}
colorScheme={pendingInsertIndex != null && (pendingInsertIndex % containerGridCols) === col ? 'blue' : 'gray'}
onClick={(e) => {
e.stopPropagation();
const idx = computeInsertIndexForColumn(col);
setPendingInsertIndex(idx);
toast({ title: 'Pozice zvolena', description: `Sloupec ${col + 1}`, status: 'info', duration: 1500 });
}}
>
Sloupec {col + 1}
</Button>
))}
<Button size="sm" variant="ghost" onClick={() => setPendingInsertIndex(null)}>Zrušit pozici</Button>
</HStack>
</VStack>
</Box>
)}
{/* Layers Panel - Visual element list with drag-drop */}
{isEditing && showLayersPanel && (
<Box
@@ -2023,10 +2490,11 @@ const MyUIbrixStyleEditor: React.FC<MyUIbrixStyleEditorProps> = ({ pageType, onC
position="fixed"
left={panelPositions.layersPanel.x === 0 ? undefined : `${panelPositions.layersPanel.x}px`}
right={panelPositions.layersPanel.x === 0 ? 4 : undefined}
top={panelPositions.layersPanel.y === 0 ? "50%" : `${panelPositions.layersPanel.y}px`}
transform={panelPositions.layersPanel.y === 0 ? "translateY(-50%)" : undefined}
top={panelPositions.layersPanel.y === 0 ? 4 : `${panelPositions.layersPanel.y}px`}
bottom={panelPositions.layersPanel.y === 0 ? 4 : undefined}
transform={undefined}
width={`${panelPositions.layersPanel.width}px`}
height={`${panelPositions.layersPanel.height}px`}
height={panelPositions.layersPanel.y === 0 ? 'auto' : `${panelPositions.layersPanel.height}px`}
bg="rgba(255, 255, 255, 0.97)"
backdropFilter="blur(16px) saturate(180%)"
borderRadius="2xl"
@@ -2038,10 +2506,12 @@ const MyUIbrixStyleEditor: React.FC<MyUIbrixStyleEditorProps> = ({ pageType, onC
onMouseDown={(e) => handlePanelMouseDown('layersPanel', e)}
cursor={draggingPanel === 'layersPanel' ? 'grabbing' : 'default'}
fontFamily="var(--chakra-fonts-body)"
display="flex"
flexDirection="column"
sx={{
'@keyframes slideInRight': {
from: { opacity: 0, transform: panelPositions.layersPanel.y === 0 ? 'translate(40px, -50%)' : 'translateX(40px)' },
to: { opacity: 1, transform: panelPositions.layersPanel.y === 0 ? 'translateY(-50%)' : 'translateX(0)' }
from: { opacity: 0, transform: 'translateX(40px)' },
to: { opacity: 1, transform: 'translateX(0)' }
},
animation: 'slideInRight 0.3s cubic-bezier(0.4, 0, 0.2, 1)'
}}
@@ -2073,8 +2543,18 @@ const MyUIbrixStyleEditor: React.FC<MyUIbrixStyleEditorProps> = ({ pageType, onC
/>
</Flex>
{/* Blank layout action */}
<Box p={2} borderBottom="1px" borderColor="whiteAlpha.400" bg="whiteAlpha.200">
<HStack justify="space-between">
<Text fontSize="xs" opacity={0.85}>Začít s prázdným rozložením</Text>
<Button size="xs" variant="outline" onClick={handleStartBlank}>
Začít s prázdným rozložením
</Button>
</HStack>
</Box>
{/* Layers List */}
<VStack align="stretch" p={3} spacing={2} maxH="calc(80vh - 60px)" overflowY="auto">
<VStack align="stretch" p={3} spacing={2} flex={1} overflowY="auto">
{elementOrder.map((elementName, index) => {
const element = PREDEFINED_ELEMENTS.find(e => e.name === elementName);
const isVisible = visibleElements.has(elementName);
@@ -2192,6 +2672,20 @@ const MyUIbrixStyleEditor: React.FC<MyUIbrixStyleEditorProps> = ({ pageType, onC
newVisible.add(elementName);
setVisibleElements(newVisible);
setHasChanges(true);
// Live preview: show element again
if (isEditing) {
window.dispatchEvent(new CustomEvent('myuibrix-change', {
detail: { elementName, variant: localChanges[elementName], visible: true, previewMode: true }
}));
}
// Restore element display and re-apply current visual order
setTimeout(() => {
const el = safeDOM.querySelector(`[data-element="${elementName}"]`);
if (el) {
(el as HTMLElement).style.display = '';
}
applyVisualReorder(elementOrder);
}, 0);
}
}}
/>
@@ -2404,8 +2898,7 @@ const MyUIbrixStyleEditor: React.FC<MyUIbrixStyleEditorProps> = ({ pageType, onC
);
const availableElements = elements.filter(e => {
if (visibleElements.has(e.name)) return false;
// Filter by search query
// Filter by search query only; allow duplicates (we'll warn and move existing)
if (searchQuery) {
const query = searchQuery.toLowerCase();
return e.label.toLowerCase().includes(query) ||
@@ -1,4 +1,4 @@
import React, { useState } from 'react';
import React, { useState, useEffect, useMemo } from 'react';
import {
Box,
VStack,
@@ -34,6 +34,7 @@ import CustomCSSEditor from './CustomCSSEditor';
import ColumnLayoutManager from './ColumnLayoutManager';
import ContextualAdminLinks from './ContextualAdminLinks';
import { useClubTheme } from '../../contexts/ClubThemeContext';
import { FONT_PAIRINGS, loadGoogleFont } from '../../config/fonts';
interface VisualStylePanelProps {
elementName: string;
@@ -94,12 +95,70 @@ const VisualStylePanel: React.FC<VisualStylePanelProps> = ({
...currentStyles,
});
// Sync local styles state when switching element or when parent provides updated styles
useEffect(() => {
setStyles({
// Typography
fontFamily: currentStyles.fontFamily || 'Inter',
fontSize: currentStyles.fontSize || 16,
fontWeight: currentStyles.fontWeight || 400,
lineHeight: currentStyles.lineHeight || 1.5,
letterSpacing: currentStyles.letterSpacing || 0,
textTransform: currentStyles.textTransform || 'none',
// Colors
color: currentStyles.color || '#000000',
backgroundColor: currentStyles.backgroundColor || '#ffffff',
// Spacing
paddingTop: currentStyles.paddingTop || 0,
paddingRight: currentStyles.paddingRight || 0,
paddingBottom: currentStyles.paddingBottom || 0,
paddingLeft: currentStyles.paddingLeft || 0,
marginTop: currentStyles.marginTop || 0,
marginRight: currentStyles.marginRight || 0,
marginBottom: currentStyles.marginBottom || 0,
marginLeft: currentStyles.marginLeft || 0,
// Layout
width: currentStyles.width || 'auto',
height: currentStyles.height || 'auto',
display: currentStyles.display || 'block',
// Grid Layout
gridTemplateColumns: currentStyles.gridTemplateColumns || 'repeat(3, 1fr)',
gridTemplateRows: currentStyles.gridTemplateRows || 'auto',
gridColumnGap: currentStyles.gridColumnGap || 16,
gridRowGap: currentStyles.gridRowGap || 16,
gridAutoFlow: currentStyles.gridAutoFlow || 'row',
alignItems: currentStyles.alignItems || 'stretch',
justifyItems: currentStyles.justifyItems || 'stretch',
// Custom CSS
customCSS: currentStyles.customCSS || '',
...currentStyles,
});
}, [elementName, currentStyles]);
const updateStyle = (key: string, value: any) => {
const newStyles = { ...styles, [key]: value };
setStyles(newStyles);
onStyleChange(newStyles);
};
// Font pairing and curated font options
const [pairingId, setPairingId] = useState<string>(FONT_PAIRINGS[0]?.id || '');
const selectedPairing = useMemo(() => FONT_PAIRINGS.find(p => p.id === pairingId) || FONT_PAIRINGS[0], [pairingId]);
const curatedFonts = useMemo(() => {
const map = new Map<string, { name: string; googleFontsUrl: string }>();
FONT_PAIRINGS.forEach(p => {
map.set(p.heading, { name: p.heading, googleFontsUrl: p.googleFontsUrl });
map.set(p.body, { name: p.body, googleFontsUrl: p.googleFontsUrl });
});
return Array.from(map.values());
}, []);
return (
<Box
width="280px"
@@ -112,24 +171,70 @@ const VisualStylePanel: React.FC<VisualStylePanelProps> = ({
>
<Tabs size="sm" colorScheme="blue">
<TabList px={2} flexWrap="wrap">
<Tab><FiType /> <Text ml={1}>Content</Text></Tab>
<Tab><FiLayout /> <Text ml={1}>Style</Text></Tab>
<Tab><FiColumns /> <Text ml={1}>Layout</Text></Tab>
<Tab><FiType /> <Text ml={1}>Obsah</Text></Tab>
<Tab><FiLayout /> <Text ml={1}>Styl</Text></Tab>
<Tab><FiColumns /> <Text ml={1}>Rozvržení</Text></Tab>
<Tab><FiCode /> <Text ml={1}>CSS</Text></Tab>
<Tab><FiExternalLink /> <Text ml={1}>Admin</Text></Tab>
</TabList>
<TabPanels>
{/* Content Tab */}
{/* Záložka: Obsah */}
<TabPanel>
<VStack align="stretch" spacing={4}>
<Text fontWeight="bold" fontSize="sm" textTransform="uppercase" color="gray.500">
Typography
Typografie
</Text>
{/* Font Family */}
{/* Font pairing from Setup (curated) */}
<FormControl>
<FormLabel fontSize="xs">Font Family</FormLabel>
<FormLabel fontSize="xs">Párování fontů (Setup)</FormLabel>
<HStack>
<Select
size="sm"
value={pairingId}
onChange={(e) => {
const id = e.target.value;
setPairingId(id);
const p = FONT_PAIRINGS.find(pp => pp.id === id);
if (p) {
loadGoogleFont(p.googleFontsUrl);
}
}}
>
{FONT_PAIRINGS.map((p) => (
<option key={p.id} value={p.id}>{p.name}</option>
))}
</Select>
</HStack>
<HStack spacing={2} mt={2}>
<Button size="xs" variant="outline" onClick={() => { if (selectedPairing) { loadGoogleFont(selectedPairing.googleFontsUrl); updateStyle('fontFamily', selectedPairing.cssHeading); } }}>Použít nadpisový</Button>
<Button size="xs" variant="outline" onClick={() => { if (selectedPairing) { loadGoogleFont(selectedPairing.googleFontsUrl); updateStyle('fontFamily', selectedPairing.cssBody); } }}>Použít textový</Button>
</HStack>
</FormControl>
{/* Curated font list (unique from pairing set) */}
<FormControl>
<FormLabel fontSize="xs">Dostupné fonty (Setup)</FormLabel>
<Select
size="sm"
value={styles.fontFamily}
onChange={(e) => {
const val = e.target.value;
updateStyle('fontFamily', val);
const found = FONT_PAIRINGS.find(p => p.cssHeading === val || p.cssBody === val);
if (found) loadGoogleFont(found.googleFontsUrl);
}}
>
{curatedFonts.map(f => (
<option key={f.name} value={f.name}>{f.name}</option>
))}
</Select>
</FormControl>
{/* Rodina písma */}
<FormControl>
<FormLabel fontSize="xs">Písmo</FormLabel>
<Select
size="sm"
value={styles.fontFamily}
@@ -146,9 +251,9 @@ const VisualStylePanel: React.FC<VisualStylePanelProps> = ({
</Select>
</FormControl>
{/* Font Size */}
{/* Velikost písma */}
<FormControl>
<FormLabel fontSize="xs">Size (px)</FormLabel>
<FormLabel fontSize="xs">Velikost (px)</FormLabel>
<HStack>
<NumberInput
size="sm"
@@ -167,9 +272,9 @@ const VisualStylePanel: React.FC<VisualStylePanelProps> = ({
</HStack>
</FormControl>
{/* Font Weight */}
{/* Tloušťka písma */}
<FormControl>
<FormLabel fontSize="xs">Weight</FormLabel>
<FormLabel fontSize="xs">Tloušťka</FormLabel>
<HStack spacing={2}>
<Slider
value={styles.fontWeight}
@@ -188,9 +293,9 @@ const VisualStylePanel: React.FC<VisualStylePanelProps> = ({
</HStack>
</FormControl>
{/* Line Height */}
{/* Řádkování */}
<FormControl>
<FormLabel fontSize="xs">Line Height</FormLabel>
<FormLabel fontSize="xs">Řádkování</FormLabel>
<HStack spacing={2}>
<Slider
value={styles.lineHeight}
@@ -209,9 +314,9 @@ const VisualStylePanel: React.FC<VisualStylePanelProps> = ({
</HStack>
</FormControl>
{/* Letter Spacing */}
{/* Mezery mezi písmeny */}
<FormControl>
<FormLabel fontSize="xs">Letter Spacing (px)</FormLabel>
<FormLabel fontSize="xs">Mezera mezi písmeny (px)</FormLabel>
<HStack spacing={2}>
<Slider
value={styles.letterSpacing}
@@ -230,33 +335,33 @@ const VisualStylePanel: React.FC<VisualStylePanelProps> = ({
</HStack>
</FormControl>
{/* Text Transform */}
{/* Transformace textu */}
<FormControl>
<FormLabel fontSize="xs">Transform</FormLabel>
<FormLabel fontSize="xs">Transformace</FormLabel>
<Select
size="sm"
value={styles.textTransform}
onChange={(e) => updateStyle('textTransform', e.target.value)}
>
<option value="none">None</option>
<option value="uppercase">UPPERCASE</option>
<option value="lowercase">lowercase</option>
<option value="capitalize">Capitalize</option>
<option value="none">Žádné</option>
<option value="uppercase">VELKÁ PÍSMENA</option>
<option value="lowercase">malá písmena</option>
<option value="capitalize">První písmena velká</option>
</Select>
</FormControl>
</VStack>
</TabPanel>
{/* Style Tab */}
{/* Záložka: Styl */}
<TabPanel>
<VStack align="stretch" spacing={4}>
<Text fontWeight="bold" fontSize="sm" textTransform="uppercase" color="gray.500">
Colors
Barvy
</Text>
{/* Text Color */}
{/* Barva textu */}
<FormControl>
<FormLabel fontSize="xs">Text Color</FormLabel>
<FormLabel fontSize="xs">Barva textu</FormLabel>
<HStack>
<Input
type="color"
@@ -275,9 +380,9 @@ const VisualStylePanel: React.FC<VisualStylePanelProps> = ({
</HStack>
</FormControl>
{/* Background Color */}
{/* Barva pozadí */}
<FormControl>
<FormLabel fontSize="xs">Background Color</FormLabel>
<FormLabel fontSize="xs">Barva pozadí</FormLabel>
<HStack>
<Input
type="color"
@@ -299,15 +404,15 @@ const VisualStylePanel: React.FC<VisualStylePanelProps> = ({
<Divider my={2} />
<Text fontWeight="bold" fontSize="sm" textTransform="uppercase" color="gray.500">
Spacing
Odsazení a okraje
</Text>
{/* Padding */}
{/* Vnitřní odsazení (padding) */}
<FormControl>
<FormLabel fontSize="xs">Padding (px)</FormLabel>
<FormLabel fontSize="xs">Vnitřní odsazení (px)</FormLabel>
<VStack spacing={2}>
<HStack width="100%">
<Text fontSize="xs" minW="20px">T</Text>
<Text fontSize="xs" minW="20px">N</Text>
<NumberInput
size="xs"
value={styles.paddingTop}
@@ -319,7 +424,7 @@ const VisualStylePanel: React.FC<VisualStylePanelProps> = ({
</NumberInput>
</HStack>
<HStack width="100%">
<Text fontSize="xs" minW="20px">R</Text>
<Text fontSize="xs" minW="20px">P</Text>
<NumberInput
size="xs"
value={styles.paddingRight}
@@ -331,7 +436,7 @@ const VisualStylePanel: React.FC<VisualStylePanelProps> = ({
</NumberInput>
</HStack>
<HStack width="100%">
<Text fontSize="xs" minW="20px">B</Text>
<Text fontSize="xs" minW="20px">D</Text>
<NumberInput
size="xs"
value={styles.paddingBottom}
@@ -357,12 +462,12 @@ const VisualStylePanel: React.FC<VisualStylePanelProps> = ({
</VStack>
</FormControl>
{/* Margin */}
{/* Vnější okraj (margin) */}
<FormControl>
<FormLabel fontSize="xs">Margin (px)</FormLabel>
<FormLabel fontSize="xs">Vnější okraj (px)</FormLabel>
<VStack spacing={2}>
<HStack width="100%">
<Text fontSize="xs" minW="20px">T</Text>
<Text fontSize="xs" minW="20px">N</Text>
<NumberInput
size="xs"
value={styles.marginTop}
@@ -373,7 +478,7 @@ const VisualStylePanel: React.FC<VisualStylePanelProps> = ({
</NumberInput>
</HStack>
<HStack width="100%">
<Text fontSize="xs" minW="20px">R</Text>
<Text fontSize="xs" minW="20px">P</Text>
<NumberInput
size="xs"
value={styles.marginRight}
@@ -384,7 +489,7 @@ const VisualStylePanel: React.FC<VisualStylePanelProps> = ({
</NumberInput>
</HStack>
<HStack width="100%">
<Text fontSize="xs" minW="20px">B</Text>
<Text fontSize="xs" minW="20px">D</Text>
<NumberInput
size="xs"
value={styles.marginBottom}
@@ -410,16 +515,16 @@ const VisualStylePanel: React.FC<VisualStylePanelProps> = ({
</VStack>
</TabPanel>
{/* Layout Tab (was Grid Tab) */}
{/* Záložka: Rozvržení (mřížka) */}
<TabPanel>
<VStack align="stretch" spacing={4}>
<Text fontWeight="bold" fontSize="sm" textTransform="uppercase" color="gray.500">
Grid Layout
Mřížkové rozložení
</Text>
{/* Enable Grid */}
{/* Povolit mřížkové rozložení */}
<FormControl display="flex" alignItems="center">
<FormLabel fontSize="xs" mb={0} flex={1}>Enable Grid Layout</FormLabel>
<FormLabel fontSize="xs" mb={0} flex={1}>Povolit mřížkové rozložení</FormLabel>
<Switch
size="sm"
isChecked={styles.display === 'grid'}
@@ -437,9 +542,9 @@ const VisualStylePanel: React.FC<VisualStylePanelProps> = ({
<>
<Divider />
{/* Quick Templates */}
{/* Rychlé šablony */}
<FormControl>
<FormLabel fontSize="xs" fontWeight="bold">Quick Templates</FormLabel>
<FormLabel fontSize="xs" fontWeight="bold">Rychlé šablony</FormLabel>
<VStack spacing={2}>
<Button
size="xs"
@@ -450,7 +555,7 @@ const VisualStylePanel: React.FC<VisualStylePanelProps> = ({
>
<HStack spacing={2}>
<FiSmartphone />
<Text>Single Column</Text>
<Text>Jeden sloupec</Text>
</HStack>
</Button>
<Button
@@ -462,7 +567,7 @@ const VisualStylePanel: React.FC<VisualStylePanelProps> = ({
>
<HStack spacing={2}>
<FaColumns />
<Text>Two Equal (50% / 50%)</Text>
<Text>Dva stejné (50 % / 50 %)</Text>
</HStack>
</Button>
<Button
@@ -474,7 +579,7 @@ const VisualStylePanel: React.FC<VisualStylePanelProps> = ({
>
<HStack spacing={2}>
<FiBarChart2 />
<Text>Left Larger (66% / 33%)</Text>
<Text>Vlevo větší (66 % / 33 %)</Text>
</HStack>
</Button>
<Button
@@ -486,7 +591,7 @@ const VisualStylePanel: React.FC<VisualStylePanelProps> = ({
>
<HStack spacing={2}>
<FiBarChart2 style={{ transform: 'scaleX(-1)' }} />
<Text>Right Larger (33% / 66%)</Text>
<Text>Vpravo větší (33 % / 66 %)</Text>
</HStack>
</Button>
<Button
@@ -498,7 +603,7 @@ const VisualStylePanel: React.FC<VisualStylePanelProps> = ({
>
<HStack spacing={2}>
<FiGrid />
<Text>Three Equal (33% / 33% / 33%)</Text>
<Text>Tři stejné (33 % / 33 % / 33 %)</Text>
</HStack>
</Button>
<Button
@@ -510,7 +615,7 @@ const VisualStylePanel: React.FC<VisualStylePanelProps> = ({
>
<HStack spacing={2}>
<FaRegNewspaper />
<Text>Featured + Two (50% / 25% / 25%)</Text>
<Text>Zvýrazněný + dva (50 % / 25 % / 25 %)</Text>
</HStack>
</Button>
<Button
@@ -522,7 +627,7 @@ const VisualStylePanel: React.FC<VisualStylePanelProps> = ({
>
<HStack spacing={2}>
<FaRegSquare />
<Text>Four Equal (25% each)</Text>
<Text>Čtyři stejné (25 % každá)</Text>
</HStack>
</Button>
<Button
@@ -534,7 +639,7 @@ const VisualStylePanel: React.FC<VisualStylePanelProps> = ({
>
<HStack spacing={2}>
<FiSidebar />
<Text>Main + Sidebar (75% / 25%)</Text>
<Text>Hlavní + postranní (75 % / 25 %)</Text>
</HStack>
</Button>
</VStack>
@@ -542,38 +647,38 @@ const VisualStylePanel: React.FC<VisualStylePanelProps> = ({
<Divider />
{/* Custom Columns */}
{/* Vlastní sloupce */}
<FormControl>
<FormLabel fontSize="xs">Grid Template Columns</FormLabel>
<FormLabel fontSize="xs">Sloupce mřížky</FormLabel>
<Input
size="sm"
value={styles.gridTemplateColumns}
onChange={(e) => updateStyle('gridTemplateColumns', e.target.value)}
placeholder="e.g. 1fr 2fr or 300px 1fr"
placeholder="např. 1fr 2fr nebo 300px 1fr"
fontFamily="monospace"
fontSize="xs"
/>
<Text fontSize="10px" color="gray.500" mt={1}>
Examples: 1fr 1fr | 2fr 1fr | 200px 1fr | repeat(3, 1fr)
Příklady: 1fr 1fr | 2fr 1fr | 200px 1fr | repeat(3, 1fr)
</Text>
</FormControl>
{/* Grid Template Rows */}
{/* Řádky mřížky */}
<FormControl>
<FormLabel fontSize="xs">Grid Template Rows</FormLabel>
<FormLabel fontSize="xs">Řádky mřížky</FormLabel>
<Input
size="sm"
value={styles.gridTemplateRows}
onChange={(e) => updateStyle('gridTemplateRows', e.target.value)}
placeholder="auto or 200px 1fr"
placeholder="auto nebo 200px 1fr"
fontFamily="monospace"
fontSize="xs"
/>
</FormControl>
{/* Column Gap */}
{/* Mezera mezi sloupci */}
<FormControl>
<FormLabel fontSize="xs">Column Gap (px)</FormLabel>
<FormLabel fontSize="xs">Mezera mezi sloupci (px)</FormLabel>
<HStack spacing={2}>
<Slider
value={styles.gridColumnGap}
@@ -592,9 +697,9 @@ const VisualStylePanel: React.FC<VisualStylePanelProps> = ({
</HStack>
</FormControl>
{/* Row Gap */}
{/* Mezera mezi řádky */}
<FormControl>
<FormLabel fontSize="xs">Row Gap (px)</FormLabel>
<FormLabel fontSize="xs">Mezera mezi řádky (px)</FormLabel>
<HStack spacing={2}>
<Slider
value={styles.gridRowGap}
@@ -615,49 +720,49 @@ const VisualStylePanel: React.FC<VisualStylePanelProps> = ({
<Divider />
{/* Grid Auto Flow */}
{/* Automatické rozmístění */}
<FormControl>
<FormLabel fontSize="xs">Auto Flow</FormLabel>
<FormLabel fontSize="xs">Automatické rozmístění</FormLabel>
<Select
size="sm"
value={styles.gridAutoFlow}
onChange={(e) => updateStyle('gridAutoFlow', e.target.value)}
>
<option value="row">Row (horizontal)</option>
<option value="column">Column (vertical)</option>
<option value="row dense">Row Dense</option>
<option value="column dense">Column Dense</option>
<option value="row">Řádek (vodorovně)</option>
<option value="column">Sloupec (svisle)</option>
<option value="row dense">Řádek (zahuštěný)</option>
<option value="column dense">Sloupec (zahuštěný)</option>
</Select>
</FormControl>
{/* Align Items */}
{/* Zarovnání (vertikálně) */}
<FormControl>
<FormLabel fontSize="xs">Align Items (vertical)</FormLabel>
<FormLabel fontSize="xs">Zarovnání prvků (vertikálně)</FormLabel>
<Select
size="sm"
value={styles.alignItems}
onChange={(e) => updateStyle('alignItems', e.target.value)}
>
<option value="stretch">Stretch</option>
<option value="start">Start</option>
<option value="center">Center</option>
<option value="end">End</option>
<option value="baseline">Baseline</option>
<option value="stretch">Roztáhnout</option>
<option value="start">Začátek</option>
<option value="center">Střed</option>
<option value="end">Konec</option>
<option value="baseline">Základní řádek</option>
</Select>
</FormControl>
{/* Justify Items */}
{/* Zarovnání (horizontálně) */}
<FormControl>
<FormLabel fontSize="xs">Justify Items (horizontal)</FormLabel>
<FormLabel fontSize="xs">Zarovnání prvků (horizontálně)</FormLabel>
<Select
size="sm"
value={styles.justifyItems}
onChange={(e) => updateStyle('justifyItems', e.target.value)}
>
<option value="stretch">Stretch</option>
<option value="start">Start</option>
<option value="center">Center</option>
<option value="end">End</option>
<option value="stretch">Roztáhnout</option>
<option value="start">Začátek</option>
<option value="center">Střed</option>
<option value="end">Konec</option>
</Select>
</FormControl>
</>
@@ -665,16 +770,18 @@ const VisualStylePanel: React.FC<VisualStylePanelProps> = ({
</VStack>
</TabPanel>
{/* Custom CSS Tab */}
{/* Záložka: Vlastní CSS */}
<TabPanel p={0}>
<CustomCSSEditor
elementName={elementName}
onCSSChange={(css) => updateStyle('customCSS', css)}
currentCSS={styles.customCSS || ''}
currentStyles={styles}
theme={{ primary: clubTheme.primary, secondary: clubTheme.secondary, accent: (clubTheme as any).accent }}
/>
</TabPanel>
{/* Admin Links Tab */}
{/* Záložka: Admin odkazy */}
<TabPanel>
<ContextualAdminLinks elementName={elementName} />
</TabPanel>
@@ -0,0 +1,111 @@
// Static TSX context snippets for AI CSS generation. Keep concise and representative.
// These snippets help the AI understand structure and common selectors per section.
export const ELEMENT_TSX_CONTEXT: Record<string, { tsx: string; selectors?: string[]; notes?: string }> = {
hero: {
tsx: `
<section data-element="hero" className="hero-grid">
{/* variant: grid | scroller | swiper | swiper_full */}
<a className="hero-card big">
<div className="bg" />
<div className="meta">
<div className="tag">Aktuality</div>
<h2 className="title">Nadpis</h2>
</div>
</a>
<a className="hero-card" />
<a className="hero-card" />
</section>
`.trim(),
selectors: ['.hero-grid', '.hero-card', '.bg', '.meta', '.tag', '.title'],
notes: 'Full-bleed variants may use negative margins and viewport width tricks.'
},
matches: {
tsx: `
<section data-element="matches" className="next-match">
<button className="nav prev" />
<div className="team">
<img className="logo" />
<div>Domácí</div>
</div>
<div className="countdown">Začátek zápasu</div>
<div className="team">
<img className="logo" />
<div>Hosté</div>
</div>
<button className="nav next" />
</section>
`.trim(),
selectors: ['.next-match', '.team', '.logo', '.countdown', '.nav.prev', '.nav.next'],
notes: 'Center content, strong contrast on countdown. Keep buttons accessible.'
},
'matches-slider': {
tsx: `
<section data-element="matches-slider" className="matches-slider">
<div className="section-head">
<h3>Zápasy</h3>
<a className="see-all" />
</div>
<div className="matches-grid">
<div className="matches-track">
<div className="match-card">
<div className="match-meta" />
<div className="teams">
<div className="team"><img /><div className="name" /></div>
<div className="score"><span className="home" /><span className="sep" /><span className="away" /></div>
<div className="team"><img /><div className="name" /></div>
</div>
</div>
</div>
<div className="matches-tabs"><button className="active" /></div>
</div>
</section>
`.trim(),
selectors: ['.matches-slider', '.section-head', '.see-all', '.matches-grid', '.matches-track', '.match-card', '.match-meta', '.teams', '.team', '.score', '.matches-tabs'],
notes: 'Horizontal scrolling track with cards; consider responsive card widths and gaps.'
},
news: {
tsx: `
<section data-element="news" className="news-list">
<div className="section-head"><h3>Další aktuality</h3></div>
<div className="blog-list">
<a className="card">
<div className="thumb" />
<div><h4>Title</h4><div className="excerpt" /></div>
</a>
</div>
<div><a className="btn">Zobrazit všechny aktuality</a></div>
</section>
`.trim(),
selectors: ['.news-list', '.section-head', '.blog-list', '.card', '.thumb', '.btn'],
},
table: {
tsx: `
<section data-element="table" className="standings">
<div className="table-card">
<div className="section-head">
<h3>Tabulky</h3>
<a className="see-all" />
</div>
<div className="standings-table-wrapper">
<table className="standings-table-compact">
<thead><tr><th>#</th><th>Tým</th><th>Z</th><th>V</th><th>R</th><th>P</th><th className="hide-mobile">Skóre</th><th>Body</th></tr></thead>
<tbody><tr><td>#1</td><td><img />Tým</td><td>…</td></tr></tbody>
</table>
</div>
</div>
</section>
`.trim(),
selectors: ['.standings', '.table-card', '.section-head', '.standings-table-wrapper', '.standings-table-compact', '.see-all'],
notes: 'Compact table; mind overflow-x on small screens.'
},
sponsors: {
tsx: `
<section data-element="sponsors" className="sponsors">
<div className="section-head"><h3>Sponzoři</h3></div>
<div className="sponsors-grid"><a className="sponsor-tile"><img /></a></div>
</section>
`.trim(),
selectors: ['.sponsors', '.section-head', '.sponsors-grid', '.sponsor-tile'],
},
};
@@ -0,0 +1,177 @@
import React, { useEffect, useMemo, useState } from 'react';
import { Link as RouterLink, useLocation } from 'react-router-dom';
import '../../styles/sparta-styles.css';
import { usePublicSettings } from '../../hooks/usePublicSettings';
import { useClubTheme } from '../../contexts/ClubThemeContext';
import { getNavigationItems, NavigationItem, seedDefaultNavigation } from '../../services/navigation';
import { getCategories, Category } from '../../services/public';
// Minimal NavLink type used to render items
type NavLink = { label: string; to?: string; items?: { label: string; to: string }[]; external?: boolean };
const SpartaNavbar: React.FC = () => {
const { data: settings } = usePublicSettings();
const theme = useClubTheme();
const location = useLocation();
const [mobileOpen, setMobileOpen] = useState(false);
const [dynamicNavItems, setDynamicNavItems] = useState<NavigationItem[]>([]);
const [navLoading, setNavLoading] = useState(true);
const [navCategories, setNavCategories] = useState<Category[] | null>(null);
// Load dynamic navigation
useEffect(() => {
let active = true;
(async () => {
try {
const items = await getNavigationItems();
if (active && Array.isArray(items)) {
const publicItems = items.filter(item => !item.requires_admin);
if (publicItems.length === 0) {
try {
await seedDefaultNavigation();
const newItems = await getNavigationItems();
if (active && Array.isArray(newItems)) {
const publicNewItems = newItems.filter(item => !item.requires_admin);
setDynamicNavItems(publicNewItems);
}
} catch {
setDynamicNavItems([]);
}
} else {
setDynamicNavItems(publicItems);
}
}
} catch {
// leave empty, fallback will handle
} finally {
if (active) setNavLoading(false);
}
})();
return () => { active = false };
}, []);
// Load categories (for Blog dropdown fallback)
useEffect(() => {
let active = true;
(async () => {
try {
const cats = await getCategories();
if (active && Array.isArray(cats) && cats.length > 0) {
setNavCategories(cats);
} else if (active && Array.isArray(settings?.categories)) {
setNavCategories(settings!.categories as any);
}
} catch {
if (active && Array.isArray(settings?.categories)) {
setNavCategories(settings!.categories as any);
}
}
})();
return () => { active = false };
}, [settings?.categories]);
const isPathActive = (to?: string) => {
if (!to) return false;
return location.pathname === to || location.pathname.startsWith((to || '') + '/');
};
const convertToNavLink = (item: NavigationItem): NavLink => {
const link: NavLink = {
label: item.label,
to: item.url || '#',
external: item.type === 'external',
};
if (item.type === 'dropdown' && item.children && item.children.length > 0) {
link.items = item.children.map(child => ({ label: child.label, to: child.url || '#' }));
}
return link;
};
const categoryItems = useMemo(() => {
const source = Array.isArray(navCategories) && navCategories.length > 0 ? navCategories : [];
return source.map((cat: any) => ({ label: cat.name, to: cat.url || (cat.slug ? `/blog?category=${encodeURIComponent(cat.slug)}` : '/blog') }));
}, [navCategories]);
const NAV_LINKS: NavLink[] = useMemo(() => {
if (!navLoading && dynamicNavItems.length > 0) {
const navLinks = dynamicNavItems.map(convertToNavLink);
if (categoryItems.length > 0) {
const idx = navLinks.findIndex(l => l.label === 'Články' || l.label === 'Blog' || l.to === '/blog');
if (idx !== -1) navLinks[idx] = { ...navLinks[idx], items: categoryItems };
}
return navLinks;
}
// Fallback minimal menu
const links: NavLink[] = [
{ label: 'Domů', to: '/' },
...(settings?.show_about_in_nav === false ? [] : [{ label: 'O klubu', to: '/o-klubu' } as NavLink]),
{ label: 'Kalendář', to: '/kalendar' },
{ label: 'Zápasy', to: '/zapasy' },
{ label: 'Aktivity', to: '/aktivity' },
{ label: 'Hráči', to: '/hraci' },
categoryItems.length > 0 ? { label: 'Články', to: '/blog', items: categoryItems } : { label: 'Články', to: '/blog' },
{ label: 'Videa', to: '/videa' },
{ label: settings?.gallery_label || 'Fotogalerie', to: '/galerie' },
...(settings?.shop_url ? [{ label: 'Fanshop', to: settings.shop_url, external: true } as NavLink] : []),
{ label: 'Sponzoři', to: '/sponzori' },
{ label: 'Kontakt', to: '/kontakt' },
];
return links;
}, [navLoading, dynamicNavItems, settings?.show_about_in_nav, settings?.shop_url, settings?.gallery_label, categoryItems]);
const logoUrl = settings?.club_logo_url || theme.logoUrl || '/dist/img/logo-club-empty.svg';
const clubName = settings?.club_name || theme.name || 'Klub';
return (
<div className="sparta-navbar-container">
<div className="sparta-navbar">
{/* Burger toggle for mobile */}
<button
aria-label="Menu"
className="sparta-navbar-toggle"
onClick={() => setMobileOpen(o => !o)}
>
<div className="sparta-burger-icon" aria-hidden>
<div className="sparta-burger-line" />
<div className="sparta-burger-line" />
<div className="sparta-burger-line" />
</div>
</button>
{/* Brand */}
<RouterLink to="/" className="sparta-navbar-brand" onClick={() => setMobileOpen(false)}>
<img src={logoUrl} alt={clubName} />
</RouterLink>
{/* Links */}
<nav
className="sparta-navbar-links"
style={{ display: mobileOpen ? 'flex' : undefined, flexWrap: 'wrap' }}
>
{NAV_LINKS.map((nav) => {
const isActive = isPathActive(nav.to);
const className = isActive ? 'sparta-button-tertiary' : 'sparta-button-tertiary';
if (nav.external && nav.to) {
return (
<a key={nav.label} href={nav.to} target="_blank" rel="noreferrer" className={className} onClick={() => setMobileOpen(false)}>
{nav.label}
</a>
);
}
return (
<RouterLink key={nav.label} to={nav.to || '#'} className={className} onClick={() => setMobileOpen(false)}>
{nav.label}
</RouterLink>
);
})}
</nav>
</div>
</div>
);
};
export default SpartaNavbar;
+1 -1
View File
@@ -237,7 +237,7 @@ const Footer: React.FC = () => {
</Box>
{/* MyClub Watermark - Clean White Branding */}
<Box bg="white" borderTop="1px" borderColor="gray.200" py={6}>
<Box bg="white" borderTop="1px" borderColor="gray.200" py={6} data-watermark="myclub">
<Container maxW="container.xl">
<Stack
direction={{ base: 'column', md: 'row' }}
+39 -6
View File
@@ -3,13 +3,18 @@ import { ReactNode, useEffect, useState } from 'react';
import { FiChevronUp } from 'react-icons/fi';
import Navbar from '../Navbar';
import Footer from './Footer';
import { useAllPageElementConfigs } from '../../hooks/usePageElementConfig';
import SpartaNavbar from '../elements/SpartaNavbar';
interface MainLayoutProps {
children: ReactNode;
headerInsideContainer?: boolean;
}
export const MainLayout: React.FC<MainLayoutProps> = ({ children }) => {
export const MainLayout: React.FC<MainLayoutProps> = ({ children, headerInsideContainer = false }) => {
const [showTop, setShowTop] = useState(false);
const { getStyles, getVariant } = useAllPageElementConfigs('homepage');
const headerVariant = getVariant('header', 'unified');
useEffect(() => {
const onScroll = () => {
@@ -33,11 +38,39 @@ export const MainLayout: React.FC<MainLayoutProps> = ({ children }) => {
return (
<Box minH="100vh" bg="bg.app" overflowX="hidden">
<Box id="top" position="absolute" top={0} left={0} />
<Navbar />
<Container maxW="container.xl" py={8}>
{children}
</Container>
<Footer />
{headerInsideContainer ? (
<>
<Container maxW="container.xl" py={8}>
<Box as="header" data-element="header" style={{ ...getStyles('header') }}>
{headerVariant === 'sparta_navbar' ? (
<SpartaNavbar />
) : (
<Navbar fullWidth={headerVariant === 'fullwidth'} />
)}
</Box>
{children}
</Container>
<Box as="footer" data-element="footer" style={{ ...getStyles('footer') }}>
<Footer />
</Box>
</>
) : (
<>
<Box as="header" data-element="header" style={{ ...getStyles('header') }}>
{headerVariant === 'sparta_navbar' ? (
<SpartaNavbar />
) : (
<Navbar fullWidth={headerVariant === 'fullwidth'} />
)}
</Box>
<Container maxW="container.xl" py={8}>
{children}
</Container>
<Box as="footer" data-element="footer" style={{ ...getStyles('footer') }}>
<Footer />
</Box>
</>
)}
{showTop && (
<IconButton
aria-label="Zpět nahoru"
@@ -40,9 +40,9 @@ export default function NewsletterSubscribe() {
toast({
title: 'Přihlášení k odběru proběhlo úspěšně',
description: 'Děkujeme za přihlášení k odběru našeho newsletteru!',
description: 'Vytvořili jsme vám fanouškovský účet a poslali email s heslem a odkazy pro správu newsletteru.',
status: 'success',
duration: 5000,
duration: 7000,
isClosable: true,
});
reset();
@@ -77,7 +77,7 @@ export default function NewsletterSubscribe() {
Přihlaste se k odběru novinek
</Text>
<Text textAlign="center" color={textColor} mb={2}>
Budeme vás informovat o novinkách, zápasech a akcích našeho klubu.
Budeme vás informovat o novinkách, zápasech a akcích našeho klubu. Současně pro vás vytvoříme fanouškovský účet a pošleme heslo emailem.
</Text>
<form onSubmit={handleSubmit(onSubmit)}>
@@ -118,8 +118,7 @@ export default function NewsletterSubscribe() {
</form>
<Text fontSize="xs" color={disclaimerColor} textAlign="center" mt={2}>
Odesláním formuláře souhlasíte se zpracováním osobních údajů.
Vaši e-mailovou adresu budeme používat pouze pro zasílání novinek.
Odesláním formuláře souhlasíte se zpracováním osobních údajů. Z odběru se můžete kdykoli odhlásit a nastavení upravit v zaslaném emailu. Heslo lze změnit přes stránku pro obnovení hesla.
</Text>
</VStack>
</Box>
+148 -4
View File
@@ -15,9 +15,13 @@ import {
Image,
Heading,
useColorModeValue,
Input,
FormControl,
FormLabel,
Link,
} from '@chakra-ui/react';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { CheckIcon } from '@chakra-ui/icons';
import { CheckIcon, StarIcon } from '@chakra-ui/icons';
import {
Poll,
PollOption,
@@ -25,6 +29,9 @@ import {
getPollResults,
generateSessionToken,
} from '../../services/polls';
import { useUmami } from '../../hooks/useUmami';
import { useAuth } from '../../contexts/AuthContext';
import { Link as RouterLink, useLocation } from 'react-router-dom';
interface PollCardProps {
poll: Poll;
@@ -43,11 +50,32 @@ const PollCard: React.FC<PollCardProps> = ({
}) => {
const toast = useToast();
const queryClient = useQueryClient();
const { trackEvent } = useUmami();
const { isAuthenticated, user } = useAuth();
const location = useLocation();
const [selectedOptions, setSelectedOptions] = useState<number[]>([]);
const [hasVoted, setHasVoted] = useState(initialHasVoted);
const [canShowResults, setCanShowResults] = useState(initialCanShowResults);
const [results, setResults] = useState<any[]>([]);
const [showingResults, setShowingResults] = useState(initialCanShowResults);
const [voterName, setVoterName] = useState('');
const [voterEmail, setVoterEmail] = useState('');
const isRating = poll.type === 'rating';
const ratingOptionsSorted = [...poll.options].sort((a, b) => (a.display_order || 0) - (b.display_order || 0));
const maxRating = ratingOptionsSorted.length > 0
? Math.max(...ratingOptionsSorted.map(o => (o.display_order || 0))) || ratingOptionsSorted.length
: 5;
const [ratingValue, setRatingValue] = useState<number | null>(null);
const selectOptionForRating = (value: number) => {
setRatingValue(value);
const byOrder = ratingOptionsSorted.find(o => (o.display_order || 0) === value);
const fallback = ratingOptionsSorted[value - 1];
const opt = byOrder || fallback;
if (opt) {
setSelectedOptions([opt.id]);
}
};
const bgCard = useColorModeValue('white', 'gray.800');
const borderColor = useColorModeValue('gray.200', 'gray.600');
@@ -60,6 +88,8 @@ const PollCard: React.FC<PollCardProps> = ({
return votePoll(poll.id, {
option_ids: selectedOptions,
session_token: sessionToken,
voter_name: isAuthenticated ? (voterName || (user as any)?.name || undefined) : undefined,
voter_email: isAuthenticated ? (voterEmail || user?.email || undefined) : undefined,
});
},
onSuccess: async () => {
@@ -85,6 +115,19 @@ const PollCard: React.FC<PollCardProps> = ({
duration: 3000,
});
// Analytics tracking (Umami + backend)
try {
trackEvent('Poll Vote', {
poll_id: poll.id,
poll_title: poll.title,
type: poll.type,
option_ids: selectedOptions,
rating: ratingValue || undefined,
});
} catch (e) {
// swallow
}
if (onVoteSuccess) {
onVoteSuccess();
}
@@ -134,6 +177,28 @@ const PollCard: React.FC<PollCardProps> = ({
}
};
const handleOptionClick = (optionId: number) => {
if (poll.allow_multiple) {
const isSelected = selectedOptions.includes(optionId);
if (isSelected) {
setSelectedOptions(selectedOptions.filter((id) => id !== optionId));
} else {
if (selectedOptions.length >= poll.max_choices) {
toast({
title: 'Příliš mnoho voleb',
description: `Můžete vybrat maximálně ${poll.max_choices} možností.`,
status: 'warning',
duration: 3000,
});
return;
}
setSelectedOptions([...selectedOptions, optionId]);
}
} else {
setSelectedOptions([optionId]);
}
};
const loadResults = async () => {
try {
const resultsData = await getPollResults(poll.id);
@@ -263,7 +328,38 @@ const PollCard: React.FC<PollCardProps> = ({
{isActive && (
<>
{poll.allow_multiple ? (
{isRating ? (
<VStack align="stretch" spacing={2}>
{maxRating <= 5 ? (
<HStack>
{Array.from({ length: maxRating }).map((_, i) => (
<StarIcon
key={i}
boxSize={6}
cursor="pointer"
color={i < (ratingValue || 0) ? 'yellow.400' : 'gray.300'}
onClick={() => selectOptionForRating(i + 1)}
/>
))}
<Text ml={2}>{ratingValue ? `${ratingValue}/${maxRating}` : 'Vyberte hodnocení'}</Text>
</HStack>
) : (
<HStack flexWrap="wrap" spacing={2}>
{Array.from({ length: maxRating }).map((_, i) => (
<Button
key={i}
size="sm"
variant={ratingValue === i + 1 ? 'solid' : 'outline'}
colorScheme="blue"
onClick={() => selectOptionForRating(i + 1)}
>
{i + 1}
</Button>
))}
</HStack>
)}
</VStack>
) : poll.allow_multiple ? (
<CheckboxGroup
value={selectedOptions.map(String)}
onChange={handleMultipleChoice}
@@ -280,8 +376,17 @@ const PollCard: React.FC<PollCardProps> = ({
borderRadius="md"
_hover={{ bg: hoverBg }}
cursor="pointer"
role="button"
tabIndex={0}
onClick={() => handleOptionClick(option.id)}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
handleOptionClick(option.id);
}
}}
>
<Checkbox value={String(option.id)}>
<Checkbox value={String(option.id)} onClick={(e) => e.stopPropagation()}>
<VStack align="start" spacing={1}>
<Text>{option.text}</Text>
{option.description && (
@@ -309,8 +414,17 @@ const PollCard: React.FC<PollCardProps> = ({
borderRadius="md"
_hover={{ bg: hoverBg }}
cursor="pointer"
role="button"
tabIndex={0}
onClick={() => handleOptionClick(option.id)}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
handleOptionClick(option.id);
}
}}
>
<Radio value={String(option.id)}>
<Radio value={String(option.id)} onClick={(e) => e.stopPropagation()}>
<VStack align="start" spacing={1}>
<Text>{option.text}</Text>
{option.description && (
@@ -342,6 +456,36 @@ const PollCard: React.FC<PollCardProps> = ({
</RadioGroup>
)}
{isAuthenticated ? (
<VStack spacing={3} align="stretch">
<FormControl>
<FormLabel fontSize="sm">Jméno (volitelné)</FormLabel>
<Input
size="sm"
value={voterName || ((user as any)?.name || '')}
onChange={(e) => setVoterName(e.target.value)}
/>
</FormControl>
<FormControl>
<FormLabel fontSize="sm">E-mail (volitelné)</FormLabel>
<Input
size="sm"
type="email"
value={voterEmail || (user?.email || '')}
onChange={(e) => setVoterEmail(e.target.value)}
/>
</FormControl>
</VStack>
) : (
<Text fontSize="sm" color="gray.500">
Chcete připojit své jméno k hlasu?{' '}
<Link as={RouterLink} color="blue.500" to="/login" state={{ from: location }}>
Přihlaste se
</Link>
.
</Text>
)}
<Button
colorScheme="blue"
onClick={handleVote}
+35 -8
View File
@@ -6,20 +6,31 @@ import { PageElementConfig } from '../services/pageElements';
// Elements that are actually implemented on HomePage
// Only these should be available in the editor
export const HOMEPAGE_IMPLEMENTED_ELEMENTS = [
'header', // Site navigation/header
'hero', // Hero section with news cards (grid/scroller/swiper variants)
'news', // Featured news articles
'matches', // Upcoming/recent matches
'matches-slider', // Matches slider by competition
'table', // League standings table
'team', // Players scroller
'gallery', // Photo gallery albums from Zonerama
'videos', // Videos section
'merch', // Merchandise/fanshop
'newsletter',// Newsletter subscription
'poll', // Polls / voting widget
'sponsors', // Sponsors/partners
'banner', // Advertisement banners (various placements)
];
export const DEFAULT_HOMEPAGE_ELEMENTS: PageElementConfig[] = [
{
page_type: 'homepage',
element_name: 'header',
variant: 'unified',
visible: true,
display_order: 0,
settings: {},
},
{
page_type: 'homepage',
element_name: 'hero',
@@ -33,7 +44,7 @@ export const DEFAULT_HOMEPAGE_ELEMENTS: PageElementConfig[] = [
element_name: 'news',
variant: 'grid',
visible: true,
display_order: 2,
display_order: 11,
settings: {},
},
{
@@ -41,7 +52,15 @@ export const DEFAULT_HOMEPAGE_ELEMENTS: PageElementConfig[] = [
element_name: 'matches',
variant: 'compact',
visible: true,
display_order: 3,
display_order: 2,
settings: {},
},
{
page_type: 'homepage',
element_name: 'matches-slider',
variant: 'carousel',
visible: true,
display_order: 4,
settings: {},
},
{
@@ -49,7 +68,7 @@ export const DEFAULT_HOMEPAGE_ELEMENTS: PageElementConfig[] = [
element_name: 'sponsors',
variant: 'grid',
visible: true,
display_order: 4,
display_order: 9,
settings: {},
},
{
@@ -65,7 +84,7 @@ export const DEFAULT_HOMEPAGE_ELEMENTS: PageElementConfig[] = [
element_name: 'videos',
variant: 'grid',
visible: false,
display_order: 6,
display_order: 7,
settings: {},
},
{
@@ -73,7 +92,7 @@ export const DEFAULT_HOMEPAGE_ELEMENTS: PageElementConfig[] = [
element_name: 'team',
variant: 'grid',
visible: false,
display_order: 7,
display_order: 6,
settings: {},
},
{
@@ -81,7 +100,7 @@ export const DEFAULT_HOMEPAGE_ELEMENTS: PageElementConfig[] = [
element_name: 'merch',
variant: 'grid',
visible: true,
display_order: 7,
display_order: 8,
settings: {},
},
{
@@ -89,7 +108,7 @@ export const DEFAULT_HOMEPAGE_ELEMENTS: PageElementConfig[] = [
element_name: 'table',
variant: 'split_news',
visible: true,
display_order: 8,
display_order: 3,
settings: {},
},
{
@@ -105,7 +124,15 @@ export const DEFAULT_HOMEPAGE_ELEMENTS: PageElementConfig[] = [
element_name: 'newsletter',
variant: 'default',
visible: false,
display_order: 9,
display_order: 4,
settings: {},
},
{
page_type: 'homepage',
element_name: 'poll',
variant: 'vertical',
visible: false,
display_order: 12,
settings: {},
},
];
+20 -3
View File
@@ -91,8 +91,18 @@ export const useAllPageElementConfigs = (pageType: string) => {
setVisibility(visMap);
setElementOrder(order);
// Apply initial order to DOM if elements exist
if (order.length > 0) {
// Apply initial order to DOM only in editor/preview mode
const isEditingMode = (() => {
try {
if (typeof document !== 'undefined' && (document.body?.classList?.contains('myuibrix-edit-mode'))) return true;
const params = new URLSearchParams(window.location.search);
return params.get('myuibrix') === 'edit';
} catch {
return false;
}
})();
if (order.length > 0 && isEditingMode) {
requestAnimationFrame(() => {
applyDOMOrder(order);
});
@@ -137,7 +147,14 @@ export const useAllPageElementConfigs = (pageType: string) => {
const handleMyUIbrixReorder = ((event: CustomEvent) => {
const { order } = event.detail;
setElementOrder(order);
applyDOMOrder(order);
try {
const inEdit = document.body?.classList?.contains('myuibrix-edit-mode') || false;
if (inEdit) {
applyDOMOrder(order);
}
} catch {
// no-op
}
}) as EventListener;
// Listen for style changes from VisualStylePanel
+35 -40
View File
@@ -3,7 +3,7 @@ import MainLayout from '../components/layout/MainLayout';
import { useParams, Link as RouterLink } from 'react-router-dom';
import { getEvent } from '../services/eventService';
import { Box, Container, Heading, Text, VStack, HStack, Badge, Spinner, Button, Image, Link as ChakraLink, Divider, Icon, useColorModeValue } from '@chakra-ui/react';
import { FiDownload, FiFile, FiImage, FiMapPin, FiClock } from 'react-icons/fi';
import { FiDownload, FiMapPin, FiClock } from 'react-icons/fi';
import DOMPurify from 'dompurify';
import { assetUrl } from '../utils/url';
import EventLocationMap from '../components/events/EventLocationMap';
@@ -90,61 +90,33 @@ const ActivityDetailPage: React.FC = () => {
)}
{!loading && !error && data && (
<VStack align="stretch" spacing={5}>
{/* Hero image */}
{data.image_url && (
<Box borderRadius="xl" overflow="hidden" borderWidth="1px">
<Image src={assetUrl(data.image_url) || data.image_url} alt={data.title} w="100%" maxH="420px" objectFit="cover" />
</Box>
)}
{/* Title and meta */}
<VStack align="stretch" spacing={1}>
<HStack justify="space-between" align="start">
<Heading as="h1" size="lg" lineHeight={1.2}>{data.title}</Heading>
<Badge colorScheme={typeColor(data.type)}>{typeLabel(data.type)}</Badge>
</HStack>
<HStack spacing={4} color={mutedText} fontSize="sm">
<HStack>
<Icon as={FiClock} />
<Text>
{new Date(data.start_time).toLocaleString()} {data.end_time ? ` ${new Date(data.end_time).toLocaleString()}` : ''}
</Text>
</HStack>
{data.location && (
<HStack>
<Icon as={FiMapPin} />
<Text>{data.location}</Text>
</HStack>
)}
<HStack>
<Icon as={FiClock} />
<Text>
{new Date(data.start_time).toLocaleString()} {data.end_time ? ` ${new Date(data.end_time).toLocaleString()}` : ''}
</Text>
</HStack>
</HStack>
</VStack>
{data.location && (
<EventLocationMap
location={data.location}
title={data.title}
latitude={data.latitude}
longitude={data.longitude}
/>
)}
{/* YouTube Video */}
{data.youtube_url && getYouTubeEmbedUrl(data.youtube_url) && (
<Box borderRadius="lg" overflow="hidden" boxShadow="md">
<Box position="relative" paddingBottom="56.25%" height={0}>
<iframe
style={{ position: 'absolute', top: 0, left: 0, width: '100%', height: '100%' }}
src={getYouTubeEmbedUrl(data.youtube_url) || ''}
title={data.title}
frameBorder="0"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
allowFullScreen
/>
</Box>
</Box>
)}
{/* Content */}
{data.description && (
<Box
bg={cardBg}
@@ -164,7 +136,34 @@ const ActivityDetailPage: React.FC = () => {
/>
)}
{/* Attachments with Preview */}
{data.youtube_url && getYouTubeEmbedUrl(data.youtube_url) && (
<Box borderRadius="lg" overflow="hidden" boxShadow="md">
<Box position="relative" paddingBottom="56.25%" height={0}>
<iframe
style={{ position: 'absolute', top: 0, left: 0, width: '100%', height: '100%' }}
src={getYouTubeEmbedUrl(data.youtube_url) || ''}
title={data.title}
frameBorder="0"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
allowFullScreen
/>
</Box>
</Box>
)}
{data.location && (
<EventLocationMap
location={data.location}
title={data.title}
latitude={data.latitude}
longitude={data.longitude}
/>
)}
{data?.id && (
<EmbeddedPoll eventId={data.id} />
)}
{(Array.isArray(data.attachments) && data.attachments.length > 0) && (
<VStack align="stretch" spacing={3}>
<Heading as="h3" size="sm">Přílohy</Heading>
@@ -183,7 +182,6 @@ const ActivityDetailPage: React.FC = () => {
</VStack>
)}
{/* Legacy single file_url */}
{data.file_url && (
<HStack>
<Button as={ChakraLink} href={assetUrl(data.file_url) || data.file_url} isExternal variant="outline" leftIcon={<FiDownload />}>
@@ -192,7 +190,6 @@ const ActivityDetailPage: React.FC = () => {
</HStack>
)}
{/* Back links */}
<Divider />
<HStack>
<Button as={RouterLink} to="/aktivity" variant="outline">Zpět na aktivity</Button>
@@ -203,8 +200,6 @@ const ActivityDetailPage: React.FC = () => {
</Container>
</Box>
{/* Embedded Poll - shows polls related to this event */}
{data?.id && <EmbeddedPoll eventId={data.id} />}
</MainLayout>
);
};
+1 -1
View File
@@ -60,7 +60,7 @@ const AuthPage: React.FC = () => {
return <Navigate to="/admin" replace />;
}
const from = (location.state as LocationState)?.from?.pathname || '/admin';
const from = (location.state as LocationState)?.from?.pathname || '/';
const handleLogin = async (e: React.FormEvent) => {
e.preventDefault();
+109 -148
View File
@@ -2,6 +2,7 @@ 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';
import '../styles/sparta-styles.css';
import './styles/UnifiedHome.css';
import { getPublicSettings } from '../services/settings';
import { assetUrl, sanitizeClubName } from '../utils/url';
@@ -11,9 +12,11 @@ import BlogCardsScroller from '../components/home/BlogCardsScroller';
import BlogSwiper from '../components/home/BlogSwiper';
import VideosSection from '../components/home/VideosSection';
import MerchSection from '../components/home/MerchSection';
import PollsWidget from '../components/home/PollsWidget';
import GallerySection from '../components/home/GallerySection';
import { getArticles as apiGetArticles, Article as ApiArticle } from '../services/articles';
import { getCompetitionAliasesPublic, CompetitionAlias } from '../services/competitionAliases';
import { getUpcomingEvents } from '../services/eventService';
import NewsletterSubscribe from '../components/newsletter/NewsletterSubscribe';
import MyUIbrixStyleEditor from '../components/editor/MyUIbrixEditor';
import MyUIbrixErrorBoundary from '../components/editor/MyUIbrixErrorBoundary';
@@ -91,6 +94,7 @@ const HomePage: React.FC = () => {
type UiSponsor = { id:number|string; name:string; logo:string; url?:string };
type UiBanner = { id:number|string; name:string; image:string; url?:string; placement?:string; width?:number; height?:number };
type UiMerch = { id?: number|string; title?: string; image_url: string; url?: string };
type UiEvent = { id:number|string; title:string; start_time:string; end_time?:string|null; location?:string|null; type?:string; image_url?:string|null };
const [players, setPlayers] = useState<UiPlayer[]>([]);
const [sponsors, setSponsors] = useState<UiSponsor[]>([]);
const [banners, setBanners] = useState<UiBanner[]>([]);
@@ -99,6 +103,7 @@ const HomePage: React.FC = () => {
const [videosRich, setVideosRich] = useState<Array<{ url:string; title?:string; length?:string; uploaded_at?:string; thumbnail_url?:string }>>([]);
const [merchItems, setMerchItems] = useState<UiMerch[]>([]);
const [merchEnabled, setMerchEnabled] = useState<boolean>(false);
const [upcomingEvents, setUpcomingEvents] = useState<UiEvent[]>([]);
// Aliases
const [aliases, setAliases] = useState<CompetitionAlias[]>([]);
const [aliasMap, setAliasMap] = useState<Record<string, { alias: string; original_name?: string }>>({});
@@ -540,76 +545,7 @@ const HomePage: React.FC = () => {
setTimeout(run, 0);
}
}, [facrCompetitions, matchesTab, closestIndexByComp]);
// Auto-theme from club logo dominant color
useEffect(() => {
if (!clubLogo) return;
let disposed = false;
const toHex = (v: number) => {
const h = Math.max(0, Math.min(255, Math.round(v))).toString(16).padStart(2, '0');
return h;
};
const rgbToHex = (r: number, g: number, b: number) => `#${toHex(r)}${toHex(g)}${toHex(b)}`;
const lighten = (r: number, g: number, b: number, amt = 20) => [
Math.min(255, r + amt),
Math.min(255, g + amt),
Math.min(255, b + amt),
] as const;
const darkenIfLowContrast = (r: number, g: number, b: number) => {
// ensure contrast versus white text (used in .next-match)
const luminance = (0.2126 * r + 0.7152 * g + 0.0722 * b) / 255; // 0..1
if (luminance > 0.75) {
// too light, darken
return [r * 0.6, g * 0.6, b * 0.6] as const;
}
return [r, g, b] as const;
};
const img = new Image();
img.crossOrigin = 'anonymous';
img.src = assetUrl(clubLogo) || clubLogo;
img.onload = () => {
if (disposed) return;
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
if (!ctx) return;
const w = 100, h = 100;
canvas.width = w; canvas.height = h;
try {
ctx.drawImage(img, 0, 0, w, h);
const data = ctx.getImageData(0, 0, w, h).data;
let r = 0, g = 0, b = 0, n = 0;
for (let i = 0; i < data.length; i += 4) {
const a = data[i + 3];
if (a < 64) continue; // skip transparent
const rr = data[i], gg = data[i + 1], bb = data[i + 2];
// skip near-white background pixels to better catch logo color
if (rr > 240 && gg > 240 && bb > 240) continue;
r += rr; g += gg; b += bb; n++;
}
if (n > 0) {
r /= n; g /= n; b /= n;
// adjust for readability
[r, g, b] = darkenIfLowContrast(r, g, b);
const [lr, lg, lb] = lighten(r, g, b, 24);
const primary = rgbToHex(r, g, b);
const primaryLight = rgbToHex(lr, lg, lb);
// secondary: golden fallback if color is blueish, else a subtle accent
const isBlueish = b > r && b > g;
const secondary = isBlueish ? '#d69e2e' : '#2c5282';
const root = document.documentElement;
root.style.setProperty('--primary', primary);
root.style.setProperty('--primary-light', primaryLight);
root.style.setProperty('--secondary', secondary);
}
} catch {
// ignore CORS or canvas tainting
}
};
return () => { disposed = true; };
}, [clubLogo]);
// MyUIbrix events are handled by useAllPageElementConfigs hook
// It automatically updates getVariant() and isVisible() when changes occur in edit mode
@@ -656,6 +592,26 @@ const HomePage: React.FC = () => {
return () => clearInterval(id);
}, [matches, facrCompetitions, matchesTab]);
useEffect(() => {
let active = true;
(async () => {
try {
const evs = await getUpcomingEvents();
const mapped: UiEvent[] = (evs || []).map((e: any) => ({
id: e.id,
title: e.title,
start_time: e.start_time,
end_time: e.end_time,
location: e.location,
type: e.type,
image_url: e.image_url,
}));
if (active) setUpcomingEvents(mapped);
} catch {}
})();
return () => { active = false; };
}, []);
// Removed: Edge auto-cycle
// Removed: Aurora layout
@@ -1381,8 +1337,8 @@ const HomePage: React.FC = () => {
// }
return (
<MainLayout>
<div className="container">
<MainLayout headerInsideContainer>
<div className="container" data-element="container" style={{ ...getStyles('container') }}>
{/* Header: logo + club name */}
<div className="home-header">
<img src={assetUrl(clubLogo) || '/images/club-logo.png'} alt="Klub" />
@@ -1553,7 +1509,7 @@ const HomePage: React.FC = () => {
</section>
) : null}
{/* Matches slider with scores by competition */}
{/* Matches slider with scores by competition (moved after news+tables) */}
{facrCompetitions.length > 0 && (
<section data-element="matches-slider" className="matches-slider" style={{ position: 'relative', ...getStyles('matches-slider') }}>
<div className="section-head" style={{ marginTop: 16, marginBottom: 16 }}>
@@ -1612,54 +1568,54 @@ const HomePage: React.FC = () => {
</div>
</section>
)}
{/* Competition tables moved into right column below */}
{/* Standings: tabs per competition (only FACR), clicking row opens ClubModal */}
{isVisible('table', true) && (() => {
// Match standings to current competition by name instead of assuming same index
{/* News + Tables: split into two independent sections */}
{(() => {
// Compute matching standings for the selected competition
const currentCompetition = facrCompetitions[matchesTab];
const currentCompetitionName = currentCompetition?.name || '';
const matchingStanding = standings.find((s: any) => s.name === currentCompetitionName);
const hasStandingsForCurrentTab = matchingStanding && (
const hasStandingsForCurrentTab = !!matchingStanding && (
(matchingStanding.table && matchingStanding.table.length > 0) ||
(matchingStanding.rows && matchingStanding.rows.length > 0)
);
return (
<section
data-element="table"
className="standings"
data-variant={hasStandingsForCurrentTab ? undefined : 'standard'}
style={{ marginTop: 32, ...getStyles('table') }}
>
<div>
<div className="section-head" style={{ marginTop: 0 }}>
<h3>Další aktuality</h3>
</div>
<div className="blog-list">
{news.length > 0 ? news.slice(0, 4).map((n) => (
<a key={n.id} href={`/news/${n.slug || n.id}`} className="card" style={{ textDecoration: 'none', color: 'inherit' }}>
<div className="thumb" style={{ backgroundImage: `url(${assetUrl(n.image) || '/images/news/placeholder.jpg'})` }} />
<div>
<h4>{n.title}</h4>
<div style={{ color: 'var(--dark-gray)', fontSize: '0.9rem' }}>{n.excerpt}</div>
<>
{isVisible('news', true) && (
<section data-element="news" className="news-list" style={{ marginTop: 32, ...getStyles('news') }}>
<div className="section-head" style={{ marginTop: 0 }}>
<h3>Další aktuality</h3>
</div>
<div className="blog-list">
{news.length > 0 ? news.slice(0, 4).map((n) => (
<a key={n.id} href={`/news/${n.slug || n.id}`} className="card" style={{ textDecoration: 'none', color: 'inherit' }}>
<div className="thumb" style={{ backgroundImage: `url(${assetUrl(n.image) || '/images/news/placeholder.jpg'})` }} />
<div>
<h4>{n.title}</h4>
<div style={{ color: 'var(--dark-gray)', fontSize: '0.9rem' }}>{n.excerpt}</div>
</div>
</a>
)) : (
<div style={{ padding: '24px', textAlign: 'center', color: 'var(--dark-gray)', background: 'var(--bg-soft)', borderRadius: '12px' }}>
<p>Zatím nejsou k dispozici žádné aktuality.</p>
</div>
</a>
)) : (
<div style={{ padding: '24px', textAlign: 'center', color: 'var(--dark-gray)', background: 'var(--bg-soft)', borderRadius: '12px' }}>
<p>Zatím nejsou k dispozici žádné aktuality.</p>
)}
</div>
{news.length > 0 && (
<div style={{ marginTop: 12 }}>
<a className="btn" href="/news">Zobrazit všechny aktuality</a>
</div>
)}
</div>
{news.length > 0 && (
<div style={{ marginTop: 12 }}>
<a className="btn" href="/news">Zobrazit všechny aktuality</a>
</div>
)}
</div>
{hasStandingsForCurrentTab && (
<div>
</section>
)}
{isVisible('table', true) && hasStandingsForCurrentTab && (
<section
data-element="table"
className="standings"
style={{ marginTop: 32, ...getStyles('table') }}
>
<div className="table-card">
<div className="section-head" style={{ marginTop: 0, marginBottom: 12 }}>
<h3>Tabulky</h3>
@@ -1709,12 +1665,10 @@ const HomePage: React.FC = () => {
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)';
}}
@@ -1745,13 +1699,39 @@ const HomePage: React.FC = () => {
</table>
</div>
</div>
</div>
</section>
)}
</section>
</>
);
})()}
{/* Players scroller (optional) */}
{/* Competition tables moved into right column below */}
{upcomingEvents.length > 0 && isVisible('activities', true) && (
<section data-element="activities" style={{ marginTop: 32, marginBottom: 16, position: 'relative', ...getStyles('activities') }}>
<div style={{ maxWidth: 1200, margin: '0 auto', padding: '0 12px' }}>
<div className="section-head" style={{ marginTop: 0 }}>
<h3>Aktivity</h3>
<a href="/aktivity" className="see-all">Zobrazit vše <FiArrowRight /></a>
</div>
<div className="blog-list">
{upcomingEvents.slice(0,4).map((e) => (
<a key={e.id} href={`/aktivita/${e.id}`} className="card" style={{ textDecoration: 'none', color: 'inherit' }}>
<div className="thumb" style={{ backgroundImage: `url(${assetUrl(e.image_url) || '/images/news/placeholder.jpg'})` }} />
<div>
<h4>{e.title}</h4>
<div style={{ color: 'var(--dark-gray)', fontSize: '0.9rem' }}>
{new Date(e.start_time).toLocaleDateString()} {e.location ? `${e.location}` : ''}
</div>
</div>
</a>
))}
</div>
</div>
</section>
)}
{/* Players scroller */}
{players.length > 0 && isVisible('team', false) && (
<section data-element="team" className="players-scroller" style={{ marginTop: 32, position: 'relative', ...getStyles('team') }}>
<div className="section-head">
@@ -1770,22 +1750,6 @@ const HomePage: React.FC = () => {
</section>
)}
{/* Merchandise / clothing (optional; only if shop URL is set) */}
{shopUrl && (
<section className="merch-cta">
<div className="card">
<div>
<h3>Oficiální fanshop</h3>
<p>Pořiďte si dresy, šály a další. Podpořte tým!</p>
<a className="btn" href={shopUrl || undefined} target="_blank" rel="noopener noreferrer">Přejít do eshopu</a>
</div>
<div className="mockup" aria-hidden>
<div className="shirt" />
</div>
</div>
</section>
)}
{/* Gallery */}
{isVisible('gallery', false) && (
<section data-element="gallery" style={{ marginTop: 32, marginBottom: 32, position: 'relative', ...getStyles('gallery') }}>
@@ -1812,27 +1776,15 @@ const HomePage: React.FC = () => {
</section>
)}
{/* Newsletter subscription CTA */}
{isVisible('newsletter', false) && (
<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 />
{/* Polls / Voting */}
{isVisible('poll', false) && (
<section data-element="poll" style={{ marginTop: 32, marginBottom: 32, position: 'relative', ...getStyles('poll') }}>
<div style={{ maxWidth: 1200, margin: '0 auto', padding: '0 12px' }}>
<PollsWidget featuredOnly={true} maxPolls={1} title="Anketa" />
</div>
</section>
)}
{/* Banner: homepage_top */}
{(banners || []).some(b => b.placement === 'homepage_top') && (
<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 */}
<img src={b.image} alt={b.name} style={{ maxWidth: '100%', width: b.width ? `${b.width}px` : undefined, height: b.height ? `${b.height}px` : 'auto' }} />
</a>
))}
</section>
)}
{/* Banner: homepage_footer */}
{(banners || []).some(b => b.placement === 'homepage_footer') && (
<section data-element="banner" className="banner banner-footer" style={{ margin: '24px 0', textAlign: 'center', ...getStyles('banner') }}>
@@ -1845,6 +1797,15 @@ const HomePage: React.FC = () => {
</section>
)}
{/* CTA (Newsletter) moved up */}
{isVisible('newsletter', false) && (
<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>
</section>
)}
{/* Sponsors: grid or slider (controlled by settings); dark theme supported; full-bleed */}
{isVisible('sponsors', true) && (
<section
+117
View File
@@ -0,0 +1,117 @@
import { useState } from 'react';
import { useNavigate, useLocation } from 'react-router-dom';
import {
Box,
Button,
FormControl,
FormLabel,
Input,
VStack,
Heading,
useToast,
Text,
Link,
} from '@chakra-ui/react';
import { useAuth } from '../contexts/AuthContext';
import api from '../services/api';
interface LocationState {
from: {
pathname: string;
};
}
const RegisterPage: React.FC = () => {
const [name, setName] = useState('');
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [isLoading, setIsLoading] = useState(false);
const { login } = useAuth();
const navigate = useNavigate();
const location = useLocation();
const toast = useToast();
const from = (location.state as LocationState)?.from?.pathname || '/';
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setIsLoading(true);
try {
// Backend Register accepts name or first/last
const response = await api.post('/auth/register', {
email,
password,
name,
});
const { token, user } = response.data;
await login(token, user, true);
toast({ title: 'Účet vytvořen', status: 'success', duration: 3000 });
navigate(from, { replace: true });
} catch (error: any) {
toast({
title: 'Registrace selhala',
description: error?.response?.data?.error || error?.message || 'Zkuste to znovu.',
status: 'error',
duration: 5000,
isClosable: true,
});
} finally {
setIsLoading(false);
}
};
return (
<Box minH="100vh" display="flex" alignItems="center" justifyContent="center">
<Box w="100%" maxW="md" p={8} borderWidth={1} borderRadius={8} boxShadow="lg">
<VStack as="form" onSubmit={handleSubmit} spacing={4} align="stretch">
<Heading as="h2" size="lg" textAlign="center" mb={2}>
Vytvořit účet
</Heading>
<FormControl id="name" isRequired>
<FormLabel>Jméno a příjmení</FormLabel>
<Input
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="např. Jan Novák"
/>
</FormControl>
<FormControl id="email" isRequired>
<FormLabel>Email</FormLabel>
<Input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="např. jan@klub.cz"
/>
</FormControl>
<FormControl id="password" isRequired>
<FormLabel>Heslo</FormLabel>
<Input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="Zadejte heslo (min. 8 znaků)"
/>
</FormControl>
<Button type="submit" colorScheme="blue" width="full" mt={2} isLoading={isLoading}>
Zaregistrovat se
</Button>
<Text fontSize="sm" textAlign="center">
máte účet?{' '}
<Link color="blue.500" href="/login">
Přihlaste se
</Link>
</Text>
</VStack>
</Box>
</Box>
);
};
export default RegisterPage;
+117
View File
@@ -0,0 +1,117 @@
import React, { useEffect, useState } from 'react';
import {
Box,
Button,
Container,
FormControl,
FormLabel,
Input,
Tab,
TabList,
TabPanel,
TabPanels,
Tabs,
Heading,
Text,
useToast,
VStack,
HStack,
Link as ChakraLink,
} from '@chakra-ui/react';
import { useAuth } from '../contexts/AuthContext';
import api from '../services/api';
const SemiAdminPage: React.FC = () => {
const { user, updateUser } = useAuth();
const splitName = (full?: string) => {
const v = String(full || '').trim();
if (!v) return { fn: '', ln: '' };
const parts = v.split(/\s+/);
if (parts.length === 1) return { fn: parts[0], ln: '' };
return { fn: parts[0], ln: parts.slice(1).join(' ') };
};
const init = splitName(user?.name);
const [firstName, setFirstName] = useState(init.fn);
const [lastName, setLastName] = useState(init.ln);
const [isSaving, setIsSaving] = useState(false);
const [prefsToken, setPrefsToken] = useState<string>('');
const toast = useToast();
useEffect(() => {
const s = splitName(user?.name);
setFirstName(s.fn);
setLastName(s.ln);
}, [user?.name]);
useEffect(() => {
(async () => {
try {
const res = await api.get('/newsletter/token/me');
setPrefsToken(res.data?.token || '');
} catch {}
})();
}, []);
const handleSave = async (e: React.FormEvent) => {
e.preventDefault();
setIsSaving(true);
try {
const res = await api.put('/me', { first_name: firstName, last_name: lastName });
const updated = res.data?.user;
if (updated) {
const n = `${updated.first_name || firstName} ${updated.last_name || lastName}`.trim();
updateUser({ name: n });
}
toast({ title: 'Uloženo', description: 'Osobní údaje byly aktualizovány.', status: 'success', duration: 3000 });
} catch (err: any) {
toast({ title: 'Chyba', description: err?.response?.data?.error || 'Nelze uložit změny', status: 'error' });
} finally {
setIsSaving(false);
}
};
const prefsUrl = prefsToken ? `/newsletter/preferences?token=${encodeURIComponent(prefsToken)}` : '';
return (
<Container maxW="5xl" py={8}>
<Heading size="lg" mb={6}>Fan zóna</Heading>
<Tabs colorScheme="blue" isFitted variant="enclosed">
<TabList>
<Tab>Osobní údaje</Tab>
<Tab>Newsletter</Tab>
</TabList>
<TabPanels>
<TabPanel>
<Box as="form" onSubmit={handleSave} maxW="lg">
<VStack align="stretch" spacing={4}>
<FormControl>
<FormLabel>Jméno</FormLabel>
<Input value={firstName} onChange={(e) => setFirstName(e.target.value)} placeholder="Jméno" />
</FormControl>
<FormControl>
<FormLabel>Příjmení</FormLabel>
<Input value={lastName} onChange={(e) => setLastName(e.target.value)} placeholder="Příjmení" />
</FormControl>
<HStack>
<Button type="submit" colorScheme="blue" isLoading={isSaving}>Uložit</Button>
</HStack>
</VStack>
</Box>
</TabPanel>
<TabPanel>
<VStack align="start" spacing={4}>
<Text>Spravujte předvolby newsletteru nebo se odhlaste.</Text>
{prefsUrl ? (
<Button as={ChakraLink} href={prefsUrl} colorScheme="blue">Otevřít nastavení newsletteru</Button>
) : (
<Text>Načítám odkaz na nastavení</Text>
)}
</VStack>
</TabPanel>
</TabPanels>
</Tabs>
</Container>
);
};
export default SemiAdminPage;
+32 -4
View File
@@ -19,6 +19,22 @@ import MapStyleSelector from '../components/admin/MapStyleSelector';
import { MapCoordinates } from '../utils/mapUrlParser';
import { fetchLogoFromLogoAPI } from '../utils/sportLogosAPI';
const normalizePhone = (raw: string, country?: string) => {
let s = (raw || '').trim();
if (!s) return '';
s = s.replace(/[\s\-.()]/g, '');
s = s.replace(/^00/, '+');
if (s.startsWith('+')) return s;
if (/^420\d{9}$/.test(s)) return '+' + s;
if (/^\d{9}$/.test(s)) {
const c = (country || '').toLowerCase();
if (c.includes('česk') || c.includes('czech')) {
return '+420' + s;
}
}
return s;
};
const SetupPage: React.FC = () => {
const [loading, setLoading] = useState(true);
const [submitting, setSubmitting] = useState(false);
@@ -126,6 +142,8 @@ const SetupPage: React.FC = () => {
return out;
};
const isValidEmail = (val: string) => /^(?:[^\s@]+)@(?:[^\s@]+)\.(?:[^\s@]+)$/.test((val || '').trim());
useEffect(() => {
let mounted = true;
@@ -175,7 +193,7 @@ const SetupPage: React.FC = () => {
// Auto-fill SMTP username from contact email
useEffect(() => {
if (contactEmail && !smtpUser) {
if (contactEmail && !smtpUser && isValidEmail(contactEmail)) {
setSmtpUser(contactEmail);
}
}, [contactEmail, smtpUser]);
@@ -285,7 +303,7 @@ const SetupPage: React.FC = () => {
contact_city: contactCity || undefined,
contact_zip: contactPostalCode || undefined,
contact_country: contactCountry || undefined,
contact_phone: contactPhone || undefined,
contact_phone: normalizePhone(contactPhone, contactCountry) || undefined,
contact_email: contactEmail || undefined,
smtp: (smtpHost || smtpPort || smtpUser || smtpPass || smtpFromName) ? {
host: smtpHost || undefined,
@@ -352,6 +370,12 @@ const SetupPage: React.FC = () => {
});
if (logoApiRes.ok) {
toast({ title: 'Logo nahráno', description: 'Logo bylo nahráno na logoapi i lokálně', status: 'success', duration: 3000 });
try {
const apiUrl = await fetchLogoFromLogoAPI(clubId, clubName || undefined);
if (apiUrl) {
setClubLogoUrl(apiUrl);
}
} catch {}
}
} catch (logoApiErr) {
console.warn('Failed to upload to logoapi:', logoApiErr);
@@ -726,7 +750,11 @@ const SetupPage: React.FC = () => {
setGpsLat(coords.latitude);
setGpsLng(coords.longitude);
// Auto-fill address fields if available from geocoding
if (coords.street) setContactStreet(coords.street);
if (coords.street) {
setContactStreet(coords.street);
} else if (coords.houseNumber && coords.city) {
setContactStreet(`${coords.city} ${coords.houseNumber}`);
}
if (coords.city) setContactCity(coords.city);
if (coords.zip) setContactPostalCode(coords.zip);
if (coords.country) setContactCountry(coords.country);
@@ -768,7 +796,7 @@ const SetupPage: React.FC = () => {
</FormControl>
<FormControl>
<FormLabel>E-mail</FormLabel>
<Input type="email" placeholder="kontakt@klub.cz" value={contactEmail} onChange={(e) => setContactEmail(e.target.value)} />
<Input type="email" placeholder="kontakt@klub.cz" value={contactEmail} onChange={(e) => setContactEmail(e.target.value)} onBlur={() => { if (!smtpUser && isValidEmail(contactEmail)) { setSmtpUser(contactEmail); } }} />
<FormHelperText>Hlavní kontaktní e-mail klubu</FormHelperText>
</FormControl>
</SimpleGrid>
+18 -11
View File
@@ -40,13 +40,7 @@ const MatchLinkBadge: React.FC<{ articleId: number }> = ({ articleId }) => {
retry: false,
});
// Show loading state while fetching
if (linkQ.isLoading) {
return <Badge colorScheme="gray">Načítání...</Badge>;
}
const mid = (linkQ.data as any)?.external_match_id;
if (!mid) return <Badge colorScheme="gray">Nepropojeno</Badge>;
const facrQ = useQuery({
queryKey: ['facr-cached-match', mid],
@@ -77,6 +71,13 @@ const MatchLinkBadge: React.FC<{ articleId: number }> = ({ articleId }) => {
}
});
// Show loading state while fetching (after hooks are declared to keep order consistent)
if (linkQ.isLoading) {
return <Badge colorScheme="gray">Načítání...</Badge>;
}
if (!mid) return <Badge colorScheme="gray">Nepropojeno</Badge>;
// Guard against errors
if (facrQ.isError || linkQ.isError) {
return <Badge colorScheme="red">Chyba načítání</Badge>;
@@ -1164,6 +1165,12 @@ const ArticlesAdminPage = () => {
}
};
const matchBgSelected = useColorModeValue('blue.50', 'blue.900');
const matchBgDefault = useColorModeValue('white', 'gray.700');
const matchHoverBg = useColorModeValue('blue.50', 'gray.600');
const albumLinkHasPhotosBg = useColorModeValue('green.50', 'green.900');
const albumCardBg = useColorModeValue('white', 'gray.700');
return (
<AdminLayout requireAdmin={false}>
<Box>
@@ -1500,9 +1507,9 @@ const ArticlesAdminPage = () => {
borderWidth="2px"
borderRadius="md"
borderColor={isSelected ? 'blue.500' : 'gray.200'}
bg={isSelected ? useColorModeValue('blue.50', 'blue.900') : useColorModeValue('white', 'gray.700')}
bg={isSelected ? matchBgSelected : matchBgDefault}
cursor="pointer"
_hover={{ borderColor: 'blue.300', bg: useColorModeValue('blue.50', 'gray.600') }}
_hover={{ borderColor: 'blue.300', bg: matchHoverBg }}
transition="all 0.2s"
onClick={async () => {
const val = matchId;
@@ -1605,7 +1612,7 @@ const ArticlesAdminPage = () => {
placeholder="https://eu.zonerama.com/…"
value={zAlbumLink}
onChange={(e) => setZAlbumLink(e.target.value)}
bg={zAlbumPhotos.length > 0 ? useColorModeValue('green.50', 'green.900') : undefined}
bg={zAlbumPhotos.length > 0 ? albumLinkHasPhotosBg : undefined}
/>
</InputGroup>
<FormHelperText fontSize="xs">
@@ -2111,7 +2118,7 @@ const ArticlesAdminPage = () => {
{!galleryLoading && cachedAlbums.length > 0 && (
<VStack align="stretch" spacing={6}>
{cachedAlbums.map((album) => (
<Box key={album.id} borderWidth="1px" borderRadius="md" p={4} bg={useColorModeValue('white', 'gray.700')}>
<Box key={album.id} borderWidth="1px" borderRadius="md" p={4} bg={albumCardBg}>
<HStack justify="space-between" mb={3}>
<VStack align="start" spacing={0}>
<Text fontWeight="bold" fontSize="lg">{album.title || 'Album bez názvu'}</Text>
@@ -2200,7 +2207,7 @@ const ArticlesAdminPage = () => {
{!galleryLoading && cachedAlbums.length > 0 && (
<VStack align="stretch" spacing={6}>
{cachedAlbums.map((album) => (
<Box key={album.id} borderWidth="1px" borderRadius="md" p={4} bg={useColorModeValue('white', 'gray.700')}>
<Box key={album.id} borderWidth="1px" borderRadius="md" p={4} bg={albumCardBg}>
<HStack justify="space-between" mb={3}>
<VStack align="start" spacing={0}>
<Text fontWeight="bold" fontSize="lg">{album.title || 'Album bez názvu'}</Text>
+82 -13
View File
@@ -38,14 +38,14 @@ import {
Select
} from '@chakra-ui/react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { TeamLogo } from '../../components/common/TeamLogo';
import AdminLayout from '../../layouts/AdminLayout';
import { putMatchOverride, patchMatchOverride, searchClubs, uploadImage, fetchLogoAsBlob, uploadToLogaSportcreative } from '../../services/adminMatches';
import { putMatchOverride, patchMatchOverride, searchClubs, uploadImage, fetchLogoAsBlob, uploadToLogaSportcreative, fetchTeamLogoOverrides } from '../../services/adminMatches';
import { getPublicSettings } from '../../services/settings';
import { useEffect, useMemo, useRef, useState } from 'react';
import { useSearchParams } from 'react-router-dom';
import { parse } from 'date-fns';
import { assetUrl } from '../../utils/url';
import { batchFetchLogosFromSportLogosAPI } from '../../utils/sportLogosAPI';
const MatchesAdminPage = () => {
const queryClient = useQueryClient();
@@ -63,6 +63,60 @@ const MatchesAdminPage = () => {
notes: '',
});
const { data: overrides = {} } = useQuery({
queryKey: ['teamLogoOverrides'],
queryFn: fetchTeamLogoOverrides,
staleTime: 5 * 60 * 1000,
});
const normalizeName = (s: string) => {
let out = String(s || '');
out = out
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
.toLowerCase();
out = out.replace(/[\u2012\u2013\u2014\u2015\u2212]/g, '-');
const orgPhrases = [
'fotbalovy klub',
'sportovni klub',
'telovychovna jednota',
'skolni sportovni klub',
'fotbal',
'futsal',
];
for (const phrase of orgPhrases) {
const re = new RegExp(`(^|\\b)${phrase}(\\b|$)`, 'g');
out = out.replace(re, ' ');
}
out = out.replace(/\b(1\.)?\s*(sfc|afc|fc|fk|mfk|tj|sk|afk)\b\.?/g, ' ');
out = out.replace(/[\.,!;:()\[\]{}]/g, ' ');
out = out.replace(/\s+/g, ' ').trim();
return out;
};
const byName: Record<string, string> = (overrides as any)?.by_name || {};
const byNameNormalized = useMemo(() => {
const idx: Record<string, string> = {};
for (const k of Object.keys(byName)) idx[normalizeName(k)] = byName[k];
return idx;
}, [byName]);
const [sportLogosMap, setSportLogosMap] = useState<Record<string, string>>({});
const getLogo = (teamName?: string, teamId?: string, facrOriginal?: string) => {
if (!teamName) return assetUrl('/dist/img/logo-club-empty.svg') as string;
if (teamId && sportLogosMap[String(teamId)]) return sportLogosMap[String(teamId)];
let overrideUrl = byName[teamName];
if (!overrideUrl) overrideUrl = byNameNormalized[normalizeName(teamName)];
if (overrideUrl) {
if (overrideUrl.startsWith('/')) return assetUrl(overrideUrl) as string;
return overrideUrl;
}
if (facrOriginal) return facrOriginal;
return '/dist/img/logo-club-empty.svg';
};
// External logo upload helpers/state
const [homeExternalTeamId, setHomeExternalTeamId] = useState<string>('');
const [awayExternalTeamId, setAwayExternalTeamId] = useState<string>('');
@@ -137,7 +191,24 @@ const MatchesAdminPage = () => {
}));
},
});
useEffect(() => {
if (!Array.isArray(matches) || matches.length === 0) return;
const ids = new Set<string>();
for (const m of matches as any[]) {
if (m.home_id) ids.add(String(m.home_id));
if (m.away_id) ids.add(String(m.away_id));
}
if (ids.size === 0) return;
(async () => {
try {
const map = await batchFetchLogosFromSportLogosAPI(Array.from(ids));
setSportLogosMap(map);
} catch (e) {
console.warn('Failed to batch fetch logos:', e);
}
})();
}, [matches]);
// Filters
const [teamFilter, setTeamFilter] = useState('');
const [dateFrom, setDateFrom] = useState<string>(''); // YYYY-MM-DD
@@ -870,12 +941,11 @@ const MatchesAdminPage = () => {
</Td>
<Td>
<HStack spacing={2}>
<TeamLogo
teamId={m.home_id}
teamName={m.home || m.home_team || ''}
facrLogo={m.home_logo_url}
size="custom"
<Image
src={getLogo(m.home || m.home_team || '', m.home_id, m.home_logo_url)}
alt={m.home || m.home_team || ''}
boxSize="24px"
objectFit="contain"
/>
<Text fontWeight={isPast ? 'normal' : 'medium'}>{m.home || m.home_team || ''}</Text>
<Button size="xs" variant="outline" onClick={() => openEdit(m, 'home')} borderRadius="md" _hover={{ borderColor: 'brand.primary', color: 'brand.primary' }}>Tým</Button>
@@ -888,12 +958,11 @@ const MatchesAdminPage = () => {
</Td>
<Td>
<HStack spacing={2}>
<TeamLogo
teamId={m.away_id}
teamName={m.away || m.away_team || ''}
facrLogo={m.away_logo_url}
size="custom"
<Image
src={getLogo(m.away || m.away_team || '', m.away_id, m.away_logo_url)}
alt={m.away || m.away_team || ''}
boxSize="24px"
objectFit="contain"
/>
<Text fontWeight={isPast ? 'normal' : 'medium'}>{m.away || m.away_team || ''}</Text>
<Button size="xs" variant="outline" onClick={() => openEdit(m, 'away')} borderRadius="md" _hover={{ borderColor: 'brand.primary', color: 'brand.primary' }}>Tým</Button>
+65 -15
View File
@@ -4,6 +4,7 @@ import {
Button,
FormControl,
FormLabel,
FormErrorMessage,
Heading,
HStack,
IconButton,
@@ -229,6 +230,13 @@ const PlayersAdminPage: React.FC = () => {
const [editing, setEditing] = useState<Editing | null>(null);
const { isOpen, onOpen, onClose } = useDisclosure();
const JERSEY_MIN = 0;
const JERSEY_MAX = 99;
const HEIGHT_MIN = 0;
const HEIGHT_MAX = 250;
const WEIGHT_MIN = 0;
const WEIGHT_MAX = 200;
// Local state to persist partial DOB selections so the user sees what they picked
const [dobParts, setDobParts] = useState<{ day: string; month: string; year: string }>({ day: '', month: '', year: '' });
@@ -276,14 +284,47 @@ const PlayersAdminPage: React.FC = () => {
},
});
const maybeSplitName = () => {
setEditing((p) => {
if (!p) return p;
const fn = (p.first_name || '').trim();
const ln = (p.last_name || '').trim();
if (!ln && fn.includes(' ')) {
const parts = fn.split(/\s+/).filter(Boolean);
if (parts.length >= 2) {
return { ...(p as any), first_name: parts[0], last_name: parts[parts.length - 1] } as any;
}
}
return p as any;
});
};
const onSubmit = async () => {
if (!editing) return;
const fn = (editing.first_name || '').trim();
const ln = (editing.last_name || '').trim();
let fn = (editing.first_name || '').trim();
let ln = (editing.last_name || '').trim();
if (!ln && fn.includes(' ')) {
const parts = fn.split(/\s+/).filter(Boolean);
if (parts.length >= 2) {
fn = parts[0];
ln = parts[parts.length - 1];
}
}
if (!fn || !ln) {
toast({ title: 'Jméno a příjmení jsou povinné', status: 'warning' });
return;
}
const tooBig = (
typeof editing.jersey_number === 'number' && Number.isFinite(editing.jersey_number) && editing.jersey_number > JERSEY_MAX
) || (
typeof editing.height === 'number' && Number.isFinite(editing.height) && editing.height > HEIGHT_MAX
) || (
typeof editing.weight === 'number' && Number.isFinite(editing.weight) && editing.weight > WEIGHT_MAX
);
if (tooBig) {
toast({ title: 'Neplatná čísla', description: `Maxima: číslo dresu ${JERSEY_MAX}, výška ${HEIGHT_MAX} cm, váha ${WEIGHT_MAX} kg`, status: 'warning' });
return;
}
// Build payload by including only present values to satisfy backend validation
const payload: any = {
first_name: fn,
@@ -291,10 +332,16 @@ const PlayersAdminPage: React.FC = () => {
};
if (editing.date_of_birth) payload.date_of_birth = editing.date_of_birth;
if (editing.position) payload.position = editing.position;
if (typeof editing.jersey_number === 'number' && Number.isFinite(editing.jersey_number) && editing.jersey_number > 0) payload.jersey_number = editing.jersey_number;
if (typeof editing.jersey_number === 'number' && Number.isFinite(editing.jersey_number) && editing.jersey_number > 0) {
payload.jersey_number = editing.jersey_number;
}
if (editing.nationality) payload.nationality = editing.nationality;
if (typeof editing.height === 'number' && editing.height > 0) payload.height = editing.height;
if (typeof editing.weight === 'number' && editing.weight > 0) payload.weight = editing.weight;
if (typeof editing.height === 'number' && Number.isFinite(editing.height) && editing.height > 0) {
payload.height = editing.height;
}
if (typeof editing.weight === 'number' && Number.isFinite(editing.weight) && editing.weight > 0) {
payload.weight = editing.weight;
}
if (editing.image_url) payload.image_url = editing.image_url;
if (typeof editing.is_active === 'boolean') payload.is_active = editing.is_active;
const email = ((editing as any).email || '').trim();
@@ -373,7 +420,7 @@ const PlayersAdminPage: React.FC = () => {
<SimpleGrid columns={[1, 2]} spacing={4}>
<FormControl isRequired>
<FormLabel>Jméno</FormLabel>
<Input value={editing?.first_name || ''} onChange={(e) => setEditing((p) => ({ ...(p as any), first_name: e.target.value }))} />
<Input value={editing?.first_name || ''} onChange={(e) => setEditing((p) => ({ ...(p as any), first_name: e.target.value }))} onBlur={maybeSplitName} />
</FormControl>
<FormControl isRequired>
<FormLabel>Příjmení</FormLabel>
@@ -414,11 +461,12 @@ const PlayersAdminPage: React.FC = () => {
</Select>
</FormControl>
<FormControl>
<FormControl isInvalid={typeof editing?.jersey_number === 'number' && (editing?.jersey_number as number) > JERSEY_MAX}>
<FormLabel>Číslo dresu</FormLabel>
<NumberInput value={editing?.jersey_number ?? 0} onChange={(_, v) => setEditing((p) => ({ ...(p as any), jersey_number: Number.isFinite(v) ? v : 0 }))}>
<NumberInputField />
<NumberInput min={JERSEY_MIN} max={JERSEY_MAX} keepWithinRange={false} clampValueOnBlur={false} value={typeof editing?.jersey_number === 'number' ? editing?.jersey_number : ''} onChange={(_, v) => setEditing((p) => ({ ...(p as any), jersey_number: Number.isFinite(v) ? v : undefined }))}>
<NumberInputField inputMode="numeric" />
</NumberInput>
<FormErrorMessage>Maximální číslo dresu je {JERSEY_MAX}.</FormErrorMessage>
</FormControl>
<FormControl>
@@ -466,17 +514,19 @@ const PlayersAdminPage: React.FC = () => {
</VStack>
</FormControl>
<FormControl>
<FormControl isInvalid={typeof editing?.height === 'number' && (editing?.height as number) > HEIGHT_MAX}>
<FormLabel>Výška (cm)</FormLabel>
<NumberInput value={editing?.height ?? 0} onChange={(_, v) => setEditing((p) => ({ ...(p as any), height: Number.isFinite(v) ? v : 0 }))}>
<NumberInputField />
<NumberInput min={HEIGHT_MIN} max={HEIGHT_MAX} keepWithinRange={false} clampValueOnBlur={false} value={typeof editing?.height === 'number' ? editing?.height : ''} onChange={(_, v) => setEditing((p) => ({ ...(p as any), height: Number.isFinite(v) ? v : undefined }))}>
<NumberInputField inputMode="numeric" />
</NumberInput>
<FormErrorMessage>Maximální výška je {HEIGHT_MAX} cm.</FormErrorMessage>
</FormControl>
<FormControl>
<FormControl isInvalid={typeof editing?.weight === 'number' && (editing?.weight as number) > WEIGHT_MAX}>
<FormLabel>Váha (kg)</FormLabel>
<NumberInput value={editing?.weight ?? 0} onChange={(_, v) => setEditing((p) => ({ ...(p as any), weight: Number.isFinite(v) ? v : 0 }))}>
<NumberInputField />
<NumberInput min={WEIGHT_MIN} max={WEIGHT_MAX} keepWithinRange={false} clampValueOnBlur={false} value={typeof editing?.weight === 'number' ? editing?.weight : ''} onChange={(_, v) => setEditing((p) => ({ ...(p as any), weight: Number.isFinite(v) ? v : undefined }))}>
<NumberInputField inputMode="numeric" />
</NumberInput>
<FormErrorMessage>Maximální váha je {WEIGHT_MAX} kg.</FormErrorMessage>
</FormControl>
{/* Optional contact info (not shown publicly) */}
<FormControl>
+77 -6
View File
@@ -211,6 +211,65 @@ const PollsAdminPage: React.FC = () => {
onOpen();
};
const applyPreset = (preset: 'rating5' | 'rating10' | 'attendance') => {
if (preset === 'rating5') {
const options = Array.from({ length: 5 }).map((_, i) => ({
text: String(i + 1),
display_order: i + 1,
}));
setFormData({
title: 'Hodnocení zápasu',
description: 'Ohodnoťte zápas (1 = nejhorší, 5 = nejlepší)',
type: 'rating',
status: 'active',
allow_multiple: false,
max_choices: 1,
show_results: 'after_vote',
require_auth: false,
allow_guest_vote: true,
featured: false,
options,
});
} else if (preset === 'rating10') {
const options = Array.from({ length: 10 }).map((_, i) => ({
text: String(i + 1),
display_order: i + 1,
}));
setFormData({
title: 'Hodnocení zápasu (110)',
description: 'Ohodnoťte zápas (1 = nejhorší, 10 = nejlepší)',
type: 'rating',
status: 'active',
allow_multiple: false,
max_choices: 1,
show_results: 'after_vote',
require_auth: false,
allow_guest_vote: true,
featured: false,
options,
});
} else if (preset === 'attendance') {
setFormData({
title: 'Dorazíš na schůzku?',
description: 'Dej nám vědět, zda dorazíš.',
type: 'single',
status: 'active',
allow_multiple: false,
max_choices: 1,
show_results: 'after_vote',
require_auth: false,
allow_guest_vote: true,
featured: false,
options: [
{ text: 'Ano', display_order: 0 },
{ text: 'Ne', display_order: 1 },
{ text: 'Možná', display_order: 2 },
],
});
}
onOpen();
};
const handleOpenEdit = (poll: Poll) => {
setEditingPoll(poll);
setFormData({
@@ -362,9 +421,21 @@ const PollsAdminPage: React.FC = () => {
<VStack spacing={6} align="stretch">
<HStack justify="space-between">
<Heading size="lg">Správa anket</Heading>
<Button leftIcon={<AddIcon />} colorScheme="blue" onClick={handleOpenCreate}>
Nová anketa
</Button>
<HStack>
<Menu>
<MenuButton as={Button} rightIcon={<ChevronDownIcon />} variant="outline">
Předvolby
</MenuButton>
<MenuList>
<MenuItem onClick={() => applyPreset('rating5')}>Hodnocení zápasu (5 hvězd)</MenuItem>
<MenuItem onClick={() => applyPreset('rating10')}>Hodnocení zápasu (110)</MenuItem>
<MenuItem onClick={() => applyPreset('attendance')}>Dorazíš na schůzku?</MenuItem>
</MenuList>
</Menu>
<Button leftIcon={<AddIcon />} colorScheme="blue" onClick={handleOpenCreate}>
Nová anketa
</Button>
</HStack>
</HStack>
<Alert status="info">
@@ -818,7 +889,7 @@ const PollsAdminPage: React.FC = () => {
Výsledky
</Heading>
<VStack spacing={2} align="stretch">
{statsData.poll.options.map((option) => {
{(statsData.poll.options || []).map((option) => {
const percentage =
statsData.poll.total_votes > 0
? (option.vote_count / statsData.poll.total_votes) * 100
@@ -851,13 +922,13 @@ const PollsAdminPage: React.FC = () => {
</VStack>
</Box>
{statsData.votes_by_day.length > 0 && (
{(statsData.votes_by_day?.length ?? 0) > 0 && (
<Box>
<Heading size="sm" mb={4}>
Hlasy podle dnů
</Heading>
<VStack spacing={2} align="stretch">
{statsData.votes_by_day.map((day) => (
{(statsData.votes_by_day || []).map((day) => (
<HStack key={day.date} justify="space-between">
<Text>{new Date(day.date).toLocaleDateString('cs-CZ')}</Text>
<Badge>{day.count} hlasů</Badge>
+12 -21
View File
@@ -51,7 +51,7 @@ import { searchClubs, uploadImage, putTeamLogoOverride, fetchTeamLogoOverrides,
import { getFacrTablesCache } from '../../services/facr/cache';
import { assetUrl } from '../../utils/url';
import { useEffect, useMemo, useRef, useState } from 'react';
import { TeamLogo } from '../../components/common/TeamLogo';
type TableRow = {
rank?: string;
@@ -291,38 +291,26 @@ const TeamsAdminPage = () => {
.map((s) => s.trim())
.filter(Boolean);
// Save override for each variant name so editing one updates all duplicates
await Promise.all(
names.map((n) => putTeamLogoOverride(form.external_team_id, n, logoUrl))
);
// Also upload to logoapi.sportcreative.eu (non-blocking, best-effort)
// Upload to logoapi.sportcreative.eu first (best-effort). If successful, prefer that URL for overrides.
if (logoUrl) {
setExternalUploadStatus('uploading');
setExternalUploadError(null);
try {
let logoFileToUpload: File | Blob | null = uploadedFile;
// If no file was uploaded but we have a logo URL, fetch it as blob
if (!logoFileToUpload && logoUrl) {
logoFileToUpload = await fetchLogoAsBlob(logoUrl);
}
if (logoFileToUpload) {
// Upload to the logo service (loga.sportcreative.eu)
const logaResult = await uploadToLogaSportcreative(
form.external_team_id,
logoFileToUpload,
{
{
filename: `${form.external_team_id}.${logoFileToUpload instanceof File ? logoFileToUpload.name.split('.').pop() : 'png'}`,
clubName: form.team_name || selected?.teamName || 'Neznámý klub'
}
);
if (logaResult.success) {
setExternalUploadStatus('success');
// Use the URL from loga.sportcreative.eu
if (logaResult.url) {
logoUrl = logaResult.url;
}
@@ -339,7 +327,12 @@ const TeamsAdminPage = () => {
setExternalUploadError(error?.message || 'Upload failed');
}
}
// Save override for each variant name so editing one updates all duplicates
await Promise.all(
names.map((n) => putTeamLogoOverride(form.external_team_id, n, logoUrl))
);
return true;
},
onSuccess: () => {
@@ -495,12 +488,10 @@ const TeamsAdminPage = () => {
<Td py={1.5} fontSize="xs">{r.rank}</Td>
<Td py={1.5}>
<HStack spacing={2} align="center">
<TeamLogo
teamId={(r as any).team_id}
teamName={r.team}
facrLogo={r.team_logo_url}
size="small"
<Image
src={getLogo(r.team, (r as any).team_id, r.team_logo_url)}
alt={r.team}
boxSize="24px"
objectFit="contain"
/>
<Text fontSize="xs" noOfLines={1}>{r.team}</Text>
+5 -4
View File
@@ -49,7 +49,7 @@ interface User {
id: string;
email: string;
name: string;
role: 'admin' | 'editor';
role: 'admin' | 'editor' | 'fan';
isActive: boolean;
createdAt: string;
}
@@ -65,7 +65,7 @@ const UsersAdminPage = () => {
email: '',
password: '',
currentPassword: '',
role: 'editor' as 'admin' | 'editor',
role: 'editor' as 'admin' | 'editor' | 'fan',
isActive: true,
});
const toast = useToast();
@@ -254,8 +254,8 @@ const UsersAdminPage = () => {
<Td>{user.name}</Td>
<Td>{user.email}</Td>
<Td>
<Badge colorScheme={user.role === 'admin' ? 'purple' : 'blue'}>
{user.role === 'admin' ? 'Admin' : 'Editor'}
<Badge colorScheme={user.role === 'admin' ? 'purple' : (user.role === 'editor' ? 'blue' : 'gray')}>
{user.role === 'admin' ? 'Admin' : user.role === 'editor' ? 'Editor' : 'Fan'}
</Badge>
</Td>
<Td>
@@ -385,6 +385,7 @@ const UsersAdminPage = () => {
value={formData.role}
onChange={handleInputChange}
>
<option value="fan">Fan</option>
<option value="editor" disabled={!!selectedUser && selectedUser.role === 'admin'}>Editor</option>
<option value="admin">Admin</option>
</Select>
+24
View File
@@ -28,6 +28,30 @@ export async function generateBlogAI(payload: AIGenerateBlogReq): Promise<AIGene
return parsedData;
}
export interface AIGenerateCSSReq {
prompt: string;
element_name?: string;
root_selector?: string;
current_css?: string;
current_styles?: Record<string, any>;
theme?: Record<string, string>;
breakpoints?: number[];
context?: Record<string, any>;
}
export interface AIGenerateCSSResp {
css: string;
}
export async function generateCSSAI(payload: AIGenerateCSSReq): Promise<AIGenerateCSSResp> {
const { data } = await api.post<AIGenerateCSSResp>('/ai/css/generate', payload);
let parsed = data as any;
if (typeof parsed === 'string') {
try { parsed = JSON.parse(parsed); } catch { parsed = { css: '' }; }
}
return parsed as AIGenerateCSSResp;
}
export interface AIGenerateAboutReq {
prompt: string;
club_name?: string;
+8
View File
@@ -122,6 +122,7 @@ export const PREDEFINED_ELEMENTS: PredefinedElement[] = [
// Content - Obsah
{ name: 'news', label: 'Novinky', description: 'Nejnovější články a zprávy', icon: FaNewspaper, category: 'content', defaultVariant: 'grid' },
{ name: 'matches', label: 'Zápasy', description: 'Nadcházející a poslední zápasy', icon: FaFutbol, category: 'content', defaultVariant: 'compact' },
{ name: 'matches-slider', label: 'Zápasy (slider)', description: 'Přehled zápasů podle soutěže ve slideru', icon: FaFutbol, category: 'content', defaultVariant: 'carousel' },
{ name: 'team', label: 'Tým', description: 'Hráči a realizační tým', icon: FaUsers, category: 'content', defaultVariant: 'grid' },
{ name: 'table', label: 'Tabulka', description: 'Ligová tabulka', icon: FaTable, category: 'content', defaultVariant: 'split_news' },
{ name: 'stats', label: 'Statistiky', description: 'Týmové a hráčské statistiky', icon: FaChartLine, category: 'content', defaultVariant: 'cards' },
@@ -161,6 +162,8 @@ export const ELEMENT_VARIANTS: Record<string, ElementVariant[]> = {
{ value: 'sticky', label: 'Přilepený', description: 'Pevně přilepená hlavička při scrollování' },
{ value: 'transparent', label: 'Průhledný', description: 'Průhledná hlavička s efektem' },
{ value: 'sparta_navbar', label: 'Sparta Navbar', description: 'AC Sparta Praha styl - burger menu, logo, navigace, vyhledávání' },
{ value: 'current', label: 'Současný', description: 'Stávající navigace' },
{ value: 'fullwidth', label: 'Šířka 100%', description: 'Navigace přes celou šířku obrazovky' },
],
hero: [
{ value: 'grid', label: 'Mřížka', description: 'Rozložení ve formě mřížky' },
@@ -192,6 +195,11 @@ export const ELEMENT_VARIANTS: Record<string, ElementVariant[]> = {
{ value: 'scoreboard', label: 'Tabule', description: 'TV broadcast style - velká tabule skóre s live aktualizacemi' },
{ value: 'ticker', label: 'Ticker', description: 'Scrollující ticker s výsledky a nadcházejícími zápasy' },
],
'matches-slider': [
{ value: 'carousel', label: 'Karusel', description: 'Horizontální karusel zápasů' },
{ value: 'scroller', label: 'Posuvník', description: 'Plynulý horizontální posuvník' },
{ value: 'ticker', label: 'Ticker', description: 'Úzký ticker výsledků a zápasů' },
],
sponsors: [
{ value: 'grid', label: 'Mřížka', description: 'Mřížkové rozložení' },
{ value: 'slider', label: 'Posuvník', description: 'Animovaný posuvník' },
+2
View File
@@ -70,6 +70,8 @@ export interface PollOption {
export interface PollVoteRequest {
option_ids: number[];
session_token?: string;
voter_name?: string;
voter_email?: string;
}
export interface PollResult {
@@ -96,3 +96,9 @@ export async function unsubscribeToken(token: string): Promise<{ message: string
const { data } = await api.post<{ message: string }>(`/newsletter/unsubscribe-token`, { token });
return data;
}
// Fetch a short-lived newsletter preferences token for the currently authenticated user
export async function getMyNewsletterToken(): Promise<{ token: string }> {
const { data } = await api.get<{ token: string }>(`/newsletter/token/me`);
return data;
}
+14 -3
View File
@@ -1,11 +1,22 @@
const { createProxyMiddleware } = require('http-proxy-middleware');
function resolveBackendOrigin() {
const raw = process.env.REACT_APP_API_BASE_URL || process.env.REACT_APP_API_URL || 'http://localhost:8080/api/v1';
try {
const u = new URL(raw);
u.pathname = '/';
return u.toString();
} catch (e) {
return 'http://localhost:8080';
}
}
module.exports = function(app) {
// Proxy /uploads requests to backend
app.use(
'/uploads',
createProxyMiddleware({
target: process.env.REACT_APP_API_BASE_URL || 'http://localhost:8080',
target: resolveBackendOrigin(),
changeOrigin: true,
logLevel: 'debug',
onError: (err, req, res) => {
@@ -18,7 +29,7 @@ module.exports = function(app) {
app.use(
'/static',
createProxyMiddleware({
target: process.env.REACT_APP_API_BASE_URL || 'http://localhost:8080',
target: resolveBackendOrigin(),
changeOrigin: true,
logLevel: 'debug',
onError: (err, req, res) => {
@@ -31,7 +42,7 @@ module.exports = function(app) {
app.use(
'/cache',
createProxyMiddleware({
target: process.env.REACT_APP_API_BASE_URL || 'http://localhost:8080',
target: resolveBackendOrigin(),
changeOrigin: true,
logLevel: 'debug',
onError: (err, req, res) => {
+2
View File
@@ -11,6 +11,7 @@ export interface MapCoordinates {
source: 'mapy.cz' | 'google-maps' | 'unknown';
// Detailed address components from reverse geocoding
street?: string;
houseNumber?: string;
city?: string;
zip?: string;
country?: string;
@@ -210,6 +211,7 @@ export async function reverseGeocode(lat: number, lng: number): Promise<Partial<
return {
address: data.display_name,
street: addr.road || addr.street || addr.pedestrian || addr.footway,
houseNumber: addr.house_number,
city: addr.city || addr.town || addr.village || addr.municipality,
zip: addr.postcode,
country: addr.country || 'Česká republika',
+2 -7
View File
@@ -216,7 +216,7 @@ export const fetchLogoFromLogoAPI = async (teamId: string, teamName?: string): P
try {
// Check cache first
const cached = await getCachedLogo(teamId);
if (cached?.url) {
if (cached?.url && !cached.url.startsWith('blob:')) {
await updateLastUsed(teamId);
return cached.url;
}
@@ -230,15 +230,10 @@ export const fetchLogoFromLogoAPI = async (teamId: string, teamName?: string): P
if (!res.ok) return null;
const data = await res.json();
let url = data.logo_url_svg || data.logo_url_png || data.logo_url;
const url = data.logo_url_svg || data.logo_url_png || data.logo_url;
if (!url) return null;
// Optimize SVG if it's an SVG
if (url.includes('.svg') || data.logo_url_svg) {
url = await optimizeSVG(url);
}
// Cache the logo
await saveCachedLogo({
id: teamId,