This commit is contained in:
Tomas Dvorak
2025-10-28 22:38:27 +01:00
parent 3d621e2187
commit 823fabee02
106 changed files with 9011 additions and 3930 deletions
@@ -40,7 +40,7 @@ import {
useColorModeValue,
Image as ChakraImage,
} from '@chakra-ui/react';
import { FiEdit2, FiPlus, FiTrash2 } from 'react-icons/fi';
import { FiEdit2, FiPlus, FiTrash2, FiLink } from 'react-icons/fi';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { Event } from '../../types/event';
import { uploadFile } from '../../services/articles';
@@ -60,9 +60,10 @@ 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, FiLink } from 'react-icons/fi';
import { FiVideo, FiYoutube } from 'react-icons/fi';
import ThumbnailPreview from '../../components/common/ThumbnailPreview';
import { assetUrl } from '../../utils/url';
import { createShortLink } from '../../services/shortlinks';
const types: Array<{ value: Event['type']; label: string }> = [
{ value: 'match', label: 'Zápas' },
@@ -124,6 +125,13 @@ const AdminActivitiesPage: React.FC = () => {
});
const events = data || [];
// Localized label for event type
const typeLabel = (t?: string) => {
const v = String(t || '').trim() as any;
const found = types.find((x) => x.value === v);
return found ? found.label : 'Jiné';
};
// Load club YouTube videos
useEffect(() => {
(async () => {
@@ -266,22 +274,18 @@ const AdminActivitiesPage: React.FC = () => {
const e = editing || {};
// Build a helpful Czech prompt including known fields
const lines: string[] = [];
const clubName = String(settingsQ?.data?.club_name || '').trim();
if (clubName) lines.push(`Klub: ${clubName}`);
if (e.type) lines.push(`Typ: ${e.type}`);
if (e.location) lines.push(`Místo: ${e.location}`);
if (e.start_time) {
try { lines.push(`Začátek: ${new Date(e.start_time as any).toLocaleString('cs-CZ')}`); } catch {}
}
if (e.end_time) {
try { lines.push(`Konec: ${new Date(e.end_time as any).toLocaleString('cs-CZ')}`); } catch {}
}
if (e.description) lines.push(`Poznámky: ${e.description}`);
const base = lines.join('\n');
const toneText = aiTone === 'informative' ? 'informativním a věcným stylem' : aiTone === 'formal' ? 'formálním a profesionálním stylem' : 'přátelským, pozitivním a lákavým stylem';
const safeUserPrompt = (aiPrompt || 'Vytvoř krátké oznámení pro fanoušky o klubové aktivitě.').trim();
const prompt = `${safeUserPrompt}\n\nPiš ${toneText}, česky, s důrazem na jasnost a pozvánku k účasti.\nDetaily:\n${base}`.trim();
const constraints = 'Nevkládej datum ani místo (lokalitu) do textu. Neuváděj konkrétní čas nebo adresu.';
const prompt = `${safeUserPrompt}\n\nPiš ${toneText}, česky, s důrazem na jasnost a pozvánku k účasti. ${constraints}\nDetaily:\n${base}`.trim();
const { data } = await api.post('/ai/blog/generate', {
prompt,
audience: 'Fanoušci klubu, oznámení/pozvánka',
audience: clubName ? `Fanoušci klubu ${clubName}, oznámení/pozvánka` : 'Fanoušci klubu, oznámení/pozvánka',
min_words: 120,
});
@@ -485,7 +489,7 @@ const AdminActivitiesPage: React.FC = () => {
)}
</Td>
<Td>{ev.title}</Td>
<Td>{ev.type}</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>
<Td>{ev.location || '-'}</Td>
@@ -494,6 +498,23 @@ const AdminActivitiesPage: React.FC = () => {
<HStack>
<IconButton aria-label="Upravit" size="sm" icon={<FiEdit2 />} onClick={() => openEdit(ev)} />
<IconButton aria-label="Smazat" size="sm" colorScheme="red" icon={<FiTrash2 />} onClick={() => deleteMut.mutate(ev.id)} />
<IconButton
aria-label="Zkrátit odkaz"
size="sm"
icon={<FiLink />}
title="Zkrátit odkaz pro sdílení"
onClick={async () => {
try {
const origin = window.location.origin;
const target = `${origin}/aktivita/${ev.id}`;
const res = await createShortLink({ target_url: target, title: ev.title, source_type: 'event', source_id: ev.id as any });
await navigator.clipboard.writeText(res.short_url);
toast({ title: 'Zkrácený odkaz zkopírován', description: res.short_url, status: 'success', duration: 4000 });
} catch (e: any) {
toast({ title: 'Vytvoření odkazu selhalo', description: e?.message || 'Zkuste to znovu', status: 'error' });
}
}}
/>
</HStack>
</Td>
</Tr>
@@ -70,7 +70,8 @@ import {
FiZap,
FiTrendingUp,
FiCalendar,
FiSearch
FiSearch,
FiInfo
} from 'react-icons/fi';
// Register ChartJS components
@@ -188,6 +189,11 @@ 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'
},
'Poll Vote': {
name: 'Hlasování v anketě',
source: 'Ankety',
description: 'Uživatel hlasoval v anketě'
}
};
@@ -213,6 +219,7 @@ const AnalyticsAdminPage: React.FC = () => {
const [timeRange, setTimeRange] = useState('0'); // Default to "today"
const [hasData, setHasData] = useState(false);
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const [noDataInfo, setNoDataInfo] = useState<string | null>(null);
const [selectedCountry, setSelectedCountry] = useState<{
code: string;
name: string;
@@ -230,6 +237,7 @@ const AnalyticsAdminPage: React.FC = () => {
const fetchAnalytics = async (days: string) => {
setLoading(true);
setErrorMessage(null);
setNoDataInfo(null);
try {
const daysNum = parseInt(days);
@@ -292,14 +300,19 @@ const AnalyticsAdminPage: React.FC = () => {
setPageviewsData(pageviewsDataArray);
// Determine if we have data
const hasPageviews = pageviewsDataArray.length > 0 && pageviewsDataArray.some(d => d.value > 0);
const hasMetrics = pages.data?.length > 0 || countries.data?.length > 0;
setHasData(hasAnyStats || hasPageviews || hasMetrics);
// Set error message if no data
if (!hasAnyStats && !hasPageviews && !hasMetrics) {
setErrorMessage('Umami není správně nakonfigurováno nebo ještě nebyly zaznamenány žádné návštěvy. Zkontrolujte UMAMI_WEBSITE_ID v .env souboru.');
const noData = !hasAnyStats && !hasPageviews && !hasMetrics;
if (noData) {
if (daysNum <= 1) {
setNoDataInfo('Pro vybrané denní období zatím nebyla zaznamenána žádná návštěvnost. Zkuste později nebo zvolte delší období.');
} else {
setErrorMessage('Umami není správně nakonfigurováno nebo ještě nebyly zaznamenány žádné návštěvy. Zkontrolujte UMAMI_WEBSITE_ID v .env souboru.');
}
} else {
setNoDataInfo(null);
}
} catch (error) {
console.error('Failed to fetch analytics:', error);
@@ -478,6 +491,21 @@ const AnalyticsAdminPage: React.FC = () => {
</HStack>
</HStack>
{/* No data info for daily view */}
{noDataInfo && (
<Card bg="yellow.50" borderColor="yellow.300" borderWidth={2}>
<CardBody>
<HStack spacing={3} align="start">
<Icon as={FiInfo} color="yellow.600" boxSize={6} mt={1} />
<VStack align="start" spacing={1}>
<Text fontWeight="bold" color="yellow.800">Zatím žádná data</Text>
<Text fontSize="sm" color="yellow.700">{noDataInfo}</Text>
</VStack>
</HStack>
</CardBody>
</Card>
)}
{/* Stats Overview */}
<SimpleGrid columns={{ base: 1, md: 2, lg: 5 }} spacing={4}>
<Card bg={bgColor} borderColor={borderColor}>
+101 -152
View File
@@ -8,7 +8,7 @@ import {
Select, Badge, Tabs, TabList, TabPanels, Tab, TabPanel, Accordion, AccordionItem,
AccordionButton, AccordionPanel, AccordionIcon, AspectRatio, Link, Alert, AlertIcon
} from '@chakra-ui/react';
import { FiEdit2, FiTrash2, FiPlus, FiSearch, FiUpload, FiExternalLink, FiVideo, FiX, FiRefreshCcw } from 'react-icons/fi';
import { FiEdit2, FiTrash2, FiPlus, FiSearch, FiUpload, FiExternalLink, FiVideo, FiX, FiRefreshCcw, FiLink } from 'react-icons/fi';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import AdminLayout from '../../layouts/AdminLayout';
import { Article, deleteArticle, getArticles, createArticle, updateArticle, uploadFile, CreateArticlePayload, UpdateArticlePayload, getArticleMatchLink, putArticleMatchLink, deleteArticleMatchLink } from '../../services/articles';
@@ -30,6 +30,7 @@ import DraftRecoveryModal from '../../components/common/DraftRecoveryModal';
import { useAutoSave, loadDraft, getDraftMetadata } from '../../hooks/useAutoSave';
import { getCachedYouTube, YouTubeVideo } from '../../services/youtube';
import { createShortLink } from '../../services/shortlinks';
// Inline small component to show match link badge in list (with short label)
const MatchLinkBadge: React.FC<{ articleId: number }> = ({ articleId }) => {
@@ -581,15 +582,31 @@ const ArticlesAdminPage = () => {
return dateStr.includes(matchDateFilter);
});
}
// Sort by proximity to current date (recent matches first)
const now = Date.now();
const parseTime = (s?: string): number => {
if (!s) return Number.MAX_SAFE_INTEGER;
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 h = m[4] ? parseInt(m[4], 10) : 0;
const mi = m[5] ? parseInt(m[5], 10) : 0;
return new Date(y, mo, d, h, mi).getTime();
}
const t = Date.parse(s);
return isNaN(t) ? Number.MAX_SAFE_INTEGER : t;
};
opts = opts.sort((a, b) => {
const dateA = new Date(a.date || 0).getTime();
const dateB = new Date(b.date || 0).getTime();
const diffA = Math.abs(now - dateA);
const diffB = Math.abs(now - dateB);
return diffA - diffB; // Closest to today first
const ta = parseTime(a.date);
const tb = parseTime(b.date);
const da = ta - now;
const db = tb - now;
const aUpcoming = da >= 0;
const bUpcoming = db >= 0;
if (aUpcoming !== bUpcoming) return aUpcoming ? -1 : 1;
if (aUpcoming) return da - db;
return Math.abs(da) - Math.abs(db);
});
return opts;
@@ -1267,6 +1284,25 @@ const ArticlesAdminPage = () => {
<HStack spacing={1}>
<IconButton aria-label="Upravit" size="sm" icon={<FiEdit2 />} onClick={() => openEdit(a)} />
<IconButton aria-label="Smazat" size="sm" colorScheme="red" icon={<FiTrash2 />} onClick={() => handleDeleteArticle(a)} />
<IconButton
aria-label="Zkrátit odkaz"
size="sm"
icon={<FiLink />}
title="Zkrátit odkaz pro sdílení"
onClick={async () => {
try {
const origin = window.location.origin;
const slug = (a as any)?.slug || (a as any)?.Slug;
const path = slug ? `/news/${slug}` : `/articles/${a.id}`;
const target = `${origin}${path}`;
const res = await createShortLink({ target_url: target, title: a.title, source_type: 'article', source_id: a.id });
await navigator.clipboard.writeText(res.short_url);
toast({ title: 'Zkrácený odkaz zkopírován', description: res.short_url, status: 'success', duration: 4000 });
} catch (e: any) {
toast({ title: 'Vytvoření odkazu selhalo', description: e?.message || 'Zkuste to znovu', status: 'error' });
}
}}
/>
</HStack>
</Td>
</Tr>
@@ -1299,8 +1335,8 @@ const ArticlesAdminPage = () => {
<Tab>Základní</Tab>
<Tab>Obsah</Tab>
<Tab>Média</Tab>
<Tab>SEO</Tab>
<Tab>Anketa</Tab>
<Tab>SEO</Tab>
</TabList>
<TabPanels>
{/* AI first */}
@@ -1880,6 +1916,60 @@ const ArticlesAdminPage = () => {
</VStack>
</TabPanel>
{/* Anketa (Poll) Tab */}
<TabPanel>
<VStack align="stretch" spacing={4}>
<Box borderWidth="1px" borderRadius="md" p={4} bg={useColorModeValue('purple.50', 'purple.900')}>
<Heading as="h3" size="sm" mb={2}>📊 Ankety k článku</Heading>
<Text fontSize="sm" color="gray.700" mb={3}>
Vytvořte nebo připojte ankety přímo k tomuto článku. Ankety se zobrazí automaticky na konci článku a čtenáři mohou hlasovat.
</Text>
</Box>
{editing?.id ? (
<PollLinker articleId={editing.id} onPollsChanged={() => {
// Invalidate queries to refresh polls
qc.invalidateQueries({ queryKey: ['linked-polls'] });
}} />
) : (
<Alert status="info" borderRadius="md">
<AlertIcon />
<VStack align="start" spacing={2}>
<Text fontWeight="semibold">
{saveStatus === 'saving' ? 'Ukládání článku...' : 'Článek se ukládá automaticky'}
</Text>
<Text fontSize="sm">
Začněte psát článek na záložkách výše. Systém automaticky ukládá každou změnu jako koncept. Jakmile bude článek uložen (v záhlaví se zobrazí "Uloženo"), budete moci přidat ankety.
</Text>
{saveStatus === 'saving' && <Spinner size="sm" color="blue.500" />}
{saveStatus === 'idle' && (
<Text fontSize="xs" color="gray.600">
💡 Vyplňte název článku pro aktivaci automatického ukládání
</Text>
)}
<Button
size="sm"
colorScheme="blue"
onClick={async () => {
// Force save if needed
try {
await forceSave();
// Switch to poll tab after save
setActiveTabIndex(4); // Poll tab is index 4 after reordering
} catch (error) {
// Error is handled by onSubmit
}
}}
isLoading={createMut.isLoading}
>
Uložit jako koncept a přidat ankety
</Button>
</VStack>
</Alert>
)}
</VStack>
</TabPanel>
{/* SEO last - minimized */}
<TabPanel>
<Text fontSize="sm" color="gray.600" mb={4}>
@@ -1923,60 +2013,6 @@ const ArticlesAdminPage = () => {
</AccordionItem>
</Accordion>
</TabPanel>
{/* Anketa (Poll) Tab */}
<TabPanel>
<VStack align="stretch" spacing={4}>
<Box borderWidth="1px" borderRadius="md" p={4} bg={useColorModeValue('purple.50', 'purple.900')}>
<Heading as="h3" size="sm" mb={2}>📊 Ankety k článku</Heading>
<Text fontSize="sm" color="gray.700" mb={3}>
Vytvořte nebo připojte ankety přímo k tomuto článku. Ankety se zobrazí automaticky na konci článku a čtenáři mohou hlasovat.
</Text>
</Box>
{editing?.id ? (
<PollLinker articleId={editing.id} onPollsChanged={() => {
// Invalidate queries to refresh polls
qc.invalidateQueries({ queryKey: ['linked-polls'] });
}} />
) : (
<Alert status="info" borderRadius="md">
<AlertIcon />
<VStack align="start" spacing={2}>
<Text fontWeight="semibold">
{saveStatus === 'saving' ? 'Ukládání článku...' : 'Článek se ukládá automaticky'}
</Text>
<Text fontSize="sm">
Začněte psát článek na záložkách výše. Systém automaticky ukládá každou změnu jako koncept. Jakmile bude článek uložen (v záhlaví se zobrazí "Uloženo"), budete moci přidat ankety.
</Text>
{saveStatus === 'saving' && <Spinner size="sm" color="blue.500" />}
{saveStatus === 'idle' && (
<Text fontSize="xs" color="gray.600">
💡 Vyplňte název článku pro aktivaci automatického ukládání
</Text>
)}
<Button
size="sm"
colorScheme="blue"
onClick={async () => {
// Force save if needed
try {
await forceSave();
// Switch to poll tab after save
setActiveTabIndex(5); // Poll tab is index 5
} catch (error) {
// Error is handled by onSubmit
}
}}
isLoading={createMut.isLoading}
>
Uložit jako koncept a přidat ankety
</Button>
</VStack>
</Alert>
)}
</VStack>
</TabPanel>
</TabPanels>
</Tabs>
</ModalBody>
@@ -2097,7 +2133,7 @@ const ArticlesAdminPage = () => {
</Modal>
{/* Zonerama Gallery Picker Modal */}
<Modal isOpen={isGalleryPickerOpen} onClose={onGalleryPickerClose} size="6xl">
<Modal isOpen={isGalleryPickerOpen} onClose={onGalleryPickerClose} size="6xl" scrollBehavior="inside">
<ModalOverlay />
<ModalContent maxH="90vh">
<ModalHeader>Vybrat fotku z galerie</ModalHeader>
@@ -2185,94 +2221,7 @@ const ArticlesAdminPage = () => {
</ModalContent>
</Modal>
{/* Zonerama Gallery Picker Modal */}
<Modal isOpen={isGalleryPickerOpen} onClose={onGalleryPickerClose} size="6xl">
<ModalOverlay />
<ModalContent maxH="90vh">
<ModalHeader>Vybrat fotku z galerie</ModalHeader>
<ModalCloseButton />
<ModalBody overflowY="auto">
<VStack align="stretch" spacing={4}>
{/* Loading State */}
{galleryLoading && (
<HStack spacing={2} justify="center" py={8}>
<Spinner size="lg" color="purple.500" />
<Text color="gray.600">Načítám alba z galerie...</Text>
</HStack>
)}
{/* Albums Grid */}
{!galleryLoading && cachedAlbums.length > 0 && (
<VStack align="stretch" spacing={6}>
{cachedAlbums.map((album) => (
<Box key={album.id} borderWidth="1px" borderRadius="md" p={4} bg={albumCardBg}>
<HStack justify="space-between" mb={3}>
<VStack align="start" spacing={0}>
<Text fontWeight="bold" fontSize="lg">{album.title || 'Album bez názvu'}</Text>
<Text fontSize="sm" color="gray.500">{album.date} {album.photos.length} fotografií</Text>
</VStack>
</HStack>
<SimpleGrid columns={{ base: 3, md: 4, lg: 6 }} spacing={2}>
{album.photos.map((photo) => (
<Box
key={photo.id}
borderWidth="1px"
borderRadius="md"
overflow="hidden"
cursor="pointer"
transition="all 0.2s"
_hover={{ boxShadow: 'lg', transform: 'scale(1.05)' }}
onClick={() => {
pickZoneramaImage({
id: photo.id,
album_id: album.id,
album_url: `https://eu.zonerama.com/FKKofolaKrnov/Album/${album.id}`,
page_url: photo.page_url,
image_url: photo.image_1500,
title: album.title
});
onGalleryPickerClose();
}}
>
<AspectRatio ratio={1}>
<Image
src={photo.image_1500}
alt={photo.id}
objectFit="cover"
/>
</AspectRatio>
</Box>
))}
</SimpleGrid>
</Box>
))}
</VStack>
)}
{/* Empty State */}
{!galleryLoading && cachedAlbums.length === 0 && (
<VStack py={8} spacing={3}>
<Icon as={FiSearch} boxSize={12} color="gray.400" />
<Text color="gray.600" textAlign="center">
Žádná alba nebyla nalezena v cache.
</Text>
<Text fontSize="sm" color="gray.500" textAlign="center">
Zkontrolujte nastavení Zonerama nebo obnovte cache.
</Text>
<Button size="sm" onClick={fetchCachedGallery} leftIcon={<FiRefreshCcw />}>
Obnovit seznam
</Button>
</VStack>
)}
</VStack>
</ModalBody>
<ModalFooter>
<Button variant="ghost" onClick={onGalleryPickerClose}>
Zavřít
</Button>
</ModalFooter>
</ModalContent>
</Modal>
{/* Draft Recovery Modal */}
<DraftRecoveryModal
+49 -27
View File
@@ -41,6 +41,7 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import AdminLayout from '../../layouts/AdminLayout';
import { putMatchOverride, patchMatchOverride, searchClubs, uploadImage, fetchLogoAsBlob, uploadToLogaSportcreative, fetchTeamLogoOverrides } from '../../services/adminMatches';
import { getPublicSettings } from '../../services/settings';
import { useEffect, useMemo, useRef, useState } from 'react';
import { useSearchParams } from 'react-router-dom';
import { parse } from 'date-fns';
@@ -546,28 +547,24 @@ const MatchesAdminPage = () => {
const [canScrollLeft, setCanScrollLeft] = useState(false);
const [canScrollRight, setCanScrollRight] = useState(false);
const [showScrollHint, setShowScrollHint] = useState(true);
const thBg = useColorModeValue('gray.50', 'gray.700');
// Drag-to-scroll state
const [isDragging, setIsDragging] = useState(false);
const [startX, setStartX] = useState(0);
const [scrollLeft, setScrollLeft] = useState(0);
const [lastX, setLastX] = useState(0);
const [lastTime, setLastTime] = useState(0);
const lastXRef = useRef(0);
const lastTimeRef = useRef(0);
const velocityRef = useRef(0);
const animationRef = useRef<number | null>(null);
const scrollRaf = useRef<number | null>(null);
// Color modes for past/future matches
const pastMatchBg = useColorModeValue('gray.100', 'gray.700');
const futureMatchBg = useColorModeValue('white', 'gray.800');
const pastMatchHoverBg = useColorModeValue('gray.200', 'gray.600');
const futureMatchHoverBg = useColorModeValue('gray.50', 'gray.700');
const updateScrollShadow = () => {
const el = scrollRef.current;
if (!el) return;
setCanScrollLeft(el.scrollLeft > 0);
setCanScrollRight(el.scrollLeft + el.clientWidth < el.scrollWidth - 1);
const left = el.scrollLeft > 0;
const right = el.scrollLeft + el.clientWidth < el.scrollWidth - 1;
if (left !== canScrollLeft) setCanScrollLeft(left);
if (right !== canScrollRight) setCanScrollRight(right);
};
// Drag-to-scroll handlers
@@ -581,8 +578,8 @@ const MatchesAdminPage = () => {
setIsDragging(true);
setStartX(e.pageX - scrollRef.current.offsetLeft);
setScrollLeft(scrollRef.current.scrollLeft);
setLastX(e.pageX);
setLastTime(Date.now());
lastXRef.current = e.pageX;
lastTimeRef.current = Date.now();
velocityRef.current = 0;
scrollRef.current.style.cursor = 'grabbing';
scrollRef.current.style.userSelect = 'none';
@@ -632,13 +629,13 @@ const MatchesAdminPage = () => {
// Calculate velocity for momentum
const now = Date.now();
const timeDelta = now - lastTime;
const timeDelta = now - lastTimeRef.current;
if (timeDelta > 0) {
const currentX = e.pageX;
const distance = currentX - lastX;
const distance = currentX - lastXRef.current;
velocityRef.current = distance / timeDelta * 16; // Normalize to ~60fps
setLastX(currentX);
setLastTime(now);
lastXRef.current = currentX;
lastTimeRef.current = now;
}
};
@@ -653,8 +650,8 @@ const MatchesAdminPage = () => {
setIsDragging(true);
setStartX(touch.pageX - scrollRef.current.offsetLeft);
setScrollLeft(scrollRef.current.scrollLeft);
setLastX(touch.pageX);
setLastTime(Date.now());
lastXRef.current = touch.pageX;
lastTimeRef.current = Date.now();
velocityRef.current = 0;
if (scrollRef.current) scrollRef.current.style.scrollBehavior = 'auto';
};
@@ -667,13 +664,13 @@ const MatchesAdminPage = () => {
scrollRef.current.scrollLeft = scrollLeft - walk;
const now = Date.now();
const timeDelta = now - lastTime;
const timeDelta = now - lastTimeRef.current;
if (timeDelta > 0) {
const currentX = touch.pageX;
const distance = currentX - lastX;
const distance = currentX - lastXRef.current;
velocityRef.current = distance / timeDelta * 16;
setLastX(currentX);
setLastTime(now);
lastXRef.current = currentX;
lastTimeRef.current = now;
}
};
@@ -734,6 +731,12 @@ const MatchesAdminPage = () => {
const headerText = useColorModeValue('text.onPrimary', 'white');
const cardBg = useColorModeValue('white', 'gray.800');
const borderColor = useColorModeValue('gray.200', 'gray.700');
const edgeGradientLeft = useColorModeValue('linear(to-r, white, transparent)', 'linear(to-r, gray.800, transparent)');
const edgeGradientRight = useColorModeValue('linear(to-l, white, transparent)', 'linear(to-l, gray.800, transparent)');
const pastMatchBg = useColorModeValue('gray.100', 'gray.700');
const futureMatchBg = useColorModeValue('white', 'gray.800');
const pastMatchHoverBg = useColorModeValue('gray.200', 'gray.600');
const futureMatchHoverBg = useColorModeValue('gray.50', 'gray.700');
return (
<AdminLayout requireAdmin={false}>
@@ -856,12 +859,24 @@ const MatchesAdminPage = () => {
onTouchMove={handleTouchMove}
onTouchEnd={handleTouchEnd}
onScroll={(e) => {
updateScrollShadow();
if ((e.currentTarget as HTMLDivElement).scrollLeft > 0 && showScrollHint) setShowScrollHint(false);
if (scrollRaf.current == null) {
scrollRaf.current = requestAnimationFrame(() => {
const el = scrollRef.current;
if (el) {
updateScrollShadow();
if (el.scrollLeft > 0 && showScrollHint) setShowScrollHint(false);
}
scrollRaf.current = null;
});
}
}}
sx={{
WebkitOverflowScrolling: 'touch',
scrollBehavior: 'smooth',
transform: 'translateZ(0)',
willChange: 'transform',
overscrollBehaviorX: 'contain',
touchAction: 'pan-x',
'th, td': { whiteSpace: 'nowrap' },
'::-webkit-scrollbar': { height: '14px' },
'::-webkit-scrollbar-thumb': {
@@ -885,13 +900,13 @@ const MatchesAdminPage = () => {
{/* Gradient edges to indicate horizontal scroll */}
{canScrollLeft && (
<Box position="sticky" left={0} top={0} bottom={0} w="24px" pointerEvents="none"
bgGradient={useColorModeValue('linear(to-r, white, transparent)', 'linear(to-r, gray.800, transparent)')}
bgGradient={edgeGradientLeft}
zIndex={1}
/>
)}
{canScrollRight && (
<Box position="sticky" right={0} top={0} bottom={0} w="24px" pointerEvents="none"
bgGradient={useColorModeValue('linear(to-l, white, transparent)', 'linear(to-l, gray.800, transparent)')}
bgGradient={edgeGradientRight}
zIndex={1}
/>
)}
@@ -945,6 +960,9 @@ const MatchesAdminPage = () => {
alt={m.home || m.home_team || ''}
boxSize="24px"
objectFit="contain"
loading="lazy"
decoding="async"
draggable={false}
/>
<Text fontWeight={isPast ? 'normal' : 'medium'}>{m.home || m.home_team || ''}</Text>
<Button size="xs" variant="outline" onClick={() => openEdit(m, 'home')} borderRadius="md" _hover={{ borderColor: 'brand.primary', color: 'brand.primary' }}>Tým</Button>
@@ -962,6 +980,9 @@ const MatchesAdminPage = () => {
alt={m.away || m.away_team || ''}
boxSize="24px"
objectFit="contain"
loading="lazy"
decoding="async"
draggable={false}
/>
<Text fontWeight={isPast ? 'normal' : 'medium'}>{m.away || m.away_team || ''}</Text>
<Button size="xs" variant="outline" onClick={() => openEdit(m, 'away')} borderRadius="md" _hover={{ borderColor: 'brand.primary', color: 'brand.primary' }}>Tým</Button>
@@ -1167,6 +1188,7 @@ const MatchesAdminPage = () => {
</DrawerFooter>
</DrawerContent>
</Drawer>
</AdminLayout>
);
};
@@ -133,7 +133,77 @@ export default function NewsletterAdminPage() {
const [previewSubject, setPreviewSubject] = useState<string>('');
const [previewHtml, setPreviewHtml] = useState<string>('');
const [previewLoading, setPreviewLoading] = useState<boolean>(false);
type MailType = 'weekly' | 'matches' | 'scores' | 'blogs' | 'events';
const mailTypeLabel: Record<MailType, string> = { weekly: 'Týdenní přehled', matches: 'Zápasy', scores: 'Výsledky', blogs: 'Novinky', events: 'Akce' };
const [detailsOpen, setDetailsOpen] = useState(false);
const [activeType, setActiveType] = useState<MailType | null>(null);
const [typePreview, setTypePreview] = useState<Record<string, { subject: string; html: string } | undefined>>({});
const [detailsCompetitions, setDetailsCompetitions] = useState<string>('');
const [detailsLoading, setDetailsLoading] = useState<boolean>(false);
const [sendNowLoading, setSendNowLoading] = useState<boolean>(false);
const openDetails = (t: MailType) => { setActiveType(t); setDetailsOpen(true); };
const closeDetails = () => { setDetailsOpen(false); setActiveType(null); setDetailsCompetitions(''); };
const recipientsForType = (t: MailType): string[] => {
const key = t === 'weekly' ? 'weekly' : t;
return subscribers
.filter((s: any) => s.is_active && s?.preferences && s.preferences[key] === true)
.map((s: any) => s.email);
};
const getRecipientsFor = (t: MailType, comps?: string): string[] => {
const key = t === 'weekly' ? 'weekly' : t;
const base = (subscribers as any[]).filter((s: any) => s?.is_active && s?.preferences && s.preferences[key] === true);
if (!comps || !comps.trim() || (t !== 'matches' && t !== 'scores')) {
return base.map((s: any) => s.email);
}
const list = comps.split(',').map((v) => v.trim().toLowerCase()).filter(Boolean);
const filtered = base.filter((s: any) => {
const prefs: any = s?.preferences || {};
const raw: string = typeof prefs.competitions === 'string' && prefs.competitions ? prefs.competitions : (typeof prefs.categories === 'string' ? prefs.categories : '');
const arr = raw.split(',').map((x: string) => x.trim().toLowerCase()).filter(Boolean);
if (arr.length === 0) return true;
return arr.some((v: string) => list.includes(v));
});
return filtered.map((s: any) => s.email);
};
const exportRecipientsCSV = (t: MailType, comps?: string) => {
const list = getRecipientsFor(t, comps);
const safeCSV = (value: any) => {
const s = String(value ?? '');
return /^[=+\-@]/.test(s) ? `'${s}` : s;
};
const header = ['email','type','competitions'];
const lines = [
header.join(','),
...list.map(e => [safeCSV(e), t, (comps || '').trim()].join(','))
];
const blob = new Blob(["\ufeff" + lines.join('\n')], { type: 'text/csv;charset=utf-8;' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `newsletter_recipients_${t}.csv`;
a.click();
URL.revokeObjectURL(url);
};
const loadPreviewForType = async (t: MailType, comps?: string) => {
const prefs: any = {};
if (t === 'weekly') { prefs.blogs = true; prefs.events = true; prefs.matches = true; prefs.scores = true; }
else { (prefs as any)[t] = true; }
if (comps && comps.trim()) { prefs.competitions = comps.trim(); }
const res = await previewNewsletter({ preferences: prefs });
setTypePreview(prev => ({ ...prev, [t]: { subject: res.subject, html: res.html } }));
};
useEffect(() => {
if (detailsOpen && activeType && !typePreview[activeType]) {
(async () => {
try {
setDetailsLoading(true);
await loadPreviewForType(activeType!, detailsCompetitions);
} finally {
setDetailsLoading(false);
}
})();
}
}, [detailsOpen, activeType]);
const { isOpen, onOpen, onClose } = useDisclosure();
const testModal = useDisclosure();
@@ -501,6 +571,29 @@ export default function NewsletterAdminPage() {
</Box>
) : null}
</Box>
<Box bg={cardBg} borderRadius="lg" boxShadow="sm" p={4} mb={6}>
<Heading size="md" mb={3}>Typy emailů</Heading>
<VStack align="stretch" spacing={3}>
{(['weekly','matches','scores','blogs','events'] as MailType[]).map((t)=>{
const count = recipientsForType(t).length;
const enabled = t === 'weekly' ? !!settings?.enable_weekly : t === 'matches' ? !!settings?.enable_match_reminders : t === 'scores' ? !!settings?.enable_results : undefined;
return (
<Flex key={t} align="center" justify="space-between" p={3} borderWidth="1px" borderRadius="md" _hover={{ bg: hoverBg }}>
<HStack spacing={3}>
<Text fontWeight="600">{mailTypeLabel[t]}</Text>
{enabled !== undefined && (
<Badge colorScheme={enabled ? 'green' : 'gray'}>{enabled ? 'Zapnuto' : 'Vypnuto'}</Badge>
)}
</HStack>
<HStack spacing={4}>
<Text color={textSecondary}>Příjemci: <b>{count}</b></Text>
<Button size="sm" onClick={()=> openDetails(t)}>Detail</Button>
</HStack>
</Flex>
);
})}
</VStack>
</Box>
</TabPanel>
<TabPanel p={0}>
{/* Scheduling controls */}
@@ -519,13 +612,13 @@ export default function NewsletterAdminPage() {
<FormControl maxW="220px">
<FormLabel>Den v týdnu</FormLabel>
<Select value={weeklyDay} onChange={(e)=> setWeeklyDay(e.target.value as any)}>
<option value="sun">Neděle</option>
<option value="mon">Pondělí</option>
<option value="tue">Úterý</option>
<option value="wed">Středa</option>
<option value="thu">Čtvrtek</option>
<option value="fri">Pátek</option>
<option value="sat">Sobota</option>
<option value="sun">Neděle</option>
</Select>
</FormControl>
<FormControl maxW="160px">
@@ -534,6 +627,41 @@ export default function NewsletterAdminPage() {
</FormControl>
</HStack>
<Box h="1px" bg={useColorModeValue('gray.200', 'gray.700')} my={2} />
<HStack justify="space-between">
<Text fontWeight="600">Připomínky zápasů</Text>
<Switch isChecked={enableMatchReminders} onChange={(e)=> setEnableMatchReminders(e.target.checked)} />
</HStack>
<HStack spacing={3}>
<FormControl maxW="220px">
<FormLabel>Odeslat před (hodin)</FormLabel>
<Input type="number" min={1} max={168} value={reminderLead} onChange={(e)=> setReminderLead(Math.max(1, Math.min(168, Number(e.target.value)||0)))} />
<FormHelperText>Výchozí 48 h před výkopem. Systém posílá i upozornění v den zápasu.</FormHelperText>
</FormControl>
</HStack>
<Box h="1px" bg={useColorModeValue('gray.200', 'gray.700')} my={2} />
<HStack justify="space-between">
<Text fontWeight="600">Výsledky po zápase</Text>
<Switch isChecked={enableResults} onChange={(e)=> setEnableResults(e.target.checked)} />
</HStack>
<HStack spacing={3}>
<FormControl maxW="160px">
<FormLabel>Tiché hodiny od</FormLabel>
<Input type="number" min={0} max={23} value={quietStart} onChange={(e)=> setQuietStart(Math.max(0, Math.min(23, Number(e.target.value)||0)))} />
</FormControl>
<FormControl maxW="160px">
<FormLabel>Tiché hodiny do</FormLabel>
<Input type="number" min={0} max={23} value={quietEnd} onChange={(e)=> setQuietEnd(Math.max(0, Math.min(23, Number(e.target.value)||0)))} />
<FormHelperText>E-maily s výsledky se neposílají v tomto intervalu.</FormHelperText>
</FormControl>
</HStack>
<HStack pt={2}>
<Button colorScheme="blue" onClick={()=> saveScheduleMutation.mutate()} isLoading={saveScheduleMutation.isLoading}>Uložit plánování</Button>
</HStack>
</VStack>
)}
</Box>
@@ -871,6 +999,159 @@ export default function NewsletterAdminPage() {
</ModalContent>
</Modal>
<Modal isOpen={detailsOpen} onClose={closeDetails} size="5xl">
<ModalOverlay />
<ModalContent maxW="95vw" maxH="90vh" overflowY="auto">
<ModalHeader>
{activeType ? `Detail: ${mailTypeLabel[activeType]}` : 'Detail'}
</ModalHeader>
<ModalCloseButton />
<ModalBody>
<VStack align="stretch" spacing={4}>
<HStack spacing={4} align="flex-end">
<FormControl maxW="360px">
<FormLabel>Filtr soutěží (volitelné)</FormLabel>
<Input placeholder="NAPŘ. KP, I.A, I.B" value={detailsCompetitions} onChange={(e)=> setDetailsCompetitions(e.target.value)} />
</FormControl>
<Button onClick={async()=>{ if(!activeType) return; setDetailsLoading(true); try { await loadPreviewForType(activeType, detailsCompetitions); } finally { setDetailsLoading(false); } }} isLoading={detailsLoading}>Aktualizovat náhled</Button>
{activeType && typePreview[activeType]?.subject && (
<Badge colorScheme="blue">{typePreview[activeType]!.subject}</Badge>
)}
<Button colorScheme="blue" variant="solid" isLoading={sendNowLoading} onClick={async()=>{
if(!activeType) return;
const ok = window.confirm(`Odeslat "${mailTypeLabel[activeType]}" nyní? Email bude odeslán všem aktivním odběratelům.`);
if(!ok) return;
try {
setSendNowLoading(true);
await sendNewsletterDigest(activeType as DigestType, (detailsCompetitions || '').trim() || undefined);
toast({ title: 'Digest odeslán', status: 'success' });
} catch (e: any) {
toast({ title: 'Chyba při odeslání', description: e?.response?.data?.error || e?.message, status: 'error' });
} finally {
setSendNowLoading(false);
}
}}>Odeslat nyní</Button>
<Button variant="outline" onClick={()=>{ if(!activeType) return; exportRecipientsCSV(activeType, detailsCompetitions); }}>Export CSV</Button>
</HStack>
<Box p={3} bg={useColorModeValue('gray.50', 'gray.900')} borderRadius="md" borderWidth="1px">
<Box bg={cardBg} p={3} borderRadius="md" borderWidth="1px" dangerouslySetInnerHTML={{ __html: sanitizeHtml(activeType ? (typePreview[activeType]?.html || '<em>Náhled se zobrazí zde</em>') : '<em>Náhled se zobrazí zde</em>') }} />
</Box>
<Box>
<Heading size="sm" mb={2}>Příjemci</Heading>
{(() => {
let list: string[] = [];
if (activeType) {
const typeKey = activeType === 'weekly' ? 'weekly' : activeType;
const base = (subscribers as any[]).filter((s: any) => s?.is_active && s?.preferences && s.preferences[typeKey] === true);
let filtered = base;
const compsInput = (detailsCompetitions || '').trim();
if (compsInput && (activeType === 'matches' || activeType === 'scores')) {
const comps = compsInput.split(',').map((v) => v.trim().toLowerCase()).filter(Boolean);
if (comps.length > 0) {
filtered = base.filter((s: any) => {
const prefs: any = s?.preferences || {};
const raw: string = typeof prefs.competitions === 'string' && prefs.competitions
? prefs.competitions
: (typeof prefs.categories === 'string' ? prefs.categories : '');
const arr = raw.split(',').map((x: string) => x.trim().toLowerCase()).filter(Boolean);
if (arr.length === 0) return true;
return arr.some((v: string) => comps.includes(v));
});
}
}
list = filtered.map((s: any) => s.email);
}
const shown = list.slice(0, 50);
return (
<>
{shown.length === 0 ? (
<Text color="gray.600">Žádní příjemci pro tento typ.</Text>
) : (
<VStack align="stretch" spacing={1} maxH="240px" overflowY="auto" borderWidth="1px" borderRadius="md" p={3}>
{shown.map((e)=> (<Text key={e} fontFamily="mono">{e}</Text>))}
{list.length > shown.length && (
<Text color="gray.600"> a dalších {list.length - shown.length}</Text>
)}
</VStack>
)}
</>
);
})()}
</Box>
</VStack>
</ModalBody>
<ModalFooter>
<Button onClick={closeDetails}>Zavřít</Button>
</ModalFooter>
</ModalContent>
</Modal>
{/* SMTP Test Modal */}
<Modal isOpen={smtpModal.isOpen} onClose={smtpModal.onClose} size="lg">
<ModalOverlay />
<ModalContent maxW="90vw" maxH="90vh" overflowY="auto">
<ModalHeader>Otestovat SMTP</ModalHeader>
<ModalCloseButton />
<ModalBody>
<VStack spacing={4} align="stretch">
<HStack spacing={3} align="flex-end">
<FormControl>
<FormLabel>Host</FormLabel>
<Input placeholder="smtp.example.com" value={smtpHost} onChange={(e)=> setSmtpHost(e.target.value)} />
</FormControl>
<FormControl maxW="140px">
<FormLabel>Port</FormLabel>
<Input type="number" placeholder="465" value={smtpPort} onChange={(e)=> setSmtpPort(Number(e.target.value)||0)} />
</FormControl>
<FormControl maxW="140px">
<FormLabel>&nbsp;</FormLabel>
<Checkbox isChecked={smtpTLS} onChange={(e)=> setSmtpTLS(e.target.checked)}>TLS/SSL</Checkbox>
</FormControl>
</HStack>
<HStack spacing={3}>
<FormControl>
<FormLabel>Uživatel</FormLabel>
<Input value={smtpUser} onChange={(e)=> setSmtpUser(e.target.value)} />
</FormControl>
<FormControl>
<FormLabel>Heslo</FormLabel>
<InputGroup>
<Input type={showSmtpPass ? 'text' : 'password'} value={smtpPass} onChange={(e)=> setSmtpPass(e.target.value)} />
<InputRightElement width="4.5rem">
<Button h="1.75rem" size="sm" onClick={()=> setShowSmtpPass(v=> !v)}>
{showSmtpPass ? 'Skrýt' : 'Zobrazit'}
</Button>
</InputRightElement>
</InputGroup>
</FormControl>
</HStack>
<HStack spacing={3}>
<FormControl>
<FormLabel>From</FormLabel>
<Input placeholder="club@example.com" value={smtpFrom} onChange={(e)=> setSmtpFrom(e.target.value)} />
</FormControl>
<FormControl>
<FormLabel>To (kam poslat test)</FormLabel>
<Input placeholder="you@example.com" value={smtpTo} onChange={(e)=> setSmtpTo(e.target.value)} />
</FormControl>
</HStack>
<FormControl>
<FormLabel>Předmět</FormLabel>
<Input value={smtpSubject} onChange={(e)=> setSmtpSubject(e.target.value)} />
</FormControl>
<FormControl>
<FormLabel>Tělo zprávy (HTML)</FormLabel>
<Textarea rows={6} value={smtpBody} onChange={(e)=> setSmtpBody(e.target.value)} />
</FormControl>
</VStack>
</ModalBody>
<ModalFooter>
<Button variant="ghost" mr={3} onClick={smtpModal.onClose}>Zavřít</Button>
<Button colorScheme="blue" onClick={()=> adminSmtpTestMutation.mutate()} isLoading={adminSmtpTestMutation.isLoading}>Odeslat test</Button>
</ModalFooter>
</ModalContent>
</Modal>
{/* Test Email Modal */}
<Modal isOpen={testModal.isOpen} onClose={testModal.onClose} size="md">
<ModalOverlay />
+11 -6
View File
@@ -235,7 +235,7 @@ const PlayersAdminPage: React.FC = () => {
const HEIGHT_MIN = 0;
const HEIGHT_MAX = 250;
const WEIGHT_MIN = 0;
const WEIGHT_MAX = 200;
const WEIGHT_MAX = 400;
// Local state to persist partial DOB selections so the user sees what they picked
const [dobParts, setDobParts] = useState<{ day: string; month: string; year: string }>({ day: '', month: '', year: '' });
@@ -325,6 +325,11 @@ const PlayersAdminPage: React.FC = () => {
toast({ title: 'Neplatná čísla', description: `Maxima: číslo dresu ${JERSEY_MAX}, výška ${HEIGHT_MAX} cm, váha ${WEIGHT_MAX} kg`, status: 'warning' });
return;
}
// Require date of birth: all three values must be selected
if (!dobParts.day || !dobParts.month || !dobParts.year) {
toast({ title: 'Datum narození je povinné', description: 'Vyberte den, měsíc i rok.', status: 'warning' });
return;
}
// Build payload by including only present values to satisfy backend validation
const payload: any = {
first_name: fn,
@@ -428,19 +433,19 @@ const PlayersAdminPage: React.FC = () => {
</FormControl>
{/* Custom DOB picker: day / month / year (timezone-safe) */}
<FormControl>
<FormControl isRequired>
<FormLabel>Datum narození</FormLabel>
<HStack>
<Select value={dobParts.day} onChange={(e) => updateDobPart('day', e.target.value)}>
<option value="">Den</option>
<option value="" disabled>Den</option>
{Array.from({ length: 31 }).map((_, i) => <option key={i+1} value={(i+1).toString()}>{i+1}</option>)}
</Select>
<Select value={dobParts.month} onChange={(e) => updateDobPart('month', e.target.value)}>
<option value="">Měsíc</option>
<option value="" disabled>Měsíc</option>
{Array.from({ length: 12 }).map((_, i) => <option key={i+1} value={(i+1).toString()}>{i+1}</option>)}
</Select>
<Select value={dobParts.year} onChange={(e) => updateDobPart('year', e.target.value)}>
<option value="">Rok</option>
<option value="" disabled>Rok</option>
{Array.from({ length: 80 }).map((_, i) => { const y = new Date().getFullYear() - i; return <option key={y} value={String(y)}>{y}</option>; })}
</Select>
</HStack>
@@ -542,7 +547,7 @@ const PlayersAdminPage: React.FC = () => {
<FormControl>
<FormLabel>Fotka</FormLabel>
<HStack>
<Image src={normalizeImageUrl(editing?.image_url)} alt="photo" boxSize="56px" objectFit="cover" borderRadius="md" />
<Image src={normalizeImageUrl(editing?.image_url)} alt="photo" boxSize="56px" objectFit="cover" borderRadius="md" fallbackSrc="/dist/img/logo-club-empty.svg" />
<Button as="label" type="button" leftIcon={<FiUpload />}>Nahrát
<Input
type="file"
+126 -71
View File
@@ -309,16 +309,19 @@ const PollsAdminPage: React.FC = () => {
};
const handleSave = () => {
// Validate that all options have text
const invalidOptions = formData.options.filter(opt => !opt.text || opt.text.trim() === '');
if (invalidOptions.length > 0) {
toast({
title: 'Chyba',
description: 'Všechny možnosti musí mít vyplněný text',
status: 'error',
duration: 3000,
});
return;
if (formData.type !== 'rating') {
const invalidOptions = formData.options.filter(
(opt) => !opt.text || opt.text.trim() === ''
);
if (invalidOptions.length > 0) {
toast({
title: 'Chyba',
description: 'Všechny možnosti musí mít vyplněný text',
status: 'error',
duration: 3000,
});
return;
}
}
if (editingPoll) {
@@ -398,6 +401,35 @@ const PollsAdminPage: React.FC = () => {
}
}, [isOpen, clubVideos.length, toast]);
// Keep rating polls consistent: enforce style and auto-generate options
useEffect(() => {
if (formData.type !== 'rating') return;
const currentStyle = (formData as any).style || 'auto';
const desiredStyle = currentStyle === 'rating-scale' ? 'rating-scale' : 'rating-stars';
const count = desiredStyle === 'rating-scale' ? 10 : 5;
const optionsMatch =
formData.options.length === count &&
formData.options.every((opt, idx) => String(opt.text) === String(idx + 1));
if (
currentStyle !== desiredStyle ||
formData.allow_multiple ||
(formData.max_choices || 1) !== 1 ||
!optionsMatch
) {
setFormData({
...formData,
style: desiredStyle as any,
allow_multiple: false,
max_choices: 1,
options: Array.from({ length: count }).map((_, i) => ({
text: String(i + 1),
display_order: i,
})),
});
}
}, [formData.type, (formData as any).style, formData.options, formData.allow_multiple, formData.max_choices]);
const getStatusBadge = (status: string) => {
const colorMap: Record<string, string> = {
draft: 'gray',
@@ -542,7 +574,7 @@ const PollsAdminPage: React.FC = () => {
<Tabs>
<TabList>
<Tab>Základní</Tab>
<Tab>Možnosti</Tab>
{formData.type !== 'rating' && <Tab>Možnosti</Tab>}
<Tab>Nastavení</Tab>
</TabList>
@@ -550,6 +582,14 @@ const PollsAdminPage: React.FC = () => {
{/* Basic Info Tab */}
<TabPanel>
<VStack spacing={4}>
<HStack w="full" justify="space-between">
<Text fontWeight="semibold">Doporučené předvolby</Text>
<HStack>
<Button size="sm" onClick={() => applyPreset('rating5')}>Hodnocení (5 hvězd)</Button>
<Button size="sm" onClick={() => applyPreset('rating10')}>Hodnocení (110)</Button>
<Button size="sm" onClick={() => applyPreset('attendance')}>Docházka</Button>
</HStack>
</HStack>
<FormControl isRequired>
<FormLabel>Název ankety</FormLabel>
<Input
@@ -625,6 +665,17 @@ const PollsAdminPage: React.FC = () => {
</FormControl>
</SimpleGrid>
{formData.type === 'rating' && (
<Box w="full" borderWidth="1px" borderRadius="md" p={3} bg="gray.50">
<Text fontSize="sm" mb={2}>Možnosti se generují automaticky podle stylu:</Text>
<Text fontSize="sm">
{Array.from({ length: ((formData as any).style === 'rating-scale' ? 10 : 5) })
.map((_, i) => String(i + 1))
.join(', ')}
</Text>
</Box>
)}
<SimpleGrid columns={2} spacing={4} w="full">
<FormControl>
<FormLabel>Datum zahájení</FormLabel>
@@ -722,73 +773,77 @@ const PollsAdminPage: React.FC = () => {
</TabPanel>
{/* Options Tab */}
<TabPanel>
<VStack spacing={4} align="stretch">
{formData.options.map((option, index) => (
<Card key={index}>
<CardBody>
<HStack align="start">
<VStack flex={1} spacing={3}>
<FormControl isRequired>
<FormLabel>Možnost {index + 1}</FormLabel>
<Input
value={option.text}
onChange={(e) =>
updateOption(index, 'text', e.target.value)
}
placeholder="Text možnosti"
{formData.type !== 'rating' && (
<TabPanel>
<VStack spacing={4} align="stretch">
{formData.options.map((option, index) => (
<Card key={index}>
<CardBody>
<HStack align="start">
<VStack flex={1} spacing={3}>
<FormControl isRequired>
<FormLabel>Možnost {index + 1}</FormLabel>
<Input
value={option.text}
onChange={(e) =>
updateOption(index, 'text', e.target.value)
}
placeholder="Text možnosti"
/>
</FormControl>
<FormControl>
<FormLabel>Popis (volitelné)</FormLabel>
<Input
value={option.description || ''}
onChange={(e) =>
updateOption(index, 'description', e.target.value)
}
placeholder="Doplňující informace"
/>
</FormControl>
</VStack>
{formData.options.length > 2 && (
<IconButton
aria-label="Odstranit možnost"
icon={<DeleteIcon />}
colorScheme="red"
variant="ghost"
onClick={() => removeOption(index)}
/>
</FormControl>
<FormControl>
<FormLabel>Popis (volitelné)</FormLabel>
<Input
value={option.description || ''}
onChange={(e) =>
updateOption(index, 'description', e.target.value)
}
placeholder="Doplňující informace"
/>
</FormControl>
</VStack>
{formData.options.length > 2 && (
<IconButton
aria-label="Odstranit možnost"
icon={<DeleteIcon />}
colorScheme="red"
variant="ghost"
onClick={() => removeOption(index)}
/>
)}
</HStack>
</CardBody>
</Card>
))}
)}
</HStack>
</CardBody>
</Card>
))}
<Button
leftIcon={<AddIcon />}
onClick={addOption}
variant="outline"
colorScheme="blue"
>
Přidat možnost
</Button>
</VStack>
</TabPanel>
<Button
leftIcon={<AddIcon />}
onClick={addOption}
variant="outline"
colorScheme="blue"
>
Přidat možnost
</Button>
</VStack>
</TabPanel>
)}
{/* Settings Tab */}
<TabPanel>
<VStack spacing={4}>
<FormControl display="flex" alignItems="center">
<FormLabel mb="0">Povolit více voleb</FormLabel>
<Switch
isChecked={formData.allow_multiple}
onChange={(e) =>
setFormData({ ...formData, allow_multiple: e.target.checked })
}
/>
</FormControl>
{formData.type !== 'rating' && (
<FormControl display="flex" alignItems="center">
<FormLabel mb="0">Povolit více voleb</FormLabel>
<Switch
isChecked={formData.allow_multiple}
onChange={(e) =>
setFormData({ ...formData, allow_multiple: e.target.checked })
}
/>
</FormControl>
)}
{formData.allow_multiple && (
{formData.type !== 'rating' && formData.allow_multiple && (
<FormControl>
<FormLabel>Max. počet voleb</FormLabel>
<NumberInput
@@ -178,7 +178,7 @@ const SettingsAdminPage: React.FC = () => {
smtp_from: (settings as any).smtp_from,
smtp_from_name: (settings as any).smtp_from_name,
smtp_encryption: (settings as any).smtp_encryption as any,
smtp_auth: (settings as any).smtp_auth as any,
...(typeof (settings as any).smtp_auth === 'boolean' ? { smtp_auth: (settings as any).smtp_auth as any } : {}),
smtp_skip_verify: (settings as any).smtp_skip_verify as any,
// videos module
videos_module_enabled: (settings as any).videos_module_enabled as any,
@@ -193,8 +193,9 @@ const SettingsAdminPage: React.FC = () => {
location_latitude: (settings as any).location_latitude as any,
location_longitude: (settings as any).location_longitude as any,
map_zoom_level: (settings as any).map_zoom_level as any,
// Auto-enable map display if coordinates are set
show_map_on_homepage: ((settings as any).location_latitude && (settings as any).location_longitude) as any,
show_map_on_homepage:
(typeof (settings as any).location_latitude === 'number') &&
(typeof (settings as any).location_longitude === 'number'),
map_style: (settings as any).map_style,
// homepage matches display
finished_match_display_days: (settings as any).finished_match_display_days as any,
@@ -0,0 +1,198 @@
import React from 'react';
import AdminLayout from '../../layouts/AdminLayout';
import {
Box,
Button,
HStack,
IconButton,
Input,
Table,
Tbody,
Td,
Th,
Thead,
Tr,
useToast,
Text,
VStack,
Modal,
ModalOverlay,
ModalContent,
ModalHeader,
ModalCloseButton,
ModalBody,
ModalFooter,
useDisclosure,
Link as ChakraLink,
Badge,
} from '@chakra-ui/react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { createShortLink, listShortLinks, getShortLinkStats } from '../../services/shortlinks';
import { FiClipboard, FiExternalLink, FiRefreshCcw, FiBarChart2 } from 'react-icons/fi';
const ShortlinksAdminPage: React.FC = () => {
const toast = useToast();
const qc = useQueryClient();
const [targetUrl, setTargetUrl] = React.useState('');
const [title, setTitle] = React.useState('');
const [code, setCode] = React.useState('');
const [creating, setCreating] = React.useState(false);
const statsModal = useDisclosure();
const [statsLink, setStatsLink] = React.useState<any>(null);
const [statsData, setStatsData] = React.useState<any>(null);
const linksQ = useQuery({
queryKey: ['admin-shortlinks'],
queryFn: listShortLinks,
staleTime: 60_000,
});
const handleCreate = async () => {
const t = targetUrl.trim();
if (!t) { toast({ title: 'Zadejte cílovou URL', status: 'warning' }); return; }
try {
setCreating(true);
const res = await createShortLink({ target_url: t, title: title.trim() || undefined, code: code.trim() || undefined, active: true });
await navigator.clipboard.writeText(res.short_url);
toast({ title: 'Odkaz vytvořen', description: `Zkopírováno: ${res.short_url}`, status: 'success' });
setTargetUrl(''); setTitle(''); setCode('');
qc.invalidateQueries({ queryKey: ['admin-shortlinks'] });
} catch (e: any) {
toast({ title: 'Vytvoření selhalo', description: e?.message || 'Zkuste to znovu', status: 'error' });
} finally {
setCreating(false);
}
};
const openStats = async (item: any) => {
try {
setStatsLink(item);
setStatsData(null);
statsModal.onOpen();
const data = await getShortLinkStats(item.id);
setStatsData(data);
} catch (e: any) {
toast({ title: 'Načtení statistik selhalo', status: 'error' });
}
};
return (
<AdminLayout>
<Box>
<HStack justify="space-between" mb={4}>
<Text fontSize="xl" fontWeight="bold">Zkrácené odkazy</Text>
<IconButton aria-label="Obnovit" icon={<FiRefreshCcw />} onClick={() => qc.invalidateQueries({ queryKey: ['admin-shortlinks'] })} />
</HStack>
{/* Create form */}
<Box borderWidth="1px" borderRadius="lg" p={4} mb={6} bg="bg.card">
<Text fontWeight="semibold" mb={2}>Vytvořit nový odkaz</Text>
<HStack spacing={2} flexWrap="wrap">
<Input placeholder="https://…" value={targetUrl} onChange={(e)=>setTargetUrl(e.target.value)} flex={3} />
<Input placeholder="Titulek (volitelný)" value={title} onChange={(e)=>setTitle(e.target.value)} flex={2} />
<Input placeholder="Vlastní kód (volitelné)" value={code} onChange={(e)=>setCode(e.target.value)} flex={1} />
<Button onClick={handleCreate} isLoading={creating} colorScheme="blue">Vytvořit</Button>
</HStack>
</Box>
{/* List */}
<Box borderWidth="1px" borderRadius="lg" overflowX="auto" bg="bg.card">
<Table size="sm">
<Thead>
<Tr>
<Th>Kód</Th>
<Th>Cíl</Th>
<Th>Titulek</Th>
<Th>Zdroj</Th>
<Th>Prokliky</Th>
<Th>Akce</Th>
</Tr>
</Thead>
<Tbody>
{linksQ.data?.items?.map((it: any) => {
const shortUrl = `${window.location.origin}/s/${it.code}`;
const source = it.source_type ? `${it.source_type}${it.source_id ? `#${it.source_id}` : ''}` : '-';
return (
<Tr key={it.id}>
<Td><Badge colorScheme="blue">{it.code}</Badge></Td>
<Td maxW="420px">
<ChakraLink href={it.target_url} isExternal color="blue.600">{it.target_url}</ChakraLink>
</Td>
<Td>{it.title || '-'}</Td>
<Td>{source}</Td>
<Td>{it.click_count ?? 0}</Td>
<Td>
<HStack>
<IconButton aria-label="Otevřít krátkou URL" icon={<FiExternalLink />} as={ChakraLink as any} href={shortUrl} isExternal />
<IconButton aria-label="Zkopírovat" icon={<FiClipboard />} onClick={async ()=>{ await navigator.clipboard.writeText(shortUrl); toast({ title: 'Zkopírováno', description: shortUrl, status: 'success', duration: 2000 }); }} />
<IconButton aria-label="Statistiky" icon={<FiBarChart2 />} onClick={()=> openStats(it)} />
</HStack>
</Td>
</Tr>
);
})}
{(!linksQ.data?.items || linksQ.data.items.length === 0) && (
<Tr><Td colSpan={6}><Text p={3}>Žádné odkazy</Text></Td></Tr>
)}
</Tbody>
</Table>
</Box>
{/* Stats modal */}
<Modal isOpen={statsModal.isOpen} onClose={statsModal.onClose} size="xl">
<ModalOverlay />
<ModalContent>
<ModalHeader>Statistiky: {statsLink?.code}</ModalHeader>
<ModalCloseButton />
<ModalBody>
{!statsData ? (
<Text>Načítání</Text>
) : (
<VStack align="stretch" spacing={3}>
<Box>
<Text fontWeight="semibold" mb={1}>Prokliky za posledních 30 dní</Text>
<Table size="sm" variant="simple">
<Thead><Tr><Th>Den</Th><Th isNumeric>Počet</Th></Tr></Thead>
<Tbody>
{statsData.timeseries?.map((row: any, idx: number) => (
<Tr key={idx}><Td>{row.date}</Td><Td isNumeric>{row.count}</Td></Tr>
))}
</Tbody>
</Table>
</Box>
<Box>
<Text fontWeight="semibold" mb={1}>Referrers (Top)</Text>
<Table size="sm" variant="simple">
<Thead><Tr><Th>Referrer</Th><Th isNumeric>Počet</Th></Tr></Thead>
<Tbody>
{(statsData.referrers || []).map((r: any, i: number) => (
<Tr key={i}><Td>{r.Referrer || '-'}</Td><Td isNumeric>{r.Count}</Td></Tr>
))}
</Tbody>
</Table>
</Box>
<Box>
<Text fontWeight="semibold" mb={1}>UTM kombinace (Top)</Text>
<Table size="sm" variant="simple">
<Thead><Tr><Th>Source</Th><Th>Medium</Th><Th>Campaign</Th><Th isNumeric>Počet</Th></Tr></Thead>
<Tbody>
{(statsData.utms || []).map((r: any, i: number) => (
<Tr key={i}><Td>{r.Source || '-'}</Td><Td>{r.Medium || '-'}</Td><Td>{r.Campaign || '-'}</Td><Td isNumeric>{r.Count}</Td></Tr>
))}
</Tbody>
</Table>
</Box>
</VStack>
)}
</ModalBody>
<ModalFooter>
<Button onClick={statsModal.onClose}>Zavřít</Button>
</ModalFooter>
</ModalContent>
</Modal>
</Box>
</AdminLayout>
);
};
export default ShortlinksAdminPage;
+167 -64
View File
@@ -1,4 +1,5 @@
import { batchFetchLogosFromSportLogosAPI } from '../../utils/sportLogosAPI';
import { fetchLogoFromLogoAPI } from '../../utils/sportLogosAPI';
import {
Heading,
Text,
@@ -77,6 +78,8 @@ const TeamsAdminPage = () => {
});
const competitions: any[] = Array.isArray(data?.competitions) ? data!.competitions : [];
const mainClubId: string | undefined = (data?.club_id ? String(data.club_id).toLowerCase() : undefined);
const mainClubBase: string = useMemo(() => normalize(String(data?.name || '')), [data?.name]);
// Backend origin (used to resolve relative URLs like /uploads/...)
const backendOrigin = new URL(API_URL, typeof window !== 'undefined' ? window.location.origin : 'http://localhost:3000').origin;
@@ -86,6 +89,7 @@ const TeamsAdminPage = () => {
queryFn: fetchTeamLogoOverrides,
staleTime: 5 * 60 * 1000,
});
const overridesById: Record<string, { name?: string; logo_url?: string }> = (overrides as any)?.by_id || {};
// Fetch logos from logoapi.sportcreative.eu for all teams
const [sportLogosMap, setSportLogosMap] = useState<Record<string, string>>({});
@@ -100,6 +104,10 @@ const TeamsAdminPage = () => {
const rows: TableRow[] = comp?.table?.overall || [];
for (const r of rows) {
if (r.team_id) teamIds.add(r.team_id);
else {
const derived = deriveTeamIdFromLogoUrl(r.team_logo_url);
if (derived) teamIds.add(derived);
}
}
}
@@ -122,7 +130,7 @@ const TeamsAdminPage = () => {
// Unify various dash characters to a simple hyphen
out = out.replace(/[\u2012\u2013\u2014\u2015\u2212]/g, '-');
// Remove legal suffixes like ", z.s." / ", z. s." / " z.s." / "o.s." at end
out = out.replace(/[,\s]*(z\.?\s*s\.?|o\.?\s*s\.?)\s*$/g, '');
out = out.replace(/[,,\s]*(z\.?\s*s\.?|o\.?\s*s\.?)\s*$/g, '');
// Remove organization phrases/prefixes anywhere (keep core locality/name)
const orgPhrases = [
'fotbalovy klub',
@@ -133,7 +141,7 @@ const TeamsAdminPage = () => {
'futsal',
];
for (const phrase of orgPhrases) {
const re = new RegExp(`(^|\b)${phrase}(\b|$)`, 'g');
const re = new RegExp('(^|\\b)'+ phrase + '(\\b|$)', 'g');
out = out.replace(re, ' ');
}
// Remove common short prefixes (tokens) like FC, FK, MFK, TJ, SK, SFC, AFK at word boundaries
@@ -152,40 +160,61 @@ const TeamsAdminPage = () => {
}
return idx;
}, [byName]);
// Derive FACR team UUID from the logo URL if team_id is missing in the row
// Example: https://is1.fotbal.cz/media/kluby/<UUID>/<UUID>_crop.jpg
const deriveTeamIdFromLogoUrl = (url?: string): string | undefined => {
try {
const u = String(url || '');
if (!u) return undefined;
const m = u.match(/[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}/);
return m ? m[0].toLowerCase() : undefined;
} catch {
return undefined;
}
};
const getLogo = (teamName?: string, teamId?: string, original?: string) => {
if (!teamName) return assetUrl('/dist/img/logo-club-empty.svg') as string;
// Priority 1: Try logoapi.sportcreative.eu if we have a team ID
if (teamId && sportLogosMap[teamId]) {
return sportLogosMap[teamId];
// Priority 0: Admin override by team ID
if (teamId && overridesById[teamId] && overridesById[teamId]?.logo_url) {
const u = String(overridesById[teamId].logo_url);
if (u.startsWith('/')) return assetUrl(u) as string;
return u;
}
// Priority 2: Try exact match from local overrides
// Priority 1: Local admin override (exact + normalized)
let overrideUrl = byName[teamName];
if (!overrideUrl) {
// Fallback: diacritics-insensitive + case-insensitive + trimmed match
const norm = normalize(teamName);
overrideUrl = byNameNormalized[norm];
}
// Priority 3: Use override if found
if (overrideUrl) {
// Resolve against backend for relative assets
if (typeof overrideUrl === 'string' && overrideUrl.startsWith('/')) {
return assetUrl(overrideUrl) as string;
}
return overrideUrl;
}
// Priority 4: Use FACR original
// Priority 2: logoapi.sportcreative.eu if we have a team ID
if (teamId && sportLogosMap[teamId]) {
return sportLogosMap[teamId];
}
// Priority 3: FACR original
if (original) {
return original;
}
// Final fallback: empty logo
return '/dist/img/logo-club-empty.svg';
};
const getName = (teamName?: string, teamId?: string) => {
if (teamId && overridesById[teamId] && overridesById[teamId]?.name) {
return String(overridesById[teamId].name || '').trim() || String(teamName || '');
}
return String(teamName || '');
};
// View mode: 'table' per competition, or 'grid' of unique teams across competitions
const [viewMode, setViewMode] = useState<'table' | 'grid'>('table');
// Selected competition for quick switching (only applies in table mode)
@@ -204,32 +233,50 @@ const TeamsAdminPage = () => {
name: string; // representative name
logo: string;
variants: string[]; // all raw names found
teamId?: string;
};
const allTeamsUnique: TeamAggregate[] = useMemo(() => {
const map: Record<string, TeamAggregate> = {};
for (const comp of competitions) {
const rows: TableRow[] = comp?.table?.overall || [];
for (const r of rows) {
const teamName = (r.team || '').trim();
if (!teamName) continue;
const key = normalize(teamName);
const logo = getLogo(teamName, r.team_id, r.team_logo_url);
const rawName = (r.team || '').trim();
let teamId = ((r as any).team_id as string | undefined) || deriveTeamIdFromLogoUrl(r.team_logo_url);
if (!teamId && mainClubId) {
const rn = normalize(rawName);
if (
rn === mainClubBase ||
rn.endsWith(' ' + mainClubBase) ||
rn.startsWith(mainClubBase + ' ') ||
rn.includes(' ' + mainClubBase + ' ')
) {
teamId = mainClubId;
}
}
const canonicalName = getName(rawName, teamId);
if (!canonicalName) continue;
const key = teamId ? `id:${teamId}` : normalize(canonicalName);
const logo = getLogo(canonicalName, teamId, r.team_logo_url);
if (!map[key]) {
map[key] = { key, name: teamName, logo, variants: [teamName] };
map[key] = { key, name: canonicalName, logo, variants: [rawName, canonicalName], teamId };
} else {
map[key].variants.push(teamName);
map[key].variants.push(rawName);
map[key].variants.push(canonicalName);
// Update logo - prefer non-empty logos
const currentIsEmpty = !map[key].logo || /logo-club-empty\.svg$/.test(String(map[key].logo));
const newIsNotEmpty = logo && !/logo-club-empty\.svg$/.test(String(logo));
if (currentIsEmpty && newIsNotEmpty) {
map[key].logo = logo as string;
}
if (!map[key].teamId && teamId) {
map[key].teamId = teamId;
}
}
}
}
// Sort by representative name
return Object.values(map).sort((a, b) => a.name.localeCompare(b.name, 'cs', { sensitivity: 'base' }));
}, [competitions, getLogo]);
}, [competitions, getLogo, mainClubBase, mainClubId]);
// Fast lookup from normalized name to variant list
const variantsByKey = useMemo(() => {
@@ -253,6 +300,25 @@ const TeamsAdminPage = () => {
const [externalUploadStatus, setExternalUploadStatus] = useState<'idle' | 'uploading' | 'success' | 'error'>('idle');
const [externalUploadError, setExternalUploadError] = useState<string | null>(null);
const showExternalUploadInfo = useMemo(() => {
try {
if (uploadedFile) return true;
const raw = (form.logo_url || '').trim();
if (!raw) return false;
const abs = raw.startsWith('/') ? new URL(raw, backendOrigin).toString() : raw;
const u = new URL(abs);
const host = u.hostname.toLowerCase();
const path = u.pathname;
const backendHost = new URL(backendOrigin).hostname.toLowerCase();
const isFacr = host.endsWith('fotbal.cz') || host === 'is1.fotbal.cz';
const isLogoAPI = host === 'logoapi.sportcreative.eu';
const isLocalUpload = (host === backendHost && path.startsWith('/uploads/'));
return !isFacr && !isLogoAPI && isLocalUpload;
} catch {
return false;
}
}, [uploadedFile, form.logo_url, backendOrigin]);
// Club search
const [query, setQuery] = useState('');
const [debounced, setDebounced] = useState('');
@@ -266,7 +332,7 @@ const TeamsAdminPage = () => {
enabled: debounced.trim().length >= 2,
});
const onOpenEdit = (teamName: string, teamLogoUrl?: string, variantNames?: string[]) => {
const onOpenEdit = (teamName: string, teamLogoUrl?: string, variantNames?: string[], teamId?: string) => {
// If variants not explicitly provided (e.g., from table view), compute from normalized key
let v = variantNames;
if (!v || v.length === 0) {
@@ -274,7 +340,7 @@ const TeamsAdminPage = () => {
v = variantsByKey[key] || [];
}
setSelected({ teamName, teamLogoUrl, variantNames: v });
setForm({ external_team_id: '', team_name: teamName || '', logo_url: teamLogoUrl || '' });
setForm({ external_team_id: teamId || '', team_name: teamName || '', logo_url: teamLogoUrl || '' });
setQuery(teamName || '');
setIsOpen(true);
};
@@ -285,53 +351,79 @@ const TeamsAdminPage = () => {
throw new Error('Vyberte tým ze seznamu vyhledávání (chybí ID).');
}
let logoUrl = (form.logo_url || '').trim();
const primaryName = (selected?.teamName || form.team_name || '').trim();
// Prefer the edited input over the pre-selected name
const primaryName = (form.team_name || selected?.teamName || '').trim();
// All variants to update (deduped), always include the primary name
const names = Array.from(new Set([primaryName, ...((selected?.variantNames || []) as string[])]))
.map((s) => s.trim())
.filter(Boolean);
// Upload to logoapi.sportcreative.eu first (best-effort). If successful, prefer that URL for overrides.
if (logoUrl) {
setExternalUploadStatus('uploading');
setExternalUploadError(null);
try {
let logoFileToUpload: File | Blob | null = uploadedFile;
if (!logoFileToUpload && logoUrl) {
logoFileToUpload = await fetchLogoAsBlob(logoUrl);
// Prefer highest-quality logo from logoapi if available (unless uploading a new file)
try {
if (!uploadedFile && form.external_team_id) {
const apiLogo = await fetchLogoFromLogoAPI(form.external_team_id, primaryName);
if (apiLogo) {
logoUrl = apiLogo;
}
if (logoFileToUpload) {
const logaResult = await uploadToLogaSportcreative(
form.external_team_id,
logoFileToUpload,
{
filename: `${form.external_team_id}.${logoFileToUpload instanceof File ? logoFileToUpload.name.split('.').pop() : 'png'}`,
clubName: form.team_name || selected?.teamName || 'Neznámý klub'
}
);
if (logaResult.success) {
setExternalUploadStatus('success');
if (logaResult.url) {
logoUrl = logaResult.url;
}
} catch {}
if (logoUrl) {
let shouldUpload = Boolean(uploadedFile);
try {
const abs = logoUrl.startsWith('/') ? new URL(logoUrl, backendOrigin).toString() : logoUrl;
const u = new URL(abs);
const host = u.hostname.toLowerCase();
const path = u.pathname;
const backendHost = new URL(backendOrigin).hostname.toLowerCase();
const isFacr = host.endsWith('fotbal.cz') || host === 'is1.fotbal.cz';
const isLogoAPI = host === 'logoapi.sportcreative.eu';
const isLocalUpload = (host === backendHost && path.startsWith('/uploads/'));
if (!shouldUpload) {
shouldUpload = isLocalUpload;
}
if (isFacr || isLogoAPI) {
shouldUpload = false;
}
} catch {}
if (shouldUpload) {
setExternalUploadStatus('uploading');
setExternalUploadError(null);
try {
let logoFileToUpload: File | Blob | null = uploadedFile;
if (!logoFileToUpload && logoUrl) {
logoFileToUpload = await fetchLogoAsBlob(logoUrl);
}
if (logoFileToUpload) {
const logaResult = await uploadToLogaSportcreative(
form.external_team_id,
logoFileToUpload,
{
filename: `${form.external_team_id}.${logoFileToUpload instanceof File ? logoFileToUpload.name.split('.').pop() : 'png'}`,
clubName: form.team_name || selected?.teamName || 'Neznámý klub'
}
);
if (logaResult.success) {
setExternalUploadStatus('success');
if (logaResult.url) {
logoUrl = logaResult.url;
}
} else {
setExternalUploadStatus('error');
setExternalUploadError(logaResult.error || 'Nepodařilo se nahrát logo');
}
} else {
setExternalUploadStatus('error');
setExternalUploadError(logaResult.error || 'Nepodařilo se nahrát logo');
setExternalUploadError('Could not fetch logo file');
}
} else {
} catch (error: any) {
setExternalUploadStatus('error');
setExternalUploadError('Could not fetch logo file');
setExternalUploadError(error?.message || 'Upload failed');
}
} catch (error: any) {
setExternalUploadStatus('error');
setExternalUploadError(error?.message || 'Upload failed');
}
}
// Save override for each variant name so editing one updates all duplicates
await Promise.all(
names.map((n) => putTeamLogoOverride(form.external_team_id, n, logoUrl))
);
await putTeamLogoOverride(form.external_team_id, primaryName, logoUrl);
return true;
},
@@ -489,12 +581,12 @@ const TeamsAdminPage = () => {
<Td py={1.5}>
<HStack spacing={2} align="center">
<Image
src={getLogo(r.team, (r as any).team_id, r.team_logo_url)}
alt={r.team}
src={getLogo(r.team, ((r as any).team_id as any) || deriveTeamIdFromLogoUrl(r.team_logo_url), r.team_logo_url)}
alt={getName(r.team, ((r as any).team_id as any) || deriveTeamIdFromLogoUrl(r.team_logo_url))}
boxSize="24px"
objectFit="contain"
/>
<Text fontSize="xs" noOfLines={1}>{r.team}</Text>
<Text fontSize="xs" noOfLines={1}>{getName(r.team, ((r as any).team_id as any) || deriveTeamIdFromLogoUrl(r.team_logo_url))}</Text>
</HStack>
</Td>
<Td isNumeric py={1.5} fontSize="xs">{r.played}</Td>
@@ -504,7 +596,12 @@ const TeamsAdminPage = () => {
<Td isNumeric py={1.5} fontSize="xs">{r.score}</Td>
<Td isNumeric py={1.5} fontSize="xs" fontWeight="bold">{r.points}</Td>
<Td py={1.5}>
<Button size="xs" fontSize="xs" onClick={() => onOpenEdit(r.team || '', r.team_logo_url)}>Upravit</Button>
<Button size="xs" fontSize="xs" onClick={() => {
const tid = ((r as any).team_id as any) || deriveTeamIdFromLogoUrl(r.team_logo_url);
const displayName = getName(r.team, tid);
const key = tid ? `id:${tid}` : normalize(displayName);
onOpenEdit(displayName || '', getLogo(r.team, tid, r.team_logo_url), variantsByKey[key], tid);
}}>Upravit</Button>
</Td>
</Tr>
))}
@@ -558,7 +655,7 @@ const TeamsAdminPage = () => {
</Tooltip>
)}
</HStack>
<Button size="xs" fontSize="xs" onClick={() => onOpenEdit(t.name, t.logo, t.variants)} w="full">Upravit</Button>
<Button size="xs" fontSize="xs" onClick={() => onOpenEdit(t.name, t.logo, t.variants, t.teamId)} w="full">Upravit</Button>
</VStack>
</Box>
))}
@@ -604,9 +701,15 @@ const TeamsAdminPage = () => {
px={3}
py={2}
_hover={{ bg: 'gray.50', cursor: 'pointer' }}
onClick={() => {
onClick={async () => {
setForm((f) => ({ ...f, external_team_id: r.id, team_name: r.name, logo_url: r.logo_url || f.logo_url }));
setQuery(r.name);
try {
const apiUrl = await fetchLogoFromLogoAPI(r.id, r.name);
if (apiUrl) {
setForm((f) => ({ ...f, logo_url: apiUrl }));
}
} catch {}
}}
>
<HStack justify="space-between" spacing={3}>
@@ -656,7 +759,7 @@ const TeamsAdminPage = () => {
Upravíte také duplicitní názvy: {Array.from(new Set(selected.variantNames)).join(', ')}
</Alert>
)}
{form.logo_url && (
{showExternalUploadInfo && (
<Alert status="info" variant="left-accent">
<AlertIcon />
<VStack align="start" spacing={1}>
+111 -67
View File
@@ -236,50 +236,48 @@ const UsersAdminPage = () => {
Správa uživatelských úč a jejich oprávnění. <strong>Editor</strong> může vytvářet a upravovat články a aktivity. <strong>Admin</strong> přístup ke všem funkcím.
</Text>
<Box bg={bgColor} borderRadius="md" boxShadow="sm" overflowX="auto">
<Table variant="simple">
<Thead>
<Tr>
<Th>Name</Th>
<Th>Email</Th>
<Th>Role</Th>
<Th>Status</Th>
<Th>Created</Th>
<Th>Actions</Th>
</Tr>
</Thead>
<Tbody>
{users.map((user) => (
<Tr key={user.id}>
<Td>{user.name}</Td>
<Td>{user.email}</Td>
<Td>
<Badge colorScheme={user.role === 'admin' ? 'purple' : (user.role === 'editor' ? 'blue' : 'gray')}>
{user.role === 'admin' ? 'Admin' : user.role === 'editor' ? 'Editor' : 'Fan'}
</Badge>
</Td>
<Td>
<Badge colorScheme={user.isActive ? 'green' : 'red'}>
{user.isActive ? 'Active' : 'Inactive'}
</Badge>
</Td>
<Td>{new Date(user.createdAt).toLocaleDateString()}</Td>
<Td>
<Menu>
<MenuButton
as={IconButton}
aria-label="Options"
icon={<HamburgerIcon />}
size="sm"
variant="ghost"
/>
<MenuList>
<MenuItem
icon={<EditIcon />}
onClick={() => openEditModal(user)}
>
Edit
</MenuItem>
<Heading size="md" mb={2}>Admini a editoři</Heading>
<Box bg={bgColor} borderRadius="md" boxShadow="sm" overflowX="auto" mb={8}>
<Table variant="simple">
<Thead>
<Tr>
<Th>Name</Th>
<Th>Email</Th>
<Th>Role</Th>
<Th>Status</Th>
<Th>Created</Th>
<Th>Actions</Th>
</Tr>
</Thead>
<Tbody>
{users.filter(u => u.role !== 'fan').map((user) => (
<Tr key={user.id}>
<Td>{user.name}</Td>
<Td>{user.email}</Td>
<Td>
<Badge colorScheme={user.role === 'admin' ? 'purple' : 'blue'}>
{user.role === 'admin' ? 'Admin' : 'Editor'}
</Badge>
</Td>
<Td>
<Badge colorScheme={user.isActive ? 'green' : 'red'}>
{user.isActive ? 'Active' : 'Inactive'}
</Badge>
</Td>
<Td>{new Date(user.createdAt).toLocaleDateString()}</Td>
<Td>
<Menu>
<MenuButton
as={IconButton}
aria-label="Options"
icon={<HamburgerIcon />}
size="sm"
variant="ghost"
/>
<MenuList>
<MenuItem icon={<EditIcon />} onClick={() => openEditModal(user)}>
Edit
</MenuItem>
<MenuItem onClick={async () => {
try {
await api.post(`/admin/users/${user.id}/reset-password`);
@@ -287,34 +285,80 @@ const UsersAdminPage = () => {
} catch (e: any) {
const errorMsg = e?.response?.data?.message || e?.response?.data?.error || e?.message || 'Nelze odeslat reset hesla';
const errorDetails = e?.response?.data?.details;
toast({
title: 'Chyba při odesílání resetu hesla',
description: errorDetails ? `${errorMsg}\n\n${errorDetails}` : errorMsg,
status: 'error',
duration: 10000,
isClosable: true,
});
toast({ title: 'Chyba při odesílání resetu hesla', description: errorDetails ? `${errorMsg}\n\n${errorDetails}` : errorMsg, status: 'error', duration: 10000, isClosable: true });
}
}}>
Odeslat reset hesla
</MenuItem>
{user.role !== 'admin' && String(authUser?.id) !== String(user.id) && (
<MenuItem
icon={<DeleteIcon />}
color="red.500"
onClick={() => handleDelete(user.id)}
>
Delete
</MenuItem>
)}
</MenuList>
</Menu>
</Td>
{user.role !== 'admin' && String(authUser?.id) !== String(user.id) && (
<MenuItem icon={<DeleteIcon />} color="red.500" onClick={() => handleDelete(user.id)}>
Delete
</MenuItem>
)}
</MenuList>
</Menu>
</Td>
</Tr>
))}
</Tbody>
</Table>
</Box>
<Heading size="md" mb={2}>Fanoušci</Heading>
<Box bg={bgColor} borderRadius="md" boxShadow="sm" overflowX="auto">
<Table variant="simple">
<Thead>
<Tr>
<Th>Name</Th>
<Th>Email</Th>
<Th>Role</Th>
<Th>Status</Th>
<Th>Created</Th>
<Th>Actions</Th>
</Tr>
))}
</Tbody>
</Table>
</Box>
</Thead>
<Tbody>
{users.filter(u => u.role === 'fan').map((user) => (
<Tr key={user.id}>
<Td>{user.name}</Td>
<Td>{user.email}</Td>
<Td>
<Badge colorScheme={'gray'}>
Fan
</Badge>
</Td>
<Td>
<Badge colorScheme={user.isActive ? 'green' : 'red'}>
{user.isActive ? 'Active' : 'Inactive'}
</Badge>
</Td>
<Td>{new Date(user.createdAt).toLocaleDateString()}</Td>
<Td>
<Menu>
<MenuButton
as={IconButton}
aria-label="Options"
icon={<HamburgerIcon />}
size="sm"
variant="ghost"
/>
<MenuList>
<MenuItem icon={<EditIcon />} onClick={() => openEditModal(user)}>
Edit
</MenuItem>
{String(authUser?.id) !== String(user.id) && (
<MenuItem icon={<DeleteIcon />} color="red.500" onClick={() => handleDelete(user.id)}>
Delete
</MenuItem>
)}
</MenuList>
</Menu>
</Td>
</Tr>
))}
</Tbody>
</Table>
</Box>
{/* Add/Edit User Modal */}
<Modal isOpen={isOpen} onClose={onClose} size="lg">