mirror of
https://github.com/Dvorinka/MyClubServer.git
synced 2026-06-04 18:52:56 +00:00
de day #74
This commit is contained in:
@@ -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}>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 e‑mailů</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í? E‑mail 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> </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 />
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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í (1–10)</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;
|
||||
@@ -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}>
|
||||
|
||||
@@ -236,50 +236,48 @@ const UsersAdminPage = () => {
|
||||
Správa uživatelských účtů a jejich oprávnění. <strong>Editor</strong> může vytvářet a upravovat články a aktivity. <strong>Admin</strong> má 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">
|
||||
|
||||
Reference in New Issue
Block a user