mirror of
https://github.com/Dvorinka/MyClubServer.git
synced 2026-06-04 10:42:57 +00:00
dev day #77
This commit is contained in:
@@ -287,7 +287,7 @@ const MobileMenu = ({ isOpen, onClose, isAdmin, isAuthenticated, menuBg, divider
|
||||
</Drawer>
|
||||
);
|
||||
|
||||
const Navbar: React.FC<{ fullWidth?: boolean }> = ({ fullWidth = false }) => {
|
||||
const Navbar: React.FC<{ fullWidth?: boolean; variant?: string }> = ({ fullWidth = false, variant = 'unified' }) => {
|
||||
const { colorMode, toggleColorMode } = useColorMode();
|
||||
const { isAuthenticated, logout, user } = useAuth();
|
||||
const { isOpen, onOpen, onClose } = useDisclosure();
|
||||
@@ -726,6 +726,17 @@ const Navbar: React.FC<{ fullWidth?: boolean }> = ({ fullWidth = false }) => {
|
||||
return out;
|
||||
}, [navSplit]);
|
||||
|
||||
// Variant-driven styles for main nav bar
|
||||
const headerVariant = variant || 'unified';
|
||||
const navBgDefault = useColorModeValue('rgba(255,255,255,0.9)', 'rgba(15,17,21,0.85)');
|
||||
const navBgMinimal = useColorModeValue('white', '#0f1115');
|
||||
const isTransparent = headerVariant === 'transparent';
|
||||
const isMinimal = headerVariant === 'minimal';
|
||||
const navBg = isTransparent ? 'transparent' : (isMinimal ? navBgMinimal : navBgDefault);
|
||||
const navBackdrop = (isTransparent || isMinimal) ? 'none' : 'saturate(180%) blur(10px)';
|
||||
const navBorderBottomWidth = isTransparent ? '0px' : '1px';
|
||||
const navBoxShadow = (isTransparent || isMinimal) ? 'none' : (scrolled ? 'sm' : 'none');
|
||||
|
||||
return (
|
||||
<Box position="sticky" top={0} zIndex={1000}>
|
||||
{/* Top bar with socials and quick external links */}
|
||||
@@ -758,11 +769,11 @@ const Navbar: React.FC<{ fullWidth?: boolean }> = ({ fullWidth = false }) => {
|
||||
|
||||
{/* Main Nav Bar */}
|
||||
<Box
|
||||
bg={useColorModeValue('rgba(255,255,255,0.9)', 'rgba(15,17,21,0.85)')}
|
||||
backdropFilter="saturate(180%) blur(10px)"
|
||||
borderBottomWidth="1px"
|
||||
bg={navBg}
|
||||
backdropFilter={navBackdrop}
|
||||
borderBottomWidth={navBorderBottomWidth}
|
||||
borderColor="border.subtle"
|
||||
boxShadow={scrolled ? 'sm' : 'none'}
|
||||
boxShadow={navBoxShadow}
|
||||
transition="box-shadow 0.2s ease, background-color 0.2s ease, backdrop-filter 0.2s ease"
|
||||
>
|
||||
<MobileMenu isOpen={isOpen} onClose={onClose} isAdmin={isAdmin} isAuthenticated={isAuthenticated} 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={filteredDynamicNavItems} navLoading={navLoading} />
|
||||
|
||||
@@ -0,0 +1,199 @@
|
||||
import React from 'react';
|
||||
import { IconButton, Button, useDisclosure, Modal, ModalOverlay, ModalContent, ModalHeader, ModalBody, ModalFooter, Textarea, useToast, Tooltip, Box } from '@chakra-ui/react';
|
||||
import { Share2 } from 'lucide-react';
|
||||
import { useAuth } from '../../contexts/AuthContext';
|
||||
import { createShortLink } from '../../services/shortlinks';
|
||||
import { Article, getArticleMatchLink } from '../../services/articles';
|
||||
import { API_URL } from '../../services/api';
|
||||
import { composeInstagramPostFromArticle, composeInstagramPostFromActivity, MatchSnapshot } from '../../services/instagram';
|
||||
import { usePublicSettings } from '../../hooks/usePublicSettings';
|
||||
|
||||
interface Props {
|
||||
article?: Article;
|
||||
activity?: any;
|
||||
match?: MatchSnapshot | null;
|
||||
targetUrl?: string;
|
||||
size?: 'sm' | 'md' | 'lg';
|
||||
placement?: 'fixed' | 'inline';
|
||||
mr?: number | string;
|
||||
mb?: number | string;
|
||||
zIndex?: number;
|
||||
variant?: 'button' | 'icon';
|
||||
onGenerated?: (text: string, shortUrl: string) => void;
|
||||
}
|
||||
|
||||
const InstagramGeneratorButton: React.FC<Props> = ({
|
||||
article,
|
||||
activity,
|
||||
match,
|
||||
targetUrl,
|
||||
size = 'md',
|
||||
placement = 'fixed',
|
||||
mr = 6,
|
||||
mb = 6,
|
||||
zIndex = 40,
|
||||
variant = 'icon',
|
||||
onGenerated,
|
||||
}) => {
|
||||
const { user } = useAuth();
|
||||
const role = String(user?.role || '').toLowerCase();
|
||||
const isAdmin = role === 'admin';
|
||||
const { data: publicSettings } = usePublicSettings();
|
||||
const toast = useToast();
|
||||
const { isOpen, onOpen, onClose } = useDisclosure();
|
||||
const [text, setText] = React.useState('');
|
||||
const [shortUrl, setShortUrl] = React.useState('');
|
||||
const [loading, setLoading] = React.useState(false);
|
||||
|
||||
if (!isAdmin) return null;
|
||||
|
||||
const computeTarget = () => {
|
||||
if (targetUrl) return targetUrl;
|
||||
if (typeof window !== 'undefined') return window.location.href;
|
||||
return '';
|
||||
};
|
||||
|
||||
const withUtm = (urlStr: string) => {
|
||||
try {
|
||||
const u = new URL(urlStr, typeof window !== 'undefined' ? window.location.origin : 'http://localhost');
|
||||
if (!u.searchParams.get('utm_source')) u.searchParams.set('utm_source', 'instagram');
|
||||
if (!u.searchParams.get('utm_medium')) u.searchParams.set('utm_medium', 'social');
|
||||
const campaignBase = article ? `article-${article.id}` : (activity ? `activity-${activity.id}` : 'share');
|
||||
if (!u.searchParams.get('utm_campaign')) u.searchParams.set('utm_campaign', campaignBase);
|
||||
return u.toString();
|
||||
} catch {
|
||||
return urlStr;
|
||||
}
|
||||
};
|
||||
|
||||
const handleGenerate = async (e?: React.MouseEvent) => {
|
||||
if (e) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}
|
||||
try {
|
||||
setLoading(true);
|
||||
const fullUrl = withUtm(computeTarget());
|
||||
if (!fullUrl) throw new Error('Nelze zjistit URL článku/aktivity');
|
||||
|
||||
const payload = {
|
||||
target_url: fullUrl,
|
||||
title: article?.title || activity?.title || 'Link',
|
||||
source_type: article ? 'article' : (activity ? 'event' : 'other'),
|
||||
source_id: article?.id || activity?.id,
|
||||
} as any;
|
||||
const res = await createShortLink(payload);
|
||||
const sUrl = res?.short_url || '';
|
||||
setShortUrl(sUrl || fullUrl);
|
||||
|
||||
const clubName = publicSettings?.club_name || undefined;
|
||||
let composed = '';
|
||||
if (article) {
|
||||
let resolvedMatch = match || null;
|
||||
if (!resolvedMatch && article?.id) {
|
||||
try {
|
||||
const link = await getArticleMatchLink(article.id);
|
||||
const extId = (link as any)?.external_match_id;
|
||||
if (extId) {
|
||||
try {
|
||||
const origin = new URL(API_URL, window.location.origin).origin;
|
||||
const resp = await fetch(`${origin}/cache/prefetch/facr_club_info.json`, { cache: 'no-cache' });
|
||||
if (resp.ok) {
|
||||
const json = await resp.json();
|
||||
const comps = Array.isArray(json?.competitions) ? json.competitions : [];
|
||||
let snapshot: MatchSnapshot | null = null;
|
||||
for (const c of comps) {
|
||||
const matches = Array.isArray(c.matches) ? c.matches : [];
|
||||
for (const m of matches) {
|
||||
const mid = String(m.match_id || m.id);
|
||||
if (mid === String(extId)) {
|
||||
let score = '';
|
||||
if (m?.score && m.score !== 'vs') score = String(m.score);
|
||||
else if (m?.result_home != null && m?.result_away != null) score = `${m.result_home}:${m.result_away}`;
|
||||
snapshot = {
|
||||
external_match_id: String(extId),
|
||||
competition: String(c.name || ''),
|
||||
date_time: String(m.date_time || m.date || ''),
|
||||
venue: m.venue ? String(m.venue) : undefined,
|
||||
home: String(m.home || m.home_team || ''),
|
||||
away: String(m.away || m.away_team || ''),
|
||||
score,
|
||||
};
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (snapshot) break;
|
||||
}
|
||||
if (snapshot) resolvedMatch = snapshot;
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
composed = composeInstagramPostFromArticle({ article, trackingUrl: sUrl || fullUrl, clubName, match: resolvedMatch });
|
||||
} else if (activity) {
|
||||
composed = composeInstagramPostFromActivity({ activity, trackingUrl: sUrl || fullUrl, clubName });
|
||||
} else {
|
||||
composed = `${clubName || 'Náš klub'}\n\n🔗 ${sUrl || fullUrl}`;
|
||||
}
|
||||
setText(composed);
|
||||
onGenerated?.(composed, sUrl || fullUrl);
|
||||
onOpen();
|
||||
} catch (err: any) {
|
||||
toast({ status: 'error', title: 'Nelze vygenerovat příspěvek', description: err?.message || 'Zkuste to prosím znovu.' });
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCopy = async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
toast({ status: 'success', title: 'Zkopírováno', description: 'Text příspěvku byl zkopírován do schránky.' });
|
||||
} catch {
|
||||
toast({ status: 'warning', title: 'Nelze kopírovat', description: 'Zkopírujte prosím ručně.' });
|
||||
}
|
||||
};
|
||||
|
||||
const ButtonEl = (
|
||||
<Tooltip label="Vygenerovat Instagram příspěvek" placement="left">
|
||||
{variant === 'icon' ? (
|
||||
<IconButton aria-label="IG post" icon={<Share2 size={18} />} colorScheme="brand" onClick={handleGenerate} isLoading={loading} size={size} />
|
||||
) : (
|
||||
<Button leftIcon={<Share2 size={18} />} colorScheme="brand" onClick={handleGenerate} isLoading={loading} size={size}>
|
||||
Instagram post
|
||||
</Button>
|
||||
)}
|
||||
</Tooltip>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{placement === 'fixed' ? (
|
||||
<Box position="fixed" right={mr} bottom={mb} zIndex={zIndex}>
|
||||
{ButtonEl}
|
||||
</Box>
|
||||
) : (
|
||||
<Box position="absolute" top={2} right={2} zIndex={zIndex}>
|
||||
{ButtonEl}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<Modal isOpen={isOpen} onClose={onClose} size="xl">
|
||||
<ModalOverlay />
|
||||
<ModalContent>
|
||||
<ModalHeader>Instagram post</ModalHeader>
|
||||
<ModalBody>
|
||||
<Textarea value={text} onChange={(e) => setText(e.target.value)} rows={12} fontFamily="mono" />
|
||||
</ModalBody>
|
||||
<ModalFooter gap={3}>
|
||||
<Button variant="outline" onClick={handleCopy}>Kopírovat</Button>
|
||||
<Button onClick={onClose}>Zavřít</Button>
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default InstagramGeneratorButton;
|
||||
@@ -415,6 +415,71 @@ const PollLinker: React.FC<PollLinkerProps> = ({ articleId, eventId, onPollsChan
|
||||
{/* Tab 2: Create new poll */}
|
||||
<TabPanel px={0} py={3}>
|
||||
<VStack spacing={3} align="stretch">
|
||||
<HStack spacing={2} flexWrap="wrap">
|
||||
<Button size="xs" onClick={() => setNewPollData(prev => ({
|
||||
...prev,
|
||||
title: 'Hodnocení zápasu',
|
||||
description: 'Ohodnoťte zápas (1 = nejhorší, 5 = nejlepší)',
|
||||
type: 'rating',
|
||||
style: 'rating-stars',
|
||||
allow_multiple: false,
|
||||
max_choices: 1,
|
||||
options: Array.from({ length: 5 }).map((_, i) => ({ text: String(i+1), display_order: i+1 }))
|
||||
}))}>⭐ 5</Button>
|
||||
<Button size="xs" onClick={() => setNewPollData(prev => ({
|
||||
...prev,
|
||||
title: 'Hodnocení (1–10)',
|
||||
description: 'Ohodnoťte (1 = nejhorší, 10 = nejlepší)',
|
||||
type: 'rating',
|
||||
style: 'rating-scale',
|
||||
allow_multiple: false,
|
||||
max_choices: 1,
|
||||
options: Array.from({ length: 10 }).map((_, i) => ({ text: String(i+1), display_order: i+1 }))
|
||||
}))}>1–10</Button>
|
||||
<Button size="xs" onClick={() => setNewPollData(prev => ({
|
||||
...prev,
|
||||
title: 'Docházka',
|
||||
description: 'Dej vědět, zda dorazíš.',
|
||||
type: 'single',
|
||||
style: 'choices-chips',
|
||||
allow_multiple: false,
|
||||
max_choices: 1,
|
||||
options: [
|
||||
{ text: 'Ano', display_order: 0 },
|
||||
{ text: 'Ne', display_order: 1 },
|
||||
{ text: 'Možná', display_order: 2 },
|
||||
]
|
||||
}))}>Docházka</Button>
|
||||
<Button size="xs" onClick={() => setNewPollData(prev => ({
|
||||
...prev,
|
||||
title: 'Docházka (více možností)',
|
||||
description: 'Vyberte jednu nebo dvě možnosti.',
|
||||
type: 'multiple',
|
||||
style: 'choices-cards',
|
||||
allow_multiple: true,
|
||||
max_choices: 2,
|
||||
options: [
|
||||
{ text: 'Ano', display_order: 0 },
|
||||
{ text: 'Pozdě dorazím', display_order: 1 },
|
||||
{ text: 'Ne', display_order: 2 },
|
||||
]
|
||||
}))}>Docházka (multi)</Button>
|
||||
<Button size="xs" onClick={() => setNewPollData(prev => ({
|
||||
...prev,
|
||||
title: 'Výběr možností',
|
||||
description: 'Vyber až tři možnosti.',
|
||||
type: 'multiple',
|
||||
style: 'choices-list',
|
||||
allow_multiple: true,
|
||||
max_choices: 3,
|
||||
options: [
|
||||
{ text: 'A', display_order: 0 },
|
||||
{ text: 'B', display_order: 1 },
|
||||
{ text: 'C', display_order: 2 },
|
||||
{ text: 'D', display_order: 3 },
|
||||
]
|
||||
}))}>Multi (3)</Button>
|
||||
</HStack>
|
||||
<FormControl isRequired>
|
||||
<FormLabel fontSize="sm">Název ankety</FormLabel>
|
||||
<Input
|
||||
@@ -503,42 +568,6 @@ const PollLinker: React.FC<PollLinkerProps> = ({ articleId, eventId, onPollsChan
|
||||
>
|
||||
Přidat možnost
|
||||
</Button>
|
||||
<HStack>
|
||||
<Button size="xs" onClick={() => setNewPollData(prev => ({
|
||||
...prev,
|
||||
title: 'Hodnocení zápasu',
|
||||
description: 'Ohodnoťte zápas (1 = nejhorší, 5 = nejlepší)',
|
||||
type: 'rating',
|
||||
style: 'rating-stars',
|
||||
allow_multiple: false,
|
||||
max_choices: 1,
|
||||
options: Array.from({ length: 5 }).map((_, i) => ({ text: String(i+1), display_order: i+1 }))
|
||||
}))}>⭐ 5</Button>
|
||||
<Button size="xs" onClick={() => setNewPollData(prev => ({
|
||||
...prev,
|
||||
title: 'Hodnocení (1–10)',
|
||||
description: 'Ohodnoťte (1 = nejhorší, 10 = nejlepší)',
|
||||
type: 'rating',
|
||||
style: 'rating-scale',
|
||||
allow_multiple: false,
|
||||
max_choices: 1,
|
||||
options: Array.from({ length: 10 }).map((_, i) => ({ text: String(i+1), display_order: i+1 }))
|
||||
}))}>1–10</Button>
|
||||
<Button size="xs" onClick={() => setNewPollData(prev => ({
|
||||
...prev,
|
||||
title: 'Docházka',
|
||||
description: 'Dej vědět, zda dorazíš.',
|
||||
type: 'single',
|
||||
style: 'choices-chips',
|
||||
allow_multiple: false,
|
||||
max_choices: 1,
|
||||
options: [
|
||||
{ text: 'Ano', display_order: 0 },
|
||||
{ text: 'Ne', display_order: 1 },
|
||||
{ text: 'Možná', display_order: 2 },
|
||||
]
|
||||
}))}>Docházka</Button>
|
||||
</HStack>
|
||||
</VStack>
|
||||
</FormControl>
|
||||
|
||||
|
||||
@@ -105,11 +105,11 @@ import { DEFAULT_HOMEPAGE_ELEMENTS, HOMEPAGE_IMPLEMENTED_ELEMENTS } from '../../
|
||||
|
||||
const SUPPORTED_HOME_VARIANTS: Record<string, string[]> = {
|
||||
hero: ['grid', 'scroller', 'swiper', 'swiper_full'],
|
||||
news: ['grid_one', 'grid_two', 'grid', 'scroller'],
|
||||
news: ['grid_one', 'grid_two', 'grid', 'list', 'scroller'],
|
||||
matches: ['compact'],
|
||||
sponsors: ['grid', 'slider', 'scroller', 'pyramid'],
|
||||
gallery: ['grid'],
|
||||
videos: ['grid'],
|
||||
videos: ['grid', 'carousel'],
|
||||
merch: ['grid'],
|
||||
table: ['split_news'],
|
||||
banner: ['top'],
|
||||
@@ -426,11 +426,16 @@ const MyUIbrixStyleEditor: React.FC<MyUIbrixStyleEditorProps> = ({ pageType, onC
|
||||
...cfg,
|
||||
variant: normalizeVariant(cfg.element_name, cfg.variant)
|
||||
}));
|
||||
// Load saved custom CSS from settings
|
||||
// Load saved styles and custom CSS from settings
|
||||
const cssByElement: Record<string, string> = {};
|
||||
const stylesByElement: Record<string, Record<string, any>> = {};
|
||||
sanitizedConfigs.forEach(cfg => {
|
||||
const css = (cfg.settings && (cfg.settings as any).customCSS) || '';
|
||||
if (css) cssByElement[cfg.element_name] = String(css);
|
||||
const css = String(((cfg.settings as any)?.customCSS) || ((cfg.settings as any)?.styles?.customCSS) || '');
|
||||
if (css) cssByElement[cfg.element_name] = css;
|
||||
const st = (cfg.settings && (cfg.settings as any).styles) || {};
|
||||
if (st && typeof st === 'object' && Object.keys(st).length > 0) {
|
||||
stylesByElement[cfg.element_name] = st as Record<string, any>;
|
||||
}
|
||||
});
|
||||
setConfigs(sanitizedConfigs);
|
||||
const changes: Record<string, string> = {};
|
||||
@@ -448,16 +453,19 @@ 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) {
|
||||
// Prime style state with saved styles + custom CSS
|
||||
if (Object.keys(stylesByElement).length > 0 || Object.keys(cssByElement).length > 0) {
|
||||
setElementStyles(prev => {
|
||||
const next = { ...prev } as Record<string, any>;
|
||||
Object.entries(stylesByElement).forEach(([name, st]) => {
|
||||
next[name] = { ...(next[name] || {}), ...st };
|
||||
});
|
||||
Object.entries(cssByElement).forEach(([name, css]) => {
|
||||
next[name] = { ...(next[name] || {}), customCSS: css };
|
||||
});
|
||||
return next;
|
||||
});
|
||||
// Inject saved CSS for preview (admin only)
|
||||
// Inject saved custom CSS for preview (admin only)
|
||||
Object.entries(cssByElement).forEach(([name, css]) => {
|
||||
try {
|
||||
const styleId = `custom-css-${name}`;
|
||||
|
||||
@@ -7,6 +7,7 @@ import { Link as RouterLink } from 'react-router-dom';
|
||||
import { useClubTheme } from '../../contexts/ClubThemeContext';
|
||||
import { assetUrl } from '../../utils/url';
|
||||
import { Eye, Clock } from 'lucide-react';
|
||||
import InstagramGeneratorButton from '../admin/InstagramGeneratorButton';
|
||||
|
||||
const Card: React.FC<{ a: Article }> = ({ a }) => {
|
||||
const cardBg = useColorModeValue('white', 'gray.800');
|
||||
@@ -90,6 +91,14 @@ const Card: React.FC<{ a: Article }> = ({ a }) => {
|
||||
)}
|
||||
</HStack>
|
||||
</VStack>
|
||||
<Box position="absolute" top={2} right={2} zIndex={2}>
|
||||
<InstagramGeneratorButton
|
||||
article={a as any}
|
||||
targetUrl={typeof window !== 'undefined' ? new URL(link, window.location.origin).toString() : undefined}
|
||||
placement="inline"
|
||||
size="sm"
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useQuery } from '@tanstack/react-query';
|
||||
import { getArticles, Article } from '../../services/articles';
|
||||
import { Link as RouterLink } from 'react-router-dom';
|
||||
import { assetUrl } from '../../utils/url';
|
||||
import InstagramGeneratorButton from '../admin/InstagramGeneratorButton';
|
||||
|
||||
const BlogCard: React.FC<{ article: Article }> = ({ article }) => {
|
||||
const link = article.slug ? `/news/${article.slug}` : `/articles/${article.id}`;
|
||||
@@ -24,6 +25,7 @@ const BlogCard: React.FC<{ article: Article }> = ({ article }) => {
|
||||
borderColor={border}
|
||||
_hover={{ boxShadow: '2xl', transform: 'translateY(-4px)' }}
|
||||
transition="all 0.3s cubic-bezier(0.4, 0, 0.2, 1)"
|
||||
position="relative"
|
||||
>
|
||||
<Box position="relative" overflow="hidden">
|
||||
<Image
|
||||
@@ -70,6 +72,14 @@ const BlogCard: React.FC<{ article: Article }> = ({ article }) => {
|
||||
)}
|
||||
</HStack>
|
||||
</VStack>
|
||||
<Box position="absolute" top={2} right={2} zIndex={2}>
|
||||
<InstagramGeneratorButton
|
||||
article={article as any}
|
||||
targetUrl={typeof window !== 'undefined' ? new URL(link, window.location.origin).toString() : undefined}
|
||||
placement="inline"
|
||||
size="sm"
|
||||
/>
|
||||
</Box>
|
||||
</VStack>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -5,6 +5,7 @@ import { Link as RouterLink } from 'react-router-dom';
|
||||
import { assetUrl } from '../../utils/url';
|
||||
import { useClubTheme } from '../../contexts/ClubThemeContext';
|
||||
import { Eye, Clock } from 'lucide-react';
|
||||
import InstagramGeneratorButton from '../admin/InstagramGeneratorButton';
|
||||
|
||||
const FeaturedBlog: React.FC = () => {
|
||||
const { data, isLoading } = useQuery({
|
||||
@@ -51,13 +52,21 @@ const FeaturedBlog: React.FC = () => {
|
||||
<Text fontSize="xs" bg={theme.secondary} color="black" px={2} py={0.5} borderRadius="md" w="fit-content">Novinka</Text>
|
||||
<Heading size="md">{main.title}</Heading>
|
||||
</VStack>
|
||||
<Box position="absolute" top={3} right={3} zIndex={2}>
|
||||
<InstagramGeneratorButton
|
||||
article={main as any}
|
||||
targetUrl={typeof window !== 'undefined' ? new URL(main.slug ? `/news/${main.slug}` : `/articles/${main.id}`, window.location.origin).toString() : undefined}
|
||||
placement="inline"
|
||||
size="sm"
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
</GridItem>
|
||||
<GridItem>
|
||||
<VStack spacing={4} align="stretch">
|
||||
{[side1, side2].filter(Boolean).map((a) => (
|
||||
<HStack key={(a as Article).id} align="stretch" spacing={3} as={RouterLink} to={(a as Article).slug ? `/news/${(a as Article).slug}` : `/articles/${(a as Article).id}`}>
|
||||
<HStack key={(a as Article).id} align="stretch" spacing={3} as={RouterLink} to={(a as Article).slug ? `/news/${(a as Article).slug}` : `/articles/${(a as Article).id}`} position="relative">
|
||||
<Image src={assetUrl((a as Article).image_url) || '/logo192.png'} alt={(a as Article).title} w="40%" h="120px" objectFit="cover" borderRadius="lg" />
|
||||
<VStack align="stretch" spacing={2} flex={1}>
|
||||
<HStack spacing={2} flexWrap="wrap">
|
||||
@@ -80,6 +89,14 @@ const FeaturedBlog: React.FC = () => {
|
||||
</HStack>
|
||||
<Heading size="sm" noOfLines={3}>{(a as Article).title}</Heading>
|
||||
</VStack>
|
||||
<Box position="absolute" top={2} right={2} zIndex={2}>
|
||||
<InstagramGeneratorButton
|
||||
article={a as any}
|
||||
targetUrl={typeof window !== 'undefined' ? new URL((a as Article).slug ? `/news/${(a as Article).slug}` : `/articles/${(a as Article).id}`, window.location.origin).toString() : undefined}
|
||||
placement="inline"
|
||||
size="sm"
|
||||
/>
|
||||
</Box>
|
||||
</HStack>
|
||||
))}
|
||||
</VStack>
|
||||
|
||||
@@ -19,7 +19,7 @@ const TeamScroller: React.FC = () => {
|
||||
<Text fontSize="sm" color={useColorModeValue('gray.600', 'gray.400')}>{p.position}</Text>
|
||||
{p.date_of_birth ? (
|
||||
<Text fontSize="sm" color={useColorModeValue('gray.600', 'gray.400')}>
|
||||
Věk: {calculateAge(p.date_of_birth)} let
|
||||
Věk: {(() => { const a = calculateAge(p.date_of_birth); return a != null ? `${a} ${czYears(a)}` : '' })()}
|
||||
</Text>
|
||||
) : null}
|
||||
</VStack>
|
||||
@@ -43,4 +43,14 @@ function calculateAge(dob: string): number | null {
|
||||
}
|
||||
}
|
||||
|
||||
// Czech pluralization for years
|
||||
function czYears(n: number): string {
|
||||
const mod100 = n % 100;
|
||||
if (mod100 >= 11 && mod100 <= 14) return 'let';
|
||||
const mod10 = n % 10;
|
||||
if (mod10 === 1) return 'rok';
|
||||
if (mod10 >= 2 && mod10 <= 4) return 'roky';
|
||||
return 'let';
|
||||
}
|
||||
|
||||
export default TeamScroller;
|
||||
|
||||
@@ -8,8 +8,8 @@ import { getCachedYouTube, YouTubeVideo } from '../../services/youtube';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
|
||||
type Props = {
|
||||
// optional manual override
|
||||
videos?: string[];
|
||||
variant?: 'grid' | 'carousel';
|
||||
};
|
||||
|
||||
type RenderItem = {
|
||||
@@ -39,7 +39,7 @@ const toEmbed = (idOrUrl: string): string => {
|
||||
return `https://www.youtube.com/embed/${idOrUrl}`;
|
||||
};
|
||||
|
||||
const VideosSection: React.FC<Props> = ({ videos }) => {
|
||||
const VideosSection: React.FC<Props> = ({ videos, variant }) => {
|
||||
const cardBg = useColorModeValue('white', 'gray.800');
|
||||
const theme = useClubTheme();
|
||||
const { data: settings } = usePublicSettings();
|
||||
@@ -55,7 +55,11 @@ const VideosSection: React.FC<Props> = ({ videos }) => {
|
||||
const enabled = (typeof (settings as any)?.videos_module_enabled === 'boolean')
|
||||
? Boolean((settings as any)?.videos_module_enabled)
|
||||
: (hasManualConfigured || ((settings?.videos_source || 'auto') === 'auto' && hasAutoConfigured));
|
||||
const style = settings?.videos_style || 'slider';
|
||||
const style = (() => {
|
||||
if (variant === 'carousel') return 'slider';
|
||||
if (variant === 'grid') return 'grid';
|
||||
return settings?.videos_style || 'slider';
|
||||
})();
|
||||
const source = settings?.videos_source || 'auto';
|
||||
// Default to 6 items on homepage unless overridden by settings (max 12)
|
||||
const limit = Math.max(1, Math.min(12, settings?.videos_limit ?? 6));
|
||||
|
||||
@@ -50,7 +50,7 @@ export const MainLayout: React.FC<MainLayoutProps> = ({ children, headerInsideCo
|
||||
{headerVariant === 'sparta_navbar' ? (
|
||||
<SpartaNavbar />
|
||||
) : (
|
||||
<Navbar fullWidth={headerVariant === 'fullwidth'} />
|
||||
<Navbar fullWidth={headerVariant === 'fullwidth'} variant={headerVariant} />
|
||||
)}
|
||||
</Box>
|
||||
{children}
|
||||
@@ -70,7 +70,7 @@ export const MainLayout: React.FC<MainLayoutProps> = ({ children, headerInsideCo
|
||||
{headerVariant === 'sparta_navbar' ? (
|
||||
<SpartaNavbar />
|
||||
) : (
|
||||
<Navbar fullWidth={headerVariant === 'fullwidth'} />
|
||||
<Navbar fullWidth={headerVariant === 'fullwidth'} variant={headerVariant} />
|
||||
)}
|
||||
</Box>
|
||||
<Container maxW="container.xl" py={8}>
|
||||
|
||||
@@ -25,7 +25,8 @@ const MatchesSlider: React.FC<{
|
||||
onActiveChange: (idx: number) => void;
|
||||
onMatchClick?: (m: SliderMatch, compName?: string) => void;
|
||||
elementProps?: any;
|
||||
}> = ({ title = 'Zápasy', comps, activeIndex, onActiveChange, onMatchClick, elementProps }) => {
|
||||
variant?: 'carousel' | 'scroller' | 'ticker' | 'compact_split';
|
||||
}> = ({ title = 'Zápasy', comps, activeIndex, onActiveChange, onMatchClick, elementProps, variant }) => {
|
||||
const trackRef = useRef<HTMLDivElement | null>(null);
|
||||
const current = comps[Math.max(0, Math.min(activeIndex, comps.length - 1))];
|
||||
|
||||
@@ -56,6 +57,52 @@ const MatchesSlider: React.FC<{
|
||||
} catch {}
|
||||
}, [activeIndex, JSON.stringify(current?.matches)]);
|
||||
|
||||
// Ticker variant - continuous belt animation
|
||||
if (variant === 'ticker') {
|
||||
const items = (current?.matches || []);
|
||||
const looped = [...items, ...items, ...items];
|
||||
return (
|
||||
<section className="matches-slider matches-ticker" {...(elementProps || {})}>
|
||||
<div className="section-head" style={{ marginTop: 16, marginBottom: 8 }}>
|
||||
<h3>{title}</h3>
|
||||
<a href="/kalendar" className="see-all">Všechny zápasy</a>
|
||||
</div>
|
||||
<div className="ticker-belt">
|
||||
{looped.map((m, idx) => (
|
||||
<div
|
||||
key={`${m.id || idx}-ticker`}
|
||||
className="match-card"
|
||||
onClick={(e) => { e.preventDefault(); onMatchClick?.(m, current?.name); }}
|
||||
style={{ cursor: onMatchClick ? 'pointer' as const : 'default' as const }}
|
||||
>
|
||||
<div className="teams">
|
||||
<div className="team">
|
||||
<TeamLogo teamId={m.home_id} teamName={m.home} facrLogo={m.home_logo_url} size="custom" alt={m.home} borderRadius="full" />
|
||||
<div className="name">{sanitizeClubName(m.home || '')}</div>
|
||||
</div>
|
||||
<div className="score">
|
||||
{m.score ? (
|
||||
<>
|
||||
<span className="home">{String(m.score).split(':')[0]}</span>
|
||||
<span className="sep">:</span>
|
||||
<span className="away">{String(m.score).split(':')[1]}</span>
|
||||
</>
|
||||
) : (
|
||||
<span className="time">{m.time}</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="team">
|
||||
<TeamLogo teamId={m.away_id} teamName={m.away} facrLogo={m.away_logo_url} size="custom" alt={m.away} borderRadius="full" />
|
||||
<div className="name">{sanitizeClubName(m.away || '')}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="matches-slider" {...(elementProps || {})}>
|
||||
<div className="section-head" style={{ marginTop: 16, marginBottom: 16 }}>
|
||||
|
||||
Reference in New Issue
Block a user