mirror of
https://github.com/Dvorinka/MyClubServer.git
synced 2026-06-04 10:42:57 +00:00
dev day #62
This commit is contained in:
@@ -822,26 +822,66 @@ const CalendarPage: React.FC = () => {
|
||||
borderColor={listMatchBorder}
|
||||
_hover={{ bg: listMatchHoverBg, borderColor: 'brand.primary', cursor: 'pointer', transform: 'translateY(-2px)', boxShadow: 'md' }}
|
||||
transition="all 0.2s"
|
||||
gap={3}
|
||||
>
|
||||
<Flex direction="column" minW="180px">
|
||||
<Text fontWeight="semibold" color={listDateText}>{m.date}</Text>
|
||||
{m.venue && <Text color={listVenueText} fontSize="sm">{m.venue}</Text>}
|
||||
<Flex direction="column" minW="100px">
|
||||
<Text fontWeight="semibold" color={listDateText} fontSize="sm">{m.date}</Text>
|
||||
<Text color={listTimeText} fontSize="sm">{m.time || '—'}</Text>
|
||||
{m.venue && <Text color={listVenueText} fontSize="xs" mt={1}>{m.venue}</Text>}
|
||||
</Flex>
|
||||
<Flex direction="column" align="center" gap={1} flex="1" justify="center">
|
||||
{!isPast && countdown ? (
|
||||
<Badge colorScheme="orange" fontSize="md">za {countdown}</Badge>
|
||||
) : (
|
||||
<Flex align="center" gap={2} justify="center">
|
||||
{m.home_logo_url && (
|
||||
<Image src={m.home_logo_url} alt={m.home} boxSize="20px" borderRadius="full" objectFit="cover" />
|
||||
)}
|
||||
<Badge colorScheme={isPast && m.score ? (getSentiment(m)?.color || 'gray') : 'gray'}>{isPast && m.score ? m.score : 'vs'}</Badge>
|
||||
{m.away_logo_url && (
|
||||
<Image src={m.away_logo_url} alt={m.away} boxSize="20px" borderRadius="full" objectFit="cover" />
|
||||
)}
|
||||
</Flex>
|
||||
)}
|
||||
<Text fontSize="sm" color={listTimeText}>{m.time || '—'}</Text>
|
||||
|
||||
<Flex align="center" gap={3} flex="1">
|
||||
{/* Home Team */}
|
||||
<Flex align="center" gap={2} flex="1" justify="flex-end">
|
||||
<Text fontSize="sm" fontWeight="medium" textAlign="right" color={listDateText}>
|
||||
{m.home}
|
||||
</Text>
|
||||
{m.home_logo_url && (
|
||||
<Image
|
||||
src={m.home_logo_url}
|
||||
alt={m.home}
|
||||
boxSize="32px"
|
||||
borderRadius="full"
|
||||
objectFit="cover"
|
||||
border="2px solid"
|
||||
borderColor="gray.200"
|
||||
/>
|
||||
)}
|
||||
</Flex>
|
||||
|
||||
{/* Score or Countdown */}
|
||||
<Flex direction="column" align="center" gap={1} minW="80px">
|
||||
{!isPast && countdown ? (
|
||||
<Badge colorScheme="orange" fontSize="sm" px={2}>za {countdown}</Badge>
|
||||
) : (
|
||||
<Badge colorScheme={isPast && m.score ? (getSentiment(m)?.color || 'gray') : 'gray'} fontSize="md" px={3} py={1}>
|
||||
{isPast && m.score ? m.score : 'vs'}
|
||||
</Badge>
|
||||
)}
|
||||
{sentiment && (
|
||||
<Text fontSize="xs" color={`${sentiment.color}.600`} fontWeight="semibold">
|
||||
{sentiment.label}
|
||||
</Text>
|
||||
)}
|
||||
</Flex>
|
||||
|
||||
{/* Away Team */}
|
||||
<Flex align="center" gap={2} flex="1" justify="flex-start">
|
||||
{m.away_logo_url && (
|
||||
<Image
|
||||
src={m.away_logo_url}
|
||||
alt={m.away}
|
||||
boxSize="32px"
|
||||
borderRadius="full"
|
||||
objectFit="cover"
|
||||
border="2px solid"
|
||||
borderColor="gray.200"
|
||||
/>
|
||||
)}
|
||||
<Text fontSize="sm" fontWeight="medium" textAlign="left" color={listDateText}>
|
||||
{m.away}
|
||||
</Text>
|
||||
</Flex>
|
||||
</Flex>
|
||||
</Flex>
|
||||
{href && (
|
||||
@@ -905,10 +945,12 @@ const CalendarPage: React.FC = () => {
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
if (selected.comp?.name || selected.match.__compName) {
|
||||
const compName = selected.comp?.name || selected.match.__compName;
|
||||
// Don't show "Všechny soutěže" badge - only show specific competition names
|
||||
if (compName && compName !== 'Všechny soutěže') {
|
||||
return (
|
||||
<Flex justify="center">
|
||||
<Badge colorScheme="purple">{selected.comp?.name || selected.match.__compName}</Badge>
|
||||
<Badge colorScheme="purple">{compName}</Badge>
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
@@ -971,20 +1013,112 @@ const CalendarPage: React.FC = () => {
|
||||
<Text fontSize="md" color="gray.700">
|
||||
{selected.match.time || '—'}
|
||||
</Text>
|
||||
{(() => {
|
||||
const dt = new Date(`${selected.match.date}T${(selected.match.time || '00:00')}:00`);
|
||||
const isPast = Date.now() >= dt.getTime();
|
||||
const hasScore = Boolean(selected.match.score);
|
||||
if (!hasScore && !isPast && modalCountdown.countdownString) {
|
||||
return (
|
||||
<Badge colorScheme="orange" mt={2} fontSize="sm" px={2} py={1}>
|
||||
Začíná za {modalCountdown.countdownString}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
})()}
|
||||
</Box>
|
||||
|
||||
{/* Enhanced Countdown Display for Upcoming Matches */}
|
||||
{(() => {
|
||||
const dt = new Date(`${selected.match.date}T${(selected.match.time || '00:00')}:00`);
|
||||
const isPast = Date.now() >= dt.getTime();
|
||||
const hasScore = Boolean(selected.match.score);
|
||||
|
||||
if (!hasScore && !isPast && modalCountdown.isActive && modalCountdown.timeRemaining > 0) {
|
||||
const days = Math.floor(modalCountdown.timeRemaining / (24 * 60 * 60 * 1000));
|
||||
const hours = Math.floor((modalCountdown.timeRemaining % (24 * 60 * 60 * 1000)) / (60 * 60 * 1000));
|
||||
const minutes = Math.floor((modalCountdown.timeRemaining % (60 * 60 * 1000)) / (60 * 1000));
|
||||
const seconds = Math.floor((modalCountdown.timeRemaining % (60 * 1000)) / 1000);
|
||||
|
||||
return (
|
||||
<Box
|
||||
mt={4}
|
||||
p={4}
|
||||
bg="orange.50"
|
||||
borderRadius="lg"
|
||||
borderWidth="2px"
|
||||
borderColor="orange.200"
|
||||
>
|
||||
<Text fontSize="sm" fontWeight="semibold" color="orange.800" mb={3} textAlign="center">
|
||||
Zápas začíná za
|
||||
</Text>
|
||||
<Grid
|
||||
templateColumns={days > 0 ? "repeat(4, 1fr)" : "repeat(3, 1fr)"}
|
||||
gap={3}
|
||||
>
|
||||
{days > 0 && (
|
||||
<Box textAlign="center">
|
||||
<Box
|
||||
bg="white"
|
||||
borderRadius="md"
|
||||
p={3}
|
||||
borderWidth="1px"
|
||||
borderColor="orange.300"
|
||||
boxShadow="sm"
|
||||
>
|
||||
<Text fontSize="2xl" fontWeight="bold" color="orange.600">
|
||||
{days}
|
||||
</Text>
|
||||
</Box>
|
||||
<Text fontSize="xs" color="gray.600" mt={1} fontWeight="medium">
|
||||
{days === 1 ? 'den' : days < 5 ? 'dny' : 'dní'}
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
<Box textAlign="center">
|
||||
<Box
|
||||
bg="white"
|
||||
borderRadius="md"
|
||||
p={3}
|
||||
borderWidth="1px"
|
||||
borderColor="orange.300"
|
||||
boxShadow="sm"
|
||||
>
|
||||
<Text fontSize="2xl" fontWeight="bold" color="orange.600">
|
||||
{String(hours).padStart(2, '0')}
|
||||
</Text>
|
||||
</Box>
|
||||
<Text fontSize="xs" color="gray.600" mt={1} fontWeight="medium">
|
||||
{hours === 1 ? 'hodina' : hours < 5 ? 'hodiny' : 'hodin'}
|
||||
</Text>
|
||||
</Box>
|
||||
<Box textAlign="center">
|
||||
<Box
|
||||
bg="white"
|
||||
borderRadius="md"
|
||||
p={3}
|
||||
borderWidth="1px"
|
||||
borderColor="orange.300"
|
||||
boxShadow="sm"
|
||||
>
|
||||
<Text fontSize="2xl" fontWeight="bold" color="orange.600">
|
||||
{String(minutes).padStart(2, '0')}
|
||||
</Text>
|
||||
</Box>
|
||||
<Text fontSize="xs" color="gray.600" mt={1} fontWeight="medium">
|
||||
{minutes === 1 ? 'minuta' : minutes < 5 ? 'minuty' : 'minut'}
|
||||
</Text>
|
||||
</Box>
|
||||
<Box textAlign="center">
|
||||
<Box
|
||||
bg="white"
|
||||
borderRadius="md"
|
||||
p={3}
|
||||
borderWidth="1px"
|
||||
borderColor="orange.300"
|
||||
boxShadow="sm"
|
||||
>
|
||||
<Text fontSize="2xl" fontWeight="bold" color="orange.600">
|
||||
{String(seconds).padStart(2, '0')}
|
||||
</Text>
|
||||
</Box>
|
||||
<Text fontSize="xs" color="gray.600" mt={1} fontWeight="medium">
|
||||
{seconds === 1 ? 'sekunda' : seconds < 5 ? 'sekundy' : 'sekund'}
|
||||
</Text>
|
||||
</Box>
|
||||
</Grid>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
})()}
|
||||
<Box h="1px" bg="gray.200" />
|
||||
<Heading as="h3" size="sm">Odběr notifikací pro fanoušky</Heading>
|
||||
<Text fontSize="sm" color="gray.600">Zadejte svůj email a budeme vás informovat o novinkách a zápasech.</Text>
|
||||
|
||||
@@ -4,7 +4,7 @@ import { FiArrowRight, FiCalendar, FiUsers, FiAward, FiChevronLeft, FiChevronRig
|
||||
import '../styles/theme.css';
|
||||
import './styles/UnifiedHome.css';
|
||||
import { getPublicSettings } from '../services/settings';
|
||||
import { assetUrl } from '../utils/url';
|
||||
import { assetUrl, sanitizeClubName } from '../utils/url';
|
||||
import { getPlayers as apiGetPlayers, Player as ApiPlayer } from '../services/players';
|
||||
import { getSponsors as apiGetSponsors, Sponsor as ApiSponsor } from '../services/sponsors';
|
||||
import BlogCardsScroller from '../components/home/BlogCardsScroller';
|
||||
@@ -17,6 +17,7 @@ import NewsletterSubscribe from '../components/newsletter/NewsletterSubscribe';
|
||||
import MyUIbrixStyleEditor from '../components/editor/MyUIbrixEditor';
|
||||
import ClubModal from '../components/home/ClubModal';
|
||||
import MatchModal from '../components/home/MatchModal';
|
||||
import { useAllPageElementConfigs } from '../hooks/usePageElementConfig';
|
||||
|
||||
// Types for real API-driven data
|
||||
type NewsItem = {
|
||||
@@ -101,6 +102,9 @@ const HomePage: React.FC = () => {
|
||||
const [aliasMap, setAliasMap] = useState<Record<string, { alias: string; original_name?: string }>>({});
|
||||
const [settings, setSettings] = useState<any>(null);
|
||||
const [isLoading, setIsLoading] = useState<boolean>(true);
|
||||
|
||||
// MyUIbrix element configuration hook for live preview
|
||||
const { getVariant, isVisible, loading: configLoading } = useAllPageElementConfigs('homepage');
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
@@ -578,52 +582,8 @@ const HomePage: React.FC = () => {
|
||||
return () => { disposed = true; };
|
||||
}, [clubLogo]);
|
||||
|
||||
// Listen to MyUIbrix events for live preview
|
||||
useEffect(() => {
|
||||
const handleMyUIbrixChange = (e: CustomEvent) => {
|
||||
const { elementName, variant, visible, previewMode } = e.detail;
|
||||
if (!previewMode) return; // Only respond to preview mode changes
|
||||
|
||||
// For now, log the change - full implementation would update element visibility/variant
|
||||
console.log(`MyUIbrix: ${elementName} -> ${variant} (visible: ${visible})`);
|
||||
|
||||
// You can implement logic here to dynamically show/hide or restyle elements
|
||||
// For example:
|
||||
// - Toggle display on data-element sections based on visibility
|
||||
// - Apply variant-specific classes
|
||||
};
|
||||
|
||||
const handleMyUIbrixStyleChange = (e: CustomEvent) => {
|
||||
const { elementName, styles, previewMode } = e.detail;
|
||||
if (!previewMode) return;
|
||||
|
||||
// Apply custom styles to elements
|
||||
const elements = document.querySelectorAll(`[data-element="${elementName}"]`);
|
||||
elements.forEach((el: any) => {
|
||||
Object.keys(styles).forEach((key) => {
|
||||
el.style[key] = styles[key];
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const handleMyUIbrixReorder = (e: CustomEvent) => {
|
||||
const { order, previewMode } = e.detail;
|
||||
if (!previewMode) return;
|
||||
|
||||
// Reorder elements based on the order array
|
||||
console.log('MyUIbrix: Reorder elements', order);
|
||||
};
|
||||
|
||||
window.addEventListener('myuibrix-change' as any, handleMyUIbrixChange);
|
||||
window.addEventListener('myuibrix-style-change' as any, handleMyUIbrixStyleChange);
|
||||
window.addEventListener('myuibrix-reorder' as any, handleMyUIbrixReorder);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('myuibrix-change' as any, handleMyUIbrixChange);
|
||||
window.removeEventListener('myuibrix-style-change' as any, handleMyUIbrixStyleChange);
|
||||
window.removeEventListener('myuibrix-reorder' as any, handleMyUIbrixReorder);
|
||||
};
|
||||
}, []);
|
||||
// MyUIbrix events are handled by useAllPageElementConfigs hook
|
||||
// It automatically updates getVariant() and isVisible() when changes occur in edit mode
|
||||
|
||||
// Countdown to next match (uses selected competition upcoming if available)
|
||||
useEffect(() => {
|
||||
@@ -707,9 +667,9 @@ const HomePage: React.FC = () => {
|
||||
<div className="list">
|
||||
{(facrCompetitions[matchesTab]?.matches || []).slice(0,4).map((m:any, idx:number) => (
|
||||
<a key={m.id || idx} className="row" href={m.facr_link || m.report_url || '#'} target="_blank" rel="noopener noreferrer">
|
||||
<div className="team"><img src={assetUrl(m.home_logo_url)} alt={m.home} /><span>{m.home}</span></div>
|
||||
<div className="team"><img src={assetUrl(m.home_logo_url)} alt={m.home} /><span>{sanitizeClubName(m.home)}</span></div>
|
||||
<div className="meta"><span>{new Date(`${m.date}T${(m.time||'00:00')}:00`).toLocaleDateString()}</span><span>•</span><span>{m.time || ''}</span></div>
|
||||
<div className="team"><img src={assetUrl(m.away_logo_url)} alt={m.away} /><span>{m.away}</span></div>
|
||||
<div className="team"><img src={assetUrl(m.away_logo_url)} alt={m.away} /><span>{sanitizeClubName(m.away)}</span></div>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
@@ -834,12 +794,12 @@ const HomePage: React.FC = () => {
|
||||
<div className="row teams">
|
||||
<div className="team">
|
||||
<img src={assetUrl(m.home_logo_url)} alt={m.home} />
|
||||
<span>{m.home}</span>
|
||||
<span>{sanitizeClubName(m.home)}</span>
|
||||
</div>
|
||||
<span className="vs">vs</span>
|
||||
<div className="team">
|
||||
<img src={assetUrl(m.away_logo_url)} alt={m.away} />
|
||||
<span>{m.away}</span>
|
||||
<span>{sanitizeClubName(m.away)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
@@ -1404,8 +1364,8 @@ const HomePage: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Hero section: variant controlled by settings.hero_style */}
|
||||
{heroStyle === 'grid' && (
|
||||
{/* Hero section: variant controlled by MyUIbrix (getVariant) or fallback to settings.hero_style */}
|
||||
{getVariant('hero', heroStyle) === 'grid' && isVisible('hero', true) && (
|
||||
<section data-element="hero" className="hero-grid">
|
||||
{news[0] ? (
|
||||
<a href={`/news/${news[0].slug || news[0].id}`} className="hero-card big" style={{ textDecoration: 'none' }}>
|
||||
@@ -1435,10 +1395,11 @@ const HomePage: React.FC = () => {
|
||||
</a>
|
||||
))}
|
||||
{Array.from({ length: Math.max(0, 2 - Math.min(2, Math.max(0, news.length - 1))) }).map((_, idx) => (
|
||||
<div key={`placeholder-${idx}`} className="hero-card small" style={{ background: 'var(--bg-soft)', pointerEvents: 'none' }}>
|
||||
<div className="overlay" style={{ background: 'linear-gradient(to bottom, transparent 0%, rgba(0,0,0,0.3) 40%, rgba(0,0,0,0.6) 100%)' }}>
|
||||
<div style={{ opacity: 0.6, fontSize: '0.8rem', color: 'var(--text-on-primary)' }}>Aktuality</div>
|
||||
<h3 style={{ margin: '4px 0 0 0', color: 'var(--text-on-primary)', opacity: 0.6 }}>Připravujeme...</h3>
|
||||
<div key={`placeholder-${idx}`} className="hero-card small" style={{ pointerEvents: 'none' }}>
|
||||
<div className="bg" style={{ backgroundImage: `url('/images/news/placeholder.jpg')`, filter: 'grayscale(50%) brightness(0.7)' }} />
|
||||
<div className="overlay">
|
||||
<div style={{ opacity: 0.8, fontSize: '0.8rem', color: '#fff' }}>Aktuality</div>
|
||||
<h3 style={{ margin: '4px 0 0 0', color: '#fff' }}>Připravujeme...</h3>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
@@ -1446,7 +1407,7 @@ const HomePage: React.FC = () => {
|
||||
</section>
|
||||
)}
|
||||
{/* Banner: homepage_middle */}
|
||||
{(banners || []).some(b => b.placement === 'homepage_middle') && (
|
||||
{(banners || []).some(b => b.placement === 'homepage_middle') && isVisible('banner', true) && (
|
||||
<section data-element="banner" className="banner banner-middle" style={{ margin: '24px 0', textAlign: 'center' }}>
|
||||
{(banners || []).filter(b => b.placement === 'homepage_middle').map((b) => (
|
||||
<a key={b.id} href={b.url || '#'} target={b.url ? '_blank' : undefined} rel={b.url ? 'noopener noreferrer' : undefined} style={{ display: 'inline-block', margin: 8 }}>
|
||||
@@ -1458,7 +1419,7 @@ const HomePage: React.FC = () => {
|
||||
)}
|
||||
|
||||
{/* Featured articles grid (uses Articles.featured flag) */}
|
||||
{featured.length > 0 && (
|
||||
{featured.length > 0 && isVisible('news', true) && (
|
||||
<section data-element="news" className="three-cols" style={{ marginTop: 8 }}>
|
||||
{featured.map((n) => (
|
||||
<a key={n.id} href={`/news/${n.slug || n.id}`} className="hero-card small" style={{ textDecoration: 'none', height: 220 }}>
|
||||
@@ -1488,19 +1449,19 @@ const HomePage: React.FC = () => {
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
{heroStyle === 'scroller' && (
|
||||
{getVariant('hero', heroStyle) === 'scroller' && isVisible('hero', true) && (
|
||||
<section data-element="hero">
|
||||
<BlogCardsScroller />
|
||||
</section>
|
||||
)}
|
||||
{(heroStyle === 'swiper' || heroStyle === 'swiper_full') && (
|
||||
<section data-element="hero" style={heroStyle === 'swiper_full' ? { marginLeft: 'calc(50% - 50vw)', marginRight: 'calc(50% - 50vw)' } : undefined}>
|
||||
{(getVariant('hero', heroStyle) === 'swiper' || getVariant('hero', heroStyle) === 'swiper_full') && isVisible('hero', true) && (
|
||||
<section data-element="hero" style={getVariant('hero', heroStyle) === 'swiper_full' ? { marginLeft: 'calc(50% - 50vw)', marginRight: 'calc(50% - 50vw)' } : undefined}>
|
||||
<BlogSwiper />
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Next match: categories (competitions) with left/right navigation - synced with matchesTab */}
|
||||
{facrCompetitions.length > 0 ? (
|
||||
{facrCompetitions.length > 0 && isVisible('matches', true) ? (
|
||||
(() => {
|
||||
const comp = facrCompetitions[Math.max(0, Math.min(matchesTab, facrCompetitions.length - 1))];
|
||||
const items = Array.isArray(comp?.matches) ? comp.matches : [];
|
||||
@@ -1532,7 +1493,7 @@ const HomePage: React.FC = () => {
|
||||
</button>
|
||||
<div className="team">
|
||||
<img className="logo" src={assetUrl(show?.home_logo_url) || assetUrl(clubLogo) || '/images/club-logo.png'} alt="Domácí" />
|
||||
<div>{show?.home || matches[0]?.homeTeam || clubName}</div>
|
||||
<div>{sanitizeClubName(show?.home || matches[0]?.homeTeam || clubName)}</div>
|
||||
</div>
|
||||
<div className="countdown">
|
||||
<div style={{ fontSize: '0.8rem', opacity: 0.85, marginBottom: 4 }}>{comp?.name || 'Soutěž'}</div>
|
||||
@@ -1541,7 +1502,7 @@ const HomePage: React.FC = () => {
|
||||
</div>
|
||||
<div className="team">
|
||||
<img className="logo" src={assetUrl(show?.away_logo_url) || '/images/club-opponent.png'} alt="Hosté" />
|
||||
<div>{show?.away || matches[0]?.awayTeam || 'Soupeř'}</div>
|
||||
<div>{sanitizeClubName(show?.away || matches[0]?.awayTeam || 'Soupeř')}</div>
|
||||
</div>
|
||||
<button
|
||||
aria-label="Další soutěž"
|
||||
@@ -1554,11 +1515,11 @@ const HomePage: React.FC = () => {
|
||||
</section>
|
||||
);
|
||||
})()
|
||||
) : (
|
||||
<section className="next-match">
|
||||
) : isVisible('matches', true) ? (
|
||||
<section data-element="matches" className="next-match">
|
||||
<div className="team">
|
||||
<img className="logo" src={assetUrl(matches[0]?.homeLogoURL) || assetUrl(clubLogo) || '/images/club-logo.png'} alt="Domácí" />
|
||||
<div>{matches[0]?.homeTeam || clubName}</div>
|
||||
<div>{sanitizeClubName(matches[0]?.homeTeam || clubName)}</div>
|
||||
</div>
|
||||
<div className="countdown">
|
||||
{countdown || '—'}
|
||||
@@ -1571,10 +1532,10 @@ const HomePage: React.FC = () => {
|
||||
</div>
|
||||
<div className="team">
|
||||
<img className="logo" src={assetUrl(matches[0]?.awayLogoURL) || '/images/club-opponent.png'} alt="Hosté" />
|
||||
<div>{matches[0]?.awayTeam || 'Soupeř'}</div>
|
||||
<div>{sanitizeClubName(matches[0]?.awayTeam || 'Soupeř')}</div>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
) : null}
|
||||
|
||||
{/* Matches slider with scores by competition */}
|
||||
{facrCompetitions.length > 0 && (
|
||||
@@ -1625,7 +1586,7 @@ const HomePage: React.FC = () => {
|
||||
<div className="teams">
|
||||
<div className="team">
|
||||
<img src={assetUrl(m.home_logo_url)} alt={m.home} />
|
||||
<div className="name">{m.home}</div>
|
||||
<div className="name">{sanitizeClubName(m.home)}</div>
|
||||
</div>
|
||||
<div className="score">
|
||||
{m.score ? (
|
||||
@@ -1640,7 +1601,7 @@ const HomePage: React.FC = () => {
|
||||
</div>
|
||||
<div className="team">
|
||||
<img src={assetUrl(m.away_logo_url)} alt={m.away} />
|
||||
<div className="name">{m.away}</div>
|
||||
<div className="name">{sanitizeClubName(m.away)}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1658,6 +1619,7 @@ const HomePage: React.FC = () => {
|
||||
{/* Competition tables moved into right column below */}
|
||||
|
||||
{/* Standings: tabs per competition (only FACR), clicking row opens ClubModal */}
|
||||
{isVisible('table', true) && (
|
||||
<section data-element="table" className="standings" style={{ marginTop: 32 }}>
|
||||
<div>
|
||||
<div className="section-head" style={{ marginTop: 0 }}>
|
||||
@@ -1690,15 +1652,6 @@ const HomePage: React.FC = () => {
|
||||
<h3>Tabulky</h3>
|
||||
<a href="/tabulky" className="see-all" style={{ fontSize: '0.85rem' }}>Zobrazit vše <FiArrowRight size={14} /></a>
|
||||
</div>
|
||||
<div className="tabs" style={{ marginBottom: 12 }}>
|
||||
{standings.length > 0 ? standings.map((s:any, i: number) => (
|
||||
<button key={`${s.name}-${i}`} className={i===matchesTab ? 'active' : ''} onClick={() => setMatchesTab(i)}>
|
||||
<span>{s.name || s.competition || 'Soutěž'}</span>
|
||||
</button>
|
||||
)) : ['Liga'].map((t: string, i: number) => (
|
||||
<button key={`${t}-${i}`} className={i===matchesTab ? 'active' : ''} disabled>{t}</button>
|
||||
))}
|
||||
</div>
|
||||
{standings.length > 0 ? (
|
||||
<div className="standings">
|
||||
{(standings[matchesTab]?.table || standings[matchesTab]?.rows || []).slice(0,8).map((row: any, idx: number) => {
|
||||
@@ -1738,9 +1691,10 @@ const HomePage: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Players scroller (optional) */}
|
||||
{players.length > 0 && (
|
||||
{players.length > 0 && isVisible('team', false) && (
|
||||
<section data-element="team" className="players-scroller" style={{ marginTop: 32 }}>
|
||||
<div className="section-head">
|
||||
<h3>Hráči</h3>
|
||||
@@ -1775,13 +1729,15 @@ const HomePage: React.FC = () => {
|
||||
)}
|
||||
|
||||
{/* Videos */}
|
||||
{isVisible('videos', false) && (
|
||||
<section data-element="videos" style={{ marginTop: 32, marginBottom: 32 }}>
|
||||
<div style={{ maxWidth: 1200, margin: '0 auto', padding: '0 12px' }}>
|
||||
<VideosSection />
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{true && (
|
||||
{isVisible('merch', true) && (
|
||||
<section data-element="merch" style={{ marginTop: 24, marginBottom: 24 }}>
|
||||
<div style={{ maxWidth: 1200, margin: '0 auto', padding: '0 12px' }}>
|
||||
<MerchSection />
|
||||
@@ -1790,11 +1746,13 @@ const HomePage: React.FC = () => {
|
||||
)}
|
||||
|
||||
{/* Newsletter subscription CTA */}
|
||||
{isVisible('newsletter', false) && (
|
||||
<section data-element="newsletter" className="newsletter-cta" style={{ marginTop: 24, marginBottom: 24 }}>
|
||||
<div className="card" style={{ maxWidth: 960, margin: '0 auto' }}>
|
||||
<NewsletterSubscribe />
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Banner: homepage_top */}
|
||||
{(banners || []).some(b => b.placement === 'homepage_top') && (
|
||||
@@ -1821,6 +1779,7 @@ const HomePage: React.FC = () => {
|
||||
)}
|
||||
|
||||
{/* Sponsors: grid or slider (controlled by settings); dark theme supported; full-bleed */}
|
||||
{isVisible('sponsors', true) && (
|
||||
<section
|
||||
data-element="sponsors"
|
||||
className={`sponsors ${sponsorsTheme === 'dark' ? 'dark' : ''}`}
|
||||
@@ -1874,6 +1833,7 @@ const HomePage: React.FC = () => {
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
<ClubModal
|
||||
isOpen={isModalOpen}
|
||||
|
||||
@@ -4,7 +4,7 @@ import MainLayout from '../components/layout/MainLayout';
|
||||
import { getPublicSettings } from '../services/settings';
|
||||
import { getCompetitionAliasesPublic, CompetitionAlias } from '../services/competitionAliases';
|
||||
import { sortCategoriesWithOrder } from '../utils/categorySort';
|
||||
import { assetUrl } from '../utils/url';
|
||||
import { assetUrl, sanitizeClubName } from '../utils/url';
|
||||
import MatchModal from '../components/home/MatchModal';
|
||||
import { useCountdown, useMultipleCountdowns } from '../hooks/useCountdown';
|
||||
import '../styles/theme.css';
|
||||
@@ -54,11 +54,13 @@ const MatchesPage: React.FC = () => {
|
||||
}
|
||||
};
|
||||
|
||||
// Helper function to truncate long club names
|
||||
// Helper function to sanitize and truncate long club names
|
||||
const truncateClubName = (name: string, maxLength: number = 35) => {
|
||||
if (!name) return name;
|
||||
if (name.length <= maxLength) return name;
|
||||
return name.substring(0, maxLength).trim() + '…';
|
||||
// First sanitize the club name
|
||||
const sanitized = sanitizeClubName(name);
|
||||
if (sanitized.length <= maxLength) return sanitized;
|
||||
return sanitized.substring(0, maxLength).trim() + '…';
|
||||
};
|
||||
|
||||
// Format date to Czech format
|
||||
@@ -457,13 +459,7 @@ const MatchesPage: React.FC = () => {
|
||||
}}
|
||||
>
|
||||
<div style={{ fontSize: '0.85rem', color: textSecondary, marginBottom: 16, display: 'flex', justifyContent: 'space-between', alignItems: 'center', fontWeight: 600 }}>
|
||||
<span style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<rect x="3" y="4" width="18" height="18" rx="2" ry="2"></rect>
|
||||
<line x1="16" y1="2" x2="16" y2="6"></line>
|
||||
<line x1="8" y1="2" x2="8" y2="6"></line>
|
||||
<line x1="3" y1="10" x2="21" y2="10"></line>
|
||||
</svg>
|
||||
<span>
|
||||
{formatCzechDate(m.date, m.time || '00:00')}
|
||||
</span>
|
||||
<span style={{ background: 'var(--chakra-colors-brand-primary, #3b82f6)', color: 'white', padding: '4px 10px', borderRadius: 8, fontSize: '0.8rem', fontWeight: 700 }}>{m.time}</span>
|
||||
@@ -529,11 +525,7 @@ const MatchesPage: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
{m.venue && (
|
||||
<div style={{ fontSize: '0.85rem', color: textSecondary, marginTop: 12, textAlign: 'center', display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 6 }}>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z"></path>
|
||||
<circle cx="12" cy="10" r="3"></circle>
|
||||
</svg>
|
||||
<div style={{ fontSize: '0.85rem', color: textSecondary, marginTop: 12, textAlign: 'center' }}>
|
||||
{m.venue}
|
||||
</div>
|
||||
)}
|
||||
@@ -565,38 +557,12 @@ const MatchesPage: React.FC = () => {
|
||||
fontWeight: 700,
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.5px',
|
||||
boxShadow: `0 2px 8px ${color.shadow}`,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: 6
|
||||
boxShadow: `0 2px 8px ${color.shadow}`
|
||||
}}>
|
||||
{sentiment.label === 'Výhra' && '🏆'}
|
||||
{sentiment.label === 'Remíza' && '⚖️'}
|
||||
{sentiment.label === 'Prohra' && '😔'}
|
||||
{sentiment.label}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (hasScore && isPast) {
|
||||
return (
|
||||
<div style={{
|
||||
fontSize: '0.75rem',
|
||||
background: 'linear-gradient(135deg, #10b981 0%, #059669 100%)',
|
||||
color: 'white',
|
||||
marginTop: 12,
|
||||
padding: '6px 12px',
|
||||
borderRadius: 8,
|
||||
textAlign: 'center',
|
||||
fontWeight: 700,
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.5px',
|
||||
boxShadow: '0 2px 8px rgba(16, 185, 129, 0.3)'
|
||||
}}>
|
||||
✓ Skončeno
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (!hasScore && isPast) {
|
||||
return (
|
||||
<div style={{
|
||||
|
||||
@@ -17,6 +17,7 @@ import { FONT_PAIRINGS, loadGoogleFont, getFontStyleColor } from '../config/font
|
||||
import MapLinkImporter from '../components/admin/MapLinkImporter';
|
||||
import MapStyleSelector from '../components/admin/MapStyleSelector';
|
||||
import { MapCoordinates } from '../utils/mapUrlParser';
|
||||
import { fetchLogoFromLogoAPI } from '../utils/sportLogosAPI';
|
||||
|
||||
const SetupPage: React.FC = () => {
|
||||
const [loading, setLoading] = useState(true);
|
||||
@@ -41,6 +42,8 @@ const SetupPage: React.FC = () => {
|
||||
|
||||
const resolveLogoUrl = (u?: string | null) => {
|
||||
if (!u) return undefined;
|
||||
// If it's a logoapi URL, use it directly (no proxy needed)
|
||||
if (u.includes('logoapi.sportcreative.eu')) return u;
|
||||
// If it's a backend-relative path or dist asset, use assetUrl helper
|
||||
if (u.startsWith('/uploads') || u.startsWith('/dist') || u.startsWith('/api/')) return assetUrl(u);
|
||||
// If it's an absolute remote URL, route through backend proxy to avoid CORS/hotlinking issues
|
||||
@@ -170,13 +173,28 @@ const SetupPage: React.FC = () => {
|
||||
}
|
||||
}, [selectedFont]);
|
||||
|
||||
const handleSelectClub = (item: SearchResult) => {
|
||||
setClubId(item.club_id || '');
|
||||
const handleSelectClub = async (item: SearchResult) => {
|
||||
const clubIdValue = item.club_id || '';
|
||||
setClubId(clubIdValue);
|
||||
setClubType(item.club_type || 'football');
|
||||
setClubName(item.name || '');
|
||||
setClubLogoUrl(item.logo_url || '');
|
||||
setClubUrl(item.url || '');
|
||||
setClubQuery(item.name || '');
|
||||
|
||||
// Try to fetch logo from logoapi first, fallback to FACR logo
|
||||
let logoUrl = '';
|
||||
if (clubIdValue) {
|
||||
const logoApiUrl = await fetchLogoFromLogoAPI(clubIdValue, item.name);
|
||||
if (logoApiUrl) {
|
||||
logoUrl = logoApiUrl;
|
||||
}
|
||||
}
|
||||
// Fallback to FACR logo if logoapi doesn't have it
|
||||
if (!logoUrl && item.logo_url) {
|
||||
logoUrl = item.logo_url;
|
||||
}
|
||||
setClubLogoUrl(logoUrl);
|
||||
|
||||
// Auto-fill sender display name from club name if empty
|
||||
if (!smtpFromName && item.name) {
|
||||
setSmtpFromName(item.name);
|
||||
@@ -188,8 +206,8 @@ const SetupPage: React.FC = () => {
|
||||
}
|
||||
} catch {}
|
||||
// Try to extract colors
|
||||
if (item.logo_url) {
|
||||
extractPalette(item.logo_url, 5)
|
||||
if (logoUrl) {
|
||||
extractPalette(logoUrl, 5)
|
||||
.then((colors) => {
|
||||
if (!colors || colors.length === 0) return;
|
||||
const presets = generateThemeCandidates(colors);
|
||||
@@ -304,8 +322,8 @@ const SetupPage: React.FC = () => {
|
||||
const fd = new FormData();
|
||||
fd.append('file', f);
|
||||
fd.append('preserve_quality', 'true');
|
||||
// Upload should go to the API root (usually /api/v1/upload). Use configured API_URL
|
||||
const uploadUrl = `${(API_URL || '').replace(/\/$/, '')}/upload`;
|
||||
// Upload should go to the API root (usually /api/v1/upload). Use configured API_URL
|
||||
const uploadUrl = `${(API_URL || '').replace(/\/$/, '')}/upload`;
|
||||
const res = await fetch(uploadUrl, { method: 'POST', body: fd });
|
||||
if (!res.ok) throw new Error('Upload failed');
|
||||
const data = await res.json();
|
||||
@@ -315,6 +333,25 @@ const SetupPage: React.FC = () => {
|
||||
url = parsed.pathname + parsed.search + parsed.hash;
|
||||
} catch {}
|
||||
setClubLogoUrl(url);
|
||||
|
||||
// Also upload to logoapi if we have a club ID
|
||||
if (clubId) {
|
||||
try {
|
||||
const logoFd = new FormData();
|
||||
logoFd.append('logo', f);
|
||||
const logoApiRes = await fetch(`https://logoapi.sportcreative.eu/logos/${clubId}`, {
|
||||
method: 'POST',
|
||||
body: logoFd,
|
||||
});
|
||||
if (logoApiRes.ok) {
|
||||
toast({ title: 'Logo nahráno', description: 'Logo bylo nahráno na logoapi i lokálně', status: 'success', duration: 3000 });
|
||||
}
|
||||
} catch (logoApiErr) {
|
||||
console.warn('Failed to upload to logoapi:', logoApiErr);
|
||||
// Don't fail the whole upload if logoapi fails
|
||||
}
|
||||
}
|
||||
|
||||
// Try to extract colors from uploaded logo
|
||||
try { const colors = await extractPalette(url, 5); const presets = generateThemeCandidates(colors); setThemePresets(presets); if (presets[0]) { setPrimaryColor(presets[0].primary); setSecondaryColor(presets[0].secondary); setAccentColor(presets[0].accent); setBackgroundColor(presets[0].background); setTextColor(presets[0].text); setSelectedPreset(0); } } catch {}
|
||||
} catch (e) {
|
||||
@@ -376,17 +413,28 @@ const SetupPage: React.FC = () => {
|
||||
setSelectedPreset(idx);
|
||||
};
|
||||
|
||||
// Redirect if setup not required
|
||||
useEffect(() => {
|
||||
if (!loading && !requiresSetup) {
|
||||
navigate('/login', { replace: true });
|
||||
}
|
||||
}, [loading, requiresSetup, navigate]);
|
||||
|
||||
if (loading) return <Box p={8}>Načítání…</Box>;
|
||||
if (!requiresSetup) {
|
||||
navigate('/login', { replace: true });
|
||||
return null;
|
||||
}
|
||||
|
||||
// Get selected font pairing for live preview
|
||||
const selectedFontPairing = FONT_PAIRINGS.find((f) => f.id === selectedFont);
|
||||
const fontHeading = selectedFontPairing?.cssHeading || 'inherit';
|
||||
const fontBody = selectedFontPairing?.cssBody || 'inherit';
|
||||
|
||||
return (
|
||||
<Box minH="100vh" bg="gray.50" display="flex" alignItems="center" justifyContent="center" px={8} py={8}>
|
||||
<Box as="form" onSubmit={handleSubmit} w="100%" maxW="3xl" p={8} bg={bg} borderRadius="xl" boxShadow="lg" borderWidth="1px" borderColor={borderCol}>
|
||||
<Box minH="100vh" bg="gray.50" display="flex" alignItems="center" justifyContent="center" px={8} py={8} fontFamily={fontBody}>
|
||||
<Box as="form" onSubmit={handleSubmit} w="100%" maxW="3xl" p={8} bg={bg} borderRadius="xl" boxShadow="lg" borderWidth="1px" borderColor={borderCol} fontFamily={fontBody}>
|
||||
<VStack spacing={3} mb={6} align="stretch">
|
||||
<Heading size="xl">🚀 Vítejte v nastavení vašeho webu!</Heading>
|
||||
<Heading size="xl" fontFamily={fontHeading}>🚀 Vítejte v nastavení vašeho webu!</Heading>
|
||||
<Text fontSize="md" color="gray.600">
|
||||
Nastavte základní informace o vašem klubu. Můžete vše vyplnit nyní, nebo některé údaje doplnit později v administraci.
|
||||
</Text>
|
||||
@@ -401,7 +449,7 @@ const SetupPage: React.FC = () => {
|
||||
|
||||
<SimpleGrid columns={[1, 1, 2]} spacing={6}>
|
||||
<Box>
|
||||
<Heading as="h3" size="md" mb={4}>🔐 Administrátorský účet</Heading>
|
||||
<Heading as="h3" size="md" mb={4} fontFamily={fontHeading}>🔐 Administrátorský účet</Heading>
|
||||
<VStack align="stretch" spacing={4}>
|
||||
<FormControl isRequired>
|
||||
<FormLabel>E‑mail administrátora</FormLabel>
|
||||
@@ -439,7 +487,7 @@ const SetupPage: React.FC = () => {
|
||||
</Box>
|
||||
|
||||
<Box>
|
||||
<Heading as="h3" size="md" mb={4}>⚽ Informace o klubu</Heading>
|
||||
<Heading as="h3" size="md" mb={4} fontFamily={fontHeading}>⚽ Informace o klubu</Heading>
|
||||
<VStack align="stretch" spacing={4}>
|
||||
<FormControl>
|
||||
<FormLabel>Hledat klub (FAČR)</FormLabel>
|
||||
@@ -452,7 +500,7 @@ const SetupPage: React.FC = () => {
|
||||
{clubQuery && searchResults?.length > 0 && (
|
||||
<Box mt={2} borderWidth="1px" borderRadius="md" maxH="240px" overflowY="auto">
|
||||
<List spacing={0}>
|
||||
{searchResults.slice(0, 8).map((r) => (
|
||||
{searchResults.filter((r) => r.name && r.name.trim() !== '').slice(0, 8).map((r) => (
|
||||
<ListItem
|
||||
key={`${r.club_type}-${r.club_id}`}
|
||||
px={3} py={2} _hover={{ bg: 'gray.50', cursor: 'pointer' }}
|
||||
@@ -522,33 +570,7 @@ const SetupPage: React.FC = () => {
|
||||
|
||||
<Divider my={6} />
|
||||
|
||||
<Heading as="h3" size="md" mb={2}>📱 Sociální sítě a fotogalerie</Heading>
|
||||
<Text fontSize="sm" mb={3} color="gray.600">Zadejte odkazy na profily klubu a volitelně na fotogalerii. Lze později upravit v administraci.</Text>
|
||||
<SimpleGrid columns={[1, 1, 2]} spacing={6} mb={2}>
|
||||
<FormControl>
|
||||
<FormLabel>Facebook URL</FormLabel>
|
||||
<Input placeholder="https://www.facebook.com/vas.klub" value={facebookUrl} onChange={(e) => setFacebookUrl(e.target.value)} />
|
||||
</FormControl>
|
||||
<FormControl>
|
||||
<FormLabel>Instagram URL</FormLabel>
|
||||
<Input placeholder="https://www.instagram.com/vas.klub" value={instagramUrl} onChange={(e) => setInstagramUrl(e.target.value)} />
|
||||
</FormControl>
|
||||
<FormControl>
|
||||
<FormLabel>YouTube URL</FormLabel>
|
||||
<Input placeholder="https://www.youtube.com/@vas_klub" value={youtubeUrl} onChange={(e) => setYoutubeUrl(e.target.value)} />
|
||||
</FormControl>
|
||||
<FormControl>
|
||||
<FormLabel>URL fotogalerie</FormLabel>
|
||||
<Input placeholder="https://photos.example.com/club" value={galleryUrl} onChange={(e) => setGalleryUrl(e.target.value)} />
|
||||
<FormHelperText>Můžete použít libovolný web (SmugMug, Flickr, Google Photos, Zonerama...).</FormHelperText>
|
||||
</FormControl>
|
||||
<FormControl>
|
||||
<FormLabel>Popisek odkazu fotogalerie</FormLabel>
|
||||
<Input placeholder="Fotogalerie" value={galleryLabel} onChange={(e) => setGalleryLabel(e.target.value)} />
|
||||
</FormControl>
|
||||
</SimpleGrid>
|
||||
|
||||
<Heading as="h3" size="md" mb={2}>🎨 Barvy a vzhled webu</Heading>
|
||||
<Heading as="h3" size="md" mb={2} fontFamily={fontHeading}>🎨 Barvy a vzhled webu</Heading>
|
||||
<Text fontSize="sm" mb={3} color="gray.600">Automaticky z loga (lze upravit). Vyberte jednu z předloh nebo barvy ručně dolaďte.</Text>
|
||||
|
||||
{/* Preset selector */}
|
||||
@@ -571,17 +593,7 @@ const SetupPage: React.FC = () => {
|
||||
<Button mt={3} variant="ghost" onClick={regenerateFromLogo}>Znovu z loga</Button>
|
||||
</Box>
|
||||
)}
|
||||
<SimpleGrid columns={[1, 1, 3]} spacing={6}>
|
||||
<FormControl>
|
||||
<FormLabel>Styl webu</FormLabel>
|
||||
<Select value={frontpageStyle} onChange={(e) => setFrontpageStyle((e.target.value as any) || 'unified')}>
|
||||
<option value="unified">Aktuální (Unified)</option>
|
||||
<option value="magazine">Nový (Magazine)</option>
|
||||
<option value="pro">Pro (Hero fullscreen)</option>
|
||||
<option value="edge">Edge (Full‑width minimal)</option>
|
||||
</Select>
|
||||
<FormHelperText>Zvolte výchozí vzhled. Lze později změnit v administraci.</FormHelperText>
|
||||
</FormControl>
|
||||
<SimpleGrid columns={[1, 1, 2]} spacing={6}>
|
||||
<FormControl>
|
||||
<FormLabel>Primární
|
||||
<Tooltip label="Hlavní barva značky (tlačítka, odkazy, zvýraznění)." hasArrow><InfoOutlineIcon ml={2} /></Tooltip>
|
||||
@@ -632,8 +644,36 @@ const SetupPage: React.FC = () => {
|
||||
|
||||
<Divider my={6} />
|
||||
|
||||
<Heading as="h3" size="md" mb={2}>✍️ Písmo a typografie</Heading>
|
||||
<Text fontSize="sm" mb={3} color="gray.600">Vyberte vzhled písma pro váš web. Můžete kdykoliv změnit v administraci.</Text>
|
||||
<Heading as="h3" size="md" mb={2} fontFamily={fontHeading}>📱 Sociální sítě a fotogalerie</Heading>
|
||||
<Text fontSize="sm" mb={3} color="gray.600">Zadejte odkazy na profily klubu a volitelně na fotogalerii. Lze později upravit v administraci.</Text>
|
||||
<SimpleGrid columns={[1, 1, 2]} spacing={6} mb={2}>
|
||||
<FormControl>
|
||||
<FormLabel>Facebook URL</FormLabel>
|
||||
<Input placeholder="https://www.facebook.com/vas.klub" value={facebookUrl} onChange={(e) => setFacebookUrl(e.target.value)} />
|
||||
</FormControl>
|
||||
<FormControl>
|
||||
<FormLabel>Instagram URL</FormLabel>
|
||||
<Input placeholder="https://www.instagram.com/vas.klub" value={instagramUrl} onChange={(e) => setInstagramUrl(e.target.value)} />
|
||||
</FormControl>
|
||||
<FormControl>
|
||||
<FormLabel>YouTube URL</FormLabel>
|
||||
<Input placeholder="https://www.youtube.com/@vas_klub" value={youtubeUrl} onChange={(e) => setYoutubeUrl(e.target.value)} />
|
||||
</FormControl>
|
||||
<FormControl>
|
||||
<FormLabel>URL fotogalerie</FormLabel>
|
||||
<Input placeholder="https://photos.example.com/club" value={galleryUrl} onChange={(e) => setGalleryUrl(e.target.value)} />
|
||||
<FormHelperText>Můžete použít libovolný web (SmugMug, Flickr, Google Photos, Zonerama...).</FormHelperText>
|
||||
</FormControl>
|
||||
<FormControl>
|
||||
<FormLabel>Popisek odkazu fotogalerie</FormLabel>
|
||||
<Input placeholder="Fotogalerie" value={galleryLabel} onChange={(e) => setGalleryLabel(e.target.value)} />
|
||||
</FormControl>
|
||||
</SimpleGrid>
|
||||
|
||||
<Divider my={6} />
|
||||
|
||||
<Heading as="h3" size="md" mb={2} fontFamily={fontHeading}>✍️ Písmo a typografie</Heading>
|
||||
<Text fontSize="sm" mb={3} color="gray.600">Vyberte vzhled písma pro váš web. Náhled se aplikuje okamžitě na celou stránku.</Text>
|
||||
<Box mb={4}>
|
||||
<SimpleGrid columns={{ base: 1, md: 3 }} spacing={3}>
|
||||
{FONT_PAIRINGS.map((font) => (
|
||||
@@ -662,8 +702,8 @@ const SetupPage: React.FC = () => {
|
||||
|
||||
<Divider my={6} />
|
||||
|
||||
<Heading as="h3" size="md" mb={2}>📍 GPS poloha a mapa</Heading>
|
||||
<Text fontSize="sm" mb={4} color="gray.600">Nastavte polohu vašeho stadionu. Můžete vložit odkaz z mapy, nebo zadat souřadnice ručně.</Text>
|
||||
<Heading as="h3" size="md" mb={2} fontFamily={fontHeading}>📍 GPS poloha a mapa</Heading>
|
||||
<Text fontSize="sm" mb={4} color="gray.600">Nastavte polohu vašeho stadionu. Můžete vložit odkaz z mapy, nebo zadat souřadnice ručně. Vyberte také styl mapy.</Text>
|
||||
|
||||
<Box mb={4}>
|
||||
<MapLinkImporter
|
||||
@@ -695,21 +735,7 @@ const SetupPage: React.FC = () => {
|
||||
|
||||
<Divider my={6} />
|
||||
|
||||
<Heading as="h3" size="md" mb={2}>🎨 Styl mapy</Heading>
|
||||
<Text fontSize="sm" mb={4} color="gray.600">Vyberte vzhled mapy, který nejlépe pasuje k barvám vašeho klubu.</Text>
|
||||
<Box mb={4}>
|
||||
<MapStyleSelector
|
||||
value={mapStyle}
|
||||
onChange={setMapStyle}
|
||||
clubPrimaryColor={primaryColor}
|
||||
clubSecondaryColor={accentColor}
|
||||
showPreview={true}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
<Divider my={6} />
|
||||
|
||||
<Heading as="h3" size="md" mb={2}>📧 Kontaktní údaje</Heading>
|
||||
<Heading as="h3" size="md" mb={2} fontFamily={fontHeading}>📧 Kontaktní údaje</Heading>
|
||||
<Text fontSize="sm" mb={3} color="gray.600">Tyto údaje se automaticky vyplní při importu z mapy. Můžete je upravit nebo doplnit ručně.</Text>
|
||||
<SimpleGrid columns={[1, 1, 2]} spacing={4} mb={4}>
|
||||
<FormControl>
|
||||
@@ -742,7 +768,7 @@ const SetupPage: React.FC = () => {
|
||||
|
||||
<Divider my={6} />
|
||||
|
||||
<Heading as="h3" size="md" mb={4}>🔒 Zabezpečení a SMTP</Heading>
|
||||
<Heading as="h3" size="md" mb={4} fontFamily={fontHeading}>🔒 Zabezpečení a SMTP</Heading>
|
||||
<SimpleGrid columns={[1, 1, 2]} spacing={6}>
|
||||
<FormControl>
|
||||
<FormLabel>JWT tajemství</FormLabel>
|
||||
|
||||
@@ -220,6 +220,8 @@ const AnalyticsAdminPage: React.FC = () => {
|
||||
} | null>(null);
|
||||
const [countryDetails, setCountryDetails] = useState<any>(null);
|
||||
const [loadingCountryDetails, setLoadingCountryDetails] = useState(false);
|
||||
const [umamiConfig, setUmamiConfig] = useState<any>(null);
|
||||
const [showDiagnostics, setShowDiagnostics] = useState(false);
|
||||
const { isOpen, onOpen, onClose } = useDisclosure();
|
||||
|
||||
const bgColor = useColorModeValue('white', 'gray.800');
|
||||
@@ -310,8 +312,18 @@ const AnalyticsAdminPage: React.FC = () => {
|
||||
|
||||
useEffect(() => {
|
||||
fetchAnalytics(timeRange);
|
||||
fetchUmamiConfig();
|
||||
}, [timeRange]);
|
||||
|
||||
const fetchUmamiConfig = async () => {
|
||||
try {
|
||||
const response = await api.get('/umami/config');
|
||||
setUmamiConfig(response.data);
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch Umami config:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCountryClick = async (countryCode: string, countryName: string, value: number) => {
|
||||
setSelectedCountry({ code: countryCode, name: countryName, value });
|
||||
setLoadingCountryDetails(true);
|
||||
@@ -544,6 +556,117 @@ const AnalyticsAdminPage: React.FC = () => {
|
||||
</Card>
|
||||
</SimpleGrid>
|
||||
|
||||
{/* Diagnostics Panel */}
|
||||
{(!hasData || showDiagnostics) && (
|
||||
<Card bg="blue.50" borderColor="blue.300" borderWidth={2}>
|
||||
<CardBody>
|
||||
<HStack spacing={3} align="start">
|
||||
<Icon as={FiActivity} color="blue.500" boxSize={6} mt={1} />
|
||||
<VStack align="start" spacing={3} flex={1}>
|
||||
<HStack justify="space-between" w="full">
|
||||
<Text fontWeight="bold" color="blue.800" fontSize="lg">Diagnostika analytiky</Text>
|
||||
<Button
|
||||
size="xs"
|
||||
variant="ghost"
|
||||
onClick={() => setShowDiagnostics(!showDiagnostics)}
|
||||
>
|
||||
{showDiagnostics ? 'Skrýt' : 'Zobrazit detaily'}
|
||||
</Button>
|
||||
</HStack>
|
||||
|
||||
{/* Umami Connection Status */}
|
||||
<Box w="full">
|
||||
<HStack spacing={2} mb={2}>
|
||||
<Badge colorScheme={umamiConfig?.enabled ? 'green' : 'red'}>
|
||||
{umamiConfig?.enabled ? 'Připojeno' : 'Nepřipojeno'}
|
||||
</Badge>
|
||||
<Text fontSize="sm" fontWeight="semibold" color="blue.800">
|
||||
Stav Umami
|
||||
</Text>
|
||||
</HStack>
|
||||
{umamiConfig && (
|
||||
<VStack align="start" spacing={1} pl={4}>
|
||||
<Text fontSize="xs" color="blue.700">
|
||||
<strong>Aktivováno:</strong> {umamiConfig.enabled ? 'Ano' : 'Ne'}
|
||||
</Text>
|
||||
{umamiConfig.website_id && (
|
||||
<Text fontSize="xs" color="blue.700">
|
||||
<strong>Website ID:</strong> {umamiConfig.website_id}
|
||||
</Text>
|
||||
)}
|
||||
{umamiConfig.reason && (
|
||||
<Text fontSize="xs" color="red.600">
|
||||
<strong>Důvod:</strong> {umamiConfig.reason}
|
||||
</Text>
|
||||
)}
|
||||
</VStack>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<Divider borderColor="blue.200" />
|
||||
|
||||
{/* Why No Data */}
|
||||
{!hasData && (
|
||||
<>
|
||||
<Text fontSize="sm" color="blue.800" fontWeight="semibold">
|
||||
Proč nejsou k dispozici žádná data?
|
||||
</Text>
|
||||
<VStack align="start" spacing={1} pl={4}>
|
||||
<Text fontSize="xs" color="blue.700">
|
||||
• Umami tracking ještě nezaznamenal žádné návštěvy
|
||||
</Text>
|
||||
<Text fontSize="xs" color="blue.700">
|
||||
• Tracking script se načítá pouze na veřejných stránkách (ne na /admin)
|
||||
</Text>
|
||||
<Text fontSize="xs" color="blue.700">
|
||||
• Data se aktualizují v reálném čase po návštěvě veřejných stránek
|
||||
</Text>
|
||||
</VStack>
|
||||
|
||||
<Divider borderColor="blue.200" />
|
||||
|
||||
<Text fontSize="sm" color="blue.800" fontWeight="semibold">
|
||||
Jak vygenerovat testovací data:
|
||||
</Text>
|
||||
<VStack align="start" spacing={1} pl={4}>
|
||||
<Text fontSize="xs" color="blue.700">
|
||||
1. Otevřete hlavní stránku webu v novém okně inkognito
|
||||
</Text>
|
||||
<Text fontSize="xs" color="blue.700">
|
||||
2. Procházejte několik veřejných stránek (Blog, O klubu, Kontakt...)
|
||||
</Text>
|
||||
<Text fontSize="xs" color="blue.700">
|
||||
3. Počkejte 1-2 minuty a obnovte tuto stránku analytiky
|
||||
</Text>
|
||||
</VStack>
|
||||
|
||||
<HStack spacing={2} mt={2}>
|
||||
<Button
|
||||
size="sm"
|
||||
colorScheme="blue"
|
||||
leftIcon={<Icon as={FiGlobe} />}
|
||||
onClick={() => window.open('/', '_blank')}
|
||||
>
|
||||
Otevřít hlavní stránku
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
colorScheme="blue"
|
||||
variant="outline"
|
||||
leftIcon={<Icon as={FiZap} />}
|
||||
onClick={() => window.location.reload()}
|
||||
>
|
||||
Obnovit analytiku
|
||||
</Button>
|
||||
</HStack>
|
||||
</>
|
||||
)}
|
||||
</VStack>
|
||||
</HStack>
|
||||
</CardBody>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Error Message */}
|
||||
{errorMessage && (
|
||||
<Card bg="orange.50" borderColor="orange.300" borderWidth={2}>
|
||||
|
||||
@@ -0,0 +1,514 @@
|
||||
/**
|
||||
* DevDocsPage - Admin Documentation Viewer
|
||||
*
|
||||
* REQUIRED DEPENDENCIES:
|
||||
* npm install react-markdown react-syntax-highlighter
|
||||
* npm install --save-dev @types/react-syntax-highlighter
|
||||
*
|
||||
* This component requires these packages to render markdown with syntax highlighting.
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Container,
|
||||
Heading,
|
||||
Text,
|
||||
VStack,
|
||||
HStack,
|
||||
Button,
|
||||
Input,
|
||||
InputGroup,
|
||||
InputLeftElement,
|
||||
Select,
|
||||
Tabs,
|
||||
TabList,
|
||||
TabPanels,
|
||||
Tab,
|
||||
TabPanel,
|
||||
Badge,
|
||||
Icon,
|
||||
useColorModeValue,
|
||||
Divider,
|
||||
Code,
|
||||
Alert,
|
||||
AlertIcon,
|
||||
Breadcrumb,
|
||||
BreadcrumbItem,
|
||||
BreadcrumbLink,
|
||||
Spinner,
|
||||
useToast,
|
||||
} from '@chakra-ui/react';
|
||||
import {
|
||||
FiSearch,
|
||||
FiBook,
|
||||
FiCode,
|
||||
FiFileText,
|
||||
FiLayers,
|
||||
FiTool,
|
||||
FiHome,
|
||||
FiDownload,
|
||||
FiRefreshCw,
|
||||
} from 'react-icons/fi';
|
||||
import { Link as RouterLink } from 'react-router-dom';
|
||||
|
||||
// @ts-ignore - Install with: npm install react-markdown
|
||||
import ReactMarkdown from 'react-markdown';
|
||||
// @ts-ignore - Install with: npm install react-syntax-highlighter
|
||||
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
|
||||
// @ts-ignore
|
||||
import { vscDarkPlus } from 'react-syntax-highlighter/dist/esm/styles/prism';
|
||||
|
||||
interface DocFile {
|
||||
name: string;
|
||||
path: string;
|
||||
category: string;
|
||||
description: string;
|
||||
icon: any;
|
||||
tags: string[];
|
||||
}
|
||||
|
||||
const DevDocsPage: React.FC = () => {
|
||||
const [selectedDoc, setSelectedDoc] = useState<string>('');
|
||||
const [docContent, setDocContent] = useState<string>('');
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [selectedCategory, setSelectedCategory] = useState('all');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const toast = useToast();
|
||||
|
||||
const bgColor = useColorModeValue('white', 'gray.800');
|
||||
const borderColor = useColorModeValue('gray.200', 'gray.600');
|
||||
const sidebarBg = useColorModeValue('gray.50', 'gray.900');
|
||||
|
||||
// Documentation files registry
|
||||
const docFiles: DocFile[] = [
|
||||
{
|
||||
name: 'MyUIbrix Elementor Features',
|
||||
path: '/DOCS/MYUIBRIX_ELEMENTOR_FEATURES.md',
|
||||
category: 'Features',
|
||||
description: 'Complete guide to Elementor-style page builder features',
|
||||
icon: FiLayers,
|
||||
tags: ['myuibrix', 'elementor', 'editor', 'features'],
|
||||
},
|
||||
{
|
||||
name: 'MyUIbrix Enhancement Summary',
|
||||
path: '/DOCS/MYUIBRIX_ENHANCEMENT_SUMMARY.md',
|
||||
category: 'Features',
|
||||
description: 'Implementation summary of Elementor enhancements',
|
||||
icon: FiTool,
|
||||
tags: ['myuibrix', 'enhancement', 'summary'],
|
||||
},
|
||||
{
|
||||
name: 'MyUIbrix Quick Start',
|
||||
path: '/DOCS/MYUIBRIX_QUICK_START.md',
|
||||
category: 'Guides',
|
||||
description: 'Quick reference guide for MyUIbrix editor',
|
||||
icon: FiBook,
|
||||
tags: ['myuibrix', 'quick-start', 'guide'],
|
||||
},
|
||||
{
|
||||
name: 'MyUIbrix Fixes',
|
||||
path: '/DOCS/MYUIBRIX_FIXES.md',
|
||||
category: 'Technical',
|
||||
description: 'Technical fixes and improvements documentation',
|
||||
icon: FiTool,
|
||||
tags: ['myuibrix', 'fixes', 'technical'],
|
||||
},
|
||||
{
|
||||
name: 'Integration Guide',
|
||||
path: '/DOCS/INTEGRATION_GUIDE.md',
|
||||
category: 'Development',
|
||||
description: 'How to integrate MyUIbrix components',
|
||||
icon: FiCode,
|
||||
tags: ['integration', 'development', 'components'],
|
||||
},
|
||||
{
|
||||
name: 'CSS Classes Reference',
|
||||
path: '/DOCS/CSS_CLASSES_REFERENCE.md',
|
||||
category: 'Reference',
|
||||
description: 'Complete CSS classes and selectors reference',
|
||||
icon: FiFileText,
|
||||
tags: ['css', 'styling', 'classes', 'reference'],
|
||||
},
|
||||
{
|
||||
name: 'Admin Functionality Report',
|
||||
path: '/DOCS/ADMIN_FUNCTIONALITY_REPORT.md',
|
||||
category: 'Admin',
|
||||
description: 'Complete admin panel functionality documentation',
|
||||
icon: FiTool,
|
||||
tags: ['admin', 'functionality', 'report'],
|
||||
},
|
||||
{
|
||||
name: 'Setup Improvements',
|
||||
path: '/DOCS/SETUP_IMPROVEMENTS.md',
|
||||
category: 'Setup',
|
||||
description: 'Initial setup and configuration guide',
|
||||
icon: FiBook,
|
||||
tags: ['setup', 'configuration', 'improvements'],
|
||||
},
|
||||
{
|
||||
name: 'Docker Enhancements',
|
||||
path: '/DOCS/DOCKER_ENHANCEMENTS_SUMMARY.md',
|
||||
category: 'DevOps',
|
||||
description: 'Docker setup and deployment guide',
|
||||
icon: FiCode,
|
||||
tags: ['docker', 'deployment', 'devops'],
|
||||
},
|
||||
];
|
||||
|
||||
const categories = ['all', 'Features', 'Guides', 'Technical', 'Development', 'Reference', 'Admin', 'Setup', 'DevOps'];
|
||||
|
||||
// Filter documents
|
||||
const filteredDocs = docFiles.filter(doc => {
|
||||
const matchesSearch = searchQuery === '' ||
|
||||
doc.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
doc.description.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
doc.tags.some(tag => tag.toLowerCase().includes(searchQuery.toLowerCase()));
|
||||
|
||||
const matchesCategory = selectedCategory === 'all' || doc.category === selectedCategory;
|
||||
|
||||
return matchesSearch && matchesCategory;
|
||||
});
|
||||
|
||||
// Load document content
|
||||
const loadDocument = async (docPath: string) => {
|
||||
setLoading(true);
|
||||
setSelectedDoc(docPath);
|
||||
|
||||
try {
|
||||
// In production, fetch from backend API
|
||||
const response = await fetch(docPath);
|
||||
if (!response.ok) throw new Error('Failed to load document');
|
||||
|
||||
const content = await response.text();
|
||||
setDocContent(content);
|
||||
} catch (error) {
|
||||
console.error('Error loading document:', error);
|
||||
|
||||
// Fallback: show error message
|
||||
setDocContent(`# Document Not Found\n\nThe requested documentation file could not be loaded.\n\n**Path**: ${docPath}\n\nPlease ensure the documentation files are properly deployed.`);
|
||||
|
||||
toast({
|
||||
title: 'Error loading document',
|
||||
description: 'The documentation file could not be loaded',
|
||||
status: 'error',
|
||||
duration: 3000,
|
||||
});
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Load first document on mount
|
||||
useEffect(() => {
|
||||
if (docFiles.length > 0) {
|
||||
loadDocument(docFiles[0].path);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Custom markdown components
|
||||
const markdownComponents = {
|
||||
code({ node, inline, className, children, ...props }: any) {
|
||||
const match = /language-(\w+)/.exec(className || '');
|
||||
return !inline && match ? (
|
||||
<SyntaxHighlighter
|
||||
style={vscDarkPlus}
|
||||
language={match[1]}
|
||||
PreTag="div"
|
||||
{...props}
|
||||
>
|
||||
{String(children).replace(/\n$/, '')}
|
||||
</SyntaxHighlighter>
|
||||
) : (
|
||||
<Code {...props}>{children}</Code>
|
||||
);
|
||||
},
|
||||
h1: ({ children }: any) => (
|
||||
<Heading as="h1" size="2xl" mb={6} mt={8}>
|
||||
{children}
|
||||
</Heading>
|
||||
),
|
||||
h2: ({ children }: any) => (
|
||||
<Heading as="h2" size="xl" mb={4} mt={6}>
|
||||
{children}
|
||||
</Heading>
|
||||
),
|
||||
h3: ({ children }: any) => (
|
||||
<Heading as="h3" size="lg" mb={3} mt={5}>
|
||||
{children}
|
||||
</Heading>
|
||||
),
|
||||
p: ({ children }: any) => (
|
||||
<Text mb={4} lineHeight="tall">
|
||||
{children}
|
||||
</Text>
|
||||
),
|
||||
ul: ({ children }: any) => (
|
||||
<VStack as="ul" align="stretch" spacing={2} mb={4} pl={6}>
|
||||
{children}
|
||||
</VStack>
|
||||
),
|
||||
li: ({ children }: any) => (
|
||||
<Text as="li" mb={1}>
|
||||
{children}
|
||||
</Text>
|
||||
),
|
||||
};
|
||||
|
||||
// Download document
|
||||
const downloadDocument = () => {
|
||||
const blob = new Blob([docContent], { type: 'text/markdown' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = selectedDoc.split('/').pop() || 'document.md';
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
|
||||
toast({
|
||||
title: 'Document downloaded',
|
||||
status: 'success',
|
||||
duration: 2000,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Box minH="100vh" bg={useColorModeValue('gray.50', 'gray.900')}>
|
||||
{/* Breadcrumb */}
|
||||
<Box bg={bgColor} borderBottom="1px" borderColor={borderColor} py={4}>
|
||||
<Container maxW="container.xl">
|
||||
<Breadcrumb>
|
||||
<BreadcrumbItem>
|
||||
<BreadcrumbLink as={RouterLink} to="/admin">
|
||||
<HStack spacing={2}>
|
||||
<FiHome />
|
||||
<Text>Admin</Text>
|
||||
</HStack>
|
||||
</BreadcrumbLink>
|
||||
</BreadcrumbItem>
|
||||
<BreadcrumbItem isCurrentPage>
|
||||
<BreadcrumbLink>
|
||||
<HStack spacing={2}>
|
||||
<FiBook />
|
||||
<Text>Developer Documentation</Text>
|
||||
</HStack>
|
||||
</BreadcrumbLink>
|
||||
</BreadcrumbItem>
|
||||
</Breadcrumb>
|
||||
</Container>
|
||||
</Box>
|
||||
|
||||
{/* Header */}
|
||||
<Box bg={bgColor} borderBottom="1px" borderColor={borderColor} py={6}>
|
||||
<Container maxW="container.xl">
|
||||
<VStack align="stretch" spacing={4}>
|
||||
<HStack justify="space-between" align="start">
|
||||
<VStack align="start" spacing={2}>
|
||||
<Heading size="lg">📚 Developer Documentation</Heading>
|
||||
<Text color="gray.600">
|
||||
Complete technical documentation for MyUIbrix and admin features
|
||||
</Text>
|
||||
</VStack>
|
||||
<HStack spacing={2}>
|
||||
<Button
|
||||
leftIcon={<FiDownload />}
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={downloadDocument}
|
||||
isDisabled={!selectedDoc}
|
||||
>
|
||||
Download
|
||||
</Button>
|
||||
<Button
|
||||
leftIcon={<FiRefreshCw />}
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => selectedDoc && loadDocument(selectedDoc)}
|
||||
isLoading={loading}
|
||||
>
|
||||
Refresh
|
||||
</Button>
|
||||
</HStack>
|
||||
</HStack>
|
||||
|
||||
{/* Search and Filter */}
|
||||
<HStack spacing={4}>
|
||||
<InputGroup maxW="400px">
|
||||
<InputLeftElement pointerEvents="none">
|
||||
<FiSearch color="gray.300" />
|
||||
</InputLeftElement>
|
||||
<Input
|
||||
placeholder="Search documentation..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
bg={bgColor}
|
||||
/>
|
||||
</InputGroup>
|
||||
|
||||
<Select
|
||||
maxW="200px"
|
||||
value={selectedCategory}
|
||||
onChange={(e) => setSelectedCategory(e.target.value)}
|
||||
bg={bgColor}
|
||||
>
|
||||
{categories.map(cat => (
|
||||
<option key={cat} value={cat}>
|
||||
{cat === 'all' ? 'All Categories' : cat}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
|
||||
<Badge colorScheme="blue" fontSize="md" px={3} py={1}>
|
||||
{filteredDocs.length} docs
|
||||
</Badge>
|
||||
</HStack>
|
||||
</VStack>
|
||||
</Container>
|
||||
</Box>
|
||||
|
||||
{/* Main Content */}
|
||||
<Container maxW="container.xl" py={8}>
|
||||
<HStack align="start" spacing={6}>
|
||||
{/* Sidebar */}
|
||||
<VStack
|
||||
width="350px"
|
||||
bg={sidebarBg}
|
||||
borderRadius="lg"
|
||||
p={4}
|
||||
align="stretch"
|
||||
spacing={3}
|
||||
maxH="calc(100vh - 300px)"
|
||||
overflowY="auto"
|
||||
position="sticky"
|
||||
top="20px"
|
||||
>
|
||||
<Text fontWeight="bold" fontSize="sm" textTransform="uppercase" color="gray.500">
|
||||
Documentation Files
|
||||
</Text>
|
||||
|
||||
{filteredDocs.length === 0 ? (
|
||||
<Alert status="info" borderRadius="md">
|
||||
<AlertIcon />
|
||||
No documents found
|
||||
</Alert>
|
||||
) : (
|
||||
filteredDocs.map((doc) => (
|
||||
<Box
|
||||
key={doc.path}
|
||||
p={4}
|
||||
bg={selectedDoc === doc.path ? 'blue.50' : bgColor}
|
||||
borderRadius="md"
|
||||
cursor="pointer"
|
||||
transition="all 0.2s"
|
||||
borderWidth="2px"
|
||||
borderColor={selectedDoc === doc.path ? 'blue.400' : 'transparent'}
|
||||
_hover={{
|
||||
transform: 'translateX(4px)',
|
||||
borderColor: 'blue.300',
|
||||
}}
|
||||
onClick={() => loadDocument(doc.path)}
|
||||
>
|
||||
<HStack spacing={3} mb={2}>
|
||||
<Icon as={doc.icon} boxSize={5} color="blue.500" />
|
||||
<VStack align="start" spacing={0} flex={1}>
|
||||
<Text fontWeight="bold" fontSize="sm">
|
||||
{doc.name}
|
||||
</Text>
|
||||
<Badge colorScheme="purple" fontSize="xs">
|
||||
{doc.category}
|
||||
</Badge>
|
||||
</VStack>
|
||||
</HStack>
|
||||
<Text fontSize="xs" color="gray.600">
|
||||
{doc.description}
|
||||
</Text>
|
||||
<HStack spacing={1} mt={2} flexWrap="wrap">
|
||||
{doc.tags.slice(0, 3).map(tag => (
|
||||
<Badge key={tag} size="sm" variant="outline" fontSize="xs">
|
||||
{tag}
|
||||
</Badge>
|
||||
))}
|
||||
</HStack>
|
||||
</Box>
|
||||
))
|
||||
)}
|
||||
</VStack>
|
||||
|
||||
{/* Content Area */}
|
||||
<Box
|
||||
flex={1}
|
||||
bg={bgColor}
|
||||
borderRadius="lg"
|
||||
p={8}
|
||||
boxShadow="sm"
|
||||
minH="600px"
|
||||
>
|
||||
{loading ? (
|
||||
<VStack spacing={4} py={12}>
|
||||
<Spinner size="xl" color="blue.500" />
|
||||
<Text color="gray.500">Loading documentation...</Text>
|
||||
</VStack>
|
||||
) : docContent ? (
|
||||
<Box
|
||||
sx={{
|
||||
'& pre': {
|
||||
borderRadius: 'md',
|
||||
marginBottom: '1rem',
|
||||
},
|
||||
'& table': {
|
||||
width: '100%',
|
||||
marginBottom: '1rem',
|
||||
borderCollapse: 'collapse',
|
||||
},
|
||||
'& th': {
|
||||
background: useColorModeValue('gray.100', 'gray.700'),
|
||||
padding: '12px',
|
||||
textAlign: 'left',
|
||||
borderBottom: '2px solid',
|
||||
borderColor: borderColor,
|
||||
},
|
||||
'& td': {
|
||||
padding: '12px',
|
||||
borderBottom: '1px solid',
|
||||
borderColor: borderColor,
|
||||
},
|
||||
'& hr': {
|
||||
margin: '2rem 0',
|
||||
borderColor: borderColor,
|
||||
},
|
||||
'& blockquote': {
|
||||
borderLeft: '4px solid',
|
||||
borderColor: 'blue.400',
|
||||
paddingLeft: '1rem',
|
||||
marginLeft: 0,
|
||||
fontStyle: 'italic',
|
||||
color: 'gray.600',
|
||||
},
|
||||
'& img': {
|
||||
maxWidth: '100%',
|
||||
borderRadius: 'md',
|
||||
boxShadow: 'md',
|
||||
marginBottom: '1rem',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<ReactMarkdown components={markdownComponents}>
|
||||
{docContent}
|
||||
</ReactMarkdown>
|
||||
</Box>
|
||||
) : (
|
||||
<VStack spacing={4} py={12}>
|
||||
<Icon as={FiBook} boxSize={16} color="gray.300" />
|
||||
<Text color="gray.500">Select a document to view</Text>
|
||||
</VStack>
|
||||
)}
|
||||
</Box>
|
||||
</HStack>
|
||||
</Container>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default DevDocsPage;
|
||||
@@ -25,7 +25,8 @@ import {
|
||||
useColorModeValue,
|
||||
} from '@chakra-ui/react';
|
||||
import { RefreshCw, ExternalLink, Calendar, Image as ImageIcon, Eye } from 'lucide-react';
|
||||
import AdminLayout from '../../components/layout/AdminLayout';
|
||||
import AdminLayout from '../../layouts/AdminLayout';
|
||||
import api from '../../services/api';
|
||||
|
||||
interface Album {
|
||||
id: string;
|
||||
@@ -114,20 +115,8 @@ const GalleryAdminPage: React.FC = () => {
|
||||
setRefreshing(true);
|
||||
|
||||
try {
|
||||
const apiUrl = process.env.REACT_APP_API_URL || 'http://localhost:8080/api/v1';
|
||||
const token = localStorage.getItem('token');
|
||||
|
||||
const response = await fetch(`${apiUrl}/admin/gallery/refresh`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Chyba při obnově galerie');
|
||||
}
|
||||
// Use the api service which automatically includes authentication
|
||||
await api.post('/admin/gallery/refresh');
|
||||
|
||||
toast({
|
||||
title: 'Galerie obnovena',
|
||||
@@ -140,13 +129,17 @@ const GalleryAdminPage: React.FC = () => {
|
||||
// Reload albums after refresh
|
||||
await fetchAlbums();
|
||||
} catch (err: any) {
|
||||
const errorMessage = err.response?.data?.error || err.message || 'Nepodařilo se obnovit galerii';
|
||||
|
||||
toast({
|
||||
title: 'Chyba',
|
||||
description: err.message || 'Nepodařilo se obnovit galerii',
|
||||
title: 'Chyba při obnově galerie',
|
||||
description: errorMessage,
|
||||
status: 'error',
|
||||
duration: 5000,
|
||||
isClosable: true,
|
||||
});
|
||||
|
||||
console.error('Gallery refresh error:', err);
|
||||
} finally {
|
||||
setRefreshing(false);
|
||||
}
|
||||
|
||||
@@ -562,9 +562,9 @@ const MatchesAdminPage = () => {
|
||||
return (
|
||||
<AdminLayout requireAdmin={false}>
|
||||
<Box>
|
||||
<Box bg={headerBg} color={headerText} borderRadius="xl" p={6} mb={6} boxShadow="lg">
|
||||
<Box mb={6}>
|
||||
<Heading size="lg" mb={2}>Správa zápasů</Heading>
|
||||
<Text opacity={0.9}>
|
||||
<Text color={useColorModeValue('gray.600', 'gray.400')}>
|
||||
Správa a úprava zápasů. Můžete upravovat informace o zápasech, včetně názvů týmů, termínů, log a dalších detailů.
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
@@ -719,7 +719,7 @@ const NavigationAdminPage = () => {
|
||||
<Box flex="1">
|
||||
<HStack spacing={4}>
|
||||
<Text fontSize="sm">
|
||||
<strong>Načteno:</strong> {navItems.length} webových, {adminNavItems.length} admin, {socialLinks.length} sociálních
|
||||
<strong>Načteno:</strong> {navItems.length} webových, {adminNavItems.length} admin
|
||||
</Text>
|
||||
</HStack>
|
||||
</Box>
|
||||
@@ -731,8 +731,7 @@ const NavigationAdminPage = () => {
|
||||
<Text fontWeight="bold">Oddělená správa navigace</Text>
|
||||
<Text fontSize="sm" mt={1}>
|
||||
• <strong>Webová navigace:</strong> Menu na veřejném webu<br/>
|
||||
• <strong>Admin panel:</strong> Postranní menu v administraci<br/>
|
||||
• <strong>Sociální sítě:</strong> Odkazy na sociální média
|
||||
• <strong>Admin panel:</strong> Postranní menu v administraci
|
||||
</Text>
|
||||
</Box>
|
||||
</Alert>
|
||||
@@ -741,7 +740,6 @@ const NavigationAdminPage = () => {
|
||||
<TabList>
|
||||
<Tab>Webová navigace</Tab>
|
||||
<Tab>Admin panel</Tab>
|
||||
<Tab>Sociální sítě</Tab>
|
||||
</TabList>
|
||||
|
||||
<TabPanels>
|
||||
@@ -874,100 +872,6 @@ const NavigationAdminPage = () => {
|
||||
</VStack>
|
||||
</VStack>
|
||||
</TabPanel>
|
||||
|
||||
{/* Social Links Tab */}
|
||||
<TabPanel>
|
||||
<VStack spacing={4} align="stretch">
|
||||
<Button leftIcon={<AddIcon />} colorScheme="blue" onClick={() => openSocialModal()}>
|
||||
Přidat sociální síť
|
||||
</Button>
|
||||
|
||||
{socialLinks.length === 0 ? (
|
||||
<Alert status="warning">
|
||||
<AlertIcon />
|
||||
<Box>
|
||||
<Text fontWeight="bold">Žádné sociální sítě</Text>
|
||||
<Text fontSize="sm" mt={1}>
|
||||
Nebyly nalezeny žádné odkazy na sociální sítě. Klikněte na "Přidat sociální síť" pro vytvoření odkazu.
|
||||
</Text>
|
||||
</Box>
|
||||
</Alert>
|
||||
) : (
|
||||
<Box borderWidth="1px" borderRadius="lg" overflow="hidden">
|
||||
<Table variant="simple">
|
||||
<Thead>
|
||||
<Tr>
|
||||
<Th width="100px">Pořadí</Th>
|
||||
<Th>Ikona</Th>
|
||||
<Th>Platforma</Th>
|
||||
<Th>URL</Th>
|
||||
<Th>Viditelné</Th>
|
||||
<Th>Akce</Th>
|
||||
</Tr>
|
||||
</Thead>
|
||||
<Tbody>
|
||||
{socialLinks.map((link, index) => {
|
||||
const IconComponent = getSocialIcon(link.platform);
|
||||
return (
|
||||
<Tr key={link.id} bg={link.visible ? 'transparent' : 'gray.50'}>
|
||||
<Td>
|
||||
<HStack spacing={1}>
|
||||
<IconButton
|
||||
aria-label="Nahoru"
|
||||
icon={<ChevronUpIcon />}
|
||||
size="sm"
|
||||
isDisabled={index === 0}
|
||||
onClick={() => moveSocialLink(index, 'up')}
|
||||
/>
|
||||
<IconButton
|
||||
aria-label="Dolů"
|
||||
icon={<ChevronDownIcon />}
|
||||
size="sm"
|
||||
isDisabled={index === socialLinks.length - 1}
|
||||
onClick={() => moveSocialLink(index, 'down')}
|
||||
/>
|
||||
</HStack>
|
||||
</Td>
|
||||
<Td>
|
||||
<IconComponent size={24} />
|
||||
</Td>
|
||||
<Td fontWeight="bold">{link.platform}</Td>
|
||||
<Td>
|
||||
<Text fontSize="sm" color="gray.600" isTruncated maxW="300px">
|
||||
{link.url}
|
||||
</Text>
|
||||
</Td>
|
||||
<Td>
|
||||
<Badge colorScheme={link.visible ? 'green' : 'gray'}>
|
||||
{link.visible ? 'Ano' : 'Ne'}
|
||||
</Badge>
|
||||
</Td>
|
||||
<Td>
|
||||
<HStack spacing={2}>
|
||||
<IconButton
|
||||
aria-label="Upravit"
|
||||
icon={<EditIcon />}
|
||||
size="sm"
|
||||
onClick={() => openSocialModal(link)}
|
||||
/>
|
||||
<IconButton
|
||||
aria-label="Smazat"
|
||||
icon={<DeleteIcon />}
|
||||
size="sm"
|
||||
colorScheme="red"
|
||||
onClick={() => deleteSocial(link.id!)}
|
||||
/>
|
||||
</HStack>
|
||||
</Td>
|
||||
</Tr>
|
||||
);
|
||||
})}
|
||||
</Tbody>
|
||||
</Table>
|
||||
</Box>
|
||||
)}
|
||||
</VStack>
|
||||
</TabPanel>
|
||||
</TabPanels>
|
||||
</Tabs>
|
||||
</VStack>
|
||||
@@ -1184,61 +1088,6 @@ const NavigationAdminPage = () => {
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
|
||||
{/* Social Link Modal */}
|
||||
<Modal isOpen={isSocialModalOpen} onClose={onSocialModalClose} size="xl">
|
||||
<ModalOverlay />
|
||||
<ModalContent>
|
||||
<ModalHeader>{editingSocial?.id ? 'Upravit odkaz' : 'Nový odkaz'}</ModalHeader>
|
||||
<ModalCloseButton />
|
||||
<ModalBody>
|
||||
<VStack spacing={4}>
|
||||
<FormControl isRequired>
|
||||
<FormLabel>Platforma</FormLabel>
|
||||
<Select
|
||||
value={editingSocial?.platform || 'facebook'}
|
||||
onChange={(e) =>
|
||||
setEditingSocial({ ...editingSocial!, platform: e.target.value })
|
||||
}
|
||||
>
|
||||
{SOCIAL_PLATFORMS.map((platform) => (
|
||||
<option key={platform.value} value={platform.value}>
|
||||
{platform.label}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
|
||||
<FormControl isRequired>
|
||||
<FormLabel>URL</FormLabel>
|
||||
<Input
|
||||
value={editingSocial?.url || ''}
|
||||
onChange={(e) => setEditingSocial({ ...editingSocial!, url: e.target.value })}
|
||||
placeholder="https://www.facebook.com/..."
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
<FormControl display="flex" alignItems="center">
|
||||
<FormLabel mb="0">Viditelné</FormLabel>
|
||||
<Switch
|
||||
isChecked={editingSocial?.visible ?? true}
|
||||
onChange={(e) =>
|
||||
setEditingSocial({ ...editingSocial!, visible: e.target.checked })
|
||||
}
|
||||
/>
|
||||
</FormControl>
|
||||
</VStack>
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<Button variant="ghost" mr={3} onClick={onSocialModalClose}>
|
||||
Zrušit
|
||||
</Button>
|
||||
<Button colorScheme="blue" onClick={saveSocialLink}>
|
||||
Uložit
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
</Container>
|
||||
</AdminLayout>
|
||||
);
|
||||
|
||||
@@ -1040,7 +1040,7 @@ html {
|
||||
/* Standings section (Další aktuality + Tabulky) - default two-column layout */
|
||||
section.standings {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
grid-template-columns: 2fr 1fr;
|
||||
gap: 24px;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user