This commit is contained in:
Tomas Dvorak
2025-11-14 15:53:12 +01:00
parent f3db65d350
commit c941313fd5
149 changed files with 4366 additions and 12935 deletions
+69 -36
View File
@@ -1,6 +1,6 @@
import { Box, Container, Heading, Image, Spinner, Stack, Text, HStack, Badge, Link, SimpleGrid, Button, AspectRatio, useColorModeValue, Flex, VStack, Tag, Breadcrumb, BreadcrumbItem, BreadcrumbLink } from '@chakra-ui/react';
import { useQuery } from '@tanstack/react-query';
import { useParams, Link as RouterLink } from 'react-router-dom';
import { useParams, Link as RouterLink, useNavigate } from 'react-router-dom';
import { getArticle, getArticleBySlug, getArticleMatchLink, trackArticleView, getArticles } from '../services/articles';
import { articleRead } from '../services/engagement';
import MainLayout from '../components/layout/MainLayout';
@@ -37,6 +37,7 @@ const toText = (html?: string) => {
const ArticleDetailPage: React.FC = () => {
const { id, slug } = useParams<{ id?: string; slug?: string }>();
const navigate = useNavigate();
const { data, isLoading, isError } = useQuery({
queryKey: ['article', slug ? `slug:${slug}` : `id:${id}`],
queryFn: () => (slug ? getArticleBySlug(slug!) : getArticle(id!)),
@@ -44,6 +45,8 @@ const ArticleDetailPage: React.FC = () => {
});
// Load competition aliases to resolve category → alias mapping for MatchesWidget filtering
const aliasesQ = useQuery<{ list: CompetitionAlias[] }>({
@@ -116,6 +119,21 @@ const ArticleDetailPage: React.FC = () => {
}
}, [data]);
// Ensure unified link: if opened by numeric ID and slug exists, redirect to /news/:slug
React.useEffect(() => {
if (!id) return;
try {
const s = (data as any)?.slug;
if (s && typeof s === 'string') {
const target = `/news/${s}`;
const cur = (typeof window !== 'undefined') ? (window.location.pathname + (window.location.search || '')) : '';
if (cur && !cur.startsWith(target)) {
navigate(target, { replace: true });
}
}
} catch {}
}, [id, (data as any)?.slug, navigate]);
// Award engagement for article read after 15s dwell (once per article per device)
React.useEffect(() => {
const aid = (data as any)?.id;
@@ -163,14 +181,6 @@ const ArticleDetailPage: React.FC = () => {
staleTime: 60_000,
});
// Track match view when resolved
React.useEffect(() => {
const mid = (matchLinkQuery.data as any)?.external_match_id;
if (mid) {
umamiTrackMatchView(mid);
}
}, [(matchLinkQuery.data as any)?.external_match_id]);
// From cached FACR club info, resolve the match details
const facrMatchQuery = useQuery({
queryKey: ['facr-cached-match', (matchLinkQuery.data as any)?.external_match_id],
@@ -196,6 +206,35 @@ const ArticleDetailPage: React.FC = () => {
staleTime: 60_000,
});
// Derive opponent color (away team) for right-side accent based on logo palette
React.useEffect(() => {
let cancelled = false;
(async () => {
try {
const m: any = facrMatchQuery?.data as any;
if (!m) { if (!cancelled) setOpponentColor(null); return; }
const awayId = String(m?.away_team_id || m?.away_id || '') || undefined;
const awayName = String(m?.away || m?.away_team || '') || undefined;
const facrLogo = (m as any)?.away_logo_url as string | undefined;
const url = await getTeamLogo(awayId, awayName, facrLogo);
const palette = await extractPalette(url);
const picked = Array.isArray(palette) && palette.length ? palette[0] : null;
if (!cancelled) setOpponentColor(picked);
} catch {
if (!cancelled) setOpponentColor(null);
}
})();
return () => { cancelled = true; };
}, [facrMatchQuery?.data]);
// Track match view when resolved
React.useEffect(() => {
const mid = (matchLinkQuery.data as any)?.external_match_id;
if (mid) {
umamiTrackMatchView(mid);
}
}, [(matchLinkQuery.data as any)?.external_match_id]);
// Build a snapshot usable for sharing if available (FACR data or article fallback)
const matchSnapshot: MatchSnapshot | null = React.useMemo(() => {
const m: any = facrMatchQuery?.data as any;
@@ -521,13 +560,13 @@ const ArticleDetailPage: React.FC = () => {
</HStack>
) : null}
{publishedAt && (
<Tag as={RouterLink} to={`/news?month=${monthParam}`} size="sm" variant="subtle">{new Date(publishedAt).toLocaleDateString('cs-CZ')}</Tag>
<Tag as={RouterLink} to={`/blog?month=${monthParam}`} size="sm" variant="subtle">{new Date(publishedAt).toLocaleDateString('cs-CZ')}</Tag>
)}
{(data as any)?.category?.id && (
<Tag as={RouterLink} to={`/news?category_id=${(data as any).category.id}`} size="sm" variant="subtle">{(data as any).category.name || 'Kategorie'}</Tag>
<Tag as={RouterLink} to={`/blog?category_id=${(data as any).category.id}`} size="sm" variant="subtle">{(data as any).category.name || 'Kategorie'}</Tag>
)}
{(matchLinkQuery.data as any)?.external_match_id && (
<Tag as={RouterLink} to={`/news?match_id=${(matchLinkQuery.data as any).external_match_id}`} size="sm" variant="subtle">Zápas</Tag>
<Tag as={RouterLink} to={`/blog?match_id=${(matchLinkQuery.data as any).external_match_id}`} size="sm" variant="subtle">Zápas</Tag>
)}
{(data as any).view_count ? (
<HStack spacing={1} ml={{ base: 0, md: 2 }}>
@@ -545,7 +584,7 @@ const ArticleDetailPage: React.FC = () => {
</BreadcrumbItem>
{(data as any)?.category?.id ? (
<BreadcrumbItem>
<BreadcrumbLink as={RouterLink} to={`/news?category_id=${(data as any).category.id}`}>{(data as any).category.name}</BreadcrumbLink>
<BreadcrumbLink as={RouterLink} to={`/blog?category_id=${(data as any).category.id}`}>{(data as any).category.name}</BreadcrumbLink>
</BreadcrumbItem>
) : null}
<BreadcrumbItem isCurrentPage>
@@ -592,13 +631,26 @@ const ArticleDetailPage: React.FC = () => {
</VStack>
<VStack minW={{ base: '100px', md: '140px' }}>
{(() => {
const dRaw = String((facrMatchQuery.data as any).date_time || (facrMatchQuery.data as any).date || '');
const d = new Date(dRaw);
const raw = String((facrMatchQuery.data as any).date_time || (facrMatchQuery.data as any).date || '');
const hasScore = ((facrMatchQuery.data as any).result_home != null && (facrMatchQuery.data as any).result_away != null) || Boolean((facrMatchQuery.data as any).score && (facrMatchQuery.data as any).score !== 'vs');
if (hasScore) {
const score = String((facrMatchQuery.data as any).score || `${(facrMatchQuery.data as any).result_home}:${(facrMatchQuery.data as any).result_away}`);
return (<Heading size="2xl">{score}</Heading>);
}
const parseFacr = (s: string): Date => {
const m = s.match(/^(\d{1,2})\.(\d{1,2})\.(\d{4})(?:\s+(\d{1,2}):(\d{2}))?/);
if (m) {
const d = parseInt(m[1],10);
const mo = parseInt(m[2],10)-1;
const y = parseInt(m[3],10);
const hh = m[4] ? parseInt(m[4],10) : 0;
const mm = m[5] ? parseInt(m[5],10) : 0;
return new Date(y, mo, d, hh, mm);
}
const t = Date.parse(s);
return isNaN(t) ? new Date() : new Date(t);
};
const d = parseFacr(raw);
const now = Date.now();
const ms = d.getTime() - now;
const days = Math.max(0, Math.floor(ms / (1000*60*60*24)));
@@ -719,7 +771,7 @@ const ArticleDetailPage: React.FC = () => {
rightIcon={<ArrowRight size={16} />}
onClick={() => umamiTrackEvent('Gallery Link Click', { album_id: (data as any).gallery_album_id })}
>
Zobrazit galerii
Zobrazit celou galerii
</Button>
</HStack>
{Array.isArray(galleryAlbumQuery.data?.photos) && (galleryAlbumQuery.data?.photos?.length || 0) > 0 && (() => {
@@ -746,7 +798,7 @@ const ArticleDetailPage: React.FC = () => {
<Image src={photos[2].image_1500} alt={String(photos[2].id)} sx={{ gridColumn: 2, gridRow: '1 / span 2' }} objectFit="cover" w="100%" h="100%" borderRadius="md" />
<Image src={photos[3].image_1500} alt={String(photos[3].id)} sx={{ gridColumn: 3, gridRow: 1 }} objectFit="cover" w="100%" h="100%" borderRadius="md" />
<Image src={photos[4].image_1500} alt={String(photos[4].id)} sx={{ gridColumn: 3, gridRow: 2 }} objectFit="cover" w="100%" h="100%" borderRadius="md" />
<Button as={RouterLink} to={(data as any).gallery_album_id ? `/galerie/album/${(data as any).gallery_album_id}` : '#'} size="sm" colorScheme="blue" position="absolute" top="50%" left="50%" transform="translate(-50%, -50%)" onClick={() => umamiTrackEvent('Gallery Link Click', { album_id: (data as any).gallery_album_id })}>Zobrazit galerii</Button>
<Button as={RouterLink} to={(data as any).gallery_album_id ? `/galerie/album/${(data as any).gallery_album_id}` : '#'} size="sm" colorScheme="blue" position="absolute" top="50%" left="50%" transform="translate(-50%, -50%)" onClick={() => umamiTrackEvent('Gallery Link Click', { album_id: (data as any).gallery_album_id })}>Zobrazit celou galerii</Button>
</Box>
);
})()}
@@ -841,25 +893,6 @@ const ArticleDetailPage: React.FC = () => {
</SimpleGrid>
</Container>
</Box>
{/* Polls (Ankety) above attachments */}
{data?.id && <EmbeddedPoll articleId={(data as any).id} maxPolls={3} />}
{/* Attachments - bottom above comments */}
{Array.isArray((data as any)?.attachments) && (data as any).attachments.length > 0 && (
<Container maxW="7xl" mt={4}>
<Box borderWidth="1px" borderRadius="lg" p={{ base: 3, md: 4 }} bg={attachmentsBg}>
<Heading as="h3" size="md" mb={2}>Přílohy</Heading>
<Stack spacing={2}>
{(data as any).attachments.map((f: any, idx: number) => (
<HStack key={idx} justify="space-between">
<Text noOfLines={1}>{f.name || f.url}</Text>
<FilePreview url={assetUrl(f.url) || f.url} name={f.name || ''} mimeType={f.mime_type || ''} size={f.size} />
</HStack>
))}
</Stack>
</Box>
</Container>
)}
{/* Comments at the end */}
{(data as any)?.id ? (
<Container maxW="7xl" mt={4}>
+11 -5
View File
@@ -193,6 +193,12 @@ const HomePage: React.FC = () => {
} catch {}
}, [upcomingCompIndices, nextCompIdx]);
useEffect(() => {
try {
setNextCompIdx(matchesTab);
} catch {}
}, [matchesTab]);
useEffect(() => {
let cancelled = false;
@@ -1544,9 +1550,7 @@ const HomePage: React.FC = () => {
facrCompetitions.length > 0 ? (
upcomingCompIndices.length > 0 ? (
(() => {
const safeIndex = Math.max(0, Math.min(nextCompIdx, facrCompetitions.length - 1));
const pos = upcomingCompIndices.indexOf(safeIndex);
const effectiveIndex = pos >= 0 ? upcomingCompIndices[pos] : upcomingCompIndices[0];
const effectiveIndex = Math.max(0, Math.min(matchesTab, facrCompetitions.length - 1));
const comp = facrCompetitions[effectiveIndex];
const items = Array.isArray(comp?.matches) ? comp.matches : [];
const upcoming = items
@@ -1555,6 +1559,8 @@ const HomePage: React.FC = () => {
.sort((a: any, b: any) => a.t - b.t)[0]?.m;
const show = upcoming || null;
const link = (show && (show.facr_link || show.report_url)) || comp?.matches_link || nextMatchLink;
// Compute prev/next among competitions that actually have upcoming matches
const pos = upcomingCompIndices.indexOf(effectiveIndex);
const prevIdx = upcomingCompIndices[(Math.max(0, pos) - 1 + upcomingCompIndices.length) % upcomingCompIndices.length];
const nextIdx = upcomingCompIndices[(Math.max(0, pos) + 1) % upcomingCompIndices.length];
const handleNextMatchClick = () => {
@@ -1574,8 +1580,8 @@ const HomePage: React.FC = () => {
data={show}
competitionName={comp?.name}
countdown={countdown}
onPrev={() => setNextCompIdx(prevIdx)}
onNext={() => setNextCompIdx(nextIdx)}
onPrev={() => { setNextCompIdx(prevIdx); setMatchesTab(prevIdx); }}
onNext={() => { setNextCompIdx(nextIdx); setMatchesTab(nextIdx); }}
onOpen={handleNextMatchClick}
elementProps={{
'data-element': 'matches' as any,
+3 -2
View File
@@ -2,7 +2,8 @@ import React from 'react';
import { Box, Center, Spinner, useColorModeValue } from '@chakra-ui/react';
import { useQuery } from '@tanstack/react-query';
import { getPublicScoreboard, ScoreboardState } from '@/services/scoreboard';
import ScoreboardDisplay from '@/components/scoreboard/ScoreboardDisplay';
import MyClubOverlay from '@/components/scoreboard/MyClubOverlay';
import ScoreboardPreview from '@/components/scoreboard/ScoreboardPreview';
// Public overlay page intended for OBS/browser source.
// Minimal chrome, transparent-friendly background.
@@ -20,7 +21,7 @@ const OverlayScoreboardPage: React.FC = () => {
{isLoading || !data ? (
<Center><Spinner /></Center>
) : (
<ScoreboardDisplay state={data} />
(data.theme === 'pill' ? <MyClubOverlay state={data} /> : <ScoreboardPreview state={data} />)
)}
</Box>
);
+122 -15
View File
@@ -1,28 +1,135 @@
import React from 'react';
import { Box, Center, Spinner, SimpleGrid, Image, useColorModeValue } from '@chakra-ui/react';
import React, { useEffect, useMemo, useRef, useState } from 'react';
import { Box, Center, Spinner, useColorModeValue } from '@chakra-ui/react';
import { useQuery } from '@tanstack/react-query';
import { listSponsorsPublic } from '@/services/scoreboard';
import { getPublicScoreboard, getQr, listSponsorsPublic } from '@/services/scoreboard';
const css = `
html, body { margin: 0; padding: 0; background: transparent; height: 100%; overflow: hidden; }
.bar { position: fixed; left: 0; right: 0; bottom: 0; height: 80px; background: #000000; display: flex; align-items: center; padding: 0; box-sizing: border-box; overflow: hidden; }
.scroller { position: relative; width: 100%; height: 100%; overflow: hidden; background: #ffffff; }
.track { display: inline-flex; align-items: center; gap: 48px; height: 100%; white-space: nowrap; will-change: transform; animation: scroll linear infinite; animation-duration: var(--scroll-duration, 40s); }
.item { height: 60px; width: auto; display: flex; align-items: center; justify-content: center; flex-shrink: 0; }
.item img { height: 30%; width: auto; max-width: 280px; min-width: 60px; object-fit: contain; display: block; filter: none; image-rendering: -webkit-optimize-contrast; image-rendering: crisp-edges; -webkit-backface-visibility: hidden; backface-visibility: hidden; transform: translateZ(0); }
@keyframes scroll { from { transform: translateX(0); } to { transform: translateX(-50%); } }
.qr-float { position: fixed; right: 16px; bottom: 100px; width: 160px; height: 160px; background: #ffffff; border: 1px solid #e5e7eb; border-radius: 12px; box-shadow: 0 10px 30px rgba(0,0,0,0.22); display: grid; place-items: center; opacity: 0; visibility: hidden; transform: translateY(10px) scale(0.96); transition: opacity .35s ease, transform .35s ease, visibility 0s linear .35s; z-index: 9999; }
.qr-float.show { opacity: 1; visibility: visible; transform: translateY(0) scale(1); transition: opacity .35s ease, transform .35s ease, visibility 0s; }
.qr-float img { max-width: 88%; max-height: 88%; object-fit: contain; display: block; }
`;
const OverlaySponsorsPage: React.FC = () => {
const bg = useColorModeValue('transparent', 'transparent');
const { data, isLoading } = useQuery<string[]>({
const { data: sponsors, isLoading } = useQuery<string[]>({
queryKey: ['public-sponsors-list'],
queryFn: listSponsorsPublic,
refetchInterval: 10000,
staleTime: 5000,
refetchInterval: 60000,
staleTime: 30000,
});
const list = useMemo(() => Array.isArray(sponsors) ? sponsors.slice(0, 80) : [], [sponsors]);
const trackRef = useRef<HTMLDivElement | null>(null);
const containerRef = useRef<HTMLDivElement | null>(null);
const [duration, setDuration] = useState<number>(40);
const [qrUrl, setQrUrl] = useState<string>('');
const [qrVisible, setQrVisible] = useState<boolean>(false);
const scheduleRef = useRef<{ intId?: any } | null>(null);
useEffect(() => {
if (!trackRef.current) return;
// After images load, compute total width and set duration
const imgs = Array.from(trackRef.current.querySelectorAll('img')) as HTMLImageElement[];
if (imgs.length === 0) {
setDuration(40);
return;
}
let completed = 0;
const check = () => {
completed++;
if (completed >= imgs.length) {
// compute combined width of first half (since content duplicated)
const children = Array.from(trackRef.current!.children) as HTMLElement[];
const half = Math.floor(children.length / 2);
let total = 0; const gap = 48;
for (let i = 0; i < half; i++) {
const el = children[i];
if (el && (el as HTMLElement).style.display !== 'none') total += el.getBoundingClientRect().width;
}
const visible = half; // approximate
if (visible > 0) total += (visible - 1) * gap;
const halfWidth = total; // track anim scrolls by half
const pps = 60; // pixels per second
const d = halfWidth > 0 ? Math.max(15, halfWidth / pps) : 40;
setDuration(Math.round(d));
}
};
imgs.forEach((img) => {
if (img.complete && img.naturalWidth > 0) check();
else {
img.addEventListener('load', check, { once: true });
img.addEventListener('error', () => {
const parent = img.parentElement as HTMLElement | null;
if (parent) parent.style.display = 'none';
check();
}, { once: true });
}
});
return () => {
imgs.forEach((img) => {
img.onload = null; img.onerror = null;
});
};
}, [list]);
useEffect(() => {
let mounted = true;
(async () => {
try {
const qr = await getQr();
if (mounted && qr) setQrUrl(qr);
} catch {}
try {
const st = await getPublicScoreboard();
const everyMin = Math.max(1, Number(st.qrEvery || st.qrEvery === 0 ? st.qrEvery : (st as any).QRShowEveryMinutes || 5));
const durSec = Math.max(5, Number(st.qrDuration || st.qrDuration === 0 ? st.qrDuration : (st as any).QRShowDurationSeconds || 60));
// First show shortly after load, then on schedule
const show = () => {
setQrVisible(true);
window.setTimeout(() => setQrVisible(false), durSec * 1000);
};
window.setTimeout(show, 2500);
scheduleRef.current = { intId: window.setInterval(show, everyMin * 60 * 1000) };
} catch {}
})();
return () => {
mounted = false;
if (scheduleRef.current?.intId) window.clearInterval(scheduleRef.current.intId);
};
}, []);
return (
<Box minH="100vh" bg={bg} display="flex" alignItems="center" justifyContent="center" p={4}>
<Box minH="100vh" bg={bg}>
<style>{css}</style>
{isLoading ? (
<Center><Spinner /></Center>
<Center minH="100vh"><Spinner /></Center>
) : (
<SimpleGrid columns={{ base: 2, sm: 3, md: 4, lg: 5 }} spacing={{ base: 6, md: 10 }}>
{(data || []).map((src, i) => (
<Box key={`${src}-${i}`} p={3} bg="rgba(255,255,255,0.0)" borderRadius="md">
<Image src={src} alt={`sponsor-${i}`} maxH="64px" mx="auto" objectFit="contain"/>
</Box>
))}
</SimpleGrid>
<>
<div className="bar" ref={containerRef}>
<div className="scroller">
<div className="track" ref={trackRef} style={{ ['--scroll-duration' as any]: `${duration}s` }}>
{/* duplicate content for seamless loop */}
{list.concat(list).map((src, i) => (
<div className="item" key={`${src}-${i}`}>
<img src={src} alt="" loading="eager" decoding="async" onError={(e)=>{ (e.currentTarget.parentElement as HTMLElement).style.display='none'; }} />
</div>
))}
</div>
</div>
</div>
{qrUrl ? (
<div className={`qr-float${qrVisible ? ' show' : ''}`} aria-hidden={!qrVisible}>
<img src={qrUrl} alt="QR" />
</div>
) : null}
</>
)}
</Box>
);
@@ -59,7 +59,6 @@ import ContactMap from '../../components/home/ContactMap';
import RichTextEditor from '../../components/common/RichTextEditor';
import { getCachedYouTube, YouTubeVideo } from '../../services/youtube';
import SaveStatusIndicator from '../../components/common/SaveStatusIndicator';
import DraftRecoveryModal from '../../components/common/DraftRecoveryModal';
import { useAutoSave, loadDraft, getDraftMetadata } from '../../hooks/useAutoSave';
import { FiVideo, FiYoutube } from 'react-icons/fi';
import ThumbnailPreview from '../../components/common/ThumbnailPreview';
@@ -84,8 +83,8 @@ const AdminActivitiesPage: React.FC = () => {
const qc = useQueryClient();
const { isOpen, onOpen, onClose } = useDisclosure();
const [editing, setEditing] = useState<Partial<Event> | null>(null);
const [showDraftRecovery, setShowDraftRecovery] = useState(false);
const [draftKey, setDraftKey] = useState<string>('');
const [localDraft, setLocalDraft] = useState<Partial<Event> | null>(null);
const [aiPrompt, setAiPrompt] = useState<string>('');
const [aiLoading, setAiLoading] = useState<boolean>(false);
const [aiTone, setAiTone] = useState<'informative'|'friendly'|'formal'>('informative');
@@ -141,6 +140,28 @@ const AdminActivitiesPage: React.FC = () => {
enabled: isOpen && editing !== null,
});
// Load local new-draft and expose in list (no popup)
const refreshLocalDraft = React.useCallback(() => {
try {
const key = 'draft-activity-new';
const metadata = getDraftMetadata(key);
if (metadata && metadata.age < 1440) {
const d = loadDraft<Partial<Event>>(key);
if (d) {
const restored: any = { ...d };
if (restored.id) delete restored.id;
setLocalDraft(restored);
return;
}
}
} catch {}
setLocalDraft(null);
}, []);
React.useEffect(() => {
refreshLocalDraft();
}, [refreshLocalDraft]);
const { data, isLoading } = useQuery({
queryKey: ['admin-events'],
queryFn: () => getEvents(),
@@ -236,20 +257,16 @@ const AdminActivitiesPage: React.FC = () => {
};
const openCreate = () => {
// Check for existing draft
const key = 'draft-activity-new';
setDraftKey(key);
const metadata = getDraftMetadata(key);
if (metadata && metadata.age < 1440) {
// Show recovery modal
setShowDraftRecovery(true);
if (localDraft) {
setEditing(localDraft);
} else {
// No draft, start fresh
setEditing({ title: '', description: '', type: 'other', is_public: true } as any);
setLocationLat(undefined);
setLocationLng(undefined);
onOpen();
setEditing({ title: '', description: '', type: 'other', is_public: false } as any);
}
setLocationLat(undefined);
setLocationLng(undefined);
onOpen();
};
const openEdit = (ev: Event) => {
// Set unique draft key for this event
@@ -272,42 +289,9 @@ const AdminActivitiesPage: React.FC = () => {
setLocationLat(undefined);
setLocationLng(undefined);
onClose();
refreshLocalDraft();
};
// Draft recovery handlers
const handleRecoverDraft = () => {
const draft = loadDraft<Partial<Event>>(draftKey);
if (draft) {
const isNewDraft = draftKey === 'draft-activity-new';
const restored: any = { ...draft };
if (isNewDraft && restored.id) {
delete restored.id;
}
setEditing(restored);
// Restore location if present
if ((restored as any)?.latitude && (restored as any)?.longitude) {
setLocationLat((restored as any).latitude);
setLocationLng((restored as any).longitude);
}
onOpen();
}
setShowDraftRecovery(false);
};
const handleDiscardDraft = () => {
clearDraft();
setEditing({ title: '', description: '', type: 'other', is_public: true } as any);
setLocationLat(undefined);
setLocationLng(undefined);
setShowDraftRecovery(false);
onOpen();
};
const handleDeleteOnly = () => {
clearDraft();
setShowDraftRecovery(false);
// Don't open the modal - just delete and close
};
const createMut = useMutation({
mutationFn: (payload: Partial<Event>) => createEvent(payload),
@@ -562,6 +546,53 @@ const AdminActivitiesPage: React.FC = () => {
{isLoading && (
<Tr><Td colSpan={8}>Načítání</Td></Tr>
)}
{!isLoading && localDraft && (
<Tr key="local-draft" opacity={0.6}>
<Td>
{(localDraft as any).image_url ? (
<ThumbnailPreview
src={assetUrl((localDraft as any).image_url) || (localDraft as any).image_url}
alt={(localDraft as any).title || 'Koncept'}
size="48px"
previewSize="350px"
/>
) : (
<ChakraImage
src={assetUrl(settingsQ.data?.club_logo_url) || assetUrl('/dist/img/logo-club-empty.svg') || '/dist/img/logo-club-empty.svg'}
alt="No image"
boxSize="48px"
objectFit="contain"
opacity={0.3}
/>
)}
</Td>
<Td>
<VStack align="start" spacing={0}>
<Text fontWeight="medium">{(localDraft as any).title || 'Bez názvu (koncept)'}</Text>
<Text fontSize="xs" color="gray.500">Koncept (lokálně uložený)</Text>
</VStack>
</Td>
<Td>
<Badge colorScheme="gray" fontSize="xs">{(localDraft as any).type ? typeLabel((localDraft as any).type as any) : '—'}</Badge>
</Td>
<Td></Td>
<Td></Td>
<Td>{(localDraft as any).location || '—'}</Td>
<Td><Badge colorScheme="gray">Koncept</Badge></Td>
<Td isNumeric>
<HStack spacing={1} justify="flex-end">
<IconButton aria-label="Upravit koncept" size="sm" icon={<FiEdit2 />} onClick={openCreate} />
<IconButton
aria-label="Smazat koncept"
size="sm"
colorScheme="red"
icon={<FiTrash2 />}
onClick={() => { try { localStorage.removeItem('draft-activity-new'); } catch {} setLocalDraft(null); toast({ title: 'Koncept odstraněn', status: 'success', duration: 2000 }); }}
/>
</HStack>
</Td>
</Tr>
)}
{!isLoading && events.map(ev => (
<Tr key={ev.id} opacity={ev.is_public ? 1 : 0.6}>
<Td>
@@ -582,7 +613,12 @@ const AdminActivitiesPage: React.FC = () => {
/>
)}
</Td>
<Td>{ev.title}</Td>
<Td>
<VStack align="start" spacing={0}>
<Text fontWeight="medium">{ev.title}</Text>
<Text fontSize="xs" color="gray.500">{ev.is_public ? '✓ Veřejná' : '○ Koncept'}</Text>
</VStack>
</Td>
<Td>{typeLabel(ev.type as any)}</Td>
<Td>{new Date(ev.start_time).toLocaleString()}</Td>
<Td>{ev.end_time ? new Date(ev.end_time).toLocaleString() : '-'}</Td>
@@ -1163,16 +1199,7 @@ const AdminActivitiesPage: React.FC = () => {
</ModalContent>
</Modal>
{/* Draft Recovery Modal */}
<DraftRecoveryModal
isOpen={showDraftRecovery}
onClose={() => setShowDraftRecovery(false)}
onRecover={handleRecoverDraft}
onDiscard={handleDiscardDraft}
onDeleteOnly={handleDeleteOnly}
draftAge={getDraftMetadata(draftKey)?.age || null}
entityType="aktivitu"
/>
</Box>
</AdminLayout>
);
@@ -130,6 +130,51 @@ const getEventTranslation = (eventName: string): { name: string; source: string;
name: 'Kliknutí na externí odkaz',
source: 'Různé stránky',
description: 'Uživatel klikl na odkaz vedoucí mimo web'
},
'Button Click': {
name: 'Kliknutí na tlačítko',
source: 'Různé stránky',
description: 'Uživatel klikl na tlačítko'
},
'Navigation': {
name: 'Navigace',
source: 'Menu / odkazy',
description: 'Uživatel přešel na jinou stránku'
},
'Search': {
name: 'Vyhledávání',
source: 'Vyhledávání',
description: 'Uživatel vyhledával na webu'
},
'Email Open': {
name: 'Otevření emailu',
source: 'Email',
description: 'Příjemce otevřel email'
},
'Email Click': {
name: 'Kliknutí v emailu',
source: 'Email',
description: 'Příjemce klikl na odkaz v emailu'
},
'Email Spam': {
name: 'Označeno jako spam',
source: 'Email',
description: 'Příjemce označil zprávu jako spam'
},
'Email Unsubscribe': {
name: 'Odhlášení z emailu',
source: 'Email',
description: 'Příjemce se odhlásil z odběru'
},
'ShortLink Click': {
name: 'Kliknutí na zkrácený odkaz',
source: 'Zkrácené odkazy',
description: 'Uživatel klikl na zkrácený odkaz'
},
'Link Redirect': {
name: 'Přesměrování odkazu',
source: 'Sledování odkazů',
description: 'Zaznamenané přesměrování sledovaného odkazu'
}
};
@@ -161,11 +206,11 @@ const AdminDashboardPage = () => {
staleTime: 10 * 60 * 1000,
});
// Fetch top events from Umami
// Fetch top events (adblock-safe alias)
const { data: topEvents } = useQuery<Array<{ x: string; y: number }>>({
queryKey: ['admin', 'analytics', 'umami-events'],
queryFn: async () => {
const response = await api.get('/admin/umami/metrics/event?days=7');
const response = await api.get('/admin/insights/breakdown/event?days=7');
return response.data || [];
},
staleTime: 10 * 60 * 1000,
+60 -15
View File
@@ -194,6 +194,51 @@ const getEventTranslation = (eventName: string): { name: string; source: string;
name: 'Hlasování v anketě',
source: 'Ankety',
description: 'Uživatel hlasoval v anketě'
},
'Button Click': {
name: 'Kliknutí na tlačítko',
source: 'Různé stránky',
description: 'Uživatel klikl na tlačítko'
},
'Navigation': {
name: 'Navigace',
source: 'Menu / odkazy',
description: 'Uživatel přešel na jinou stránku'
},
'Search': {
name: 'Vyhledávání',
source: 'Vyhledávání',
description: 'Uživatel vyhledával na webu'
},
'Email Open': {
name: 'Otevření emailu',
source: 'Email',
description: 'Příjemce otevřel email'
},
'Email Click': {
name: 'Kliknutí v emailu',
source: 'Email',
description: 'Příjemce klikl na odkaz v emailu'
},
'Email Spam': {
name: 'Označeno jako spam',
source: 'Email',
description: 'Příjemce označil zprávu jako spam'
},
'Email Unsubscribe': {
name: 'Odhlášení z emailu',
source: 'Email',
description: 'Příjemce se odhlásil z odběru'
},
'ShortLink Click': {
name: 'Kliknutí na zkrácený odkaz',
source: 'Zkrácené odkazy',
description: 'Uživatel klikl na zkrácený odkaz'
},
'Link Redirect': {
name: 'Přesměrování odkazu',
source: 'Sledování odkazů',
description: 'Zaznamenané přesměrování sledovaného odkazu'
}
};
@@ -242,7 +287,7 @@ const AnalyticsAdminPage: React.FC = () => {
const daysNum = parseInt(days);
// Fetch stats with calculated time range
const statsResponse = await api.get(`/admin/umami/stats?days=${days}`);
const statsResponse = await api.get(`/admin/insights/summary?days=${days}`);
setStats(statsResponse.data);
// Check if we have any data
@@ -253,14 +298,14 @@ const AnalyticsAdminPage: React.FC = () => {
// Fetch metrics
const [pages, browsers, os, countries, devices, events, queries, pageviews] = await Promise.all([
api.get(`/admin/umami/metrics/url?days=${days}`),
api.get(`/admin/umami/metrics/browser?days=${days}`),
api.get(`/admin/umami/metrics/os?days=${days}`),
api.get(`/admin/umami/metrics/country?days=${days}`),
api.get(`/admin/umami/metrics/device?days=${days}`),
api.get(`/admin/umami/metrics/event?days=${days}`),
api.get(`/admin/umami/metrics/query?days=${days}`).catch(() => ({ data: [] })),
api.get(`/admin/umami/pageviews?days=${days}`),
api.get(`/admin/insights/breakdown/url?days=${days}`),
api.get(`/admin/insights/breakdown/browser?days=${days}`),
api.get(`/admin/insights/breakdown/os?days=${days}`),
api.get(`/admin/insights/breakdown/country?days=${days}`),
api.get(`/admin/insights/breakdown/device?days=${days}`),
api.get(`/admin/insights/breakdown/event?days=${days}`),
api.get(`/admin/insights/breakdown/query?days=${days}`).catch(() => ({ data: [] })),
api.get(`/admin/insights/pageviews?days=${days}`),
]);
setPageMetrics(pages.data || []);
@@ -330,7 +375,7 @@ const AnalyticsAdminPage: React.FC = () => {
const fetchUmamiConfig = async () => {
try {
const response = await api.get('/umami/config');
const response = await api.get('/insights/config');
setUmamiConfig(response.data);
} catch (error) {
console.error('Failed to fetch Umami config:', error);
@@ -345,11 +390,11 @@ const AnalyticsAdminPage: React.FC = () => {
try {
// Fetch detailed analytics for the selected country
const [pages, browsers, os, devices, events] = await Promise.all([
api.get(`/admin/umami/metrics/url?days=${timeRange}&country=${countryCode}`),
api.get(`/admin/umami/metrics/browser?days=${timeRange}&country=${countryCode}`),
api.get(`/admin/umami/metrics/os?days=${timeRange}&country=${countryCode}`),
api.get(`/admin/umami/metrics/device?days=${timeRange}&country=${countryCode}`),
api.get(`/admin/umami/metrics/event?days=${timeRange}&country=${countryCode}`),
api.get(`/admin/insights/breakdown/url?days=${timeRange}&country=${countryCode}`),
api.get(`/admin/insights/breakdown/browser?days=${timeRange}&country=${countryCode}`),
api.get(`/admin/insights/breakdown/os?days=${timeRange}&country=${countryCode}`),
api.get(`/admin/insights/breakdown/device?days=${timeRange}&country=${countryCode}`),
api.get(`/admin/insights/breakdown/event?days=${timeRange}&country=${countryCode}`),
]);
setCountryDetails({
+39 -11
View File
@@ -249,6 +249,7 @@ const ArticlesAdminPage = () => {
const [aiPrompt, setAiPrompt] = useState('');
const [aiAudience, setAiAudience] = useState('Fanoušci klubu');
const [aiMinWords, setAiMinWords] = useState<number>(500);
const [aiMinWordsInput, setAiMinWordsInput] = useState<string>('500');
const [featSwitchLoading, setFeatSwitchLoading] = useState<boolean>(false);
const [competitions, setCompetitions] = useState<Array<{ code?: string; name: string }>>([]);
const [aliasesMap, setAliasesMap] = useState<Record<string, string>>({});
@@ -755,7 +756,11 @@ const ArticlesAdminPage = () => {
}, []);
const aiMut = useMutation({
mutationFn: () => generateBlogAI({ prompt: aiPrompt, audience: aiAudience, min_words: aiMinWords }),
mutationFn: () => {
const parsed = parseInt(String(aiMinWordsInput || '').trim(), 10);
const effective = Number.isFinite(parsed) && !isNaN(parsed) && parsed > 0 ? parsed : aiMinWords;
return generateBlogAI({ prompt: aiPrompt, audience: aiAudience, min_words: effective });
},
onSuccess: (res) => {
console.log('AI blog response:', res);
@@ -1517,7 +1522,7 @@ const ArticlesAdminPage = () => {
</ModalHeader>
<ModalCloseButton />
<ModalBody maxH="calc(90vh - 120px)" overflowY="auto">
<Tabs variant="enclosed" colorScheme="blue" isFitted index={activeTabIndex} onChange={(index) => setActiveTabIndex(index)} isLazy lazyBehavior="unmount">
<Tabs variant="enclosed" colorScheme="blue" isFitted index={activeTabIndex} onChange={(index) => setActiveTabIndex(index)} isLazy lazyBehavior="keepMounted">
<TabList>
<Tab>AI</Tab>
<Tab>Základní</Tab>
@@ -1558,7 +1563,28 @@ const ArticlesAdminPage = () => {
</FormControl>
<FormControl w="180px">
<FormLabel>Min. slov</FormLabel>
<Input type="number" value={aiMinWords} onChange={(e) => setAiMinWords(Math.max(200, Number(e.target.value || 0)))} bg={inputBg} />
<Input
type="text"
inputMode="numeric"
placeholder="500"
value={aiMinWordsInput}
onChange={(e) => {
const v = e.target.value;
const digits = v.replace(/[^0-9]/g, '');
setAiMinWordsInput(digits);
}}
onBlur={() => {
const n = parseInt(String(aiMinWordsInput || '').trim(), 10);
if (!isNaN(n) && Number.isFinite(n) && n > 0) {
setAiMinWords(n);
setAiMinWordsInput(String(n));
} else {
// Reset to last valid numeric value
setAiMinWordsInput(String(aiMinWords || 500));
}
}}
bg={inputBg}
/>
</FormControl>
</HStack>
<HStack>
@@ -1806,14 +1832,16 @@ const ArticlesAdminPage = () => {
Vložit fotografie z alba
</Button>
</HStack>
<RichTextEditor
value={editing?.content || ''}
onChange={(val: string) => setEditing((prev) => ({ ...(prev as any), content: val }))}
placeholder="Začněte psát obsah článku..."
height="60vh"
onImageUpload={uploadFile}
toolbar="full"
/>
{activeTabIndex === 2 && (
<RichTextEditor
value={editing?.content || ''}
onChange={(val: string) => setEditing((prev) => ({ ...(prev as any), content: val }))}
placeholder="Začněte psát obsah článku..."
height="60vh"
onImageUpload={uploadFile}
toolbar="full"
/>
)}
</FormControl>
</TabPanel>
+20 -10
View File
@@ -1,10 +1,10 @@
import React from 'react';
import AdminLayout from '../../layouts/AdminLayout';
import { Box, Heading, HStack, VStack, Button, Select, Input, Table, Thead, Tbody, Tr, Th, Td, Text, Badge, IconButton, useToast, Modal, ModalOverlay, ModalContent, ModalHeader, ModalBody, ModalFooter, ModalCloseButton, useDisclosure, FormControl, FormLabel, NumberInput, NumberInputField, Switch } from '@chakra-ui/react';
import { Box, Heading, HStack, VStack, Button, Select, Input, Table, Thead, Tbody, Tr, Th, Td, Text, Badge, IconButton, useToast, Modal, ModalOverlay, ModalContent, ModalHeader, ModalBody, ModalFooter, ModalCloseButton, useDisclosure, FormControl, FormLabel, NumberInput, NumberInputField, Switch, Tooltip } from '@chakra-ui/react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { adminListComments, adminUpdateCommentStatus, adminBanUser, adminListUnbanRequests, adminResolveUnban, adminListBans, adminLiftBan } from '../../services/admin/comments';
import { deleteComment } from '../../services/comments';
import { FiTrash2 } from 'react-icons/fi';
import { FiTrash2, FiEye, FiEyeOff } from 'react-icons/fi';
import { getArticles } from '../../services/articles';
import { getEvents } from '../../services/eventService';
import { getCachedYouTube } from '../../services/youtube';
@@ -132,7 +132,7 @@ const CommentsAdminPage: React.FC = () => {
<Heading size="md" mb={4}>Komentáře (moderace)</Heading>
<VStack align="stretch" spacing={3} mb={4}>
<HStack>
<Select placeholder="Status" value={status} onChange={(e) => { setStatus(e.target.value); setPage(1); }} maxW="200px">
<Select placeholder="Stav" value={status} onChange={(e) => { setStatus(e.target.value); setPage(1); }} maxW="200px">
<option value="visible">Viditelné</option>
<option value="hidden">Skryté</option>
</Select>
@@ -172,7 +172,7 @@ const CommentsAdminPage: React.FC = () => {
<Th>Obsah</Th>
<Th>Spam</Th>
<Th>Hlášení</Th>
<Th>Status</Th>
<Th>Stav</Th>
<Th>Akce</Th>
</Tr>
</Thead>
@@ -189,10 +189,16 @@ const CommentsAdminPage: React.FC = () => {
<Td>{(c as any).spam_score ? <Badge colorScheme={(c as any).spam_score > 0.5 ? 'orange' : 'green'}>{(c as any).spam_score.toFixed(2)}</Badge> : '-'}</Td>
<Td>{(c as any).reports ? <Badge colorScheme={(c as any).reports > 2 ? 'red' : 'yellow'}>{(c as any).reports}</Badge> : '-'}</Td>
<Td>
<HStack>
<Button size="xs" variant={c.status === 'visible' ? 'solid' : 'outline'} onClick={() => updateStatusMut.mutate({ id: c.id, s: 'visible' })}>Viditelné</Button>
<Button size="xs" variant={c.status === 'hidden' ? 'solid' : 'outline'} onClick={() => updateStatusMut.mutate({ id: c.id, s: 'hidden' })}>Skryté</Button>
</HStack>
<Tooltip label={c.status === 'visible' ? 'Viditelné' : 'Skryté'}>
<IconButton
aria-label={c.status === 'visible' ? 'Viditelné' : 'Skryté'}
size="xs"
variant="ghost"
colorScheme={c.status === 'visible' ? 'green' : 'gray'}
icon={c.status === 'visible' ? <FiEye /> : <FiEyeOff />}
onClick={() => updateStatusMut.mutate({ id: c.id, s: (c.status === 'visible' ? 'hidden' : 'visible') as any })}
/>
</Tooltip>
</Td>
<Td>
<HStack>
@@ -222,7 +228,7 @@ const CommentsAdminPage: React.FC = () => {
<Th>ID</Th>
<Th>Uživatel</Th>
<Th>Text</Th>
<Th>Status</Th>
<Th>Stav</Th>
<Th>Akce</Th>
</Tr>
</Thead>
@@ -232,7 +238,11 @@ const CommentsAdminPage: React.FC = () => {
<Td>#{r.id}</Td>
<Td>#{r.user?.id} {r.user?.first_name} {r.user?.last_name} <Text as="span" color="gray.500" fontSize="sm">{r.user?.email}</Text></Td>
<Td maxW="480px"><Text noOfLines={2}>{r.message}</Text></Td>
<Td><Badge>{r.status}</Badge></Td>
<Td>
{r.status === 'pending' && <Badge colorScheme="yellow">Čeká na vyřízení</Badge>}
{r.status === 'approved' && <Badge colorScheme="green">Schváleno</Badge>}
{r.status === 'rejected' && <Badge colorScheme="red">Zamítnuto</Badge>}
</Td>
<Td>
<HStack>
<Button size="xs" colorScheme="green" variant="outline" onClick={() => resolveUnbanMut.mutate({ id: r.id, action: 'approve' })}>Povolit</Button>
+51 -25
View File
@@ -34,7 +34,7 @@ import {
} from '@chakra-ui/react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import AdminLayout from '../../layouts/AdminLayout';
import { putMatchOverride, fetchAdminMatches } from '../../services/adminMatches';
import { patchMatchOverride, fetchAdminMatches } from '../../services/adminMatches';
import { getPublicSettings } from '../../services/settings';
import { useEffect, useMemo, useRef, useState } from 'react';
@@ -52,7 +52,9 @@ const MatchesAdminPage = () => {
const [form, setForm] = useState({
venue_override: '',
date_time_edit: '',
score_override: '',
});
const [origDateLocal, setOrigDateLocal] = useState<string>('');
const normalizeName = (s: string) => {
let out = String(s || '');
@@ -88,7 +90,7 @@ const MatchesAdminPage = () => {
const items = await fetchAdminMatches();
const FACR_DATE_FMT = 'dd.MM.yyyy HH:mm';
const parseTs = (obj: any): number => {
const s = String(obj?.date_time || obj?.date || '').trim();
const s = String(obj?.date_time || (obj?.date && obj?.time ? `${obj.date} ${obj.time}` : obj?.date) || '').trim();
if (!s) return Number.MAX_SAFE_INTEGER;
try {
const dt = parse(s, FACR_DATE_FMT, new Date());
@@ -112,8 +114,9 @@ const MatchesAdminPage = () => {
const [sideFilter, setSideFilter] = useState<'home' | 'away' | ''>('');
const normalizedTeam = teamFilter.trim().toLowerCase();
const FACR_DATE_FMT = 'dd.MM.yyyy HH:mm';
const formatDisplayDate = (s: string): string => {
const str = String(s || '').trim();
const formatDisplayDate = (s: string, t?: string): string => {
// Prefer full date-time string; if only date and time parts exist, combine them
const str = String((s && t ? `${s} ${t}` : s) || '').trim();
if (!str) return '';
try {
const dt = parse(str, FACR_DATE_FMT, new Date());
@@ -216,7 +219,7 @@ const MatchesAdminPage = () => {
}
// date parse
const dtStr = String(m.date_time || m.date || '');
const dtStr = String(m.date_time || (m.date && m.time ? `${m.date} ${m.time}` : m.date) || '');
let ts = NaN;
try {
ts = parse(dtStr, FACR_DATE_FMT, new Date()).getTime();
@@ -346,14 +349,24 @@ const MatchesAdminPage = () => {
mutationFn: async () => {
const externalMatchId: string = String((selected?.match_id ?? selected?.id ?? '')).trim();
if (!externalMatchId) throw new Error('Chybí match_id');
const payload: any = {
venue_override: form.venue_override,
date_time_override: form.date_time_edit,
};
Object.keys(payload).forEach((k) => {
if (payload[k as keyof typeof payload] === '') payload[k as keyof typeof payload] = null;
});
await putMatchOverride(externalMatchId, payload);
const body: any = {};
// Venue: send only if changed
const currentVenue = String(selected?.venue || '');
if (form.venue_override.trim() !== currentVenue) {
body.venue_override = form.venue_override.trim() === '' ? null : form.venue_override.trim();
}
// Score: send only if changed
const currentScore = String(selected?.score || (selected?.result_home != null && selected?.result_away != null ? `${selected.result_home}:${selected.result_away}` : '') || '');
if (form.score_override.trim() !== currentScore) {
body.score_override = form.score_override.trim() === '' ? null : form.score_override.trim();
}
// Datetime: send only if changed
if (form.date_time_edit !== origDateLocal) {
body.date_time_override = form.date_time_edit.trim() === '' ? null : form.date_time_edit;
}
// If nothing changed, do nothing
if (Object.keys(body).length === 0) return { ok: true };
await patchMatchOverride(externalMatchId, body);
return { ok: true };
},
onSuccess: () => {
@@ -369,7 +382,7 @@ const MatchesAdminPage = () => {
const openEdit = (m: any) => {
setSelected(m);
const facrStr: string = m.date_time || m.date || '';
const facrStr: string = m.date_time || (m.date && m.time ? `${m.date} ${m.time}` : m.date) || '';
let localStr = '';
if (facrStr) {
try {
@@ -386,9 +399,11 @@ const MatchesAdminPage = () => {
}
}
}
setOrigDateLocal(localStr);
setForm({
venue_override: m.venue || '',
date_time_edit: localStr,
score_override: String(m.score || (m.result_home != null && m.result_away != null ? `${m.result_home}:${m.result_away}` : '') || ''),
});
setIsOpen(true);
};
@@ -554,17 +569,19 @@ const MatchesAdminPage = () => {
// Utility to check if match is in the past
const isMatchPast = (dateTimeStr: string): boolean => {
if (!dateTimeStr) return false;
// Try full date+time first: dd.MM.yyyy HH:mm
try {
const dt = parse(dateTimeStr, FACR_DATE_FMT, new Date());
if (!isNaN(dt.getTime())) {
return dt.getTime() < Date.now();
}
} catch (_) {
const d = new Date(dateTimeStr);
if (!isNaN(d.getTime())) {
return d.getTime() < Date.now();
}
}
if (!isNaN(dt.getTime())) return dt.getTime() < Date.now();
} catch {}
// If only date is present: dd.MM.yyyy
try {
const dOnly = parse(dateTimeStr, 'dd.MM.yyyy', new Date());
if (!isNaN(dOnly.getTime())) return dOnly.getTime() < Date.now();
} catch {}
// Fallback to Date constructor (RFC3339 etc.)
const d2 = new Date(dateTimeStr);
if (!isNaN(d2.getTime())) return d2.getTime() < Date.now();
return false;
};
@@ -785,7 +802,7 @@ const MatchesAdminPage = () => {
</Tr>
) : (
visibleMatches.map((m: any, idx: number) => {
const isPast = isMatchPast(m.date_time || m.date || '');
const isPast = isMatchPast(m.date_time || (m.date && m.time ? `${m.date} ${m.time}` : m.date) || '');
const hasScore = m.score || (m.result_home != null && m.result_away != null);
return (
<Tr
@@ -797,7 +814,7 @@ const MatchesAdminPage = () => {
>
<Td>
<HStack spacing={2}>
<Text>{formatDisplayDate(String(m.date_time || m.date || ''))}</Text>
<Text>{formatDisplayDate(String(m.date || m.date_time || ''), String(m.time || ''))}</Text>
{isPast && <Badge colorScheme="gray" fontSize="xs">Odehráno</Badge>}
{!isPast && <Badge colorScheme="green" fontSize="xs">Nadcházející</Badge>}
</HStack>
@@ -899,6 +916,15 @@ const MatchesAdminPage = () => {
/>
</FormControl>
<FormControl>
<FormLabel>Skóre</FormLabel>
<Input
placeholder="např. 2:1"
value={form.score_override}
onChange={(e) => setForm((f) => ({ ...f, score_override: e.target.value }))}
/>
</FormControl>
{/* Team name/logo editing removed */}
@@ -1,8 +1,8 @@
import React, { useEffect, useMemo, useRef, useState } from 'react';
import { Box, Button, Center, HStack, Heading, Image, SimpleGrid, Text, useColorModeValue, useToast, VStack } from '@chakra-ui/react';
import { Box, Button, Center, HStack, Heading, Image, SimpleGrid, Text, useColorModeValue, useToast, VStack, Badge } from '@chakra-ui/react';
import AdminLayout from '@/layouts/AdminLayout';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { getAdminScoreboard, updateAdminScoreboard, ScoreboardState, startTimer, pauseTimer, resetTimer } from '@/services/scoreboard';
import { getAdminScoreboard, updateAdminScoreboard, ScoreboardState, startTimer, pauseTimer, resetTimer, swapSides, startSecondHalf } from '@/services/scoreboard';
const MobileScoreboardControlPage: React.FC = () => {
const toast = useToast();
@@ -81,6 +81,13 @@ const MobileScoreboardControlPage: React.FC = () => {
<Button variant="outline" onClick={handleResetTimer}>Reset</Button>
</HStack>
<Text fontSize="2xl" fontFamily="mono">{mmss}</Text>
<HStack>
<Badge colorScheme="purple">Poločas: {state.half || 1}</Badge>
</HStack>
<HStack>
<Button size="sm" variant="outline" onClick={async ()=>{ try { await swapSides(); await qc.invalidateQueries({ queryKey: ['admin-scoreboard-mobile'] }); toast({ title: 'Strany prohozeny', status: 'success' }); } catch { toast({ title: 'Prohození selhalo', status: 'error' }); } }}>Prohodit strany</Button>
<Button size="sm" colorScheme="purple" onClick={async ()=>{ try { await startSecondHalf(); await qc.invalidateQueries({ queryKey: ['admin-scoreboard-mobile'] }); toast({ title: 'Začal 2. poločas', status: 'success' }); } catch { toast({ title: 'Akce selhala', status: 'error' }); } }}>Začít 2. poločas</Button>
</HStack>
</VStack>
<VStack spacing={2}>
{state.awayLogo ? <Image src={state.awayLogo} alt="HOS" boxSize="64px" objectFit="contain" /> : null}
@@ -381,7 +381,7 @@ const PlayersAdminPage: React.FC = () => {
<Tr key={p.id} opacity={p.is_active ? 1 : 0.6}>
<Td>
<ThumbnailPreview
src={assetUrl(p.image_url) || '/logo192.png'}
src={assetUrl(p.image_url) || '/dist/img/player-placeholder.svg'}
alt={`${p.first_name} ${p.last_name}`}
size="48px"
previewSize="300px"
@@ -72,7 +72,7 @@ const SettingsAdminPage: React.FC = () => {
const fetchUmamiConfig = async () => {
try {
const response = await api.get('/umami/config');
const response = await api.get('/insights/config');
setUmamiConfig(response.data);
if (response.data?.website_id) {
setUmamiWebsiteId(response.data.website_id);
@@ -618,7 +618,7 @@ const SettingsAdminPage: React.FC = () => {
}
setUmamiInitializing(true);
try {
const response = await api.post('/admin/umami/initialize', {
const response = await api.post('/admin/insights/initialize', {
name: umamiName,
domain: umamiDomain,
});