This commit is contained in:
Tomas Dvorak
2025-10-23 22:26:50 +02:00
parent 63700eedb2
commit 70ea0c3c91
75 changed files with 3337 additions and 1160 deletions
+35 -40
View File
@@ -3,7 +3,7 @@ import MainLayout from '../components/layout/MainLayout';
import { useParams, Link as RouterLink } from 'react-router-dom';
import { getEvent } from '../services/eventService';
import { Box, Container, Heading, Text, VStack, HStack, Badge, Spinner, Button, Image, Link as ChakraLink, Divider, Icon, useColorModeValue } from '@chakra-ui/react';
import { FiDownload, FiFile, FiImage, FiMapPin, FiClock } from 'react-icons/fi';
import { FiDownload, FiMapPin, FiClock } from 'react-icons/fi';
import DOMPurify from 'dompurify';
import { assetUrl } from '../utils/url';
import EventLocationMap from '../components/events/EventLocationMap';
@@ -90,61 +90,33 @@ const ActivityDetailPage: React.FC = () => {
)}
{!loading && !error && data && (
<VStack align="stretch" spacing={5}>
{/* Hero image */}
{data.image_url && (
<Box borderRadius="xl" overflow="hidden" borderWidth="1px">
<Image src={assetUrl(data.image_url) || data.image_url} alt={data.title} w="100%" maxH="420px" objectFit="cover" />
</Box>
)}
{/* Title and meta */}
<VStack align="stretch" spacing={1}>
<HStack justify="space-between" align="start">
<Heading as="h1" size="lg" lineHeight={1.2}>{data.title}</Heading>
<Badge colorScheme={typeColor(data.type)}>{typeLabel(data.type)}</Badge>
</HStack>
<HStack spacing={4} color={mutedText} fontSize="sm">
<HStack>
<Icon as={FiClock} />
<Text>
{new Date(data.start_time).toLocaleString()} {data.end_time ? ` ${new Date(data.end_time).toLocaleString()}` : ''}
</Text>
</HStack>
{data.location && (
<HStack>
<Icon as={FiMapPin} />
<Text>{data.location}</Text>
</HStack>
)}
<HStack>
<Icon as={FiClock} />
<Text>
{new Date(data.start_time).toLocaleString()} {data.end_time ? ` ${new Date(data.end_time).toLocaleString()}` : ''}
</Text>
</HStack>
</HStack>
</VStack>
{data.location && (
<EventLocationMap
location={data.location}
title={data.title}
latitude={data.latitude}
longitude={data.longitude}
/>
)}
{/* YouTube Video */}
{data.youtube_url && getYouTubeEmbedUrl(data.youtube_url) && (
<Box borderRadius="lg" overflow="hidden" boxShadow="md">
<Box position="relative" paddingBottom="56.25%" height={0}>
<iframe
style={{ position: 'absolute', top: 0, left: 0, width: '100%', height: '100%' }}
src={getYouTubeEmbedUrl(data.youtube_url) || ''}
title={data.title}
frameBorder="0"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
allowFullScreen
/>
</Box>
</Box>
)}
{/* Content */}
{data.description && (
<Box
bg={cardBg}
@@ -164,7 +136,34 @@ const ActivityDetailPage: React.FC = () => {
/>
)}
{/* Attachments with Preview */}
{data.youtube_url && getYouTubeEmbedUrl(data.youtube_url) && (
<Box borderRadius="lg" overflow="hidden" boxShadow="md">
<Box position="relative" paddingBottom="56.25%" height={0}>
<iframe
style={{ position: 'absolute', top: 0, left: 0, width: '100%', height: '100%' }}
src={getYouTubeEmbedUrl(data.youtube_url) || ''}
title={data.title}
frameBorder="0"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
allowFullScreen
/>
</Box>
</Box>
)}
{data.location && (
<EventLocationMap
location={data.location}
title={data.title}
latitude={data.latitude}
longitude={data.longitude}
/>
)}
{data?.id && (
<EmbeddedPoll eventId={data.id} />
)}
{(Array.isArray(data.attachments) && data.attachments.length > 0) && (
<VStack align="stretch" spacing={3}>
<Heading as="h3" size="sm">Přílohy</Heading>
@@ -183,7 +182,6 @@ const ActivityDetailPage: React.FC = () => {
</VStack>
)}
{/* Legacy single file_url */}
{data.file_url && (
<HStack>
<Button as={ChakraLink} href={assetUrl(data.file_url) || data.file_url} isExternal variant="outline" leftIcon={<FiDownload />}>
@@ -192,7 +190,6 @@ const ActivityDetailPage: React.FC = () => {
</HStack>
)}
{/* Back links */}
<Divider />
<HStack>
<Button as={RouterLink} to="/aktivity" variant="outline">Zpět na aktivity</Button>
@@ -203,8 +200,6 @@ const ActivityDetailPage: React.FC = () => {
</Container>
</Box>
{/* Embedded Poll - shows polls related to this event */}
{data?.id && <EmbeddedPoll eventId={data.id} />}
</MainLayout>
);
};
+1 -1
View File
@@ -60,7 +60,7 @@ const AuthPage: React.FC = () => {
return <Navigate to="/admin" replace />;
}
const from = (location.state as LocationState)?.from?.pathname || '/admin';
const from = (location.state as LocationState)?.from?.pathname || '/';
const handleLogin = async (e: React.FormEvent) => {
e.preventDefault();
+109 -148
View File
@@ -2,6 +2,7 @@ import React, { useEffect, useRef, useState, useMemo } from 'react';
import MainLayout from '../components/layout/MainLayout';
import { FiArrowRight, FiCalendar, FiUsers, FiAward, FiChevronLeft, FiChevronRight } from 'react-icons/fi';
import '../styles/theme.css';
import '../styles/sparta-styles.css';
import './styles/UnifiedHome.css';
import { getPublicSettings } from '../services/settings';
import { assetUrl, sanitizeClubName } from '../utils/url';
@@ -11,9 +12,11 @@ import BlogCardsScroller from '../components/home/BlogCardsScroller';
import BlogSwiper from '../components/home/BlogSwiper';
import VideosSection from '../components/home/VideosSection';
import MerchSection from '../components/home/MerchSection';
import PollsWidget from '../components/home/PollsWidget';
import GallerySection from '../components/home/GallerySection';
import { getArticles as apiGetArticles, Article as ApiArticle } from '../services/articles';
import { getCompetitionAliasesPublic, CompetitionAlias } from '../services/competitionAliases';
import { getUpcomingEvents } from '../services/eventService';
import NewsletterSubscribe from '../components/newsletter/NewsletterSubscribe';
import MyUIbrixStyleEditor from '../components/editor/MyUIbrixEditor';
import MyUIbrixErrorBoundary from '../components/editor/MyUIbrixErrorBoundary';
@@ -91,6 +94,7 @@ const HomePage: React.FC = () => {
type UiSponsor = { id:number|string; name:string; logo:string; url?:string };
type UiBanner = { id:number|string; name:string; image:string; url?:string; placement?:string; width?:number; height?:number };
type UiMerch = { id?: number|string; title?: string; image_url: string; url?: string };
type UiEvent = { id:number|string; title:string; start_time:string; end_time?:string|null; location?:string|null; type?:string; image_url?:string|null };
const [players, setPlayers] = useState<UiPlayer[]>([]);
const [sponsors, setSponsors] = useState<UiSponsor[]>([]);
const [banners, setBanners] = useState<UiBanner[]>([]);
@@ -99,6 +103,7 @@ const HomePage: React.FC = () => {
const [videosRich, setVideosRich] = useState<Array<{ url:string; title?:string; length?:string; uploaded_at?:string; thumbnail_url?:string }>>([]);
const [merchItems, setMerchItems] = useState<UiMerch[]>([]);
const [merchEnabled, setMerchEnabled] = useState<boolean>(false);
const [upcomingEvents, setUpcomingEvents] = useState<UiEvent[]>([]);
// Aliases
const [aliases, setAliases] = useState<CompetitionAlias[]>([]);
const [aliasMap, setAliasMap] = useState<Record<string, { alias: string; original_name?: string }>>({});
@@ -540,76 +545,7 @@ const HomePage: React.FC = () => {
setTimeout(run, 0);
}
}, [facrCompetitions, matchesTab, closestIndexByComp]);
// Auto-theme from club logo dominant color
useEffect(() => {
if (!clubLogo) return;
let disposed = false;
const toHex = (v: number) => {
const h = Math.max(0, Math.min(255, Math.round(v))).toString(16).padStart(2, '0');
return h;
};
const rgbToHex = (r: number, g: number, b: number) => `#${toHex(r)}${toHex(g)}${toHex(b)}`;
const lighten = (r: number, g: number, b: number, amt = 20) => [
Math.min(255, r + amt),
Math.min(255, g + amt),
Math.min(255, b + amt),
] as const;
const darkenIfLowContrast = (r: number, g: number, b: number) => {
// ensure contrast versus white text (used in .next-match)
const luminance = (0.2126 * r + 0.7152 * g + 0.0722 * b) / 255; // 0..1
if (luminance > 0.75) {
// too light, darken
return [r * 0.6, g * 0.6, b * 0.6] as const;
}
return [r, g, b] as const;
};
const img = new Image();
img.crossOrigin = 'anonymous';
img.src = assetUrl(clubLogo) || clubLogo;
img.onload = () => {
if (disposed) return;
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
if (!ctx) return;
const w = 100, h = 100;
canvas.width = w; canvas.height = h;
try {
ctx.drawImage(img, 0, 0, w, h);
const data = ctx.getImageData(0, 0, w, h).data;
let r = 0, g = 0, b = 0, n = 0;
for (let i = 0; i < data.length; i += 4) {
const a = data[i + 3];
if (a < 64) continue; // skip transparent
const rr = data[i], gg = data[i + 1], bb = data[i + 2];
// skip near-white background pixels to better catch logo color
if (rr > 240 && gg > 240 && bb > 240) continue;
r += rr; g += gg; b += bb; n++;
}
if (n > 0) {
r /= n; g /= n; b /= n;
// adjust for readability
[r, g, b] = darkenIfLowContrast(r, g, b);
const [lr, lg, lb] = lighten(r, g, b, 24);
const primary = rgbToHex(r, g, b);
const primaryLight = rgbToHex(lr, lg, lb);
// secondary: golden fallback if color is blueish, else a subtle accent
const isBlueish = b > r && b > g;
const secondary = isBlueish ? '#d69e2e' : '#2c5282';
const root = document.documentElement;
root.style.setProperty('--primary', primary);
root.style.setProperty('--primary-light', primaryLight);
root.style.setProperty('--secondary', secondary);
}
} catch {
// ignore CORS or canvas tainting
}
};
return () => { disposed = true; };
}, [clubLogo]);
// MyUIbrix events are handled by useAllPageElementConfigs hook
// It automatically updates getVariant() and isVisible() when changes occur in edit mode
@@ -656,6 +592,26 @@ const HomePage: React.FC = () => {
return () => clearInterval(id);
}, [matches, facrCompetitions, matchesTab]);
useEffect(() => {
let active = true;
(async () => {
try {
const evs = await getUpcomingEvents();
const mapped: UiEvent[] = (evs || []).map((e: any) => ({
id: e.id,
title: e.title,
start_time: e.start_time,
end_time: e.end_time,
location: e.location,
type: e.type,
image_url: e.image_url,
}));
if (active) setUpcomingEvents(mapped);
} catch {}
})();
return () => { active = false; };
}, []);
// Removed: Edge auto-cycle
// Removed: Aurora layout
@@ -1381,8 +1337,8 @@ const HomePage: React.FC = () => {
// }
return (
<MainLayout>
<div className="container">
<MainLayout headerInsideContainer>
<div className="container" data-element="container" style={{ ...getStyles('container') }}>
{/* Header: logo + club name */}
<div className="home-header">
<img src={assetUrl(clubLogo) || '/images/club-logo.png'} alt="Klub" />
@@ -1553,7 +1509,7 @@ const HomePage: React.FC = () => {
</section>
) : null}
{/* Matches slider with scores by competition */}
{/* Matches slider with scores by competition (moved after news+tables) */}
{facrCompetitions.length > 0 && (
<section data-element="matches-slider" className="matches-slider" style={{ position: 'relative', ...getStyles('matches-slider') }}>
<div className="section-head" style={{ marginTop: 16, marginBottom: 16 }}>
@@ -1612,54 +1568,54 @@ const HomePage: React.FC = () => {
</div>
</section>
)}
{/* Competition tables moved into right column below */}
{/* Standings: tabs per competition (only FACR), clicking row opens ClubModal */}
{isVisible('table', true) && (() => {
// Match standings to current competition by name instead of assuming same index
{/* News + Tables: split into two independent sections */}
{(() => {
// Compute matching standings for the selected competition
const currentCompetition = facrCompetitions[matchesTab];
const currentCompetitionName = currentCompetition?.name || '';
const matchingStanding = standings.find((s: any) => s.name === currentCompetitionName);
const hasStandingsForCurrentTab = matchingStanding && (
const hasStandingsForCurrentTab = !!matchingStanding && (
(matchingStanding.table && matchingStanding.table.length > 0) ||
(matchingStanding.rows && matchingStanding.rows.length > 0)
);
return (
<section
data-element="table"
className="standings"
data-variant={hasStandingsForCurrentTab ? undefined : 'standard'}
style={{ marginTop: 32, ...getStyles('table') }}
>
<div>
<div className="section-head" style={{ marginTop: 0 }}>
<h3>Další aktuality</h3>
</div>
<div className="blog-list">
{news.length > 0 ? news.slice(0, 4).map((n) => (
<a key={n.id} href={`/news/${n.slug || n.id}`} className="card" style={{ textDecoration: 'none', color: 'inherit' }}>
<div className="thumb" style={{ backgroundImage: `url(${assetUrl(n.image) || '/images/news/placeholder.jpg'})` }} />
<div>
<h4>{n.title}</h4>
<div style={{ color: 'var(--dark-gray)', fontSize: '0.9rem' }}>{n.excerpt}</div>
<>
{isVisible('news', true) && (
<section data-element="news" className="news-list" style={{ marginTop: 32, ...getStyles('news') }}>
<div className="section-head" style={{ marginTop: 0 }}>
<h3>Další aktuality</h3>
</div>
<div className="blog-list">
{news.length > 0 ? news.slice(0, 4).map((n) => (
<a key={n.id} href={`/news/${n.slug || n.id}`} className="card" style={{ textDecoration: 'none', color: 'inherit' }}>
<div className="thumb" style={{ backgroundImage: `url(${assetUrl(n.image) || '/images/news/placeholder.jpg'})` }} />
<div>
<h4>{n.title}</h4>
<div style={{ color: 'var(--dark-gray)', fontSize: '0.9rem' }}>{n.excerpt}</div>
</div>
</a>
)) : (
<div style={{ padding: '24px', textAlign: 'center', color: 'var(--dark-gray)', background: 'var(--bg-soft)', borderRadius: '12px' }}>
<p>Zatím nejsou k dispozici žádné aktuality.</p>
</div>
</a>
)) : (
<div style={{ padding: '24px', textAlign: 'center', color: 'var(--dark-gray)', background: 'var(--bg-soft)', borderRadius: '12px' }}>
<p>Zatím nejsou k dispozici žádné aktuality.</p>
)}
</div>
{news.length > 0 && (
<div style={{ marginTop: 12 }}>
<a className="btn" href="/news">Zobrazit všechny aktuality</a>
</div>
)}
</div>
{news.length > 0 && (
<div style={{ marginTop: 12 }}>
<a className="btn" href="/news">Zobrazit všechny aktuality</a>
</div>
)}
</div>
{hasStandingsForCurrentTab && (
<div>
</section>
)}
{isVisible('table', true) && hasStandingsForCurrentTab && (
<section
data-element="table"
className="standings"
style={{ marginTop: 32, ...getStyles('table') }}
>
<div className="table-card">
<div className="section-head" style={{ marginTop: 0, marginBottom: 12 }}>
<h3>Tabulky</h3>
@@ -1709,12 +1665,10 @@ const HomePage: React.FC = () => {
transition: 'all 0.2s ease',
}}
onMouseEnter={(e) => {
e.currentTarget.style.transform = 'translateX(2px)';
e.currentTarget.style.boxShadow = '0 4px 12px rgba(0,0,0,0.08)';
e.currentTarget.style.borderColor = 'var(--primary)';
}}
onMouseLeave={(e) => {
e.currentTarget.style.transform = 'translateX(0)';
e.currentTarget.style.boxShadow = 'none';
e.currentTarget.style.borderColor = 'var(--card-border)';
}}
@@ -1745,13 +1699,39 @@ const HomePage: React.FC = () => {
</table>
</div>
</div>
</div>
</section>
)}
</section>
</>
);
})()}
{/* Players scroller (optional) */}
{/* Competition tables moved into right column below */}
{upcomingEvents.length > 0 && isVisible('activities', true) && (
<section data-element="activities" style={{ marginTop: 32, marginBottom: 16, position: 'relative', ...getStyles('activities') }}>
<div style={{ maxWidth: 1200, margin: '0 auto', padding: '0 12px' }}>
<div className="section-head" style={{ marginTop: 0 }}>
<h3>Aktivity</h3>
<a href="/aktivity" className="see-all">Zobrazit vše <FiArrowRight /></a>
</div>
<div className="blog-list">
{upcomingEvents.slice(0,4).map((e) => (
<a key={e.id} href={`/aktivita/${e.id}`} className="card" style={{ textDecoration: 'none', color: 'inherit' }}>
<div className="thumb" style={{ backgroundImage: `url(${assetUrl(e.image_url) || '/images/news/placeholder.jpg'})` }} />
<div>
<h4>{e.title}</h4>
<div style={{ color: 'var(--dark-gray)', fontSize: '0.9rem' }}>
{new Date(e.start_time).toLocaleDateString()} {e.location ? `${e.location}` : ''}
</div>
</div>
</a>
))}
</div>
</div>
</section>
)}
{/* Players scroller */}
{players.length > 0 && isVisible('team', false) && (
<section data-element="team" className="players-scroller" style={{ marginTop: 32, position: 'relative', ...getStyles('team') }}>
<div className="section-head">
@@ -1770,22 +1750,6 @@ const HomePage: React.FC = () => {
</section>
)}
{/* Merchandise / clothing (optional; only if shop URL is set) */}
{shopUrl && (
<section className="merch-cta">
<div className="card">
<div>
<h3>Oficiální fanshop</h3>
<p>Pořiďte si dresy, šály a další. Podpořte tým!</p>
<a className="btn" href={shopUrl || undefined} target="_blank" rel="noopener noreferrer">Přejít do eshopu</a>
</div>
<div className="mockup" aria-hidden>
<div className="shirt" />
</div>
</div>
</section>
)}
{/* Gallery */}
{isVisible('gallery', false) && (
<section data-element="gallery" style={{ marginTop: 32, marginBottom: 32, position: 'relative', ...getStyles('gallery') }}>
@@ -1812,27 +1776,15 @@ const HomePage: React.FC = () => {
</section>
)}
{/* Newsletter subscription CTA */}
{isVisible('newsletter', false) && (
<section data-element="newsletter" className="newsletter-cta" style={{ marginTop: 24, marginBottom: 24, position: 'relative', ...getStyles('newsletter') }}>
<div className="card" style={{ maxWidth: 960, margin: '0 auto' }}>
<NewsletterSubscribe />
{/* Polls / Voting */}
{isVisible('poll', false) && (
<section data-element="poll" style={{ marginTop: 32, marginBottom: 32, position: 'relative', ...getStyles('poll') }}>
<div style={{ maxWidth: 1200, margin: '0 auto', padding: '0 12px' }}>
<PollsWidget featuredOnly={true} maxPolls={1} title="Anketa" />
</div>
</section>
)}
{/* Banner: homepage_top */}
{(banners || []).some(b => b.placement === 'homepage_top') && (
<section data-element="banner" className="banner banner-top" style={{ margin: '24px 0', textAlign: 'center', ...getStyles('banner') }}>
{(banners || []).filter(b => b.placement === 'homepage_top').map((b) => (
<a key={b.id} href={b.url || '#'} target={b.url ? '_blank' : undefined} rel={b.url ? 'noopener noreferrer' : undefined} style={{ display: 'inline-block', margin: 8 }}>
{/* eslint-disable-next-line jsx-a11y/alt-text */}
<img src={b.image} alt={b.name} style={{ maxWidth: '100%', width: b.width ? `${b.width}px` : undefined, height: b.height ? `${b.height}px` : 'auto' }} />
</a>
))}
</section>
)}
{/* Banner: homepage_footer */}
{(banners || []).some(b => b.placement === 'homepage_footer') && (
<section data-element="banner" className="banner banner-footer" style={{ margin: '24px 0', textAlign: 'center', ...getStyles('banner') }}>
@@ -1845,6 +1797,15 @@ const HomePage: React.FC = () => {
</section>
)}
{/* CTA (Newsletter) moved up */}
{isVisible('newsletter', false) && (
<section data-element="newsletter" className="newsletter-cta" style={{ marginTop: 24, marginBottom: 24, position: 'relative', ...getStyles('newsletter') }}>
<div className="card" style={{ maxWidth: 960, margin: '0 auto' }}>
<NewsletterSubscribe />
</div>
</section>
)}
{/* Sponsors: grid or slider (controlled by settings); dark theme supported; full-bleed */}
{isVisible('sponsors', true) && (
<section
+117
View File
@@ -0,0 +1,117 @@
import { useState } from 'react';
import { useNavigate, useLocation } from 'react-router-dom';
import {
Box,
Button,
FormControl,
FormLabel,
Input,
VStack,
Heading,
useToast,
Text,
Link,
} from '@chakra-ui/react';
import { useAuth } from '../contexts/AuthContext';
import api from '../services/api';
interface LocationState {
from: {
pathname: string;
};
}
const RegisterPage: React.FC = () => {
const [name, setName] = useState('');
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [isLoading, setIsLoading] = useState(false);
const { login } = useAuth();
const navigate = useNavigate();
const location = useLocation();
const toast = useToast();
const from = (location.state as LocationState)?.from?.pathname || '/';
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setIsLoading(true);
try {
// Backend Register accepts name or first/last
const response = await api.post('/auth/register', {
email,
password,
name,
});
const { token, user } = response.data;
await login(token, user, true);
toast({ title: 'Účet vytvořen', status: 'success', duration: 3000 });
navigate(from, { replace: true });
} catch (error: any) {
toast({
title: 'Registrace selhala',
description: error?.response?.data?.error || error?.message || 'Zkuste to znovu.',
status: 'error',
duration: 5000,
isClosable: true,
});
} finally {
setIsLoading(false);
}
};
return (
<Box minH="100vh" display="flex" alignItems="center" justifyContent="center">
<Box w="100%" maxW="md" p={8} borderWidth={1} borderRadius={8} boxShadow="lg">
<VStack as="form" onSubmit={handleSubmit} spacing={4} align="stretch">
<Heading as="h2" size="lg" textAlign="center" mb={2}>
Vytvořit účet
</Heading>
<FormControl id="name" isRequired>
<FormLabel>Jméno a příjmení</FormLabel>
<Input
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="např. Jan Novák"
/>
</FormControl>
<FormControl id="email" isRequired>
<FormLabel>Email</FormLabel>
<Input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="např. jan@klub.cz"
/>
</FormControl>
<FormControl id="password" isRequired>
<FormLabel>Heslo</FormLabel>
<Input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="Zadejte heslo (min. 8 znaků)"
/>
</FormControl>
<Button type="submit" colorScheme="blue" width="full" mt={2} isLoading={isLoading}>
Zaregistrovat se
</Button>
<Text fontSize="sm" textAlign="center">
máte účet?{' '}
<Link color="blue.500" href="/login">
Přihlaste se
</Link>
</Text>
</VStack>
</Box>
</Box>
);
};
export default RegisterPage;
+117
View File
@@ -0,0 +1,117 @@
import React, { useEffect, useState } from 'react';
import {
Box,
Button,
Container,
FormControl,
FormLabel,
Input,
Tab,
TabList,
TabPanel,
TabPanels,
Tabs,
Heading,
Text,
useToast,
VStack,
HStack,
Link as ChakraLink,
} from '@chakra-ui/react';
import { useAuth } from '../contexts/AuthContext';
import api from '../services/api';
const SemiAdminPage: React.FC = () => {
const { user, updateUser } = useAuth();
const splitName = (full?: string) => {
const v = String(full || '').trim();
if (!v) return { fn: '', ln: '' };
const parts = v.split(/\s+/);
if (parts.length === 1) return { fn: parts[0], ln: '' };
return { fn: parts[0], ln: parts.slice(1).join(' ') };
};
const init = splitName(user?.name);
const [firstName, setFirstName] = useState(init.fn);
const [lastName, setLastName] = useState(init.ln);
const [isSaving, setIsSaving] = useState(false);
const [prefsToken, setPrefsToken] = useState<string>('');
const toast = useToast();
useEffect(() => {
const s = splitName(user?.name);
setFirstName(s.fn);
setLastName(s.ln);
}, [user?.name]);
useEffect(() => {
(async () => {
try {
const res = await api.get('/newsletter/token/me');
setPrefsToken(res.data?.token || '');
} catch {}
})();
}, []);
const handleSave = async (e: React.FormEvent) => {
e.preventDefault();
setIsSaving(true);
try {
const res = await api.put('/me', { first_name: firstName, last_name: lastName });
const updated = res.data?.user;
if (updated) {
const n = `${updated.first_name || firstName} ${updated.last_name || lastName}`.trim();
updateUser({ name: n });
}
toast({ title: 'Uloženo', description: 'Osobní údaje byly aktualizovány.', status: 'success', duration: 3000 });
} catch (err: any) {
toast({ title: 'Chyba', description: err?.response?.data?.error || 'Nelze uložit změny', status: 'error' });
} finally {
setIsSaving(false);
}
};
const prefsUrl = prefsToken ? `/newsletter/preferences?token=${encodeURIComponent(prefsToken)}` : '';
return (
<Container maxW="5xl" py={8}>
<Heading size="lg" mb={6}>Fan zóna</Heading>
<Tabs colorScheme="blue" isFitted variant="enclosed">
<TabList>
<Tab>Osobní údaje</Tab>
<Tab>Newsletter</Tab>
</TabList>
<TabPanels>
<TabPanel>
<Box as="form" onSubmit={handleSave} maxW="lg">
<VStack align="stretch" spacing={4}>
<FormControl>
<FormLabel>Jméno</FormLabel>
<Input value={firstName} onChange={(e) => setFirstName(e.target.value)} placeholder="Jméno" />
</FormControl>
<FormControl>
<FormLabel>Příjmení</FormLabel>
<Input value={lastName} onChange={(e) => setLastName(e.target.value)} placeholder="Příjmení" />
</FormControl>
<HStack>
<Button type="submit" colorScheme="blue" isLoading={isSaving}>Uložit</Button>
</HStack>
</VStack>
</Box>
</TabPanel>
<TabPanel>
<VStack align="start" spacing={4}>
<Text>Spravujte předvolby newsletteru nebo se odhlaste.</Text>
{prefsUrl ? (
<Button as={ChakraLink} href={prefsUrl} colorScheme="blue">Otevřít nastavení newsletteru</Button>
) : (
<Text>Načítám odkaz na nastavení</Text>
)}
</VStack>
</TabPanel>
</TabPanels>
</Tabs>
</Container>
);
};
export default SemiAdminPage;
+32 -4
View File
@@ -19,6 +19,22 @@ import MapStyleSelector from '../components/admin/MapStyleSelector';
import { MapCoordinates } from '../utils/mapUrlParser';
import { fetchLogoFromLogoAPI } from '../utils/sportLogosAPI';
const normalizePhone = (raw: string, country?: string) => {
let s = (raw || '').trim();
if (!s) return '';
s = s.replace(/[\s\-.()]/g, '');
s = s.replace(/^00/, '+');
if (s.startsWith('+')) return s;
if (/^420\d{9}$/.test(s)) return '+' + s;
if (/^\d{9}$/.test(s)) {
const c = (country || '').toLowerCase();
if (c.includes('česk') || c.includes('czech')) {
return '+420' + s;
}
}
return s;
};
const SetupPage: React.FC = () => {
const [loading, setLoading] = useState(true);
const [submitting, setSubmitting] = useState(false);
@@ -126,6 +142,8 @@ const SetupPage: React.FC = () => {
return out;
};
const isValidEmail = (val: string) => /^(?:[^\s@]+)@(?:[^\s@]+)\.(?:[^\s@]+)$/.test((val || '').trim());
useEffect(() => {
let mounted = true;
@@ -175,7 +193,7 @@ const SetupPage: React.FC = () => {
// Auto-fill SMTP username from contact email
useEffect(() => {
if (contactEmail && !smtpUser) {
if (contactEmail && !smtpUser && isValidEmail(contactEmail)) {
setSmtpUser(contactEmail);
}
}, [contactEmail, smtpUser]);
@@ -285,7 +303,7 @@ const SetupPage: React.FC = () => {
contact_city: contactCity || undefined,
contact_zip: contactPostalCode || undefined,
contact_country: contactCountry || undefined,
contact_phone: contactPhone || undefined,
contact_phone: normalizePhone(contactPhone, contactCountry) || undefined,
contact_email: contactEmail || undefined,
smtp: (smtpHost || smtpPort || smtpUser || smtpPass || smtpFromName) ? {
host: smtpHost || undefined,
@@ -352,6 +370,12 @@ const SetupPage: React.FC = () => {
});
if (logoApiRes.ok) {
toast({ title: 'Logo nahráno', description: 'Logo bylo nahráno na logoapi i lokálně', status: 'success', duration: 3000 });
try {
const apiUrl = await fetchLogoFromLogoAPI(clubId, clubName || undefined);
if (apiUrl) {
setClubLogoUrl(apiUrl);
}
} catch {}
}
} catch (logoApiErr) {
console.warn('Failed to upload to logoapi:', logoApiErr);
@@ -726,7 +750,11 @@ const SetupPage: React.FC = () => {
setGpsLat(coords.latitude);
setGpsLng(coords.longitude);
// Auto-fill address fields if available from geocoding
if (coords.street) setContactStreet(coords.street);
if (coords.street) {
setContactStreet(coords.street);
} else if (coords.houseNumber && coords.city) {
setContactStreet(`${coords.city} ${coords.houseNumber}`);
}
if (coords.city) setContactCity(coords.city);
if (coords.zip) setContactPostalCode(coords.zip);
if (coords.country) setContactCountry(coords.country);
@@ -768,7 +796,7 @@ const SetupPage: React.FC = () => {
</FormControl>
<FormControl>
<FormLabel>E-mail</FormLabel>
<Input type="email" placeholder="kontakt@klub.cz" value={contactEmail} onChange={(e) => setContactEmail(e.target.value)} />
<Input type="email" placeholder="kontakt@klub.cz" value={contactEmail} onChange={(e) => setContactEmail(e.target.value)} onBlur={() => { if (!smtpUser && isValidEmail(contactEmail)) { setSmtpUser(contactEmail); } }} />
<FormHelperText>Hlavní kontaktní e-mail klubu</FormHelperText>
</FormControl>
</SimpleGrid>
+18 -11
View File
@@ -40,13 +40,7 @@ const MatchLinkBadge: React.FC<{ articleId: number }> = ({ articleId }) => {
retry: false,
});
// Show loading state while fetching
if (linkQ.isLoading) {
return <Badge colorScheme="gray">Načítání...</Badge>;
}
const mid = (linkQ.data as any)?.external_match_id;
if (!mid) return <Badge colorScheme="gray">Nepropojeno</Badge>;
const facrQ = useQuery({
queryKey: ['facr-cached-match', mid],
@@ -77,6 +71,13 @@ const MatchLinkBadge: React.FC<{ articleId: number }> = ({ articleId }) => {
}
});
// Show loading state while fetching (after hooks are declared to keep order consistent)
if (linkQ.isLoading) {
return <Badge colorScheme="gray">Načítání...</Badge>;
}
if (!mid) return <Badge colorScheme="gray">Nepropojeno</Badge>;
// Guard against errors
if (facrQ.isError || linkQ.isError) {
return <Badge colorScheme="red">Chyba načítání</Badge>;
@@ -1164,6 +1165,12 @@ const ArticlesAdminPage = () => {
}
};
const matchBgSelected = useColorModeValue('blue.50', 'blue.900');
const matchBgDefault = useColorModeValue('white', 'gray.700');
const matchHoverBg = useColorModeValue('blue.50', 'gray.600');
const albumLinkHasPhotosBg = useColorModeValue('green.50', 'green.900');
const albumCardBg = useColorModeValue('white', 'gray.700');
return (
<AdminLayout requireAdmin={false}>
<Box>
@@ -1500,9 +1507,9 @@ const ArticlesAdminPage = () => {
borderWidth="2px"
borderRadius="md"
borderColor={isSelected ? 'blue.500' : 'gray.200'}
bg={isSelected ? useColorModeValue('blue.50', 'blue.900') : useColorModeValue('white', 'gray.700')}
bg={isSelected ? matchBgSelected : matchBgDefault}
cursor="pointer"
_hover={{ borderColor: 'blue.300', bg: useColorModeValue('blue.50', 'gray.600') }}
_hover={{ borderColor: 'blue.300', bg: matchHoverBg }}
transition="all 0.2s"
onClick={async () => {
const val = matchId;
@@ -1605,7 +1612,7 @@ const ArticlesAdminPage = () => {
placeholder="https://eu.zonerama.com/…"
value={zAlbumLink}
onChange={(e) => setZAlbumLink(e.target.value)}
bg={zAlbumPhotos.length > 0 ? useColorModeValue('green.50', 'green.900') : undefined}
bg={zAlbumPhotos.length > 0 ? albumLinkHasPhotosBg : undefined}
/>
</InputGroup>
<FormHelperText fontSize="xs">
@@ -2111,7 +2118,7 @@ const ArticlesAdminPage = () => {
{!galleryLoading && cachedAlbums.length > 0 && (
<VStack align="stretch" spacing={6}>
{cachedAlbums.map((album) => (
<Box key={album.id} borderWidth="1px" borderRadius="md" p={4} bg={useColorModeValue('white', 'gray.700')}>
<Box key={album.id} borderWidth="1px" borderRadius="md" p={4} bg={albumCardBg}>
<HStack justify="space-between" mb={3}>
<VStack align="start" spacing={0}>
<Text fontWeight="bold" fontSize="lg">{album.title || 'Album bez názvu'}</Text>
@@ -2200,7 +2207,7 @@ const ArticlesAdminPage = () => {
{!galleryLoading && cachedAlbums.length > 0 && (
<VStack align="stretch" spacing={6}>
{cachedAlbums.map((album) => (
<Box key={album.id} borderWidth="1px" borderRadius="md" p={4} bg={useColorModeValue('white', 'gray.700')}>
<Box key={album.id} borderWidth="1px" borderRadius="md" p={4} bg={albumCardBg}>
<HStack justify="space-between" mb={3}>
<VStack align="start" spacing={0}>
<Text fontWeight="bold" fontSize="lg">{album.title || 'Album bez názvu'}</Text>
+82 -13
View File
@@ -38,14 +38,14 @@ import {
Select
} from '@chakra-ui/react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { TeamLogo } from '../../components/common/TeamLogo';
import AdminLayout from '../../layouts/AdminLayout';
import { putMatchOverride, patchMatchOverride, searchClubs, uploadImage, fetchLogoAsBlob, uploadToLogaSportcreative } from '../../services/adminMatches';
import { putMatchOverride, patchMatchOverride, searchClubs, uploadImage, fetchLogoAsBlob, uploadToLogaSportcreative, fetchTeamLogoOverrides } from '../../services/adminMatches';
import { getPublicSettings } from '../../services/settings';
import { useEffect, useMemo, useRef, useState } from 'react';
import { useSearchParams } from 'react-router-dom';
import { parse } from 'date-fns';
import { assetUrl } from '../../utils/url';
import { batchFetchLogosFromSportLogosAPI } from '../../utils/sportLogosAPI';
const MatchesAdminPage = () => {
const queryClient = useQueryClient();
@@ -63,6 +63,60 @@ const MatchesAdminPage = () => {
notes: '',
});
const { data: overrides = {} } = useQuery({
queryKey: ['teamLogoOverrides'],
queryFn: fetchTeamLogoOverrides,
staleTime: 5 * 60 * 1000,
});
const normalizeName = (s: string) => {
let out = String(s || '');
out = out
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
.toLowerCase();
out = out.replace(/[\u2012\u2013\u2014\u2015\u2212]/g, '-');
const orgPhrases = [
'fotbalovy klub',
'sportovni klub',
'telovychovna jednota',
'skolni sportovni klub',
'fotbal',
'futsal',
];
for (const phrase of orgPhrases) {
const re = new RegExp(`(^|\\b)${phrase}(\\b|$)`, 'g');
out = out.replace(re, ' ');
}
out = out.replace(/\b(1\.)?\s*(sfc|afc|fc|fk|mfk|tj|sk|afk)\b\.?/g, ' ');
out = out.replace(/[\.,!;:()\[\]{}]/g, ' ');
out = out.replace(/\s+/g, ' ').trim();
return out;
};
const byName: Record<string, string> = (overrides as any)?.by_name || {};
const byNameNormalized = useMemo(() => {
const idx: Record<string, string> = {};
for (const k of Object.keys(byName)) idx[normalizeName(k)] = byName[k];
return idx;
}, [byName]);
const [sportLogosMap, setSportLogosMap] = useState<Record<string, string>>({});
const getLogo = (teamName?: string, teamId?: string, facrOriginal?: string) => {
if (!teamName) return assetUrl('/dist/img/logo-club-empty.svg') as string;
if (teamId && sportLogosMap[String(teamId)]) return sportLogosMap[String(teamId)];
let overrideUrl = byName[teamName];
if (!overrideUrl) overrideUrl = byNameNormalized[normalizeName(teamName)];
if (overrideUrl) {
if (overrideUrl.startsWith('/')) return assetUrl(overrideUrl) as string;
return overrideUrl;
}
if (facrOriginal) return facrOriginal;
return '/dist/img/logo-club-empty.svg';
};
// External logo upload helpers/state
const [homeExternalTeamId, setHomeExternalTeamId] = useState<string>('');
const [awayExternalTeamId, setAwayExternalTeamId] = useState<string>('');
@@ -137,7 +191,24 @@ const MatchesAdminPage = () => {
}));
},
});
useEffect(() => {
if (!Array.isArray(matches) || matches.length === 0) return;
const ids = new Set<string>();
for (const m of matches as any[]) {
if (m.home_id) ids.add(String(m.home_id));
if (m.away_id) ids.add(String(m.away_id));
}
if (ids.size === 0) return;
(async () => {
try {
const map = await batchFetchLogosFromSportLogosAPI(Array.from(ids));
setSportLogosMap(map);
} catch (e) {
console.warn('Failed to batch fetch logos:', e);
}
})();
}, [matches]);
// Filters
const [teamFilter, setTeamFilter] = useState('');
const [dateFrom, setDateFrom] = useState<string>(''); // YYYY-MM-DD
@@ -870,12 +941,11 @@ const MatchesAdminPage = () => {
</Td>
<Td>
<HStack spacing={2}>
<TeamLogo
teamId={m.home_id}
teamName={m.home || m.home_team || ''}
facrLogo={m.home_logo_url}
size="custom"
<Image
src={getLogo(m.home || m.home_team || '', m.home_id, m.home_logo_url)}
alt={m.home || m.home_team || ''}
boxSize="24px"
objectFit="contain"
/>
<Text fontWeight={isPast ? 'normal' : 'medium'}>{m.home || m.home_team || ''}</Text>
<Button size="xs" variant="outline" onClick={() => openEdit(m, 'home')} borderRadius="md" _hover={{ borderColor: 'brand.primary', color: 'brand.primary' }}>Tým</Button>
@@ -888,12 +958,11 @@ const MatchesAdminPage = () => {
</Td>
<Td>
<HStack spacing={2}>
<TeamLogo
teamId={m.away_id}
teamName={m.away || m.away_team || ''}
facrLogo={m.away_logo_url}
size="custom"
<Image
src={getLogo(m.away || m.away_team || '', m.away_id, m.away_logo_url)}
alt={m.away || m.away_team || ''}
boxSize="24px"
objectFit="contain"
/>
<Text fontWeight={isPast ? 'normal' : 'medium'}>{m.away || m.away_team || ''}</Text>
<Button size="xs" variant="outline" onClick={() => openEdit(m, 'away')} borderRadius="md" _hover={{ borderColor: 'brand.primary', color: 'brand.primary' }}>Tým</Button>
+65 -15
View File
@@ -4,6 +4,7 @@ import {
Button,
FormControl,
FormLabel,
FormErrorMessage,
Heading,
HStack,
IconButton,
@@ -229,6 +230,13 @@ const PlayersAdminPage: React.FC = () => {
const [editing, setEditing] = useState<Editing | null>(null);
const { isOpen, onOpen, onClose } = useDisclosure();
const JERSEY_MIN = 0;
const JERSEY_MAX = 99;
const HEIGHT_MIN = 0;
const HEIGHT_MAX = 250;
const WEIGHT_MIN = 0;
const WEIGHT_MAX = 200;
// Local state to persist partial DOB selections so the user sees what they picked
const [dobParts, setDobParts] = useState<{ day: string; month: string; year: string }>({ day: '', month: '', year: '' });
@@ -276,14 +284,47 @@ const PlayersAdminPage: React.FC = () => {
},
});
const maybeSplitName = () => {
setEditing((p) => {
if (!p) return p;
const fn = (p.first_name || '').trim();
const ln = (p.last_name || '').trim();
if (!ln && fn.includes(' ')) {
const parts = fn.split(/\s+/).filter(Boolean);
if (parts.length >= 2) {
return { ...(p as any), first_name: parts[0], last_name: parts[parts.length - 1] } as any;
}
}
return p as any;
});
};
const onSubmit = async () => {
if (!editing) return;
const fn = (editing.first_name || '').trim();
const ln = (editing.last_name || '').trim();
let fn = (editing.first_name || '').trim();
let ln = (editing.last_name || '').trim();
if (!ln && fn.includes(' ')) {
const parts = fn.split(/\s+/).filter(Boolean);
if (parts.length >= 2) {
fn = parts[0];
ln = parts[parts.length - 1];
}
}
if (!fn || !ln) {
toast({ title: 'Jméno a příjmení jsou povinné', status: 'warning' });
return;
}
const tooBig = (
typeof editing.jersey_number === 'number' && Number.isFinite(editing.jersey_number) && editing.jersey_number > JERSEY_MAX
) || (
typeof editing.height === 'number' && Number.isFinite(editing.height) && editing.height > HEIGHT_MAX
) || (
typeof editing.weight === 'number' && Number.isFinite(editing.weight) && editing.weight > WEIGHT_MAX
);
if (tooBig) {
toast({ title: 'Neplatná čísla', description: `Maxima: číslo dresu ${JERSEY_MAX}, výška ${HEIGHT_MAX} cm, váha ${WEIGHT_MAX} kg`, status: 'warning' });
return;
}
// Build payload by including only present values to satisfy backend validation
const payload: any = {
first_name: fn,
@@ -291,10 +332,16 @@ const PlayersAdminPage: React.FC = () => {
};
if (editing.date_of_birth) payload.date_of_birth = editing.date_of_birth;
if (editing.position) payload.position = editing.position;
if (typeof editing.jersey_number === 'number' && Number.isFinite(editing.jersey_number) && editing.jersey_number > 0) payload.jersey_number = editing.jersey_number;
if (typeof editing.jersey_number === 'number' && Number.isFinite(editing.jersey_number) && editing.jersey_number > 0) {
payload.jersey_number = editing.jersey_number;
}
if (editing.nationality) payload.nationality = editing.nationality;
if (typeof editing.height === 'number' && editing.height > 0) payload.height = editing.height;
if (typeof editing.weight === 'number' && editing.weight > 0) payload.weight = editing.weight;
if (typeof editing.height === 'number' && Number.isFinite(editing.height) && editing.height > 0) {
payload.height = editing.height;
}
if (typeof editing.weight === 'number' && Number.isFinite(editing.weight) && editing.weight > 0) {
payload.weight = editing.weight;
}
if (editing.image_url) payload.image_url = editing.image_url;
if (typeof editing.is_active === 'boolean') payload.is_active = editing.is_active;
const email = ((editing as any).email || '').trim();
@@ -373,7 +420,7 @@ const PlayersAdminPage: React.FC = () => {
<SimpleGrid columns={[1, 2]} spacing={4}>
<FormControl isRequired>
<FormLabel>Jméno</FormLabel>
<Input value={editing?.first_name || ''} onChange={(e) => setEditing((p) => ({ ...(p as any), first_name: e.target.value }))} />
<Input value={editing?.first_name || ''} onChange={(e) => setEditing((p) => ({ ...(p as any), first_name: e.target.value }))} onBlur={maybeSplitName} />
</FormControl>
<FormControl isRequired>
<FormLabel>Příjmení</FormLabel>
@@ -414,11 +461,12 @@ const PlayersAdminPage: React.FC = () => {
</Select>
</FormControl>
<FormControl>
<FormControl isInvalid={typeof editing?.jersey_number === 'number' && (editing?.jersey_number as number) > JERSEY_MAX}>
<FormLabel>Číslo dresu</FormLabel>
<NumberInput value={editing?.jersey_number ?? 0} onChange={(_, v) => setEditing((p) => ({ ...(p as any), jersey_number: Number.isFinite(v) ? v : 0 }))}>
<NumberInputField />
<NumberInput min={JERSEY_MIN} max={JERSEY_MAX} keepWithinRange={false} clampValueOnBlur={false} value={typeof editing?.jersey_number === 'number' ? editing?.jersey_number : ''} onChange={(_, v) => setEditing((p) => ({ ...(p as any), jersey_number: Number.isFinite(v) ? v : undefined }))}>
<NumberInputField inputMode="numeric" />
</NumberInput>
<FormErrorMessage>Maximální číslo dresu je {JERSEY_MAX}.</FormErrorMessage>
</FormControl>
<FormControl>
@@ -466,17 +514,19 @@ const PlayersAdminPage: React.FC = () => {
</VStack>
</FormControl>
<FormControl>
<FormControl isInvalid={typeof editing?.height === 'number' && (editing?.height as number) > HEIGHT_MAX}>
<FormLabel>Výška (cm)</FormLabel>
<NumberInput value={editing?.height ?? 0} onChange={(_, v) => setEditing((p) => ({ ...(p as any), height: Number.isFinite(v) ? v : 0 }))}>
<NumberInputField />
<NumberInput min={HEIGHT_MIN} max={HEIGHT_MAX} keepWithinRange={false} clampValueOnBlur={false} value={typeof editing?.height === 'number' ? editing?.height : ''} onChange={(_, v) => setEditing((p) => ({ ...(p as any), height: Number.isFinite(v) ? v : undefined }))}>
<NumberInputField inputMode="numeric" />
</NumberInput>
<FormErrorMessage>Maximální výška je {HEIGHT_MAX} cm.</FormErrorMessage>
</FormControl>
<FormControl>
<FormControl isInvalid={typeof editing?.weight === 'number' && (editing?.weight as number) > WEIGHT_MAX}>
<FormLabel>Váha (kg)</FormLabel>
<NumberInput value={editing?.weight ?? 0} onChange={(_, v) => setEditing((p) => ({ ...(p as any), weight: Number.isFinite(v) ? v : 0 }))}>
<NumberInputField />
<NumberInput min={WEIGHT_MIN} max={WEIGHT_MAX} keepWithinRange={false} clampValueOnBlur={false} value={typeof editing?.weight === 'number' ? editing?.weight : ''} onChange={(_, v) => setEditing((p) => ({ ...(p as any), weight: Number.isFinite(v) ? v : undefined }))}>
<NumberInputField inputMode="numeric" />
</NumberInput>
<FormErrorMessage>Maximální váha je {WEIGHT_MAX} kg.</FormErrorMessage>
</FormControl>
{/* Optional contact info (not shown publicly) */}
<FormControl>
+77 -6
View File
@@ -211,6 +211,65 @@ const PollsAdminPage: React.FC = () => {
onOpen();
};
const applyPreset = (preset: 'rating5' | 'rating10' | 'attendance') => {
if (preset === 'rating5') {
const options = Array.from({ length: 5 }).map((_, i) => ({
text: String(i + 1),
display_order: i + 1,
}));
setFormData({
title: 'Hodnocení zápasu',
description: 'Ohodnoťte zápas (1 = nejhorší, 5 = nejlepší)',
type: 'rating',
status: 'active',
allow_multiple: false,
max_choices: 1,
show_results: 'after_vote',
require_auth: false,
allow_guest_vote: true,
featured: false,
options,
});
} else if (preset === 'rating10') {
const options = Array.from({ length: 10 }).map((_, i) => ({
text: String(i + 1),
display_order: i + 1,
}));
setFormData({
title: 'Hodnocení zápasu (110)',
description: 'Ohodnoťte zápas (1 = nejhorší, 10 = nejlepší)',
type: 'rating',
status: 'active',
allow_multiple: false,
max_choices: 1,
show_results: 'after_vote',
require_auth: false,
allow_guest_vote: true,
featured: false,
options,
});
} else if (preset === 'attendance') {
setFormData({
title: 'Dorazíš na schůzku?',
description: 'Dej nám vědět, zda dorazíš.',
type: 'single',
status: 'active',
allow_multiple: false,
max_choices: 1,
show_results: 'after_vote',
require_auth: false,
allow_guest_vote: true,
featured: false,
options: [
{ text: 'Ano', display_order: 0 },
{ text: 'Ne', display_order: 1 },
{ text: 'Možná', display_order: 2 },
],
});
}
onOpen();
};
const handleOpenEdit = (poll: Poll) => {
setEditingPoll(poll);
setFormData({
@@ -362,9 +421,21 @@ const PollsAdminPage: React.FC = () => {
<VStack spacing={6} align="stretch">
<HStack justify="space-between">
<Heading size="lg">Správa anket</Heading>
<Button leftIcon={<AddIcon />} colorScheme="blue" onClick={handleOpenCreate}>
Nová anketa
</Button>
<HStack>
<Menu>
<MenuButton as={Button} rightIcon={<ChevronDownIcon />} variant="outline">
Předvolby
</MenuButton>
<MenuList>
<MenuItem onClick={() => applyPreset('rating5')}>Hodnocení zápasu (5 hvězd)</MenuItem>
<MenuItem onClick={() => applyPreset('rating10')}>Hodnocení zápasu (110)</MenuItem>
<MenuItem onClick={() => applyPreset('attendance')}>Dorazíš na schůzku?</MenuItem>
</MenuList>
</Menu>
<Button leftIcon={<AddIcon />} colorScheme="blue" onClick={handleOpenCreate}>
Nová anketa
</Button>
</HStack>
</HStack>
<Alert status="info">
@@ -818,7 +889,7 @@ const PollsAdminPage: React.FC = () => {
Výsledky
</Heading>
<VStack spacing={2} align="stretch">
{statsData.poll.options.map((option) => {
{(statsData.poll.options || []).map((option) => {
const percentage =
statsData.poll.total_votes > 0
? (option.vote_count / statsData.poll.total_votes) * 100
@@ -851,13 +922,13 @@ const PollsAdminPage: React.FC = () => {
</VStack>
</Box>
{statsData.votes_by_day.length > 0 && (
{(statsData.votes_by_day?.length ?? 0) > 0 && (
<Box>
<Heading size="sm" mb={4}>
Hlasy podle dnů
</Heading>
<VStack spacing={2} align="stretch">
{statsData.votes_by_day.map((day) => (
{(statsData.votes_by_day || []).map((day) => (
<HStack key={day.date} justify="space-between">
<Text>{new Date(day.date).toLocaleDateString('cs-CZ')}</Text>
<Badge>{day.count} hlasů</Badge>
+12 -21
View File
@@ -51,7 +51,7 @@ import { searchClubs, uploadImage, putTeamLogoOverride, fetchTeamLogoOverrides,
import { getFacrTablesCache } from '../../services/facr/cache';
import { assetUrl } from '../../utils/url';
import { useEffect, useMemo, useRef, useState } from 'react';
import { TeamLogo } from '../../components/common/TeamLogo';
type TableRow = {
rank?: string;
@@ -291,38 +291,26 @@ const TeamsAdminPage = () => {
.map((s) => s.trim())
.filter(Boolean);
// Save override for each variant name so editing one updates all duplicates
await Promise.all(
names.map((n) => putTeamLogoOverride(form.external_team_id, n, logoUrl))
);
// Also upload to logoapi.sportcreative.eu (non-blocking, best-effort)
// Upload to logoapi.sportcreative.eu first (best-effort). If successful, prefer that URL for overrides.
if (logoUrl) {
setExternalUploadStatus('uploading');
setExternalUploadError(null);
try {
let logoFileToUpload: File | Blob | null = uploadedFile;
// If no file was uploaded but we have a logo URL, fetch it as blob
if (!logoFileToUpload && logoUrl) {
logoFileToUpload = await fetchLogoAsBlob(logoUrl);
}
if (logoFileToUpload) {
// Upload to the logo service (loga.sportcreative.eu)
const logaResult = await uploadToLogaSportcreative(
form.external_team_id,
logoFileToUpload,
{
{
filename: `${form.external_team_id}.${logoFileToUpload instanceof File ? logoFileToUpload.name.split('.').pop() : 'png'}`,
clubName: form.team_name || selected?.teamName || 'Neznámý klub'
}
);
if (logaResult.success) {
setExternalUploadStatus('success');
// Use the URL from loga.sportcreative.eu
if (logaResult.url) {
logoUrl = logaResult.url;
}
@@ -339,7 +327,12 @@ const TeamsAdminPage = () => {
setExternalUploadError(error?.message || 'Upload failed');
}
}
// Save override for each variant name so editing one updates all duplicates
await Promise.all(
names.map((n) => putTeamLogoOverride(form.external_team_id, n, logoUrl))
);
return true;
},
onSuccess: () => {
@@ -495,12 +488,10 @@ const TeamsAdminPage = () => {
<Td py={1.5} fontSize="xs">{r.rank}</Td>
<Td py={1.5}>
<HStack spacing={2} align="center">
<TeamLogo
teamId={(r as any).team_id}
teamName={r.team}
facrLogo={r.team_logo_url}
size="small"
<Image
src={getLogo(r.team, (r as any).team_id, r.team_logo_url)}
alt={r.team}
boxSize="24px"
objectFit="contain"
/>
<Text fontSize="xs" noOfLines={1}>{r.team}</Text>
+5 -4
View File
@@ -49,7 +49,7 @@ interface User {
id: string;
email: string;
name: string;
role: 'admin' | 'editor';
role: 'admin' | 'editor' | 'fan';
isActive: boolean;
createdAt: string;
}
@@ -65,7 +65,7 @@ const UsersAdminPage = () => {
email: '',
password: '',
currentPassword: '',
role: 'editor' as 'admin' | 'editor',
role: 'editor' as 'admin' | 'editor' | 'fan',
isActive: true,
});
const toast = useToast();
@@ -254,8 +254,8 @@ const UsersAdminPage = () => {
<Td>{user.name}</Td>
<Td>{user.email}</Td>
<Td>
<Badge colorScheme={user.role === 'admin' ? 'purple' : 'blue'}>
{user.role === 'admin' ? 'Admin' : 'Editor'}
<Badge colorScheme={user.role === 'admin' ? 'purple' : (user.role === 'editor' ? 'blue' : 'gray')}>
{user.role === 'admin' ? 'Admin' : user.role === 'editor' ? 'Editor' : 'Fan'}
</Badge>
</Td>
<Td>
@@ -385,6 +385,7 @@ const UsersAdminPage = () => {
value={formData.role}
onChange={handleInputChange}
>
<option value="fan">Fan</option>
<option value="editor" disabled={!!selectedUser && selectedUser.role === 'admin'}>Editor</option>
<option value="admin">Admin</option>
</Select>