mirror of
https://github.com/Dvorinka/MyClubServer.git
synced 2026-06-04 10:42:57 +00:00
dev day #92
This commit is contained in:
@@ -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}>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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í e‑mailu',
|
||||
source: 'E‑mail',
|
||||
description: 'Příjemce otevřel e‑mail'
|
||||
},
|
||||
'Email Click': {
|
||||
name: 'Kliknutí v e‑mailu',
|
||||
source: 'E‑mail',
|
||||
description: 'Příjemce klikl na odkaz v e‑mailu'
|
||||
},
|
||||
'Email Spam': {
|
||||
name: 'Označeno jako spam',
|
||||
source: 'E‑mail',
|
||||
description: 'Příjemce označil zprávu jako spam'
|
||||
},
|
||||
'Email Unsubscribe': {
|
||||
name: 'Odhlášení z e‑mailu',
|
||||
source: 'E‑mail',
|
||||
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,
|
||||
|
||||
@@ -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í e‑mailu',
|
||||
source: 'E‑mail',
|
||||
description: 'Příjemce otevřel e‑mail'
|
||||
},
|
||||
'Email Click': {
|
||||
name: 'Kliknutí v e‑mailu',
|
||||
source: 'E‑mail',
|
||||
description: 'Příjemce klikl na odkaz v e‑mailu'
|
||||
},
|
||||
'Email Spam': {
|
||||
name: 'Označeno jako spam',
|
||||
source: 'E‑mail',
|
||||
description: 'Příjemce označil zprávu jako spam'
|
||||
},
|
||||
'Email Unsubscribe': {
|
||||
name: 'Odhlášení z e‑mailu',
|
||||
source: 'E‑mail',
|
||||
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({
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user