mirror of
https://github.com/Dvorinka/MyClubServer.git
synced 2026-06-04 02:32:57 +00:00
dev day #69
This commit is contained in:
+51
-4
@@ -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>
|
||||
|
||||
@@ -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 e‑mailové 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}>E‑mailové 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}
|
||||
/>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
@@ -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' }}
|
||||
|
||||
@@ -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 e‑mail 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 e‑mailem.
|
||||
</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 e‑mailu. Heslo lze změnit přes stránku pro obnovení hesla.
|
||||
</Text>
|
||||
</VStack>
|
||||
</Box>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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: {},
|
||||
},
|
||||
];
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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
@@ -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 e‑shopu</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
|
||||
|
||||
@@ -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>E‑mail</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">
|
||||
Už máte účet?{' '}
|
||||
<Link color="blue.500" href="/login">
|
||||
Přihlaste se
|
||||
</Link>
|
||||
</Text>
|
||||
</VStack>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default RegisterPage;
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 (1–10)',
|
||||
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 (1–10)</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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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' },
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user