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