This commit is contained in:
Tomáš Dvořák
2025-10-16 13:32:05 +02:00
commit 12cba639b9
663 changed files with 168914 additions and 0 deletions
+396
View File
@@ -0,0 +1,396 @@
import React, { useState, useEffect } from 'react';
import {
Box,
Button,
FormControl,
FormLabel,
Input,
Textarea,
Select,
VStack,
HStack,
Heading,
Text,
useToast,
Tabs,
TabList,
TabPanels,
Tab,
TabPanel,
SimpleGrid,
Icon,
Badge,
} from '@chakra-ui/react';
import { FiSave, FiEye, FiCode, FiLayout, FiZap } from 'react-icons/fi';
import AdminLayout from '../../layouts/AdminLayout';
import RichTextEditor from '../../components/common/RichTextEditor';
import api from '../../services/api';
import { generateAboutAI } from '../../services/ai';
type AboutPageData = {
id?: number;
title: string;
subtitle: string;
style: 'default' | 'modern' | 'timeline' | 'custom';
content: string;
sections: string;
seo_title: string;
seo_description: string;
};
const AboutAdminPage: React.FC = () => {
const toast = useToast();
const [loading, setLoading] = useState(false);
const [saving, setSaving] = useState(false);
const [aiGenerating, setAiGenerating] = useState(false);
const [aiPrompt, setAiPrompt] = useState('');
const [aiAudience, setAiAudience] = useState('Fanoušci klubu');
const [data, setData] = useState<AboutPageData>({
title: '',
subtitle: '',
style: 'default',
content: '',
sections: '[]',
seo_title: '',
seo_description: '',
});
useEffect(() => {
loadData();
}, []);
const loadData = async () => {
setLoading(true);
try {
const res = await api.get('/admin/about');
if (res.data) {
setData(res.data);
}
} catch (e: any) {
toast({ title: 'Chyba', description: 'Nepodařilo se načíst data', status: 'error' });
} finally {
setLoading(false);
}
};
const handleSave = async () => {
if (!data.title.trim()) {
toast({ title: 'Chyba', description: 'Vyplňte název stránky', status: 'warning' });
return;
}
setSaving(true);
try {
await api.put('/admin/about', data);
toast({ title: 'Uloženo', description: 'Stránka O klubu byla uložena', status: 'success' });
await loadData();
} catch (e: any) {
toast({
title: 'Chyba',
description: e?.response?.data?.error || 'Nepodařilo se uložit',
status: 'error',
});
} finally {
setSaving(false);
}
};
const handleDelete = async () => {
if (!confirm('Opravdu smazat stránku O klubu?')) return;
try {
await api.delete('/admin/about');
toast({ title: 'Smazáno', description: 'Stránka byla smazána', status: 'info' });
setData({
title: '',
subtitle: '',
style: 'default',
content: '',
sections: '[]',
seo_title: '',
seo_description: '',
});
} catch (e: any) {
toast({ title: 'Chyba', description: 'Nepodařilo se smazat', status: 'error' });
}
};
const handleGenerateAI = async () => {
if (!aiPrompt.trim()) {
toast({ title: 'Chyba', description: 'Zadejte poznámky pro AI', status: 'warning' });
return;
}
setAiGenerating(true);
try {
const result = await generateAboutAI({
prompt: aiPrompt,
audience: aiAudience,
style: data.style,
});
setData((prev) => ({
...prev,
content: result.html || prev.content,
title: result.title || prev.title,
subtitle: result.subtitle || prev.subtitle,
seo_title: result.seo_title || prev.seo_title,
seo_description: result.seo_description || prev.seo_description,
}));
toast({
title: 'Hotovo!',
description: 'Obsah byl vygenerován pomocí AI',
status: 'success',
});
} catch (e: any) {
toast({
title: 'Chyba AI',
description: e?.response?.data?.error || 'Nepodařilo se vygenerovat obsah',
status: 'error',
});
} finally {
setAiGenerating(false);
}
};
const styleDescriptions: Record<string, { name: string; desc: string }> = {
default: { name: 'Výchozí', desc: 'Jednoduchý layout s titulkem a obsahem' },
modern: { name: 'Moderní', desc: 'Vizuálně atraktivní s hero obrázkem a sekcemi' },
timeline: { name: 'Timeline', desc: 'Historie klubu na časové ose' },
custom: { name: 'Vlastní HTML', desc: 'Plná kontrola nad obsahem a stylingem' },
};
return (
<AdminLayout requireAdmin={false}>
<Box>
<HStack justify="space-between" mb={4}>
<Heading size="lg">Stránka O klubu</Heading>
<HStack>
<Button
leftIcon={<FiSave />}
colorScheme="brand"
onClick={handleSave}
isLoading={saving}
>
Uložit
</Button>
</HStack>
</HStack>
<Text color="gray.600" mb={6}>
Vytvořte nebo upravte stránku O klubu. Dostupná na <strong>/o-klubu</strong>.
</Text>
<Tabs variant="enclosed" colorScheme="brand">
<TabList>
<Tab><Icon as={FiLayout} mr={2} /> Obsah</Tab>
<Tab><Icon as={FiCode} mr={2} /> Styl</Tab>
<Tab><Icon as={FiEye} mr={2} /> SEO</Tab>
</TabList>
<TabPanels>
{/* Content Tab */}
<TabPanel>
<VStack align="stretch" spacing={4}>
<FormControl isRequired>
<FormLabel>Název stránky</FormLabel>
<Input
value={data.title}
onChange={(e) => setData((prev) => ({ ...prev, title: e.target.value }))}
placeholder="O naší klubu"
/>
</FormControl>
<FormControl>
<FormLabel>Podtitulek</FormLabel>
<Input
value={data.subtitle}
onChange={(e) => setData((prev) => ({ ...prev, subtitle: e.target.value }))}
placeholder="Naše historie, hodnoty a komunita"
/>
</FormControl>
{/* Hero image removed: About page uses club logo and name from settings */}
<Box
borderWidth="1px"
borderRadius="md"
p={4}
bg="gray.50"
>
<HStack justify="space-between" mb={3} align="flex-start">
<Box>
<Heading size="sm" mb={1}>AI generátor obsahu</Heading>
<Text fontSize="sm" color="gray.600">
Napište krátké poznámky (např. historie, úspěchy, hodnoty) a nechte AI připravit návrh stránky.
</Text>
</Box>
<Button
leftIcon={<FiZap />}
colorScheme="purple"
variant="solid"
onClick={handleGenerateAI}
isLoading={aiGenerating}
>
Vygenerovat
</Button>
</HStack>
<VStack align="stretch" spacing={3}>
<FormControl>
<FormLabel>Poznámky pro AI</FormLabel>
<Textarea
value={aiPrompt}
onChange={(e) => setAiPrompt(e.target.value)}
placeholder="Popište historii klubu, současné cíle, přístup k mládeži..."
rows={4}
/>
</FormControl>
<FormControl>
<FormLabel>Cílové publikum</FormLabel>
<Input
value={aiAudience}
onChange={(e) => setAiAudience(e.target.value)}
placeholder="Fanoušci klubu"
/>
</FormControl>
</VStack>
</Box>
<FormControl>
<FormLabel>Obsah stránky</FormLabel>
<RichTextEditor
value={data.content}
onChange={(value) => setData((prev) => ({ ...prev, content: value }))}
height="400px"
toolbar="full"
showImageResize={true}
placeholder="Zadejte obsah stránky O nás..."
/>
</FormControl>
</VStack>
</TabPanel>
{/* Style Tab */}
<TabPanel>
<VStack align="stretch" spacing={4}>
<FormControl>
<FormLabel>Styl stránky</FormLabel>
<Select
value={data.style}
onChange={(e) =>
setData((prev) => ({ ...prev, style: e.target.value as any }))
}
>
{Object.entries(styleDescriptions).map(([key, { name, desc }]) => (
<option key={key} value={key}>
{name}
</option>
))}
</Select>
<Text fontSize="sm" color="gray.600" mt={2}>
{styleDescriptions[data.style]?.desc}
</Text>
</FormControl>
<Box
p={4}
borderWidth="1px"
borderRadius="md"
bg="blue.50"
borderColor="blue.200"
>
<Heading size="sm" mb={2}>Vybraný styl: {styleDescriptions[data.style]?.name}</Heading>
<SimpleGrid columns={{ base: 1, md: 2 }} spacing={3} mb={3}>
<Box>
<Badge colorScheme="blue" mb={1}>Výchozí</Badge>
<Text fontSize="sm">Jednoduchý layout, ideální pro text</Text>
</Box>
<Box>
<Badge colorScheme="purple" mb={1}>Moderní</Badge>
<Text fontSize="sm">Hero obrázek + sekce karet</Text>
</Box>
<Box>
<Badge colorScheme="green" mb={1}>Timeline</Badge>
<Text fontSize="sm">Historie klubu chronologicky</Text>
</Box>
<Box>
<Badge colorScheme="orange" mb={1}>Vlastní</Badge>
<Text fontSize="sm">Plná kontrola přes HTML/CSS</Text>
</Box>
</SimpleGrid>
</Box>
{data.style === 'custom' && (
<Box
p={3}
bg="yellow.50"
borderWidth="1px"
borderColor="yellow.300"
borderRadius="md"
>
<Text fontSize="sm" fontWeight="semibold" mb={1}>
Vlastní HTML režim
</Text>
<Text fontSize="sm">
V editoru můžete použít jakékoliv HTML a CSS. Ujistěte se, že je kód validní.
</Text>
</Box>
)}
</VStack>
</TabPanel>
{/* SEO Tab */}
<TabPanel>
<VStack align="stretch" spacing={4}>
<FormControl>
<FormLabel>SEO Titulek</FormLabel>
<Input
value={data.seo_title}
onChange={(e) => setData((prev) => ({ ...prev, seo_title: e.target.value }))}
placeholder="Pokud prázdné, použije se název stránky"
/>
</FormControl>
<FormControl>
<FormLabel>SEO Popis</FormLabel>
<Textarea
value={data.seo_description}
onChange={(e) =>
setData((prev) => ({ ...prev, seo_description: e.target.value }))
}
placeholder="Krátký popis pro vyhledávače (doporučeno 150-160 znaků)"
rows={4}
/>
<Text fontSize="sm" color="gray.600" mt={1}>
Délka: {data.seo_description.length} znaků
</Text>
</FormControl>
</VStack>
</TabPanel>
</TabPanels>
</Tabs>
<Box mt={6} pt={4} borderTopWidth="1px">
<HStack justify="space-between">
<Button colorScheme="red" variant="outline" onClick={handleDelete}>
Smazat stránku
</Button>
<HStack>
<Button as="a" href="/o-klubu" target="_blank" variant="outline">
Náhled
</Button>
<Button
leftIcon={<FiSave />}
colorScheme="brand"
onClick={handleSave}
isLoading={saving}
>
Uložit změny
</Button>
</HStack>
</HStack>
</Box>
</Box>
</AdminLayout>
);
};
export default AboutAdminPage;
@@ -0,0 +1,904 @@
import React, { useEffect, useMemo, useState } from 'react';
import AdminLayout from '../../layouts/AdminLayout';
import {
Box,
Button,
ButtonGroup,
FormControl,
FormLabel,
HStack,
Heading,
IconButton,
Input,
Modal,
ModalBody,
ModalCloseButton,
ModalContent,
ModalFooter,
ModalHeader,
ModalOverlay,
Select,
Switch,
Table,
Tbody,
Td,
Textarea,
Th,
Thead,
Tr,
useDisclosure,
useToast,
SimpleGrid,
Text,
VStack,
Tag,
TagLabel,
Tooltip,
Badge,
Wrap,
WrapItem,
useColorModeValue,
Image as ChakraImage,
} from '@chakra-ui/react';
import { FiEdit2, FiPlus, FiTrash2 } from 'react-icons/fi';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { Event } from '../../types/event';
import { uploadFile } from '../../services/articles';
import { createEvent, deleteEvent, getEvents, updateEvent } from '../../services/eventService';
import { api } from '../../services/api';
// Removed react-datepicker to prevent crash; using native date/time inputs instead
import { getPublicSettings } from '../../services/settings';
import PollLinker from '../../components/admin/PollLinker';
import { facrApi } from '../../services/facr/facrApi';
import { getCompetitionAliasesPublic } from '../../services/competitionAliases';
import MapLinkImporter from '../../components/admin/MapLinkImporter';
import { MapCoordinates } from '../../utils/mapUrlParser';
import ContactMap from '../../components/home/ContactMap';
import RichTextEditor from '../../components/common/RichTextEditor';
import { getCachedYouTube, YouTubeVideo } from '../../services/youtube';
import { FiVideo, FiYoutube, FiLink } from 'react-icons/fi';
const types: Array<{ value: Event['type']; label: string }> = [
{ value: 'match', label: 'Zápas' },
{ value: 'training', label: 'Trénink' },
{ value: 'meeting', label: 'Schůzka' },
{ value: 'other', label: 'Jiné' },
];
const AdminActivitiesPage: React.FC = () => {
const cardBg = useColorModeValue('white', 'gray.800');
const borderColor = useColorModeValue('gray.200', 'gray.700');
const inputBg = useColorModeValue('white', 'gray.700');
const textSecondary = useColorModeValue('gray.600', 'gray.400');
const toast = useToast();
const qc = useQueryClient();
const { isOpen, onOpen, onClose } = useDisclosure();
const [editing, setEditing] = useState<Partial<Event> | null>(null);
const [aiPrompt, setAiPrompt] = useState<string>('');
const [aiLoading, setAiLoading] = useState<boolean>(false);
const [aiTone, setAiTone] = useState<'informative'|'friendly'|'formal'>('friendly');
const [aiOverwrite, setAiOverwrite] = useState<boolean>(true);
// Location coordinates for map preview
const [locationLat, setLocationLat] = useState<number | undefined>(undefined);
const [locationLng, setLocationLng] = useState<number | undefined>(undefined);
// YouTube videos from club channel
const [clubVideos, setClubVideos] = useState<YouTubeVideo[]>([]);
const [youtubeTab, setYoutubeTab] = useState<'club' | 'custom'>('club');
const { data, isLoading } = useQuery({
queryKey: ['admin-events'],
queryFn: () => getEvents(),
});
const events = data || [];
// Load club YouTube videos
useEffect(() => {
(async () => {
try {
const ytData = await getCachedYouTube();
if (ytData?.videos) {
setClubVideos(ytData.videos.slice(0, 20)); // Limit to 20 most recent
}
} catch (err) {
console.error('Failed to load YouTube videos:', err);
}
})();
}, []);
// Settings for logo fetch (for placeholder image)
const settingsQ = useQuery({
queryKey: ['public-settings'],
queryFn: getPublicSettings,
staleTime: 5 * 60_000,
});
const openCreate = () => {
setEditing({ title: '', description: '', type: 'other', is_public: true } as any);
setLocationLat(undefined);
setLocationLng(undefined);
onOpen();
};
const openEdit = (ev: Event) => {
setEditing({ ...ev });
// Initialize map coordinates from event
if ((ev as any).latitude && (ev as any).longitude) {
setLocationLat((ev as any).latitude);
setLocationLng((ev as any).longitude);
} else {
setLocationLat(undefined);
setLocationLng(undefined);
}
onOpen();
};
const closeModal = () => {
setEditing(null);
setLocationLat(undefined);
setLocationLng(undefined);
onClose();
};
const createMut = useMutation({
mutationFn: (payload: Partial<Event>) => createEvent(payload),
onSuccess: () => { toast({ title: 'Událost vytvořena', status: 'success' }); qc.invalidateQueries({ queryKey: ['admin-events'] }); closeModal(); },
onError: (e: any) => toast({ title: 'Chyba při vytváření', description: e?.message || 'Došlo k chybě', status: 'error' }),
});
const updateMut = useMutation({
mutationFn: ({ id, payload }: { id: number|string; payload: Partial<Event> }) => updateEvent(id, payload),
onSuccess: () => { toast({ title: 'Událost upravena', status: 'success' }); qc.invalidateQueries({ queryKey: ['admin-events'] }); closeModal(); },
onError: (e: any) => toast({ title: 'Chyba při ukládání', description: e?.message || 'Došlo k chybě', status: 'error' }),
});
const deleteMut = useMutation({
mutationFn: (id: number) => deleteEvent(id),
onSuccess: () => { toast({ title: 'Smazáno', status: 'success' }); qc.invalidateQueries({ queryKey: ['admin-events'] }); },
onError: (e: any) => toast({ title: 'Smazání selhalo', description: e?.message || 'Došlo k chybě', status: 'error' }),
});
// Competitions list for category selection (like Articles)
const [competitions, setCompetitions] = useState<Array<{ code?: string; name: string }>>([]);
const [aliasesMap, setAliasesMap] = useState<Record<string, string>>({});
useEffect(() => {
(async () => {
try {
const settings = await getPublicSettings();
const clubId = (settings as any)?.club_id || '';
const clubType = ((settings as any)?.club_type || 'football') as 'football' | 'futsal';
const comps: Array<{ code?: string; name: string }> = [];
if (clubId) {
try {
const club = await facrApi.getClub(String(clubId), clubType);
const arr = Array.isArray((club as any)?.competitions) ? (club as any).competitions : [];
arr.forEach((c: any) => comps.push({ code: c.code, name: c.name || c.code }));
} catch {}
}
let amap: Record<string, string> = {};
try {
const list = await getCompetitionAliasesPublic();
list.forEach((a) => { if (a.code && a.alias) amap[a.code] = a.alias; });
} catch {}
const withAliases = comps.map((c) => ({ code: c.code, name: (c.code && amap[c.code]) ? amap[c.code] : c.name }));
setAliasesMap(amap);
setCompetitions(withAliases);
} catch {}
})();
}, []);
// AI generation for activity title/description (uses blog generator to produce quality Czech copy)
const generateWithAI = async () => {
try {
setAiLoading(true);
const e = editing || {};
// Build a helpful Czech prompt including known fields
const lines: string[] = [];
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 { data } = await api.post('/ai/blog/generate', {
prompt,
audience: 'Fanoušci klubu, oznámení/pozvánka',
min_words: 120,
});
// Handle potential JSON string response from AI (defensive parsing)
let parsedData = data;
if (typeof data === 'string') {
try {
parsedData = JSON.parse(data);
} catch {
throw new Error('AI vrátila neplatný formát odpovědi');
}
}
const title = String(parsedData?.title || '').trim();
const html = String(parsedData?.html || '').trim();
if (!title && !html) throw new Error('AI nevrátila obsah');
setEditing(prev => ({
...(prev || {}),
title: aiOverwrite ? (title || (prev?.title || '')) : (prev?.title || title || ''),
description: aiOverwrite ? (html || (prev?.description || '')) : `${(prev?.description || '')}${(prev?.description ? '\n\n' : '')}${html}`,
}));
toast({ title: 'Vygenerováno pomocí AI', status: 'success', duration: 3000 });
} catch (e: any) {
console.error('AI generation error:', e);
toast({ title: 'AI generování selhalo', description: e?.response?.data?.error || e?.message || 'Zkuste doplnit více detailů a opakovat.', status: 'error', duration: 5000 });
} finally {
setAiLoading(false);
}
};
// Date/time segmented inputs state
const [startDate, setStartDate] = useState<string>('');
const [startTime, setStartTime] = useState<string>('');
const [endDate, setEndDate] = useState<string>('');
const [endTime, setEndTime] = useState<string>('');
// Sync segmented date/time when editing changes
useEffect(() => {
const s = editing?.start_time ? new Date(editing.start_time as any) : null;
if (s) {
const yyyy = s.getFullYear();
const mm = String(s.getMonth() + 1).padStart(2, '0');
const dd = String(s.getDate()).padStart(2, '0');
const hh = String(s.getHours()).padStart(2, '0');
const min = String(s.getMinutes()).padStart(2, '0');
setStartDate(`${yyyy}-${mm}-${dd}`);
setStartTime(`${hh}:${min}`);
} else {
setStartDate(''); setStartTime('');
}
const e = editing?.end_time ? new Date(editing.end_time as any) : null;
if (e) {
const yyyy = e.getFullYear();
const mm = String(e.getMonth() + 1).padStart(2, '0');
const dd = String(e.getDate()).padStart(2, '0');
const hh = String(e.getHours()).padStart(2, '0');
const min = String(e.getMinutes()).padStart(2, '0');
setEndDate(`${yyyy}-${mm}-${dd}`);
setEndTime(`${hh}:${min}`);
} else {
setEndDate(''); setEndTime('');
}
}, [editing?.start_time, editing?.end_time]);
const toISO = (d?: string, t?: string) => {
if (!d || !t) return null as any;
const val = new Date(`${d}T${t}:00`);
if (isNaN(val.getTime())) return null as any;
return val.toISOString() as any;
};
const missingTitle = !String(editing?.title || '').trim();
const missingStart = !startDate || !startTime;
const ensurePlaceholderImage = async (): Promise<string | undefined> => {
try {
const logoUrl = String(settingsQ.data?.club_logo_url || '/dist/img/logo-club-empty.svg');
const canvas = document.createElement('canvas');
const W = 1200, H = 630;
canvas.width = W; canvas.height = H;
const ctx = canvas.getContext('2d');
if (!ctx) return undefined;
// White background
ctx.fillStyle = '#ffffff';
ctx.fillRect(0, 0, W, H);
// Border for visibility
ctx.strokeStyle = '#000000';
ctx.lineWidth = 6;
ctx.strokeRect(3, 3, W - 6, H - 6);
// Draw club logo centered
const img = new Image();
img.crossOrigin = 'anonymous';
const loadImg = () => new Promise<HTMLImageElement>((resolve, reject) => {
img.onload = () => resolve(img);
img.onerror = reject;
img.src = logoUrl;
});
try { await loadImg(); } catch { /* fallback */ }
const boxW = Math.round(W * 0.5);
const boxH = Math.round(H * 0.5);
const x = Math.round((W - boxW) / 2);
const y = Math.round((H - boxH) / 2);
try { ctx.drawImage(img, x, y, boxW, boxH); } catch {}
// Export and upload
const blob: Blob | null = await new Promise(resolve => canvas.toBlob(b => resolve(b), 'image/jpeg', 0.92));
if (!blob) return undefined;
const file = new File([blob], 'activity-cover.jpg', { type: 'image/jpeg' });
const res = await uploadFile(file);
return (res as any)?.url || undefined;
} catch { return undefined; }
};
const onSubmit = async () => {
if (!editing) return;
// Validation to avoid 400s
if (missingTitle) { toast({ title: 'Zadejte název', status: 'warning' }); return; }
if (missingStart) { toast({ title: 'Vyplňte datum a čas začátku', status: 'warning' }); return; }
const startISO = toISO(startDate, startTime);
const endISO = endDate && endTime ? toISO(endDate, endTime) : null;
if (endISO && startISO && new Date(endISO).getTime() < new Date(startISO).getTime()) {
toast({ title: 'Konec musí být po začátku', status: 'warning' });
return;
}
// Auto-generate image if missing
let imageUrl = (editing as any).image_url as string | undefined;
if (!imageUrl) {
imageUrl = await ensurePlaceholderImage();
if (imageUrl) setEditing(prev => ({ ...(prev || {}), image_url: imageUrl }));
}
const payload: Partial<Event> = {
title: (editing.title || '').trim(),
description: (editing.description || '').trim(),
start_time: startISO as any,
end_time: (endISO as any) || null,
location: (editing.location || '').trim(),
type: (editing.type || 'other') as any,
is_public: !!editing.is_public,
image_url: imageUrl || undefined,
file_url: (editing as any).file_url || undefined,
category_name: (editing as any)?.category_name || undefined,
attachments: Array.isArray((editing as any)?.attachments) ? ((editing as any).attachments as any[]).map((a: any) => ({ name: a.name, url: a.url, mime_type: a.mime_type, size: a.size })) : undefined,
youtube_url: (editing as any)?.youtube_url || undefined,
latitude: (editing as any)?.latitude || locationLat || undefined,
longitude: (editing as any)?.longitude || locationLng || undefined,
};
if ((editing as any).id) {
await updateMut.mutateAsync({ id: (editing as any).id, payload });
} else {
await createMut.mutateAsync(payload);
}
};
return (
<AdminLayout requireAdmin={false}>
<Box>
<HStack justify="space-between" mb={4}>
<Heading size="lg">Aktivity (Události)</Heading>
<Button leftIcon={<FiPlus />} colorScheme="blue" onClick={openCreate}>Nová aktivita</Button>
</HStack>
<Box bg={cardBg} borderWidth="1px" borderRadius="lg" overflowX="auto" boxShadow="sm" mb={6}>
<Table size="sm">
<Thead>
<Tr>
<Th>Název</Th>
<Th>Typ</Th>
<Th>Začátek</Th>
<Th>Konec</Th>
<Th>Místo</Th>
<Th>Veřejná</Th>
<Th w="140px">Akce</Th>
</Tr>
</Thead>
<Tbody>
{isLoading && (
<Tr><Td colSpan={7}>Načítání</Td></Tr>
)}
{!isLoading && events.map(ev => (
<Tr key={ev.id}>
<Td>{ev.title}</Td>
<Td>{ev.type}</Td>
<Td>{new Date(ev.start_time).toLocaleString()}</Td>
<Td>{ev.end_time ? new Date(ev.end_time).toLocaleString() : '-'}</Td>
<Td>{ev.location || '-'}</Td>
<Td>{ev.is_public ? 'Ano' : 'Ne'}</Td>
<Td>
<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)} />
</HStack>
</Td>
</Tr>
))}
</Tbody>
</Table>
</Box>
{/* Modal */}
<Modal isOpen={isOpen} onClose={closeModal} isCentered scrollBehavior="inside">
<ModalOverlay backdropFilter="blur(3px)" />
<ModalContent maxW={{ base: '96vw', md: '920px' }} maxH={{ base: '90vh', md: '86vh' }} borderRadius="2xl" overflow="hidden" boxShadow="2xl">
<ModalHeader>
<VStack align="start" spacing={1}>
<Heading size="md">{(editing as any)?.id ? 'Upravit aktivitu' : 'Nová aktivita'}</Heading>
<Text fontSize="sm" color="gray.500">Plánujte klubové akce, sdílejte s fanoušky a týmem.</Text>
</VStack>
</ModalHeader>
<ModalCloseButton />
<ModalBody overflowY="auto" maxH={{ base: '76vh', md: '70vh' }}>
<Box borderWidth="1px" borderRadius="lg" p={4} mb={5} bg={useColorModeValue('gray.50', 'gray.900')}>
<HStack justify="space-between" align="start" mb={2}>
<Heading size="sm">AI generování</Heading>
<Badge colorScheme="purple" variant="subtle">Beta</Badge>
</HStack>
<Text fontSize="sm" color="gray.600" mb={2}>Zadejte instrukce pro AI nebo klikněte na předlohy níže. AI může vytvořit nebo doplnit titulek a popis události.</Text>
<Textarea
placeholder="Např.: Trénink A-týmu, hřiště TJ Dvorce, 18:0019:30, pro všechny hráče."
value={aiPrompt}
onChange={(e)=> setAiPrompt(e.target.value)}
rows={3}
bg={cardBg}
/>
<Wrap spacing={2} mt={2}>
{[
'Pozvánka na trénink se zaměřením na kondici',
'Oznámení přátelského zápasu pro fanoušky',
'Rodičovská schůzka mládeže stručný program',
].map((t, i) => (
<WrapItem key={i}>
<Tag size="sm" variant="subtle" colorScheme="blue" _hover={{ cursor: 'pointer', opacity: 0.9 }} onClick={() => setAiPrompt(t)}>
<TagLabel>{t}</TagLabel>
</Tag>
</WrapItem>
))}
</Wrap>
<HStack mt={3} spacing={3} align="center">
<FormControl maxW="220px">
<FormLabel mb={1}>Tón</FormLabel>
<Select size="sm" value={aiTone} onChange={(e)=> setAiTone(e.target.value as any)}>
<option value="friendly">Přátelský</option>
<option value="informative">Informační</option>
<option value="formal">Formální</option>
</Select>
</FormControl>
<FormControl display="flex" alignItems="center">
<FormLabel mb="0">Přepsat existující obsah</FormLabel>
<Switch isChecked={aiOverwrite} onChange={(e)=> setAiOverwrite(e.target.checked)} />
</FormControl>
<Tooltip label="AI doplní titul a popis podle zadaných informací." hasArrow>
<Button onClick={generateWithAI} isLoading={aiLoading} leftIcon={<FiPlus />} bg="brand.primary" color="text.onPrimary" _hover={{ filter: 'brightness(0.95)' }}>
AI text
</Button>
</Tooltip>
</HStack>
</Box>
<FormControl isRequired mb={3}>
<FormLabel>Název</FormLabel>
<Input value={editing?.title || ''} onChange={(e) => setEditing(prev => ({ ...(prev || {}), title: e.target.value }))} />
{missingTitle && (
<Text fontSize="sm" color="orange.500" mt={1}>Vyplňte název aktivity.</Text>
)}
</FormControl>
<FormControl mb={3}>
<FormLabel>Popis (Rich Text Editor)</FormLabel>
<RichTextEditor
value={editing?.description || ''}
onChange={(value) => setEditing(prev => ({ ...(prev || {}), description: value }))}
height="300px"
toolbar="full"
showImageResize={true}
placeholder="Zadejte popis události..."
/>
</FormControl>
<Box mb={3}>
<FormLabel>YouTube Video (volitelné)</FormLabel>
<Box borderWidth="1px" borderRadius="md" p={3} bg={useColorModeValue('gray.50', 'gray.900')}>
<HStack mb={3} spacing={2}>
<Button
size="sm"
leftIcon={<FiYoutube />}
onClick={() => setYoutubeTab('club')}
variant={youtubeTab === 'club' ? 'solid' : 'outline'}
colorScheme={youtubeTab === 'club' ? 'red' : 'gray'}
>
Z kanálu klubu ({clubVideos.length})
</Button>
<Button
size="sm"
leftIcon={<FiLink />}
onClick={() => setYoutubeTab('custom')}
variant={youtubeTab === 'custom' ? 'solid' : 'outline'}
colorScheme={youtubeTab === 'custom' ? 'blue' : 'gray'}
>
Vlastní odkaz
</Button>
</HStack>
{youtubeTab === 'club' && (
<Box>
{clubVideos.length === 0 ? (
<Text fontSize="sm" color="gray.500">
Žádná videa z kanálu klubu. Nastavte YouTube kanál v Nastavení.
</Text>
) : (
<VStack align="stretch" spacing={2} maxH="300px" overflowY="auto">
{clubVideos.map((video) => {
const videoUrl = `https://www.youtube.com/watch?v=${video.video_id}`;
const isSelected = (editing as any)?.youtube_url?.includes(video.video_id);
return (
<HStack
key={video.video_id}
p={2}
borderWidth="1px"
borderRadius="md"
borderColor={isSelected ? 'red.500' : borderColor}
bg={isSelected ? useColorModeValue('red.50', 'red.900') : cardBg}
cursor="pointer"
_hover={{ borderColor: 'red.300', bg: useColorModeValue('red.50', 'red.900') }}
onClick={() => {
setEditing(prev => ({ ...(prev || {}), youtube_url: videoUrl } as any));
toast({
title: 'Video vybráno',
description: video.title,
status: 'success',
duration: 2000,
});
}}
>
<ChakraImage
src={video.thumbnail_url}
alt={video.title}
boxSize="60px"
objectFit="cover"
borderRadius="md"
/>
<VStack align="start" flex={1} spacing={0}>
<Text fontSize="sm" fontWeight="medium" noOfLines={2}>
{video.title}
</Text>
<HStack fontSize="xs" color="gray.500">
{video.published_text && <Text>{video.published_text}</Text>}
{video.views_text && <Text> {video.views_text}</Text>}
</HStack>
</VStack>
{isSelected && (
<Badge colorScheme="red">Vybráno</Badge>
)}
</HStack>
);
})}
</VStack>
)}
</Box>
)}
{youtubeTab === 'custom' && (
<VStack align="stretch" spacing={2}>
<Input
value={(editing as any)?.youtube_url || ''}
onChange={(e) => setEditing(prev => ({ ...(prev || {}), youtube_url: e.target.value } as any))}
placeholder="https://www.youtube.com/watch?v=... nebo https://youtu.be/..."
/>
<Text fontSize="xs" color="gray.500">
Vložte odkaz na jakékoliv YouTube video (nemusí být z vašeho kanálu).
</Text>
</VStack>
)}
{(editing as any)?.youtube_url && (
<HStack mt={2} spacing={2}>
<Badge colorScheme="green" fontSize="xs">Video nastaveno</Badge>
<Button
size="xs"
variant="ghost"
colorScheme="red"
onClick={() => setEditing(prev => ({ ...(prev || {}), youtube_url: '' } as any))}
>
Zrušit video
</Button>
</HStack>
)}
</Box>
</Box>
<Box mt={3}>
<Text fontWeight="bold" mb={2}>Datum a čas</Text>
<SimpleGrid columns={{ base: 1, md: 2 }} spacing={3}>
<FormControl isRequired>
<FormLabel>Začátek</FormLabel>
<HStack>
<Input type="date" value={startDate} onChange={(e) => {
const v = e.target.value; setStartDate(v);
const iso = toISO(v, startTime);
setEditing(prev => ({ ...(prev || {}), start_time: iso || (prev as any)?.start_time }));
}} />
<Input type="time" step="900" value={startTime} onChange={(e) => {
const v = e.target.value; setStartTime(v);
const iso = toISO(startDate, v);
// Auto-shift end if needed
setEditing(prev => {
const nextStart = iso;
const prevEnd = prev?.end_time ? new Date(prev.end_time as any) : null;
let nextEndISO = prev?.end_time as any;
if (prevEnd && nextStart && prevEnd.getTime() < new Date(nextStart).getTime()) {
nextEndISO = new Date(new Date(nextStart).getTime() + 60*60*1000).toISOString() as any;
}
return ({ ...(prev || {}), start_time: nextStart, end_time: nextEndISO });
});
}} />
</HStack>
{missingStart && (
<Text fontSize="sm" color="orange.500" mt={1}>Vyplňte datum i čas začátku.</Text>
)}
</FormControl>
<FormControl>
<FormLabel>Konec (volitelné)</FormLabel>
<HStack>
<Input type="date" value={endDate} onChange={(e) => {
const v = e.target.value; setEndDate(v);
const iso = endTime ? toISO(v, endTime) : null;
setEditing(prev => ({ ...(prev || {}), end_time: iso as any }));
}} />
<Input type="time" step="900" value={endTime} onChange={(e) => {
const v = e.target.value; setEndTime(v);
const iso = endDate ? toISO(endDate, v) : null;
setEditing(prev => ({ ...(prev || {}), end_time: iso as any }));
}} />
</HStack>
<HStack mt={2}>
<Button size="sm" variant="outline" onClick={() => setEditing(prev => {
if (!prev?.start_time) return prev || {};
const start = new Date(prev.start_time as any).getTime();
const end = new Date(start + 60*60*1000).toISOString();
return ({ ...(prev || {}), end_time: end });
})}>+60m</Button>
<Button size="sm" variant="outline" onClick={() => setEditing(prev => {
if (!prev?.start_time) return prev || {};
const start = new Date(prev.start_time as any).getTime();
const end = new Date(start + 90*60*1000).toISOString();
return ({ ...(prev || {}), end_time: end });
})}>+90m</Button>
<Button size="sm" variant="outline" onClick={() => setEditing(prev => {
if (!prev?.start_time) return prev || {};
const start = new Date(prev.start_time as any).getTime();
const end = new Date(start + 120*60*1000).toISOString();
return ({ ...(prev || {}), end_time: end });
})}>+120m</Button>
<Button size="sm" variant="ghost" onClick={() => { setEndDate(''); setEndTime(''); setEditing(prev => ({ ...(prev || {}), end_time: null })); }}>Zrušit</Button>
</HStack>
</FormControl>
</SimpleGrid>
{/* Quick presets for start time */}
<HStack mt={2} spacing={2}>
<Button size="sm" variant="outline" onClick={() => setEditing(prev => {
const now = new Date();
const m = now.getMinutes();
const rounded = new Date(now.getTime() + ((15 - (m % 15)) % 15) * 60000);
const yyyy = rounded.getFullYear();
const mm = String(rounded.getMonth() + 1).padStart(2, '0');
const dd = String(rounded.getDate()).padStart(2, '0');
const hh = String(rounded.getHours()).padStart(2, '0');
const min = String(rounded.getMinutes()).padStart(2, '0');
setStartDate(`${yyyy}-${mm}-${dd}`);
setStartTime(`${hh}:${min}`);
return ({ ...(prev || {}), start_time: rounded.toISOString() as any });
})}>Nyní (zaokrouhlit 15m)</Button>
<Button size="sm" variant="outline" onClick={() => setEditing(prev => {
const d = new Date(); d.setHours(18,0,0,0);
const yyyy = d.getFullYear();
const mm = String(d.getMonth() + 1).padStart(2, '0');
const dd = String(d.getDate()).padStart(2, '0');
setStartDate(`${yyyy}-${mm}-${dd}`);
setStartTime(`18:00`);
return ({ ...(prev || {}), start_time: d.toISOString() as any });
})}>Dnes 18:00</Button>
<Button size="sm" variant="outline" onClick={() => setEditing(prev => {
const d = new Date(); d.setDate(d.getDate()+1); d.setHours(18,0,0,0);
const yyyy = d.getFullYear();
const mm = String(d.getMonth() + 1).padStart(2, '0');
const dd = String(d.getDate()).padStart(2, '0');
setStartDate(`${yyyy}-${mm}-${dd}`);
setStartTime(`18:00`);
return ({ ...(prev || {}), start_time: d.toISOString() as any });
})}>Zítra 18:00</Button>
<Tooltip label="Rychlé nastavení zítřejšího rána" hasArrow>
<Button size="sm" variant="outline" onClick={() => setEditing(prev => {
const d = new Date(); d.setDate(d.getDate()+1); d.setHours(9,0,0,0);
const yyyy = d.getFullYear();
const mm = String(d.getMonth() + 1).padStart(2, '0');
const dd = String(d.getDate()).padStart(2, '0');
setStartDate(`${yyyy}-${mm}-${dd}`);
setStartTime(`09:00`);
return ({ ...(prev || {}), start_time: d.toISOString() as any });
})}>Zítra 9:00</Button>
</Tooltip>
</HStack>
<Text mt={2} fontSize="xs" color="gray.500">Časová zóna: {Intl.DateTimeFormat().resolvedOptions().timeZone}</Text>
</Box>
<Box mt={4}>
<Heading size="sm" mb={3}>Místo konání</Heading>
{/* MapLinkImporter */}
<Box bg={useColorModeValue('gray.50', 'gray.900')} p={4} borderRadius="md" borderWidth="1px" mb={3}>
<Text fontSize="sm" fontWeight="semibold" mb={2}>Importovat z odkazu na mapu</Text>
<MapLinkImporter
currentLatitude={locationLat}
currentLongitude={locationLng}
mapStyle={settingsQ.data?.map_style || 'positron'}
clubPrimaryColor={settingsQ.data?.primary_color}
clubSecondaryColor={settingsQ.data?.accent_color}
clubName={editing?.title || editing?.location || 'Místo události'}
onImport={(coords: MapCoordinates) => {
setLocationLat(coords.latitude);
setLocationLng(coords.longitude);
// Build location string from address data
let locationString = '';
if (coords.address) {
// Use full address if available
locationString = coords.address;
} else {
// Build address from parts
const parts: string[] = [];
if (coords.street) parts.push(coords.street);
if (coords.city) parts.push(coords.city);
if (coords.zip) parts.push(coords.zip);
locationString = parts.join(', ');
}
// Save coordinates and location to the event
setEditing(prev => ({
...(prev || {}),
latitude: coords.latitude,
longitude: coords.longitude,
location: locationString || prev?.location || '',
} as any));
toast({
title: 'Místo importováno',
description: locationString || `Lat: ${coords.latitude.toFixed(6)}, Lng: ${coords.longitude.toFixed(6)}`,
status: 'success',
duration: 3000,
});
}}
/>
</Box>
<HStack spacing={3} align="start" mt={3}>
<FormControl flex={2}>
<FormLabel>Název místa / Adresa</FormLabel>
<Input
value={editing?.location || ''}
onChange={(e) => setEditing(prev => ({ ...(prev || {}), location: e.target.value }))}
placeholder="např. Sportovní hala TJ Sokol"
/>
<Text fontSize="xs" color="gray.500" mt={1}>Zobrazí se návštěvníkům (s možnou mapou)</Text>
</FormControl>
<FormControl flex={1}>
<FormLabel>Typ</FormLabel>
<Select value={(editing?.type as any) || 'other'} onChange={(e) => setEditing(prev => ({ ...(prev || {}), type: e.target.value as any }))}>
{types.map(t => (<option key={t.value} value={t.value}>{t.label}</option>))}
</Select>
</FormControl>
</HStack>
</Box>
<FormControl mt={3}>
<FormLabel>Kategorie (soutěž)</FormLabel>
<Select
placeholder="Vyberte kategorii (volitelné)"
value={(editing as any)?.category_name || ''}
onChange={(e) => setEditing(prev => ({ ...(prev || {}), category_name: e.target.value } as any))}
>
{competitions.map((c, idx) => (
<option key={(c.code || c.name) + '_' + idx} value={(c.code && aliasesMap[c.code]) ? aliasesMap[c.code] : c.name}>
{(c.code && aliasesMap[c.code]) ? aliasesMap[c.code] : c.name}
</option>
))}
</Select>
</FormControl>
<FormControl display="flex" alignItems="center" mt={3}>
<FormLabel mb="0">Veřejná</FormLabel>
<Switch isChecked={!!editing?.is_public} onChange={(e) => setEditing(prev => ({ ...(prev || {}), is_public: e.target.checked }))} />
</FormControl>
{/* ... (rest of the code remains the same) */}
<HStack mt={4} align="flex-start">
<FormControl>
<FormLabel>Obrázek (náhled)</FormLabel>
<HStack>
<Input value={(editing as any)?.image_url || ''} onChange={(e) => setEditing(prev => ({ ...(prev || {}), image_url: e.target.value }))} placeholder="/uploads/...jpg" />
<Button as="label" variant="outline">
Nahrát
<input type="file" accept="image/*" style={{ display: 'none' }} onChange={async (e) => {
const f = e.target.files?.[0]; if (!f) return; const res = await uploadFile(f); setEditing(prev => ({ ...(prev || {}), image_url: (res as any).url }));
}} />
</Button>
</HStack>
</FormControl>
<FormControl>
<FormLabel>Přílohy (více souborů)</FormLabel>
<Text fontSize="xs" color={textSecondary} mb={2}>
Podporované formáty: PDF, Word (.doc, .docx), Excel (.xls, .xlsx), PowerPoint (.ppt, .pptx), Obrázky (.jpg, .png, .gif, .webp), Text (.txt), ZIP, RAR
</Text>
<HStack>
<Button as="label" variant="outline">
Nahrát
<input
type="file"
multiple
accept=".pdf,.doc,.docx,.xls,.xlsx,.ppt,.pptx,.jpg,.jpeg,.png,.gif,.webp,.txt,.zip,.rar"
style={{ display: 'none' }}
onChange={async (e) => {
const files = Array.from(e.target.files || []);
const allowedTypes = [
'application/pdf',
'application/msword',
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
'application/vnd.ms-excel',
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
'application/vnd.ms-powerpoint',
'application/vnd.openxmlformats-officedocument.presentationml.presentation',
'image/jpeg',
'image/png',
'image/gif',
'image/webp',
'text/plain',
'application/zip',
'application/x-zip-compressed',
'application/x-rar-compressed',
'application/vnd.rar',
];
for (const f of files) {
if (!allowedTypes.includes(f.type) && !f.name.match(/\.(pdf|docx?|xlsx?|pptx?|jpe?g|png|gif|webp|txt|zip|rar)$/i)) {
toast({ title: 'Nepodporovaný formát souboru', description: `Soubor "${f.name}" nelze nahrát.`, status: 'warning', duration: 4000 });
continue;
}
try {
const res = await uploadFile(f as File);
setEditing(prev => ({ ...(prev || {}), attachments: ([...((prev as any)?.attachments || []), { name: (f as File).name, url: (res as any).url, mime_type: (f as File).type, size: (f as File).size }]) as any }));
} catch (err: any) {
toast({ title: 'Chyba při nahrávání', description: `Soubor "${f.name}": ${err?.message || 'Neznámá chyba'}`, status: 'error', duration: 4000 });
}
}
}}
/>
</Button>
</HStack>
<Box mt={2}>
{Array.isArray((editing as any)?.attachments) && (editing as any).attachments.length > 0 ? (
<Table size="sm" variant="simple">
<Thead><Tr><Th>Název</Th><Th>Velikost</Th><Th>Akce</Th></Tr></Thead>
<Tbody>
{((editing as any).attachments as any[]).map((att: any, idx: number) => (
<Tr key={idx}>
<Td>{att.name || att.url}</Td>
<Td>{typeof att.size === 'number' ? `${Math.round(att.size/1024)} kB` : '-'}</Td>
<Td>
<Button size="xs" variant="outline" onClick={() => setEditing(prev => ({ ...(prev as any), attachments: ((prev as any).attachments || []).filter((_: any, i: number) => i !== idx) }))}>Odebrat</Button>
</Td>
</Tr>
))}
</Tbody>
</Table>
) : (
<Box color="gray.500">Žádné přílohy</Box>
)}
</Box>
</FormControl>
</HStack>
{/* Poll Linker */}
{editing?.id && <PollLinker eventId={editing.id} />}
</ModalBody>
<ModalFooter>
<HStack w="100%" justify="space-between">
<Text fontSize="xs" color="gray.500">
Ukládáním souhlasíte s publikací podle nastavení Veřejná.
</Text>
<HStack>
<Button variant="ghost" mr={1} onClick={closeModal}>Zrušit</Button>
<Button bg="brand.primary" color="text.onPrimary" _hover={{ filter: 'brightness(0.95)' }} onClick={onSubmit} isLoading={createMut.isLoading || updateMut.isLoading}>Uložit</Button>
</HStack>
</HStack>
</ModalFooter>
</ModalContent>
</Modal>
</Box>
</AdminLayout>
);
};
export default AdminActivitiesPage;
@@ -0,0 +1,484 @@
import { Box, Text, SimpleGrid, Stat, StatLabel, StatNumber, StatHelpText, Icon, HStack, Skeleton, Alert, AlertIcon, AlertTitle, AlertDescription, Button, Link, VStack, Badge, Heading, useColorModeValue, Divider, Table, Thead, Tbody, Tr, Th, Td, Tooltip } from '@chakra-ui/react';
import { useQuery } from '@tanstack/react-query';
import AdminLayout from '../../layouts/AdminLayout';
import { getAnalytics, AnalyticsData, getAnalyticsOverview, getTopPages, AnalyticsOverview, PageStats } from '../../services/analyticsService';
import { MatchesWidget } from '../../components/widgets/MatchesWidget';
import { ArticlesWidget } from '../../components/widgets/ArticlesWidget';
import { FaUsers, FaCalendarAlt, FaNewspaper, FaTrophy, FaChartLine, FaCog, FaBook, FaRocket, FaEye, FaMousePointer } from 'react-icons/fa';
import AdminHelp from '../../components/admin/AdminHelp';
import { getFacrTablesCache } from '../../services/facr/cache';
import ScoreboardPreview from '../../components/scoreboard/ScoreboardPreview';
import { getScoreboardState, ScoreboardState } from '../../services/scoreboard';
import { Link as RouterLink } from 'react-router-dom';
import api from '../../services/api';
const StatCard = ({ label, value, help, icon, color = 'blue' }: { label: string; value: number | string; help?: string; icon?: any; color?: string }) => {
const bgCard = useColorModeValue('white', 'gray.800');
const borderColor = useColorModeValue('gray.200', 'gray.700');
const iconBg = useColorModeValue(`${color}.50`, `${color}.900`);
const iconColor = useColorModeValue(`${color}.600`, `${color}.300`);
return (
<Box
bg={bgCard}
p={6}
borderRadius="xl"
boxShadow="md"
borderWidth="1px"
borderColor={borderColor}
_hover={{ shadow: 'lg', transform: 'translateY(-2px)' }}
transition="all 0.2s"
>
<HStack justify="space-between" align="start" mb={3}>
<Stat>
<StatLabel fontSize="sm" fontWeight="medium" color="gray.500">{label}</StatLabel>
<StatNumber fontSize="3xl" fontWeight="bold" mt={2}>{value}</StatNumber>
{help && <StatHelpText fontSize="xs" mt={1}>{help}</StatHelpText>}
</Stat>
{icon && (
<Box p={3} bg={iconBg} borderRadius="xl">
<Icon as={icon} boxSize={6} color={iconColor} />
</Box>
)}
</HStack>
</Box>
);
};
// Event translation and description mapping
const getEventTranslation = (eventName: string): { name: string; source: string; description: string } => {
const eventMap: Record<string, { name: string; source: string; description: string }> = {
'Contact Form Submit': {
name: 'Odeslání kontaktního formuláře',
source: 'Kontaktní stránka',
description: 'Uživatel odeslal kontaktní formulář na stránce Kontakt'
},
'Contact Form': {
name: 'Zobrazení kontaktního formuláře',
source: 'Kontaktní stránka',
description: 'Uživatel zobrazil kontaktní formulář'
},
'Form Submit': {
name: 'Odeslání formuláře',
source: 'Různé stránky',
description: 'Obecné odeslání formuláře na webu'
},
'Newsletter Subscribe': {
name: 'Odběr newsletteru',
source: 'Newsletter formulář',
description: 'Uživatel se přihlásil k odběru newsletteru'
},
'Newsletter Submit': {
name: 'Potvrzení newsletteru',
source: 'Newsletter formulář',
description: 'Uživatel potvrdil přihlášení k newsletteru'
},
'Newsletter Unsubscribe': {
name: 'Odhlášení z newsletteru',
source: 'Nastavení newsletteru',
description: 'Uživatel se odhlásil z odběru newsletteru'
},
'Newsletter Preferences Saved': {
name: 'Uložení předvoleb newsletteru',
source: 'Nastavení newsletteru',
description: 'Uživatel uložil své předvolby pro newsletter'
},
'Unsubscribe': {
name: 'Odhlášení z odběru',
source: 'Nastavení',
description: 'Uživatel se odhlásil z odběru'
},
'Save Preferences': {
name: 'Uložení předvoleb',
source: 'Nastavení',
description: 'Uživatel uložil své předvolby (souhlas s cookies apod.)'
},
'Refresh Preferences': {
name: 'Obnovení předvoleb',
source: 'Nastavení',
description: 'Uživatel obnovil nebo změnil své předvolby'
},
'Article View': {
name: 'Zobrazení článku',
source: 'Blog',
description: 'Uživatel si zobrazil článek na blogu'
},
'Match View': {
name: 'Zobrazení zápasu',
source: 'Stránka zápasů',
description: 'Uživatel si zobrazil detail zápasu'
},
'Gallery View': {
name: 'Zobrazení galerie',
source: 'Galerie',
description: 'Uživatel si otevřel galerii fotografií'
},
'Video Play': {
name: 'Přehrání videa',
source: 'Video sekce',
description: 'Uživatel spustil přehrávání videa'
},
'Social Share': {
name: 'Sdílení na sociálních sítích',
source: 'Sdílecí tlačítka',
description: 'Uživatel sdílel obsah na sociální síť'
},
'Download': {
name: 'Stažení souboru',
source: 'Různé stránky',
description: 'Uživatel stáhl soubor'
},
'External Link Click': {
name: 'Kliknutí na externí odkaz',
source: 'Různé stránky',
description: 'Uživatel klikl na odkaz vedoucí mimo web'
}
};
return eventMap[eventName] || {
name: eventName,
source: 'Neznámý zdroj',
description: `Událost: ${eventName}`
};
};
const AdminDashboardPage = () => {
const { data, isLoading, error, refetch, isFetching } = useQuery<AnalyticsData>({
queryKey: ['admin', 'analytics'],
queryFn: getAnalytics,
staleTime: 5 * 60 * 1000,
});
// Fetch analytics overview (page views, visitors, etc.)
const { data: analyticsOverview, isLoading: isLoadingOverview } = useQuery<AnalyticsOverview>({
queryKey: ['admin', 'analytics', 'overview'],
queryFn: getAnalyticsOverview,
staleTime: 5 * 60 * 1000,
});
// Fetch top pages
const { data: topPages } = useQuery<PageStats[]>({
queryKey: ['admin', 'analytics', 'top-pages'],
queryFn: () => getTopPages(5),
staleTime: 10 * 60 * 1000,
});
// Fetch top events from Umami
const { data: topEvents } = useQuery<Array<{ x: string; y: number }>>({
queryKey: ['admin', 'analytics', 'umami-events'],
queryFn: async () => {
const response = await api.get('/admin/umami/metrics/event?days=7');
return response.data || [];
},
staleTime: 10 * 60 * 1000,
});
// Club stats from FACR tables cache
const { data: facrTables } = useQuery<any>({
queryKey: ['facr-tables-cache'],
queryFn: getFacrTablesCache,
staleTime: 5 * 60 * 1000,
});
const competitionsCount = Array.isArray(facrTables?.competitions) ? facrTables.competitions.length : 0;
const uniqueTeamsCount = (() => {
try {
const comps = Array.isArray(facrTables?.competitions) ? facrTables.competitions : [];
const set = new Set<string>();
for (const c of comps) {
const rows = Array.isArray(c?.table?.overall) ? c.table.overall : [];
for (const r of rows) {
const name = String(r?.team || '').normalize('NFD').replace(/[\u0300-\u036f]/g, '').replace(/\s+/g, ' ').trim().toLowerCase();
if (name) set.add(name);
}
}
return set.size;
} catch {
return 0;
}
})();
// Scoreboard state for compact preview
const { data: scoreboardState } = useQuery<ScoreboardState>({
queryKey: ['scoreboard-state'],
queryFn: getScoreboardState,
staleTime: 60 * 1000,
});
return (
<AdminLayout>
<Box maxW="1600px" mx="auto">
{/* Welcome Header */}
<Box mb={8}>
<HStack spacing={3} mb={2}>
<Text fontSize="3xl">👋</Text>
<Heading size="xl">Vítejte v administraci</Heading>
</HStack>
<Text color="gray.500" fontSize="lg">
Přehled klíčových statistik a rychlý přístup k nejdůležitějším funkcím
</Text>
</Box>
{!!error && (
<Alert status="error" mb={4} borderRadius="md">
<AlertIcon />
<Box>
<AlertTitle>Nelze načíst statistiky</AlertTitle>
<AlertDescription>
Zkontrolujte připojení nebo přihlášení správce a zkuste to znovu.
</AlertDescription>
</Box>
<Button onClick={() => refetch()} ml="auto" size="sm" isLoading={isFetching}>
Zkusit znovu
</Button>
</Alert>
)}
{/* Top stats */}
<SimpleGrid columns={{ base: 1, sm: 2, lg: 3 }} spacing={4} mb={6}>
{isLoading ? (
<>
{[1,2,3].map((i) => (
<Box key={i} bg="white" p={4} borderRadius="lg" boxShadow="sm" borderWidth="1px">
<Skeleton height="20px" width="40%" mb={2} />
<Skeleton height="28px" width="50%" mb={2} />
<Skeleton height="16px" width="35%" />
</Box>
))}
</>
) : (
<>
<StatCard
label="Uživatelé (admin)"
value={data?.users?.total ?? '—'}
help={data?.users ? `Nových tento týden: ${data.users.new_this_week ?? 0}` : undefined}
icon={FaUsers}
color="blue"
/>
<StatCard
label="Události"
value={data?.events?.total ?? '—'}
help={data?.events ? `Nadcházející: ${data.events.upcoming ?? 0}` : undefined}
icon={FaCalendarAlt}
color="green"
/>
<StatCard
label="Články"
value={data?.articles?.total ?? '—'}
help={data?.articles ? `Publikovaných: ${data.articles.published ?? 0}` : undefined}
icon={FaNewspaper}
color="purple"
/>
<StatCard
label="Zobrazení stránek"
value={analyticsOverview?.total_page_views?.toLocaleString('cs-CZ') ?? '—'}
help={analyticsOverview ? `Dnes: ${analyticsOverview.page_views_today ?? 0}` : undefined}
icon={FaEye}
color="cyan"
/>
<StatCard
label="Unikátní návštěvníci"
value={analyticsOverview?.unique_visitors?.toLocaleString('cs-CZ') ?? '—'}
help={analyticsOverview ? `Tento týden: ${analyticsOverview.unique_visitors_week ?? 0}` : undefined}
icon={FaChartLine}
color="teal"
/>
<StatCard
label="Zobrazení (týden)"
value={analyticsOverview?.page_views_week?.toLocaleString('cs-CZ') ?? '—'}
help="Za posledních 7 dní"
icon={FaMousePointer}
color="orange"
/>
</>
)}
</SimpleGrid>
{/* Quick Actions */}
<Box mb={8}>
<Heading size="md" mb={4}>Rychlé akce</Heading>
<SimpleGrid columns={{ base: 2, md: 4 }} spacing={4}>
{[
{ label: 'Nastavení', icon: FaCog, to: '/admin/nastaveni', color: 'blue' },
{ label: 'Dokumentace', icon: FaBook, to: '/admin/docs', color: 'purple' },
{ label: 'Nový článek', icon: FaRocket, to: '/admin/clanky', color: 'green' },
{ label: 'Prefetch', icon: FaChartLine, to: '/admin/prefetch', color: 'orange' },
].map((action, idx) => (
<Link
key={idx}
as={RouterLink}
to={action.to}
_hover={{ textDecoration: 'none' }}
>
<Box
bg={useColorModeValue('white', 'gray.800')}
p={4}
borderRadius="lg"
borderWidth="1px"
borderColor={useColorModeValue('gray.200', 'gray.700')}
textAlign="center"
cursor="pointer"
_hover={{
shadow: 'lg',
transform: 'translateY(-2px)',
borderColor: `${action.color}.400`
}}
transition="all 0.2s"
>
<VStack spacing={2}>
<Box
p={3}
bg={useColorModeValue(`${action.color}.50`, `${action.color}.900`)}
borderRadius="lg"
>
<Icon
as={action.icon}
boxSize={6}
color={useColorModeValue(`${action.color}.600`, `${action.color}.300`)}
/>
</Box>
<Text fontWeight="semibold" fontSize="sm">{action.label}</Text>
</VStack>
</Box>
</Link>
))}
</SimpleGrid>
</Box>
<Divider my={8} />
{/* Analytics Tables */}
<Heading size="md" mb={4}>Analytika návštěvnosti</Heading>
<SimpleGrid columns={{ base: 1, lg: 2 }} spacing={6} mb={8}>
{/* Top Pages */}
<Box
bg={useColorModeValue('white', 'gray.800')}
p={5}
borderRadius="xl"
boxShadow="md"
borderWidth="1px"
borderColor={useColorModeValue('gray.200', 'gray.700')}
>
<HStack justify="space-between" mb={4}>
<Text fontWeight="bold" fontSize="lg">Nejnavštěvovanější stránky</Text>
<Link as={RouterLink} to="/admin/analytika" color="blue.500" fontSize="sm" fontWeight="semibold">
Více
</Link>
</HStack>
{topPages && topPages.length > 0 ? (
<Table size="sm" variant="simple">
<Thead>
<Tr>
<Th>Stránka</Th>
<Th isNumeric>Zobrazení</Th>
<Th isNumeric>Návštěvníci</Th>
</Tr>
</Thead>
<Tbody>
{topPages.map((page, idx) => (
<Tr key={idx}>
<Td fontSize="sm">
<Text isTruncated maxW="200px" title={page.page_name || page.page_path}>
{page.page_name || page.page_path}
</Text>
</Td>
<Td isNumeric fontWeight="semibold">{page.view_count}</Td>
<Td isNumeric>{page.unique_visitors}</Td>
</Tr>
))}
</Tbody>
</Table>
) : (
<Text color="gray.500" fontSize="sm">Zatím žádná data</Text>
)}
</Box>
{/* Top User Events */}
<Box
bg={useColorModeValue('white', 'gray.800')}
p={5}
borderRadius="xl"
boxShadow="md"
borderWidth="1px"
borderColor={useColorModeValue('gray.200', 'gray.700')}
>
<HStack justify="space-between" mb={4}>
<Text fontWeight="bold" fontSize="lg">Nejčastější interakce</Text>
<Badge colorScheme="blue">7 dní</Badge>
</HStack>
{topEvents && topEvents.length > 0 ? (
<Table size="sm" variant="simple">
<Thead>
<Tr>
<Th>Akce</Th>
<Th isNumeric>Počet</Th>
</Tr>
</Thead>
<Tbody>
{topEvents.slice(0, 10).map((event, idx) => {
const eventName = event.x || '';
const eventInfo = getEventTranslation(eventName);
return (
<Tr key={idx}>
<Td fontSize="sm">
<Tooltip label={eventInfo.description} placement="top" hasArrow>
<Box>
<Text fontWeight="medium">{eventInfo.name}</Text>
<Text fontSize="xs" color="gray.500">{eventInfo.source}</Text>
</Box>
</Tooltip>
</Td>
<Td isNumeric>
<Badge colorScheme="blue" fontWeight="semibold">{event.y}</Badge>
</Td>
</Tr>
);
})}
</Tbody>
</Table>
) : (
<Text color="gray.500" fontSize="sm">Zatím žádná data</Text>
)}
</Box>
</SimpleGrid>
<Divider my={8} />
{/* Widgets grid */}
<Heading size="md" mb={4}>Přehled aktivit</Heading>
<SimpleGrid columns={{ base: 1, md: 2, xl: 3 }} spacing={6} mb={8}>
<MatchesWidget />
<ArticlesWidget />
{/* Compact Scoreboard card */}
<Box
bg={useColorModeValue('white', 'gray.800')}
p={5}
borderRadius="xl"
boxShadow="md"
borderWidth="1px"
borderColor={useColorModeValue('gray.200', 'gray.700')}
>
<HStack justify="space-between" mb={4}>
<Text fontWeight="bold" fontSize="lg">Aktuální tabule</Text>
<Link as={RouterLink} to="/admin/scoreboard" color="blue.500" fontSize="sm" fontWeight="semibold">
Upravit
</Link>
</HStack>
{scoreboardState ? (
<Box display="flex" justifyContent="center">
<ScoreboardPreview state={scoreboardState} />
</Box>
) : (
<Skeleton height="40px" />
)}
</Box>
</SimpleGrid>
{/* Admin guidance */}
<AdminHelp />
</Box>
</AdminLayout>
);
};
export default AdminDashboardPage;
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,404 @@
import React, { useEffect, useMemo, useState } from 'react';
import { Box, Heading, Text, List, ListItem, Link, Divider, Code, OrderedList, HStack, IconButton, useColorModeValue, useToast } from '@chakra-ui/react';
import { FaLink, FaArrowUp } from 'react-icons/fa';
import AdminLayout from '../../layouts/AdminLayout';
const AdminDocsPage: React.FC = () => {
const sections = useMemo(() => [
{ id: 'nastaveni', label: 'Nastavení (branding + SMTP)' },
{ id: 'clanky', label: 'Články a kategorie' },
{ id: 'zapasy', label: 'Zápasy (FAČR)' },
{ id: 'hraci-tymy', label: 'Hráči a týmy' },
{ id: 'media', label: 'Média' },
{ id: 'sponzori-bannery', label: 'Sponzoři a bannery' },
{ id: 'newsletter', label: 'Newsletter' },
{ id: 'aliasy', label: 'Alias soutěží' },
{ id: 'prefetch', label: 'Prefetch' },
{ id: 'videa', label: 'Videa' },
{ id: 'aktivity', label: 'Aktivity' },
{ id: 'merch', label: 'Oblečení' },
{ id: 'zpravy', label: 'Zprávy' },
{ id: 'reset-admin', label: 'Reset hesla' },
{ id: 'uzivatele', label: 'Uživatelé' },
{ id: 'seo-analytics', label: 'SEO a Analytics' },
{ id: 'troubleshooting', label: 'Troubleshooting' },
], []);
const [activeId, setActiveId] = useState<string>('');
const toast = useToast();
useEffect(() => {
const observer = new IntersectionObserver(
(entries) => {
// Pick the entry closest to the top that is intersecting
const visible = entries
.filter((e) => e.isIntersecting)
.sort((a, b) => (a.boundingClientRect.top > b.boundingClientRect.top ? 1 : -1));
if (visible[0]) {
setActiveId(visible[0].target.id);
}
},
{ rootMargin: '-40% 0px -50% 0px', threshold: [0, 0.25, 0.5, 0.75, 1] }
);
const els = sections.map((s) => document.getElementById(s.id)).filter(Boolean) as Element[];
els.forEach((el) => observer.observe(el));
return () => observer.disconnect();
}, [sections]);
// Persist active section and restore on reload
useEffect(() => {
if (activeId) {
try { localStorage.setItem('adminDocs:lastAnchor', activeId); } catch {}
}
}, [activeId]);
useEffect(() => {
// Run after paint so layout is ready
const t = setTimeout(() => {
const hash = (window.location.hash || '').replace('#', '').trim();
let targetId = hash;
if (!targetId) {
try { targetId = localStorage.getItem('adminDocs:lastAnchor') || ''; } catch {}
}
if (targetId) {
const el = document.getElementById(targetId);
if (el) {
el.scrollIntoView({ behavior: 'smooth', block: 'start' });
setActiveId(targetId);
}
}
}, 50);
return () => clearTimeout(t);
}, []);
const copyDeepLink = async (id: string) => {
try {
const url = `${window.location.origin}${window.location.pathname}#${id}`;
await navigator.clipboard.writeText(url);
toast({ title: 'Odkaz zkopírován', status: 'success', duration: 1500, isClosable: true });
} catch {
toast({ title: 'Nelze zkopírovat odkaz', status: 'error', duration: 2000, isClosable: true });
}
};
const tocActiveColor = useColorModeValue('blue.700', 'blue.300');
const tocActiveBg = useColorModeValue('blue.50', 'blue.900');
return (
<AdminLayout>
<Box
display={{ base: 'block', md: 'grid' }}
gridTemplateColumns={{ base: '1fr', md: '260px 1fr' }}
gap={{ base: 0, md: 6 }}
>
{/* Sticky TOC */}
<Box display={{ base: 'none', md: 'block' }}>
<Box position="sticky" top="84px">
<Box bg="white" p={4} borderRadius="lg" borderWidth="1px" boxShadow="sm">
<Heading size="sm" mb={3}>Rychlá navigace</Heading>
<OrderedList spacing={2} pl={5}>
{sections.map((s) => (
<ListItem key={s.id}>
<Link
href={`#${s.id}`}
display="block"
px={2}
py={1}
borderRadius="md"
bg={activeId === s.id ? tocActiveBg : 'transparent'}
color={activeId === s.id ? tocActiveColor : 'inherit'}
onClick={() => {
try { localStorage.setItem('adminDocs:lastAnchor', s.id); } catch {}
}}
>
{s.label}
</Link>
</ListItem>
))}
</OrderedList>
</Box>
</Box>
</Box>
{/* Main content */}
<Box bg="white" p={6} borderRadius="lg" borderWidth="1px" boxShadow="sm">
<Box id="top" />
<Heading size="lg" mb={1}>Dokumentace</Heading>
<Text color="gray.600" mb={4}>Přehled funkcí, postupů a řešení problémů</Text>
<Heading size="md" mb={3}>Začínáme</Heading>
<Text mb={4}>
Tato stránka shrnuje hlavní možnosti administrace webu. Pro rychlý start doporučujeme nejprve vyplnit
<Link href="/admin/nastaveni" ml={1} color="blue.600">Nastavení</Link>, poté vytvořit první článek
a ověřit funkčnost emailů.
</Text>
<Heading size="sm" mt={6} mb={2}>Obsah</Heading>
<OrderedList spacing={1} mb={4} pl={5}>
<ListItem><Link href="#nastaveni">Nastavení klubu, branding a SMTP</Link></ListItem>
<ListItem><Link href="#clanky">Články a kategorie</Link></ListItem>
<ListItem><Link href="#zapasy">Zápasy a výsledky (FAČR)</Link></ListItem>
<ListItem><Link href="#hraci-tymy">Hráči a týmy</Link></ListItem>
<ListItem><Link href="#media">Média (soubory a obrázky)</Link></ListItem>
<ListItem><Link href="#sponzori-bannery">Sponzoři a bannery</Link></ListItem>
<ListItem><Link href="#newsletter">Newsletter</Link></ListItem>
<ListItem><Link href="#aliasy">Alias názvů soutěží</Link></ListItem>
<ListItem><Link href="#prefetch">Prefetch (YouTube a další data)</Link></ListItem>
<ListItem><Link href="#uzivatele">Uživatelé a přístupy</Link></ListItem>
<ListItem><Link href="#seo-analytics">SEO a Analytics</Link></ListItem>
<ListItem><Link href="#troubleshooting">Troubleshooting (řešení problémů)</Link></ListItem>
</OrderedList>
<Divider my={6} />
<HStack align="center" justify="space-between" mb={2}>
<Heading id="nastaveni" size="md">Nastavení klubu, branding a SMTP</Heading>
<IconButton aria-label="Zkopírovat odkaz" variant="ghost" size="sm" icon={<FaLink />} onClick={() => copyDeepLink('nastaveni')} />
</HStack>
<List spacing={2} styleType="disc" pl={5}>
<ListItem><strong>Branding</strong>: Nastavte název klubu, logo a barvy. Tyto hodnoty se propsají do emailů i vzhledu webu.</ListItem>
<ListItem><strong>SMTP</strong>: Vyplňte host, port, uživatele a heslo. U portu 465 se používá implicitní SSL, jinak STARTTLS.</ListItem>
<ListItem><strong>Test</strong>: V sekci Newsletter Test odešlete zkušební email a ověřte doručení.</ListItem>
</List>
<Box mt={2}><Link href="#top" color="blue.600"><HStack as="span" spacing={2}><FaArrowUp /><Text>Na začátek</Text></HStack></Link></Box>
<Divider my={6} />
<HStack align="center" justify="space-between" mb={2}>
<Heading id="clanky" size="md">Články a kategorie</Heading>
<IconButton aria-label="Zkopírovat odkaz" variant="ghost" size="sm" icon={<FaLink />} onClick={() => copyDeepLink('clanky')} />
</HStack>
<OrderedList spacing={2} pl={5}>
<ListItem>Vytvořte kategorii (pokud ještě neexistuje) a poté nový článek.</ListItem>
<ListItem>Vyplňte SEO název a popis zlepší to výsledky ve vyhledávání.</ListItem>
<ListItem>Obrázek nahrávejte do Média a vložte URL do článku.</ListItem>
</OrderedList>
<Text mt={3} fontSize="sm" color="gray.600">
Další správa kategorií je v sekci <Link href="/admin/kategorie">Kategorie</Link>.
</Text>
<Box mt={2}><Link href="#top" color="blue.600"><HStack as="span" spacing={2}><FaArrowUp /><Text>Na začátek</Text></HStack></Link></Box>
<Divider my={6} />
<HStack align="center" justify="space-between" mb={2}>
<Heading id="zapasy" size="md">Zápasy a výsledky (FAČR)</Heading>
<IconButton aria-label="Zkopírovat odkaz" variant="ghost" size="sm" icon={<FaLink />} onClick={() => copyDeepLink('zapasy')} />
</HStack>
<List spacing={2} styleType="disc" pl={5}>
<ListItem>Napojení na FAČR umožňuje zobrazit soutěže, tabulky a nadcházející utkání.</ListItem>
<ListItem>Přes <Link href="/admin/aliasy-soutezi">Alias názvů soutěží</Link> si můžete upravit názvy pro frontend.</ListItem>
<ListItem>Pokud FAČR data nevidíte, spusťte <Link href="/admin/prefetch">Prefetch</Link> pro načtení cache.</ListItem>
</List>
<Box mt={2}><Link href="#top" color="blue.600"><HStack as="span" spacing={2}><FaArrowUp /><Text>Na začátek</Text></HStack></Link></Box>
<Divider my={6} />
<HStack align="center" justify="space-between" mb={2}>
<Heading id="hraci-tymy" size="md">Hráči a týmy</Heading>
<IconButton aria-label="Zkopírovat odkaz" variant="ghost" size="sm" icon={<FaLink />} onClick={() => copyDeepLink('hraci-tymy')} />
</HStack>
<List spacing={2} styleType="disc" pl={5}>
<ListItem>Přidávejte týmy a hráče, udržujte je aktuální pro přehled na webu.</ListItem>
<ListItem>Loga týmů lze přepsat v sekci <Link href="/admin/aliasy-soutezi">Alias/Overrides</Link>, pokud se nenačtou správně.</ListItem>
</List>
<Box mt={2}><Link href="#top" color="blue.600"><HStack as="span" spacing={2}><FaArrowUp /><Text>Na začátek</Text></HStack></Link></Box>
<Divider my={6} />
<HStack align="center" justify="space-between" mb={2}>
<Heading id="media" size="md">Média (soubory a obrázky)</Heading>
<IconButton aria-label="Zkopírovat odkaz" variant="ghost" size="sm" icon={<FaLink />} onClick={() => copyDeepLink('media')} />
</HStack>
<OrderedList spacing={2} pl={5}>
<ListItem>Nahrajte soubor v sekci Média, zkopírujte URL a použijte ji v článcích nebo bannerech.</ListItem>
<ListItem>Podporované formáty: JPEG/PNG/SVG/ dle konfigurace serveru.</ListItem>
</OrderedList>
<Box mt={2}><Link href="#top" color="blue.600"><HStack as="span" spacing={2}><FaArrowUp /><Text>Na začátek</Text></HStack></Link></Box>
<Divider my={6} />
<HStack align="center" justify="space-between" mb={2}>
<Heading id="sponzori-bannery" size="md">Sponzoři a bannery</Heading>
<IconButton aria-label="Zkopírovat odkaz" variant="ghost" size="sm" icon={<FaLink />} onClick={() => copyDeepLink('sponzori-bannery')} />
</HStack>
<List spacing={2} styleType="disc" pl={5}>
<ListItem>Přidávejte sponzory s logem a odkazem. Můžete je zobrazit na vybraných sekcích webu.</ListItem>
<ListItem>Pro reklamní plochy používejte sekci Bannery a nastavte cílové URL.</ListItem>
</List>
<Box mt={2}><Link href="#top" color="blue.600"><HStack as="span" spacing={2}><FaArrowUp /><Text>Na začátek</Text></HStack></Link></Box>
<Divider my={6} />
<HStack align="center" justify="space-between" mb={2}>
<Heading id="newsletter" size="md">Newsletter</Heading>
<IconButton aria-label="Zkopírovat odkaz" variant="ghost" size="sm" icon={<FaLink />} onClick={() => copyDeepLink('newsletter')} />
</HStack>
<OrderedList spacing={2} pl={5}>
<ListItem>Nastavte SMTP a identitu odesílatele (jméno + adresa).</ListItem>
<ListItem>Odešlete test na svou adresu, ověřte zobrazení a doručení.</ListItem>
<ListItem>Hromadné rozesílky plánujte s ohledem na limity poskytovatele SMTP.</ListItem>
</OrderedList>
<Heading size="sm" mt={4} mb={2}>Postup: konfigurace SMTP (krok za krokem)</Heading>
<OrderedList spacing={1} pl={5}>
<ListItem>Otevřete <Link href="/admin/nastaveni">Nastavení</Link> a vyplňte Host, Port, Uživatelské jméno, Heslo a From adresu.</ListItem>
<ListItem>Pro port <Code>465</Code> použijte SSL. Pro port <Code>587</Code> použijte STARTTLS (Use TLS ano).</ListItem>
<ListItem>Uložte změny.</ListItem>
<ListItem>Přejděte do <Link href="/admin/newsletter">Newsletter</Link> a odešlete testovací email na vaši adresu.</ListItem>
<ListItem>Zkontrolujte doručení v inboxu a případně složku spam. Pokud nedorazí, viz Troubleshooting níže.</ListItem>
</OrderedList>
<Divider my={6} />
<HStack align="center" justify="space-between" mb={2}>
<Heading id="aliasy" size="md">Alias názvů soutěží</Heading>
<IconButton aria-label="Zkopírovat odkaz" variant="ghost" size="sm" icon={<FaLink />} onClick={() => copyDeepLink('aliasy')} />
</HStack>
<Text mb={2}>Upravit zobrazení soutěží (přejmenování, sjednocení názvů) můžete v sekci Alias. Změny se projeví na frontendu.</Text>
<Box mt={2}><Link href="#top" color="blue.600"><HStack as="span" spacing={2}><FaArrowUp /><Text>Na začátek</Text></HStack></Link></Box>
<Divider my={6} />
<HStack align="center" justify="space-between" mb={2}>
<Heading id="prefetch" size="md">Prefetch (YouTube a další data)</Heading>
<IconButton aria-label="Zkopírovat odkaz" variant="ghost" size="sm" icon={<FaLink />} onClick={() => copyDeepLink('prefetch')} />
</HStack>
<List spacing={2} styleType="disc" pl={5}>
<ListItem>Prefetch vytváří rychlou cache pro veřejné části webu (YouTube, články).</ListItem>
<ListItem>Při problémech s rychlostí načtení spusťte ručně Prefetch a zkontrolujte stav.</ListItem>
</List>
<Box mt={2}><Link href="#top" color="blue.600"><HStack as="span" spacing={2}><FaArrowUp /><Text>Na začátek</Text></HStack></Link></Box>
<Divider my={6} />
<HStack align="center" justify="space-between" mb={2}>
<Heading id="videa" size="md">Videa</Heading>
<IconButton aria-label="Zkopírovat odkaz" variant="ghost" size="sm" icon={<FaLink />} onClick={() => copyDeepLink('videa')} />
</HStack>
<List spacing={2} styleType="disc" pl={5}>
<ListItem>Správa videí a playlistů zobrazovaných na webu.</ListItem>
<ListItem>Pro rychlé načítání videí použijte <Link href="/admin/prefetch">Prefetch</Link> (YouTube cache).</ListItem>
</List>
<Box mt={2}><Link href="#top" color="blue.600"><HStack as="span" spacing={2}><FaArrowUp /><Text>Na začátek</Text></HStack></Link></Box>
<Divider my={6} />
<HStack align="center" justify="space-between" mb={2}>
<Heading id="aktivity" size="md">Aktivity</Heading>
<IconButton aria-label="Zkopírovat odkaz" variant="ghost" size="sm" icon={<FaLink />} onClick={() => copyDeepLink('aktivity')} />
</HStack>
<List spacing={2} styleType="disc" pl={5}>
<ListItem>Plánujte a publikujte klubové akce mimo soutěžní zápasy.</ListItem>
<ListItem>Aktivity se mohou zobrazovat na nástěnce administrace i na veřejném webu.</ListItem>
</List>
<Box mt={2}><Link href="#top" color="blue.600"><HStack as="span" spacing={2}><FaArrowUp /><Text>Na začátek</Text></HStack></Link></Box>
<Divider my={6} />
<HStack align="center" justify="space-between" mb={2}>
<Heading id="merch" size="md">Oblečení (Merch)</Heading>
<IconButton aria-label="Zkopírovat odkaz" variant="ghost" size="sm" icon={<FaLink />} onClick={() => copyDeepLink('merch')} />
</HStack>
<List spacing={2} styleType="disc" pl={5}>
<ListItem>Správa položek klubového merche a jejich zobrazení pro fanoušky.</ListItem>
<ListItem>Pro každou položku nastavte název, popis, cenu a obrázek (z Média).</ListItem>
</List>
<Box mt={2}><Link href="#top" color="blue.600"><HStack as="span" spacing={2}><FaArrowUp /><Text>Na začátek</Text></HStack></Link></Box>
<Divider my={6} />
<HStack align="center" justify="space-between" mb={2}>
<Heading id="zpravy" size="md">Zprávy</Heading>
<IconButton aria-label="Zkopírovat odkaz" variant="ghost" size="sm" icon={<FaLink />} onClick={() => copyDeepLink('zpravy')} />
</HStack>
<List spacing={2} styleType="disc" pl={5}>
<ListItem>Seznam zpráv odeslaných přes kontaktní formulář a systémové notifikace.</ListItem>
<ListItem>Odpovídejte uživatelům přímo z vaší schránky, systém ukládá pouze záznamy.</ListItem>
</List>
<Box mt={2}><Link href="#top" color="blue.600"><HStack as="span" spacing={2}><FaArrowUp /><Text>Na začátek</Text></HStack></Link></Box>
<Divider my={6} />
<HStack align="center" justify="space-between" mb={2}>
<Heading id="reset-admin" size="md">Reset hesla (admin nástroj)</Heading>
<IconButton aria-label="Zkopírovat odkaz" variant="ghost" size="sm" icon={<FaLink />} onClick={() => copyDeepLink('reset-admin')} />
</HStack>
<OrderedList spacing={2} pl={5}>
<ListItem>Přejděte na <Link href="/admin/users/send-reset">Nástroj pro reset hesla</Link>.</ListItem>
<ListItem>Zadejte email uživatele a odešlete ověřovací kód/odkaz pro reset.</ListItem>
<ListItem>Uživatel dokončí změnu hesla přes veřejnou stránku pro reset.</ListItem>
</OrderedList>
<Box mt={2}><Link href="#top" color="blue.600"><HStack as="span" spacing={2}><FaArrowUp /><Text>Na začátek</Text></HStack></Link></Box>
<Divider my={6} />
<HStack align="center" justify="space-between" mb={2}>
<Heading id="uzivatele" size="md">Uživatelé a přístupy</Heading>
<IconButton aria-label="Zkopírovat odkaz" variant="ghost" size="sm" icon={<FaLink />} onClick={() => copyDeepLink('uzivatele')} />
</HStack>
<List spacing={2} styleType="disc" pl={5}>
<ListItem>Rozlišujeme role <Code>admin</Code> a <Code>user</Code>. Admin přístup do všech sekcí.</ListItem>
<ListItem>Hesla mají minimální délku 8 znaků a nesmí obsahovat mezery.</ListItem>
<ListItem>Zapomenuté heslo lze obnovit přes stránku Zapomenuté heslo (ověřovací kód emailem).</ListItem>
</List>
<Box mt={2}><Link href="#top" color="blue.600"><HStack as="span" spacing={2}><FaArrowUp /><Text>Na začátek</Text></HStack></Link></Box>
<Divider my={6} />
<HStack align="center" justify="space-between" mb={2}>
<Heading id="seo-analytics" size="md">SEO a Analytics</Heading>
<IconButton aria-label="Zkopírovat odkaz" variant="ghost" size="sm" icon={<FaLink />} onClick={() => copyDeepLink('seo-analytics')} />
</HStack>
<List spacing={2} styleType="disc" pl={5}>
<ListItem>U článků vyplňujte <strong>SEO titulek</strong> a <strong>SEO popis</strong>.</ListItem>
<ListItem>V administraci je k dispozici přehled návštěvnosti a interakcí (sekce Analytics).</ListItem>
</List>
<Box mt={2}><Link href="#top" color="blue.600"><HStack as="span" spacing={2}><FaArrowUp /><Text>Na začátek</Text></HStack></Link></Box>
<Divider my={6} />
<HStack align="center" justify="space-between" mb={2}>
<Heading id="troubleshooting" size="md">Troubleshooting (řešení problémů)</Heading>
<IconButton aria-label="Zkopírovat odkaz" variant="ghost" size="sm" icon={<FaLink />} onClick={() => copyDeepLink('troubleshooting')} />
</HStack>
<Heading size="sm" mb={2}>Emaily se neodesílají</Heading>
<List spacing={2} styleType="disc" pl={5} mb={4}>
<ListItem>Zkontrolujte <Link href="/admin/nastaveni">SMTP nastavení</Link> (host, port, uživatel, heslo, šifrování).</ListItem>
<ListItem>Pro port 465 použijte SSL, pro 587 STARTTLS. Využijte tlačítko Otestovat SMTP v průvodci či v newsletteru.</ListItem>
<ListItem>Mrkněte do logů serveru na případné chyby SMTP.</ListItem>
</List>
<Heading size="sm" mb={2}>Obrázky/Logo se nezobrazuje</Heading>
<List spacing={2} styleType="disc" pl={5} mb={4}>
<ListItem>Ověřte, že URL je správná a veřejně dostupná. V případě externích zdrojů lze využít proxy `/api/v1/proxy/image`.</ListItem>
<ListItem>Nahrajte logo do sekce Média a použijte relativní cestu (např. <Code>/uploads/2025/01/logo.png</Code>).</ListItem>
</List>
<Heading size="sm" mb={2}>FAČR data nejsou aktuální</Heading>
<List spacing={2} styleType="disc" pl={5} mb={4}>
<ListItem>Spusťte <Link href="/admin/prefetch">Prefetch</Link> a porovnejte čas posledního běhu.</ListItem>
<ListItem>Zkontrolujte internetové připojení serveru a limity volání.</ListItem>
</List>
<Heading size="sm" mb={2}>Přihlášení nefunguje</Heading>
<List spacing={2} styleType="disc" pl={5} mb={4}>
<ListItem>Ověřte email/heslo a zda účet roli <Code>admin</Code>.</ListItem>
<ListItem>Pokud jste zapomněli heslo, použijte Zapomenuté heslo a kód z emailu.</ListItem>
</List>
<Heading size="sm" mb={2}>Newsletter padá do spamu</Heading>
<List spacing={2} styleType="disc" pl={5}>
<ListItem>Nastavte správně DNS záznamy <Code>SPF</Code>, <Code>DKIM</Code> a pokud možno <Code>DMARC</Code>.</ListItem>
<ListItem>Ověřte, že From doména odpovídá doméně vašeho SMTP odesílatele.</ListItem>
</List>
<Divider my={6} />
<Heading size="md" mb={3}>API základ</Heading>
<Text mb={2}>Veřejná API základna: <Code>/api/v1</Code></Text>
<Text fontSize="sm" color="gray.600">(Rozšířená dokumentace API bude doplněna v budoucnu.)</Text>
<Box mt={2}><Link href="#top" color="blue.600"><HStack as="span" spacing={2}><FaArrowUp /><Text>Na začátek</Text></HStack></Link></Box>
</Box>
</Box>
</AdminLayout>
);
};
export default AdminDocsPage;
+228
View File
@@ -0,0 +1,228 @@
import React, { useEffect, useState } from 'react';
import AdminLayout from '../../layouts/AdminLayout';
import { Box, Heading, Button, SimpleGrid, FormControl, FormLabel, Input, HStack, VStack, Text, IconButton, useToast, Divider, Textarea, Switch, NumberInput, NumberInputField } from '@chakra-ui/react';
import { getClothingAdmin, createClothing, updateClothing, deleteClothing, ClothingItem } from '../../services/clothing';
import { FiPlus, FiTrash2, FiSave } from 'react-icons/fi';
const emptyItem: Partial<ClothingItem> = {
title: '',
description: '',
price: 0,
currency: 'Kč',
image_url: '',
url: '',
is_active: true,
display_order: 0
};
const AdminMerchPage: React.FC = () => {
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [items, setItems] = useState<ClothingItem[]>([]);
const toast = useToast();
const fetchItems = async () => {
try {
const data = await getClothingAdmin();
setItems(data);
} catch (e) {
toast({ status: 'error', title: 'Chyba', description: 'Nepodařilo se načíst oblečení.' });
} finally {
setLoading(false);
}
};
useEffect(() => {
fetchItems();
}, []);
const addItem = () => {
const newItem: Partial<ClothingItem> = { ...emptyItem };
setItems((prev) => [...prev, newItem as ClothingItem]);
};
const removeItem = async (idx: number) => {
const item = items[idx];
if (item.id) {
try {
await deleteClothing(item.id);
toast({ status: 'success', title: 'Smazáno', description: 'Položka byla smazána.' });
fetchItems();
} catch (e) {
toast({ status: 'error', title: 'Chyba', description: 'Nepodařilo se smazat položku.' });
}
} else {
setItems((prev) => prev.filter((_, i) => i !== idx));
}
};
const updateField = (idx: number, key: keyof ClothingItem, val: any) => {
setItems((prev) => prev.map((it, i) => i === idx ? { ...it, [key]: val } : it));
};
const saveItem = async (idx: number) => {
const item = items[idx];
if (!item.title || !item.image_url) {
toast({ status: 'warning', title: 'Upozornění', description: 'Vyplňte alespoň název a obrázek.' });
return;
}
setSaving(true);
try {
if (item.id) {
await updateClothing(item.id, item);
toast({ status: 'success', title: 'Uloženo', description: 'Položka byla aktualizována.' });
} else {
await createClothing(item);
toast({ status: 'success', title: 'Uloženo', description: 'Položka byla vytvořena.' });
fetchItems();
}
} catch (e) {
toast({ status: 'error', title: 'Chyba', description: 'Nepodařilo se uložit položku.' });
} finally {
setSaving(false);
}
};
return (
<AdminLayout>
<Box>
<Heading size="md" mb={2}>Oblečení a Fan Shop</Heading>
<Text fontSize="sm" color="gray.600" mb={2}>
Spravujte položky oblečení a merchandisingu. Na titulní stránce se zobrazí 5 nejnovějších položek, všechny položky jsou k dispozici na stránce /obleceni.
</Text>
<Box mb={4} p={3} bg="blue.50" borderRadius="md" borderLeft="4px solid" borderColor="blue.500">
<Text fontSize="sm" color="blue.800">
💡 <strong>Tip:</strong> Přidejte cenu pro lepší přehlednost. Na veřejné stránce se zobrazí pouze aktivní položky.
</Text>
</Box>
<HStack justify="space-between" mb={3}>
<Button leftIcon={<FiPlus />} onClick={addItem}>Přidat položku</Button>
</HStack>
<Divider my={3} />
{loading ? (
<Text>Načítání</Text>
) : (
<VStack align="stretch" spacing={4}>
{items.map((it, idx) => (
<Box key={idx} borderWidth="1px" borderRadius="md" p={4} bg="white">
<HStack justify="space-between" mb={3}>
<Heading size="sm">
{it.title || `Položka #${idx + 1}`}
{!it.is_active && <Text as="span" ml={2} fontSize="xs" color="gray.500">(neaktivní)</Text>}
</Heading>
<HStack>
<Button
size="sm"
colorScheme="blue"
leftIcon={<FiSave />}
onClick={() => saveItem(idx)}
isLoading={saving}
>
Uložit
</Button>
<IconButton
aria-label="Smazat"
icon={<FiTrash2 />}
onClick={() => removeItem(idx)}
variant="outline"
colorScheme="red"
size="sm"
/>
</HStack>
</HStack>
<SimpleGrid columns={{ base: 1, md: 2 }} spacing={3}>
<FormControl isRequired>
<FormLabel>Název</FormLabel>
<Input
value={it.title || ''}
onChange={(e) => updateField(idx, 'title', e.target.value)}
placeholder="např. Dres domácí 2024/25"
/>
</FormControl>
<FormControl isRequired>
<FormLabel>Obrázek (URL)</FormLabel>
<Input
value={it.image_url || ''}
onChange={(e) => updateField(idx, 'image_url', e.target.value)}
placeholder="https://example.com/img.jpg"
/>
</FormControl>
<FormControl>
<FormLabel>Cena</FormLabel>
<HStack>
<NumberInput
value={it.price || 0}
onChange={(_, val) => updateField(idx, 'price', val)}
min={0}
precision={2}
flex={1}
>
<NumberInputField placeholder="0.00" />
</NumberInput>
<Input
value={it.currency || 'Kč'}
onChange={(e) => updateField(idx, 'currency', e.target.value)}
placeholder="Kč"
width="80px"
/>
</HStack>
</FormControl>
<FormControl>
<FormLabel>Odkaz (eshop)</FormLabel>
<Input
value={it.url || ''}
onChange={(e) => updateField(idx, 'url', e.target.value)}
placeholder="https://eshop.example.com/produkt"
/>
</FormControl>
<FormControl>
<FormLabel>Pořadí zobrazení</FormLabel>
<NumberInput
value={it.display_order || 0}
onChange={(_, val) => updateField(idx, 'display_order', val)}
min={0}
>
<NumberInputField />
</NumberInput>
</FormControl>
<FormControl display="flex" alignItems="center">
<FormLabel mb={0}>Aktivní</FormLabel>
<Switch
isChecked={it.is_active !== false}
onChange={(e) => updateField(idx, 'is_active', e.target.checked)}
/>
</FormControl>
</SimpleGrid>
<FormControl mt={3}>
<FormLabel>Popis</FormLabel>
<Textarea
value={it.description || ''}
onChange={(e) => updateField(idx, 'description', e.target.value)}
placeholder="Volitelný popis položky"
rows={2}
/>
</FormControl>
</Box>
))}
{items.length === 0 && (
<Text color="gray.600">Zatím žádné položky. Použijte tlačítko Přidat položku".</Text>
)}
</VStack>
)}
</Box>
</AdminLayout>
);
};
export default AdminMerchPage;
@@ -0,0 +1,48 @@
import React, { useState } from 'react';
import AdminLayout from '../../layouts/AdminLayout';
import { Box, Heading, FormControl, FormLabel, Input, Button, VStack, useToast, Text } from '@chakra-ui/react';
import api from '../../services/api';
const AdminResetPasswordPage: React.FC = () => {
const [email, setEmail] = useState('');
const [isLoading, setIsLoading] = useState(false);
const [sent, setSent] = useState(false);
const toast = useToast();
const sendReset = async (e: React.FormEvent) => {
e.preventDefault();
setIsLoading(true);
try {
await api.post('/admin/users/send-reset', { email });
setSent(true);
toast({ status: 'success', title: 'Odesláno', description: 'Pokud účet existuje, byl odeslán e-mail pro reset hesla.' });
} catch (err: any) {
toast({ status: 'error', title: 'Chyba', description: err.response?.data?.error || 'Nepodařilo se odeslat e-mail.' });
} finally {
setIsLoading(false);
}
};
return (
<AdminLayout>
<Box maxW="lg">
<Heading size="md" mb={4}>Odeslat reset hesla</Heading>
<Text fontSize="sm" color="gray.600" mb={3}>
Tato akce odešle uživateli e-mail s odkazem pro nastavení nového hesla. Použije se speciální SMTP konfigurace určená pouze pro reset.
</Text>
<VStack as="form" onSubmit={sendReset} spacing={4} align="stretch">
<FormControl isRequired>
<FormLabel>Email uživatele</FormLabel>
<Input type="email" value={email} onChange={(e) => setEmail(e.target.value)} placeholder="uzivatel@example.com" />
</FormControl>
<Button colorScheme="blue" type="submit" isLoading={isLoading}>Odeslat e-mail</Button>
{sent && (
<Text color="green.600">Pokud adresa existuje, e-mail s odkazem byl odeslán.</Text>
)}
</VStack>
</Box>
</AdminLayout>
);
};
export default AdminResetPasswordPage;
@@ -0,0 +1,523 @@
import React, { useEffect, useMemo, useState } from 'react';
import AdminLayout from '../../layouts/AdminLayout';
import { Box, Heading, Button, SimpleGrid, FormControl, FormLabel, Input, HStack, VStack, Text, IconButton, useToast, Divider, Alert, AlertIcon, Badge, Tooltip, Checkbox, Image, Spinner, Link, Switch, ButtonGroup } from '@chakra-ui/react';
import { getAdminSettings, updateAdminSettings, AdminSettings } from '../../services/settings';
import { FiPlus, FiTrash2, FiSave } from 'react-icons/fi';
import { triggerPrefetch } from '../../services/admin/prefetch';
import { getCachedYouTube, YouTubeVideo } from '../../services/youtube';
export type AdminVideoItem = {
url: string;
title?: string;
length?: string;
uploaded_at?: string;
thumbnail_url?: string;
};
const emptyItem: AdminVideoItem = { url: '' };
const AdminVideosPage: React.FC = () => {
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [items, setItems] = useState<AdminVideoItem[]>([]);
const [videosSource, setVideosSource] = useState<'auto'|'manual'>('manual');
const [videosEnabled, setVideosEnabled] = useState<boolean>(true);
const toast = useToast();
// YouTube Scraper API integration state
const [channelInput, setChannelInput] = useState<string>('');
const [ytLoading, setYtLoading] = useState<boolean>(false);
const [ytError, setYtError] = useState<string>('');
const [ytVideos, setYtVideos] = useState<Array<{
video_id: string;
title: string;
length?: string;
thumbnail_url?: string;
views?: number;
views_text?: string;
published_text?: string;
published_date?: string;
}>>([]);
const [selectedIds, setSelectedIds] = useState<Record<string, boolean>>({});
// Auto source preview state (cached YouTube)
const [autoVideos, setAutoVideos] = useState<YouTubeVideo[]>([]);
const [autoLoading, setAutoLoading] = useState<boolean>(false);
const [autoError, setAutoError] = useState<string>('');
const [filter, setFilter] = useState<string>('');
// Derived flags
const hasChannel = useMemo(() => (channelInput || '').trim().length > 0, [channelInput]);
useEffect(() => {
let mounted = true;
(async () => {
try {
const s: AdminSettings = await getAdminSettings();
if (!mounted) return;
const vids = Array.isArray((s as any).videos_items) ? (s as any).videos_items as AdminVideoItem[] : [];
const legacy = Array.isArray((s as any).videos) ? ((s as any).videos as string[]).map((url) => ({ url })) : [];
setItems(vids.length ? vids : legacy);
const src = (s as any).videos_source;
if (src === 'auto' || src === 'manual') setVideosSource(src);
// Default enable if not explicitly set and there are any videos configured
const explicit = (s as any).videos_module_enabled;
const hasAny = (vids.length + legacy.length) > 0;
setVideosEnabled(typeof explicit === 'boolean' ? Boolean(explicit) : hasAny);
// Prefill channel handle from settings if available (social/youtube_url)
const ytUrl = (s as any).youtube_url || (s as any).social_youtube || '';
if (ytUrl) setChannelInput(ytUrl);
} catch (e) {
// ignore
} finally {
if (mounted) setLoading(false);
}
})();
return () => { mounted = false; };
}, []);
// Load cached YouTube videos for preview when auto source is active
useEffect(() => {
let mounted = true;
const run = async () => {
if (loading) return;
if (videosSource !== 'auto') return;
setAutoError('');
setAutoLoading(true);
try {
const payload = await getCachedYouTube();
if (!mounted) return;
setAutoVideos(payload?.videos || []);
} catch (err) {
if (!mounted) return;
setAutoError('Nepodařilo se načíst cache videí. Zkuste Aktualizovat.');
} finally {
if (mounted) setAutoLoading(false);
}
};
run();
return () => { mounted = false; };
}, [loading, videosSource]);
// Auto-disable videos module if there is neither channel nor manual items configured
useEffect(() => {
if (loading) return;
if (!hasChannel && items.length === 0 && videosEnabled) {
setVideosEnabled(false);
}
}, [loading, hasChannel, items.length, videosEnabled]);
// Auto-trigger backend prefetch of YouTube cache at most once per ~24h
useEffect(() => {
if (loading) return;
if (videosSource !== 'auto') return;
const channel = (channelInput || '').trim();
if (!channel) return;
const KEY = 'youtube_autoload_last';
let last = 0;
try { last = Number(localStorage.getItem(KEY) || '0'); } catch {}
const DAY_MS = 24 * 60 * 60 * 1000;
const due = !last || (Date.now() - last) > (23 * 60 * 60 * 1000); // ~23h to allow slight drift
if (!due) return;
(async () => {
try {
// Ask backend to refresh cached files; it will update youtube_channel.json opportunistically
await triggerPrefetch();
try { localStorage.setItem(KEY, String(Date.now())); } catch {}
toast({ status: 'info', title: 'Aktualizace videí', description: 'Na pozadí se aktualizuje cache videí z YouTube.', duration: 3000 });
} catch {
// silent
}
})();
}, [loading, videosSource, channelInput, toast]);
const refreshAuto = async () => {
setAutoError('');
setAutoLoading(true);
try {
await triggerPrefetch();
const payload = await getCachedYouTube();
setAutoVideos(payload?.videos || []);
toast({ status: 'success', title: 'Aktualizováno', description: 'Cache videí byla obnovena.', duration: 3000 });
} catch (e) {
setAutoError('Aktualizace cache selhala.');
} finally {
setAutoLoading(false);
}
};
const fetchChannelVideos = async () => {
const channel = channelInput?.trim();
if (!channel) {
toast({ status: 'warning', title: 'Zadejte kanál', description: 'Zadejte YouTube handle nebo URL kanálu.' });
return;
}
setYtError('');
setYtLoading(true);
setYtVideos([]);
setSelectedIds({});
try {
const url = `https://youtube.tdvorak.dev/channel_videos?channel=${encodeURIComponent(channel)}`;
const res = await fetch(url, { method: 'GET' });
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data = await res.json();
const vids = Array.isArray(data?.videos) ? data.videos : [];
setYtVideos(vids);
if (vids.length === 0) {
setYtError('Na kanálu nebyla nalezena žádná videa.');
}
} catch (err: any) {
console.error(err);
setYtError('Nepodařilo se načíst videa z API. Zkontrolujte dostupnost služby na https://youtube.tdvorak.dev/ a CORS.');
} finally {
setYtLoading(false);
}
};
const toggleSelect = (id: string) => {
setSelectedIds((prev) => ({ ...prev, [id]: !prev[id] }));
};
const importSelected = () => {
const selected = ytVideos.filter((v) => selectedIds[v.video_id]);
if (selected.length === 0) {
toast({ status: 'info', title: 'Nic k importu', description: 'Vyberte alespoň jedno video.' });
return;
}
const newItems: AdminVideoItem[] = selected.map((v) => ({
url: `https://www.youtube.com/watch?v=${v.video_id}`,
title: v.title,
length: v.length,
uploaded_at: (v.published_date || '').slice(0,10),
thumbnail_url: v.thumbnail_url,
}));
// Avoid duplicates by URL
setItems((prev) => {
const urls = new Set(prev.map((p) => p.url));
const merged = [...prev];
for (const it of newItems) {
if (!urls.has(it.url)) {
merged.push(it);
urls.add(it.url);
}
}
return merged;
});
toast({ status: 'success', title: 'Videa přidána', description: `${selected.length} videí bylo přidáno do seznamu.` });
};
const addItem = () => setItems((prev) => [...prev, { ...emptyItem }]);
const removeItem = (idx: number) => setItems((prev) => prev.filter((_, i) => i !== idx));
const updateField = (idx: number, key: keyof AdminVideoItem, val: string) => {
setItems((prev) => prev.map((it, i) => i === idx ? { ...it, [key]: val } : it));
};
const save = async () => {
setSaving(true);
try {
const clean = items.filter((it) => it.url && it.url.trim().length > 0);
await updateAdminSettings({ videos_items: clean, videos_module_enabled: videosEnabled });
toast({ status: 'success', title: 'Uloženo', description: 'Seznam videí byl uložen.' });
} catch (e) {
toast({ status: 'error', title: 'Chyba', description: 'Nepodařilo se uložit nastavení.' });
} finally {
setSaving(false);
}
};
const setDateQuick = (idx: number, daysAgo: number) => {
const d = new Date();
d.setDate(d.getDate() - daysAgo);
const iso = d.toISOString().slice(0,10);
setItems((prev) => prev.map((it, i) => i === idx ? { ...it, uploaded_at: iso } : it));
};
// Helper: derive YouTube thumbnail safely from a URL (supports youtube.com and youtu.be)
const getThumbFromUrl = (raw: string): string | undefined => {
try {
const u = raw.trim();
if (!u) return undefined;
if (u.includes('youtu.be/')) {
const id = u.split('youtu.be/')[1]?.split(/[?&#]/)[0];
return id ? `https://i.ytimg.com/vi/${id}/hqdefault.jpg` : undefined;
}
if (u.includes('youtube.com')) {
// Try URL API first
try {
const url = new URL(u);
const id = url.searchParams.get('v') || '';
if (id) return `https://i.ytimg.com/vi/${id}/hqdefault.jpg`;
} catch {}
// Fallback regex
const m = u.match(/[?&]v=([^&#]+)/);
const id = m?.[1];
return id ? `https://i.ytimg.com/vi/${id}/hqdefault.jpg` : undefined;
}
} catch {}
return undefined;
};
return (
<AdminLayout>
<Box>
<Heading size="md" mb={2}>Videa (pro titulní stránku)</Heading>
<Text fontSize="sm" color="gray.600" mb={2}>Přidejte 5 videí (doporučeno). První se zobrazí jako hlavní, další 4 v mřížce. Podporováno YouTube/Vimeo URL.</Text>
{/* Source toggle */}
<HStack justify="space-between" mb={3} flexWrap="wrap">
<HStack>
<Text fontWeight="semibold">Zdroj videí:</Text>
<ButtonGroup size="sm" isAttached>
<Button
variant={videosSource === 'auto' ? 'solid' : 'outline'}
onClick={async () => {
if (videosSource === 'auto') return;
setVideosSource('auto');
try {
await updateAdminSettings({ videos_source: 'auto' });
toast({ status: 'success', title: 'Zdroj nastaven', description: 'Videa se načítají automaticky z YouTube.', duration: 2500 });
} catch {
toast({ status: 'error', title: 'Chyba', description: 'Nelze uložit zdroj videí.', duration: 3000 });
}
}}
>Automaticky</Button>
<Button
variant={videosSource === 'manual' ? 'solid' : 'outline'}
onClick={async () => {
if (videosSource === 'manual') return;
setVideosSource('manual');
try {
await updateAdminSettings({ videos_source: 'manual' });
toast({ status: 'success', title: 'Zdroj nastaven', description: 'Videa spravujete ručně.', duration: 2500 });
} catch {
toast({ status: 'error', title: 'Chyba', description: 'Nelze uložit zdroj videí.', duration: 3000 });
}
}}
>Ručně</Button>
</ButtonGroup>
</HStack>
<FormControl display="flex" alignItems="center" w="auto">
<FormLabel mb={0}>Zobrazit sekci Videa na titulní stránce</FormLabel>
<Switch
isChecked={videosEnabled}
isDisabled={loading || saving}
onChange={async (e) => {
const next = e.target.checked;
// Require either channel or at least one manual video to enable
if (next && !hasChannel && items.length === 0) {
setVideosEnabled(false);
toast({ status: 'warning', title: 'Doplňte kanál nebo videa', description: 'Pro zobrazení sekce vyplňte YouTube kanál nebo přidejte alespoň jedno video.', duration: 4000 });
// Try to focus channel input
setTimeout(() => {
const el = document.getElementById('admin-videos-channel-input') as HTMLInputElement | null;
if (el) el.focus();
}, 0);
return;
}
setVideosEnabled(next);
try {
await updateAdminSettings({ videos_module_enabled: next });
} catch {
toast({ status: 'error', title: 'Uložení selhalo', description: 'Nepodařilo se uložit změnu zobrazení sekce.', duration: 3000 });
}
}}
/>
</FormControl>
</HStack>
{!hasChannel && items.length === 0 && (
<Text fontSize="sm" color="orange.600" mb={2}>Pro aktivaci sekce vyplňte YouTube kanál nebo přidejte video.</Text>
)}
{videosSource === 'auto' && (
<Alert status="info" mb={3} borderRadius="md">
<AlertIcon />
Automatický režim je zapnutý. Videa se načítají z YouTube kanálu z Nastavení Sociální sítě (YouTube URL) a správy Videa (YouTube modul). Manuální seznam je v tomto režimu skryt.
</Alert>
)}
{videosSource !== 'auto' && (
<Box borderWidth="1px" borderRadius="md" p={3} mb={4}>
<Heading size="sm" mb={2}>Import z YouTube kanálu</Heading>
<Text fontSize="sm" color="gray.600" mb={3}>
Použijte Scraper API. Zadejte handle (např. <code>@FotbalKunovice</code>) nebo URL kanálu a načtěte videa z karty Videa.
Služba: <Link href="https://youtube.tdvorak.dev/" isExternal color="blue.500">https://youtube.tdvorak.dev/</Link>
</Text>
<HStack align="start" spacing={3}>
<FormControl maxW={{ base: '100%', md: '400px' }}>
<FormLabel>Kanál (handle nebo URL)</FormLabel>
<Input id="admin-videos-channel-input" placeholder="@FCBizoniUH nebo https://www.youtube.com/@FCBizoniUH/videos" value={channelInput} onChange={(e) => setChannelInput(e.target.value)} />
</FormControl>
<Button onClick={fetchChannelVideos} isLoading={ytLoading} variant="outline">Načíst videa</Button>
<Button colorScheme="green" onClick={importSelected} isDisabled={ytVideos.length === 0}>Přidat vybraná</Button>
</HStack>
{ytError && (
<Alert status="error" mt={3} borderRadius="md">
<AlertIcon />
{ytError}
</Alert>
)}
{ytLoading && (
<HStack mt={3} color="gray.600"><Spinner size="sm" /><Text>Načítám videa</Text></HStack>
)}
{!ytLoading && ytVideos.length > 0 && (
<SimpleGrid columns={{ base: 1, sm: 2, md: 3, lg: 4 }} spacing={3} mt={3}>
{ytVideos.map((v) => (
<Box key={v.video_id} borderWidth="1px" borderRadius="md" p={2}>
<VStack align="stretch" spacing={2}>
<Image src={v.thumbnail_url} alt={v.title} borderRadius="md" />
<Checkbox isChecked={!!selectedIds[v.video_id]} onChange={() => toggleSelect(v.video_id)}>
Vybrat
</Checkbox>
<Box>
<Text fontWeight="semibold" noOfLines={2}>{v.title}</Text>
<HStack spacing={2} color="gray.600" fontSize="sm">
{v.length && <Badge>{v.length}</Badge>}
{v.published_text && <Text>{v.published_text}</Text>}
</HStack>
</Box>
</VStack>
</Box>
))}
</SimpleGrid>
)}
</Box>
)}
{/* Always-visible preview of effective videos */}
<Box borderWidth="1px" borderRadius="md" p={3} mb={4}>
<HStack justify="space-between" align="center" mb={2} flexWrap="wrap">
<Heading size="sm">Náhled: všechna videa (aktivní zdroj)</Heading>
{videosSource === 'auto' && (
<HStack spacing={2}>
<Input size="sm" placeholder="Filtrovat podle názvu" value={filter} onChange={(e) => setFilter(e.target.value)} />
<Button size="sm" onClick={refreshAuto} isLoading={autoLoading} variant="outline">Aktualizovat cache</Button>
</HStack>
)}
</HStack>
{videosSource === 'auto' ? (
<>
{autoError && (
<Alert status="error" mb={2} borderRadius="md"><AlertIcon />{autoError}</Alert>
)}
{autoLoading ? (
<HStack color="gray.600"><Spinner size="sm" /><Text>Načítám videa</Text></HStack>
) : (
<>
<Text fontSize="sm" color="gray.600" mb={2}>Počet videí: {autoVideos.filter(v => v.title.toLowerCase().includes(filter.toLowerCase())).length}</Text>
<SimpleGrid columns={{ base: 1, sm: 2, md: 3, lg: 4 }} spacing={3}>
{autoVideos
.filter(v => v.title.toLowerCase().includes(filter.toLowerCase()))
.map((v) => (
<Box key={v.video_id} borderWidth="1px" borderRadius="md" p={2}>
<VStack align="stretch" spacing={2}>
<Image src={v.thumbnail_url} alt={v.title} borderRadius="md" />
<Box>
<Text fontWeight="semibold" noOfLines={2}>{v.title}</Text>
<HStack spacing={2} color="gray.600" fontSize="sm">
{v.published_date && <Badge>{new Date(v.published_date).toLocaleDateString('cs-CZ')}</Badge>}
</HStack>
</Box>
</VStack>
</Box>
))}
</SimpleGrid>
{autoVideos.length === 0 && (
<Text color="gray.600">Žádná videa v cache. Zkontrolujte YouTube URL v nastavení a použijte Aktualizovat cache.</Text>
)}
</>
)}
</>
) : (
<>
<Text fontSize="sm" color="gray.600" mb={2}>Počet videí: {items.length}</Text>
<SimpleGrid columns={{ base: 1, sm: 2, md: 3, lg: 4 }} spacing={3}>
{items.map((it, idx) => (
<Box key={`${idx}-${it.url}`} borderWidth="1px" borderRadius="md" p={2}>
<VStack align="stretch" spacing={2}>
<Image src={it.thumbnail_url || getThumbFromUrl(it.url)} alt={it.title || `Video ${idx+1}`} borderRadius="md" />
<Box>
<Text fontWeight="semibold" noOfLines={2}>{it.title || `Video ${idx+1}`}</Text>
<HStack spacing={2} color="gray.600" fontSize="sm">
{it.uploaded_at && <Badge>{(new Date(it.uploaded_at)).toLocaleDateString('cs-CZ')}</Badge>}
</HStack>
</Box>
</VStack>
</Box>
))}
</SimpleGrid>
{items.length === 0 && (
<Text color="gray.600">Zatím žádná videa.</Text>
)}
</>
)}
</Box>
<HStack justify="space-between" mb={3}>
<Button leftIcon={<FiPlus />} onClick={addItem}>Přidat video</Button>
<Button colorScheme="blue" leftIcon={<FiSave />} onClick={save} isLoading={saving}>Uložit</Button>
</HStack>
<Divider my={3} />
{loading ? (
<Text>Načítání</Text>
) : videosSource === 'auto' ? (
<Text color="gray.600">Automatický zdroj videí je aktivní. Pro ruční správu přepněte zdroj na Ručně.</Text>
) : (
<VStack align="stretch" spacing={4}>
{items.map((it, idx) => (
<Box key={idx} borderWidth="1px" borderRadius="md" p={3}>
<HStack justify="space-between">
<Heading size="sm">Video #{idx + 1}</Heading>
</HStack>
<SimpleGrid columns={{ base: 1, md: 2 }} spacing={3} mt={3}>
<FormControl>
<FormLabel>URL videa</FormLabel>
<Input value={it.url} onChange={(e) => updateField(idx, 'url', e.target.value)} placeholder="https://www.youtube.com/watch?v=..." />
</FormControl>
<FormControl>
<FormLabel>Thumbnail (volitelné)</FormLabel>
<Input value={it.thumbnail_url || ''} onChange={(e) => updateField(idx, 'thumbnail_url', e.target.value)} placeholder="https://example.com/thumb.jpg" />
</FormControl>
<FormControl>
<FormLabel>Název (volitelné)</FormLabel>
<Input value={it.title || ''} onChange={(e) => updateField(idx, 'title', e.target.value)} placeholder="Titulek videa" />
</FormControl>
<FormControl>
<FormLabel>Délka (volitelné)</FormLabel>
<Input value={it.length || ''} onChange={(e) => updateField(idx, 'length', e.target.value)} placeholder="3:45" />
</FormControl>
<FormControl>
<FormLabel>Datum nahrání (volitelné)</FormLabel>
<HStack>
<Input type="date" value={(it.uploaded_at || '').slice(0,10)} onChange={(e) => updateField(idx, 'uploaded_at', e.target.value)} />
<Tooltip label="Dnes">
<Button size="sm" variant="outline" onClick={() => setDateQuick(idx, 0)}>Dnes</Button>
</Tooltip>
<Tooltip label="Včera">
<Button size="sm" variant="outline" onClick={() => setDateQuick(idx, 1)}>Včera</Button>
</Tooltip>
<Tooltip label="Před týdnem">
<Button size="sm" variant="outline" onClick={() => setDateQuick(idx, 7)}>7 dní</Button>
</Tooltip>
<Tooltip label="Vymazat datum">
<Button size="sm" variant="ghost" onClick={() => updateField(idx, 'uploaded_at', '')}>Vymazat</Button>
</Tooltip>
</HStack>
</FormControl>
</SimpleGrid>
<HStack justify="flex-end" mt={2}>
<IconButton aria-label="Smazat" icon={<FiTrash2 />} onClick={() => removeItem(idx)} variant="outline" colorScheme="red" />
</HStack>
</Box>
))}
{items.length === 0 && (
<Text color="gray.600">Zatím žádná videa. Použijte tlačítko Přidat video.</Text>
)}
</VStack>
)}
</Box>
</AdminLayout>
);
};
export default AdminVideosPage;
@@ -0,0 +1,843 @@
import React, { useState, useEffect } from 'react';
import {
Box,
Container,
Heading,
SimpleGrid,
Stat,
StatLabel,
StatNumber,
StatHelpText,
Card,
CardHeader,
CardBody,
Select,
Tabs,
TabList,
TabPanels,
Tab,
TabPanel,
Table,
Thead,
Tbody,
Tr,
Th,
Td,
Spinner,
Text,
useColorModeValue,
VStack,
HStack,
Badge,
Divider,
Modal,
ModalOverlay,
ModalContent,
ModalHeader,
ModalBody,
ModalCloseButton,
useDisclosure,
Button,
Flex,
Progress,
Tooltip,
Icon,
} from '@chakra-ui/react';
import AdminLayout from '../../layouts/AdminLayout';
import api from '../../services/api';
import { Bar } from 'react-chartjs-2';
import {
Chart as ChartJS,
CategoryScale,
LinearScale,
BarElement,
Title,
Tooltip as ChartTooltip,
Legend,
} from 'chart.js';
import {
FiEye,
FiUsers,
FiActivity,
FiPercent,
FiClock,
FiFile,
FiLink,
FiMonitor,
FiCpu,
FiGlobe,
FiSmartphone,
FiZap,
FiTrendingUp,
FiCalendar,
FiSearch
} from 'react-icons/fi';
// Register ChartJS components
ChartJS.register(
CategoryScale,
LinearScale,
BarElement,
Title,
ChartTooltip,
Legend
);
interface Stats {
pageviews?: { value: number };
visitors?: { value: number };
visits?: { value: number };
bounces?: { value: number };
totaltime?: { value: number };
}
interface MetricItem {
x: string;
y: number;
}
interface PageviewsData {
date: string;
value: number;
}
// Event translation and description mapping
const getEventTranslation = (eventName: string): { name: string; source: string; description: string } => {
const eventMap: Record<string, { name: string; source: string; description: string }> = {
'Contact Form Submit': {
name: 'Odeslání kontaktního formuláře',
source: 'Kontaktní stránka',
description: 'Uživatel odeslal kontaktní formulář na stránce Kontakt'
},
'Contact Form': {
name: 'Zobrazení kontaktního formuláře',
source: 'Kontaktní stránka',
description: 'Uživatel zobrazil kontaktní formulář'
},
'Form Submit': {
name: 'Odeslání formuláře',
source: 'Různé stránky',
description: 'Obecné odeslání formuláře na webu'
},
'Newsletter Subscribe': {
name: 'Odběr newsletteru',
source: 'Newsletter formulář',
description: 'Uživatel se přihlásil k odběru newsletteru'
},
'Newsletter Submit': {
name: 'Potvrzení newsletteru',
source: 'Newsletter formulář',
description: 'Uživatel potvrdil přihlášení k newsletteru'
},
'Newsletter Unsubscribe': {
name: 'Odhlášení z newsletteru',
source: 'Nastavení newsletteru',
description: 'Uživatel se odhlásil z odběru newsletteru'
},
'Newsletter Preferences Saved': {
name: 'Uložení předvoleb newsletteru',
source: 'Nastavení newsletteru',
description: 'Uživatel uložil své předvolby pro newsletter'
},
'Unsubscribe': {
name: 'Odhlášení z odběru',
source: 'Nastavení',
description: 'Uživatel se odhlásil z odběru'
},
'Save Preferences': {
name: 'Uložení předvoleb',
source: 'Nastavení',
description: 'Uživatel uložil své předvolby (souhlas s cookies apod.)'
},
'Refresh Preferences': {
name: 'Obnovení předvoleb',
source: 'Nastavení',
description: 'Uživatel obnovil nebo změnil své předvolby'
},
'Article View': {
name: 'Zobrazení článku',
source: 'Blog',
description: 'Uživatel si zobrazil článek na blogu'
},
'Match View': {
name: 'Zobrazení zápasu',
source: 'Stránka zápasů',
description: 'Uživatel si zobrazil detail zápasu'
},
'Gallery View': {
name: 'Zobrazení galerie',
source: 'Galerie',
description: 'Uživatel si otevřel galerii fotografií'
},
'Video Play': {
name: 'Přehrání videa',
source: 'Video sekce',
description: 'Uživatel spustil přehrávání videa'
},
'Social Share': {
name: 'Sdílení na sociálních sítích',
source: 'Sdílecí tlačítka',
description: 'Uživatel sdílel obsah na sociální síť'
},
'Download': {
name: 'Stažení souboru',
source: 'Různé stránky',
description: 'Uživatel stáhl soubor'
},
'External Link Click': {
name: 'Kliknutí na externí odkaz',
source: 'Různé stránky',
description: 'Uživatel klikl na odkaz vedoucí mimo web'
}
};
return eventMap[eventName] || {
name: eventName,
source: 'Neznámý zdroj',
description: `Událost: ${eventName}`
};
};
const AnalyticsAdminPage: React.FC = () => {
const [stats, setStats] = useState<Stats | null>(null);
const [pageMetrics, setPageMetrics] = useState<MetricItem[]>([]);
const [referrerMetrics, setReferrerMetrics] = useState<MetricItem[]>([]);
const [browserMetrics, setBrowserMetrics] = useState<MetricItem[]>([]);
const [osMetrics, setOsMetrics] = useState<MetricItem[]>([]);
const [countryMetrics, setCountryMetrics] = useState<MetricItem[]>([]);
const [deviceMetrics, setDeviceMetrics] = useState<MetricItem[]>([]);
const [eventMetrics, setEventMetrics] = useState<MetricItem[]>([]);
const [queryMetrics, setQueryMetrics] = useState<MetricItem[]>([]);
const [pageviewsData, setPageviewsData] = useState<PageviewsData[]>([]);
const [loading, setLoading] = useState(true);
const [timeRange, setTimeRange] = useState('0'); // Default to "today"
const [hasData, setHasData] = useState(false);
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const [selectedCountry, setSelectedCountry] = useState<{
code: string;
name: string;
value: number;
} | null>(null);
const [countryDetails, setCountryDetails] = useState<any>(null);
const [loadingCountryDetails, setLoadingCountryDetails] = useState(false);
const { isOpen, onOpen, onClose } = useDisclosure();
const bgColor = useColorModeValue('white', 'gray.800');
const borderColor = useColorModeValue('gray.200', 'gray.700');
const fetchAnalytics = async (days: string) => {
setLoading(true);
setErrorMessage(null);
try {
const daysNum = parseInt(days);
// Fetch stats with calculated time range
const statsResponse = await api.get(`/admin/umami/stats?days=${days}`);
setStats(statsResponse.data);
// Check if we have any data
const hasAnyStats = statsResponse.data && (
(statsResponse.data.pageviews?.value && statsResponse.data.pageviews.value > 0) ||
(statsResponse.data.visitors?.value && statsResponse.data.visitors.value > 0)
);
// Fetch metrics
const [pages, browsers, os, countries, devices, events, queries, pageviews] = await Promise.all([
api.get(`/admin/umami/metrics/url?days=${days}`),
api.get(`/admin/umami/metrics/browser?days=${days}`),
api.get(`/admin/umami/metrics/os?days=${days}`),
api.get(`/admin/umami/metrics/country?days=${days}`),
api.get(`/admin/umami/metrics/device?days=${days}`),
api.get(`/admin/umami/metrics/event?days=${days}`),
api.get(`/admin/umami/metrics/query?days=${days}`).catch(() => ({ data: [] })),
api.get(`/admin/umami/pageviews?days=${days}`),
]);
setPageMetrics(pages.data || []);
setReferrerMetrics([]); // Removed - no longer fetching referrers
setBrowserMetrics(browsers.data || []);
setOsMetrics(os.data || []);
setCountryMetrics(countries.data || []);
setDeviceMetrics(devices.data || []);
setEventMetrics(events.data || []);
setQueryMetrics(queries.data || []);
// Process real pageviews data from Umami
const pageviewsDataArray: PageviewsData[] = [];
const pageviewsResponse = pageviews.data || [];
if (pageviewsResponse.length > 0) {
for (const item of pageviewsResponse) {
let label = '';
const timestamp = item.t || item.time || item.date;
if (daysNum === 0 || daysNum === 1) {
// Hourly data - format as "HH:00"
const date = new Date(timestamp);
label = `${date.getHours().toString().padStart(2, '0')}:00`;
} else {
// Daily data - format as "d. M."
const date = new Date(timestamp);
label = `${date.getDate()}. ${date.getMonth() + 1}.`;
}
pageviewsDataArray.push({
date: label,
value: item.y || item.value || 0
});
}
}
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.');
}
} catch (error) {
console.error('Failed to fetch analytics:', error);
setErrorMessage('Chyba při načítání analytiky. Zkontrolujte připojení k Umami.');
setHasData(false);
} finally {
setLoading(false);
}
};
useEffect(() => {
fetchAnalytics(timeRange);
}, [timeRange]);
const handleCountryClick = async (countryCode: string, countryName: string, value: number) => {
setSelectedCountry({ code: countryCode, name: countryName, value });
setLoadingCountryDetails(true);
onOpen();
try {
// Fetch detailed analytics for the selected country
const [pages, browsers, os, devices, events] = await Promise.all([
api.get(`/admin/umami/metrics/url?days=${timeRange}&country=${countryCode}`),
api.get(`/admin/umami/metrics/browser?days=${timeRange}&country=${countryCode}`),
api.get(`/admin/umami/metrics/os?days=${timeRange}&country=${countryCode}`),
api.get(`/admin/umami/metrics/device?days=${timeRange}&country=${countryCode}`),
api.get(`/admin/umami/metrics/event?days=${timeRange}&country=${countryCode}`),
]);
setCountryDetails({
pages: pages.data || [],
browsers: browsers.data || [],
os: os.data || [],
devices: devices.data || [],
events: events.data || [],
});
} catch (error) {
console.error('Failed to fetch country details:', error);
setCountryDetails(null);
} finally {
setLoadingCountryDetails(false);
}
};
const handleModalClose = () => {
onClose();
setSelectedCountry(null);
setCountryDetails(null);
};
const formatNumber = (num: number | undefined) => {
if (!num) return '0';
return new Intl.NumberFormat('cs-CZ').format(num);
};
const formatDuration = (ms: number | undefined) => {
if (!ms || ms === 0) return '0s';
// Handle both milliseconds and seconds (Umami might return either)
let totalSeconds = ms > 10000 ? Math.floor(ms / 1000) : Math.floor(ms);
const hours = Math.floor(totalSeconds / 3600);
const minutes = Math.floor((totalSeconds % 3600) / 60);
const seconds = totalSeconds % 60;
if (hours > 0) {
return `${hours}h ${minutes}m ${seconds}s`;
} else if (minutes > 0) {
return `${minutes}m ${seconds}s`;
}
return `${seconds}s`;
};
const renderMetricTable = (metrics: MetricItem[], title: string, isEventMetrics: boolean = false) => (
<Card bg={bgColor} borderColor={borderColor}>
<CardHeader>
<Heading size="sm">{title}</Heading>
</CardHeader>
<CardBody>
<Table variant="simple" size="sm">
<Thead>
<Tr>
<Th>Název</Th>
<Th isNumeric>Návštěvy</Th>
</Tr>
</Thead>
<Tbody>
{metrics.slice(0, 10).map((item, index) => {
const eventInfo = isEventMetrics ? getEventTranslation(item.x) : null;
return (
<Tr key={index}>
<Td>
{isEventMetrics && eventInfo ? (
<Tooltip label={eventInfo.description} placement="top" hasArrow>
<Box>
<Text noOfLines={1} maxW="300px" fontWeight="medium">
{eventInfo.name}
</Text>
<Text fontSize="xs" color="gray.500">
{eventInfo.source}
</Text>
</Box>
</Tooltip>
) : (
<Text noOfLines={1} maxW="300px">
{item.x || '(prázdné)'}
</Text>
)}
</Td>
<Td isNumeric>
<Badge colorScheme="blue">{formatNumber(item.y)}</Badge>
</Td>
</Tr>
);
})}
{metrics.length === 0 && (
<Tr>
<Td colSpan={2} textAlign="center">
<Text color="gray.500">Žádná data</Text>
</Td>
</Tr>
)}
</Tbody>
</Table>
</CardBody>
</Card>
);
if (loading && !stats) {
return (
<AdminLayout>
<Container maxW="container.xl" py={8}>
<VStack spacing={4}>
<Spinner size="xl" />
<Text>Načítání analytiky...</Text>
</VStack>
</Container>
</AdminLayout>
);
}
return (
<AdminLayout>
<Container maxW="container.xl" py={8}>
<VStack spacing={6} align="stretch">
{/* Header */}
<HStack justify="space-between" align="center">
<HStack spacing={3}>
<Icon as={FiCalendar} color="blue.500" boxSize={6} />
<Heading size="lg">Analytika webu</Heading>
</HStack>
<HStack>
<Icon as={FiCalendar} color="gray.500" boxSize={4} />
<Select
value={timeRange}
onChange={(e) => setTimeRange(e.target.value)}
maxW="200px"
>
<option value="0">Dnes (dnešní den)</option>
<option value="1">Včera</option>
<option value="7">Posledních 7 dní</option>
<option value="30">Posledních 30 dní</option>
<option value="90">Posledních 90 dní</option>
<option value="365">Poslední rok</option>
</Select>
</HStack>
</HStack>
{/* Stats Overview */}
<SimpleGrid columns={{ base: 1, md: 2, lg: 5 }} spacing={4}>
<Card bg={bgColor} borderColor={borderColor}>
<CardBody>
<Stat>
<HStack spacing={2} mb={2}>
<Icon as={FiEye} color="blue.500" boxSize={5} />
<StatLabel>Zobrazení stránek</StatLabel>
</HStack>
<StatNumber>{formatNumber(stats?.pageviews?.value)}</StatNumber>
<StatHelpText>Celkový počet zobrazení</StatHelpText>
</Stat>
</CardBody>
</Card>
<Card bg={bgColor} borderColor={borderColor}>
<CardBody>
<Stat>
<HStack spacing={2} mb={2}>
<Icon as={FiUsers} color="green.500" boxSize={5} />
<StatLabel>Návštěvníci</StatLabel>
</HStack>
<StatNumber>{formatNumber(stats?.visitors?.value)}</StatNumber>
<StatHelpText>Unikátní návštěvníci</StatHelpText>
</Stat>
</CardBody>
</Card>
<Card bg={bgColor} borderColor={borderColor}>
<CardBody>
<Stat>
<HStack spacing={2} mb={2}>
<Icon as={FiActivity} color="purple.500" boxSize={5} />
<StatLabel>Návštěvy</StatLabel>
</HStack>
<StatNumber>{formatNumber(stats?.visits?.value)}</StatNumber>
<StatHelpText>Celkový počet relací</StatHelpText>
</Stat>
</CardBody>
</Card>
<Card bg={bgColor} borderColor={borderColor}>
<CardBody>
<Stat>
<HStack spacing={2} mb={2}>
<Icon as={FiPercent} color="orange.500" boxSize={5} />
<StatLabel>Míra opuštění</StatLabel>
</HStack>
<StatNumber>
{stats?.bounces?.value && stats?.visits?.value
? `${Math.round((stats.bounces.value / stats.visits.value) * 100)}%`
: '0%'}
</StatNumber>
<StatHelpText>Odchody po 1 stránce</StatHelpText>
</Stat>
</CardBody>
</Card>
<Card bg={bgColor} borderColor={borderColor}>
<CardBody>
<Stat>
<HStack spacing={2} mb={2}>
<Icon as={FiClock} color="teal.500" boxSize={5} />
<StatLabel>Průměrný čas</StatLabel>
</HStack>
<StatNumber>
{formatDuration(
stats?.totaltime?.value && stats?.visits?.value
? stats.totaltime.value / stats.visits.value
: 0
)}
</StatNumber>
<StatHelpText>Průměrná délka návštěvy</StatHelpText>
</Stat>
</CardBody>
</Card>
</SimpleGrid>
{/* Error Message */}
{errorMessage && (
<Card bg="orange.50" borderColor="orange.300" borderWidth={2}>
<CardBody>
<HStack spacing={3} align="start">
<Icon as={FiZap} color="orange.500" boxSize={6} mt={1} />
<VStack align="start" spacing={2} flex={1}>
<Text fontWeight="bold" color="orange.800" fontSize="lg">Analytika není k dispozici</Text>
<Text fontSize="sm" color="orange.700">{errorMessage}</Text>
<Divider borderColor="orange.200" />
<Text fontSize="sm" color="orange.800" fontWeight="semibold">Možné příčiny:</Text>
<VStack align="start" spacing={1} pl={4}>
<Text fontSize="xs" color="orange.700"> Umami není spuštěno nebo není dostupné</Text>
<Text fontSize="xs" color="orange.700"> V Umami instanci neexistuje žádný web</Text>
<Text fontSize="xs" color="orange.700"> Nebyly ještě zaznamenány žádné návštěvy</Text>
<Text fontSize="xs" color="orange.700"> Chybné přihlašovací údaje v .env souboru</Text>
</VStack>
<Divider borderColor="orange.200" />
<Text fontSize="sm" color="orange.800" fontWeight="semibold">Řešení:</Text>
<VStack align="start" spacing={1} pl={4}>
<Text fontSize="xs" color="orange.700">
1. Zkontrolujte, že Umami běží na <strong>{process.env.REACT_APP_UMAMI_URL || 'nakonfigurované URL'}</strong>
</Text>
<Text fontSize="xs" color="orange.700">
2. Přihlaste se do Umami a vytvořte nový web, pokud žádný neexistuje
</Text>
<Text fontSize="xs" color="orange.700">
3. Restartujte backend server pro opětovné připojení
</Text>
<Text fontSize="xs" color="orange.700">
4. Zkontrolujte backend logy pro detailní chybové zprávy
</Text>
</VStack>
<Button
size="sm"
colorScheme="orange"
variant="outline"
leftIcon={<Icon as={FiZap} />}
onClick={() => window.location.reload()}
mt={2}
>
Znovu načíst stránku
</Button>
</VStack>
</HStack>
</CardBody>
</Card>
)}
{/* Pageviews Chart */}
<Card bg={bgColor} borderColor={borderColor}>
<CardHeader>
<HStack spacing={2}>
<Icon as={FiTrendingUp} color="blue.500" boxSize={5} />
<Heading size="md">Zobrazení stránek v čase</Heading>
</HStack>
</CardHeader>
<CardBody>
{loading && pageviewsData.length === 0 ? (
<Flex justify="center" py={8}>
<Spinner size="lg" />
</Flex>
) : pageviewsData.length === 0 || pageviewsData.every(d => d.value === 0) ? (
<Flex justify="center" align="center" direction="column" py={8}>
<Icon as={FiTrendingUp} color="gray.300" boxSize={12} mb={3} />
<Text color="gray.500" fontWeight="medium">Žádná data pro zobrazení</Text>
<Text color="gray.400" fontSize="sm" mt={1}>Pro vybrané časové období nejsou k dispozici žádná data o návštěvnosti</Text>
</Flex>
) : (
<Box height="300px">
<Bar
data={{
labels: pageviewsData.map(d => d.date),
datasets: [
{
label: 'Zobrazení',
data: pageviewsData.map(d => d.value),
backgroundColor: 'rgba(66, 153, 225, 0.6)',
borderColor: 'rgb(66, 153, 225)',
borderWidth: 1,
borderRadius: 4,
},
],
}}
options={{
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
display: false,
},
tooltip: {
backgroundColor: 'rgba(0, 0, 0, 0.8)',
titleColor: '#ffffff',
bodyColor: '#ffffff',
borderColor: 'rgba(255, 255, 255, 0.1)',
borderWidth: 1,
padding: 12,
displayColors: false,
callbacks: {
label: function(context) {
return `Zobrazení: ${context.parsed.y}`;
},
},
},
},
scales: {
y: {
beginAtZero: true,
ticks: {
color: '#718096',
},
grid: {
color: 'rgba(0, 0, 0, 0.1)',
},
},
x: {
ticks: {
color: '#718096',
},
grid: {
display: false,
},
},
},
}}
/>
</Box>
)}
</CardBody>
</Card>
<Divider />
{/* Country Flags Section */}
<Card bg={bgColor} borderColor={borderColor}>
<CardHeader>
<HStack spacing={2}>
<Icon as={FiGlobe} color="green.500" boxSize={5} />
<Heading size="md">Návštěvníci podle zemí</Heading>
</HStack>
</CardHeader>
<CardBody>
{loading && countryMetrics.length === 0 ? (
<Flex justify="center" py={8}>
<Spinner size="lg" />
</Flex>
) : countryMetrics.length === 0 ? (
<Text textAlign="center" color="gray.500" py={8}>
Žádná data o zemích
</Text>
) : (
<SimpleGrid columns={{ base: 1, sm: 2, md: 3, lg: 4 }} spacing={4}>
{countryMetrics.slice(0, 12).map((country, index) => {
const countryCode = country.x?.toUpperCase() || '';
const countryName = new Intl.DisplayNames(['cs', 'en'], { type: 'region' }).of(countryCode) || countryCode;
const flagEmoji = countryCode.length === 2
? String.fromCodePoint(...[...countryCode].map(c => 0x1F1E6 - 65 + c.charCodeAt(0)))
: '🏳️';
return (
<Card
key={index}
variant="outline"
cursor="pointer"
transition="all 0.2s"
_hover={{
transform: 'translateY(-2px)',
boxShadow: 'md',
borderColor: 'blue.400'
}}
onClick={() => handleCountryClick(countryCode, countryName, country.y)}
>
<CardBody p={4}>
<VStack spacing={2}>
<Text fontSize="4xl">{flagEmoji}</Text>
<Text fontWeight="semibold" fontSize="sm" noOfLines={1}>
{countryName}
</Text>
<Badge colorScheme="blue" fontSize="sm">
{formatNumber(country.y)} návštěv
</Badge>
</VStack>
</CardBody>
</Card>
);
})}
</SimpleGrid>
)}
</CardBody>
</Card>
{/* Detailed Metrics */}
<Tabs colorScheme="blue">
<TabList flexWrap="wrap">
<Tab><HStack spacing={2}><Icon as={FiFile} /><Text>Stránky</Text></HStack></Tab>
<Tab><HStack spacing={2}><Icon as={FiMonitor} /><Text>Prohlížeče</Text></HStack></Tab>
<Tab><HStack spacing={2}><Icon as={FiCpu} /><Text>Operační systémy</Text></HStack></Tab>
<Tab><HStack spacing={2}><Icon as={FiGlobe} /><Text>Země</Text></HStack></Tab>
<Tab><HStack spacing={2}><Icon as={FiSmartphone} /><Text>Zařízení</Text></HStack></Tab>
<Tab><HStack spacing={2}><Icon as={FiZap} /><Text>Události</Text></HStack></Tab>
<Tab><HStack spacing={2}><Icon as={FiSearch} /><Text>Query parametry</Text></HStack></Tab>
</TabList>
<TabPanels>
<TabPanel>
{renderMetricTable(pageMetrics, 'Nejnavštěvovanější stránky')}
</TabPanel>
<TabPanel>
{renderMetricTable(browserMetrics, 'Používané prohlížeče')}
</TabPanel>
<TabPanel>{renderMetricTable(osMetrics, 'Operační systémy')}</TabPanel>
<TabPanel>{renderMetricTable(countryMetrics, 'Země návštěvníků')}</TabPanel>
<TabPanel>{renderMetricTable(deviceMetrics, 'Typy zařízení')}</TabPanel>
<TabPanel>
{renderMetricTable(eventMetrics, 'Události uživatelů', true)}
</TabPanel>
<TabPanel>
{renderMetricTable(queryMetrics, 'Query parametry URL')}
</TabPanel>
</TabPanels>
</Tabs>
</VStack>
</Container>
{/* Country Details Modal */}
<Modal isOpen={isOpen} onClose={handleModalClose} size="6xl">
<ModalOverlay />
<ModalContent>
<ModalHeader>
<Flex align="center" gap={3}>
<Text>Analytika pro zemi: {selectedCountry?.name}</Text>
<Badge colorScheme="blue" fontSize="sm">
{formatNumber(selectedCountry?.value)} návštěv
</Badge>
</Flex>
</ModalHeader>
<ModalCloseButton />
<ModalBody pb={6}>
{loadingCountryDetails ? (
<Flex justify="center" align="center" py={8}>
<Spinner size="lg" />
<Text ml={3}>Načítání detailů...</Text>
</Flex>
) : countryDetails ? (
<Tabs colorScheme="blue">
<TabList>
<Tab>Stránky</Tab>
<Tab>Prohlížeče</Tab>
<Tab>Operační systémy</Tab>
<Tab>Zařízení</Tab>
<Tab>Události</Tab>
</TabList>
<TabPanels>
<TabPanel>
{renderMetricTable(countryDetails.pages, `Nejnavštěvovanější stránky v ${selectedCountry?.name}`)}
</TabPanel>
<TabPanel>
{renderMetricTable(countryDetails.browsers, `Používané prohlížeče v ${selectedCountry?.name}`)}
</TabPanel>
<TabPanel>
{renderMetricTable(countryDetails.os, `Operační systémy v ${selectedCountry?.name}`)}
</TabPanel>
<TabPanel>
{renderMetricTable(countryDetails.devices, `Typy zařízení v ${selectedCountry?.name}`)}
</TabPanel>
<TabPanel>
{renderMetricTable(countryDetails.events, `Události uživatelů z ${selectedCountry?.name}`, true)}
</TabPanel>
</TabPanels>
</Tabs>
) : (
<Box textAlign="center" py={8}>
<Text color="gray.500">
Pro vybranou zemi nejsou k dispozici detailní data.
</Text>
</Box>
)}
</ModalBody>
</ModalContent>
</Modal>
</AdminLayout>
);
};
export default AnalyticsAdminPage;
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,517 @@
import React, { useState, useRef } from 'react';
import { Box, Button, FormControl, FormLabel, Heading, HStack, IconButton, Image, Input, Modal, ModalBody, ModalCloseButton, ModalContent, ModalFooter, ModalHeader, ModalOverlay, Spinner, Table, Tbody, Td, Th, Thead, Tr, useColorModeValue, useDisclosure, useToast, VStack, Select, Text, Switch, Badge, Alert, AlertIcon, AlertTitle, AlertDescription, Divider, Grid, GridItem } from '@chakra-ui/react';
import { FiPlus, FiEdit2, FiTrash2, FiUpload, FiAlertCircle, FiCheckCircle } from 'react-icons/fi';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import AdminLayout from '../../layouts/AdminLayout';
import { Sponsor, getSponsors, createSponsor, updateSponsor, deleteSponsor } from '../../services/sponsors';
import { uploadFile } from '../../services/articles';
import { assetUrl } from '../../utils/url';
// Banner placement presets with dimensions and descriptions
type BannerPreset = {
value: string;
label: string;
description: string;
width: number;
height: number;
aspectRatio: number;
position: 'top' | 'middle' | 'sidebar' | 'footer' | 'article';
};
const BANNER_PRESETS: BannerPreset[] = [
{
value: 'homepage_top',
label: 'Hlavní banner (Homepage - vrchol)',
description: 'Hlavní reklamní plocha nahoře, zobrazena všem návštěvníkům',
width: 1200,
height: 200,
aspectRatio: 6,
position: 'top'
},
{
value: 'homepage_middle',
label: 'Střední banner (Homepage - střed)',
description: 'Banner ve středu stránky mezi obsahem',
width: 970,
height: 250,
aspectRatio: 3.88,
position: 'middle'
},
{
value: 'homepage_sidebar',
label: 'Postranní banner (Homepage - sidebar)',
description: 'Menší banner v pravém postranním panelu',
width: 300,
height: 250,
aspectRatio: 1.2,
position: 'sidebar'
},
{
value: 'homepage_footer',
label: 'Spodní banner (Homepage - zápatí)',
description: 'Banner v dolní části stránky před zápatím',
width: 1200,
height: 200,
aspectRatio: 6,
position: 'footer'
},
{
value: 'article_inline',
label: 'Banner v článcích',
description: 'Banner zobrazený v textu článků',
width: 728,
height: 90,
aspectRatio: 8.09,
position: 'article'
}
];
const BannersAdminPage: React.FC = () => {
const cardBg = useColorModeValue('white', 'gray.800');
const borderColor = useColorModeValue('gray.200', 'gray.700');
const inputBg = useColorModeValue('white', 'gray.700');
const toast = useToast();
const qc = useQueryClient();
const { data, isLoading } = useQuery({ queryKey: ['admin-banners'], queryFn: getSponsors });
const [editing, setEditing] = useState<Partial<Sponsor> | null>(null);
const [imageResolution, setImageResolution] = useState<{ width: number; height: number } | null>(null);
const [recommendedPlacements, setRecommendedPlacements] = useState<BannerPreset[]>([]);
const [uploadingImage, setUploadingImage] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
const { isOpen, onOpen, onClose } = useDisclosure();
// Get preset by value
const getPreset = (placement?: string): BannerPreset | undefined => {
return BANNER_PRESETS.find(p => p.value === placement);
};
// Recommend placements based on image resolution
const recommendPlacement = (imgWidth: number, imgHeight: number): BannerPreset[] => {
const imgAspectRatio = imgWidth / imgHeight;
// Sort presets by how close their aspect ratio is to the image
const scored = BANNER_PRESETS.map(preset => {
const ratioDiff = Math.abs(preset.aspectRatio - imgAspectRatio);
const widthDiff = Math.abs(preset.width - imgWidth) / preset.width;
const heightDiff = Math.abs(preset.height - imgHeight) / preset.height;
// Lower score is better
const score = ratioDiff * 2 + widthDiff + heightDiff;
return { preset, score };
}).sort((a, b) => a.score - b.score);
// Return top 3 recommendations
return scored.slice(0, 3).map(s => s.preset);
};
const openCreate = () => {
const defaultPreset = BANNER_PRESETS[0];
setEditing({
name: '',
is_active: true,
placement: defaultPreset.value,
width: defaultPreset.width,
height: defaultPreset.height
} as any);
setImageResolution(null);
setRecommendedPlacements([]);
onOpen();
};
const openEdit = (s: Sponsor) => {
setEditing({ ...s });
setImageResolution(null);
setRecommendedPlacements([]);
onOpen();
};
const closeModal = () => {
setEditing(null);
setImageResolution(null);
setRecommendedPlacements([]);
if (fileInputRef.current) {
fileInputRef.current.value = '';
}
onClose();
};
const createMut = useMutation({
mutationFn: (payload: any) => createSponsor(payload),
onSuccess: () => { toast({ title: 'Banner vytvořen', status: 'success' }); qc.invalidateQueries({ queryKey: ['admin-banners'] }); closeModal(); },
onError: (e: any) => toast({ title: 'Vytvoření selhalo', description: e?.response?.data?.message || 'Chyba', status: 'error' }),
});
const updateMut = useMutation({
mutationFn: ({ id, payload }: { id: number | string; payload: any }) => updateSponsor(id, payload),
onSuccess: () => { toast({ title: 'Banner upraven', status: 'success' }); qc.invalidateQueries({ queryKey: ['admin-banners'] }); closeModal(); },
onError: (e: any) => toast({ title: 'Aktualizace selhala', description: e?.response?.data?.message || 'Chyba', status: 'error' }),
});
const deleteMut = useMutation({
mutationFn: (id: number | string) => deleteSponsor(id),
onSuccess: () => { toast({ title: 'Banner smazán', status: 'success' }); qc.invalidateQueries({ queryKey: ['admin-banners'] }); },
onError: (e: any) => toast({ title: 'Smazání selhalo', description: e?.response?.data?.message || 'Chyba', status: 'error' }),
});
const onSubmit = async () => {
if (!editing) return;
const payload = {
name: editing.name || '',
logo_url: editing.logo_url,
website_url: editing.website_url,
is_active: editing.is_active ?? true,
placement: (editing as any).placement || '',
width: (editing as any).width || undefined,
height: (editing as any).height || undefined,
};
if ((editing as any).id != null) {
await updateMut.mutateAsync({ id: (editing as any).id, payload });
} else {
await createMut.mutateAsync(payload);
}
};
const onUpload = async (file?: File | null) => {
if (!file) return;
setUploadingImage(true);
try {
// First, detect image resolution
const img = new window.Image();
const imageLoadPromise = new Promise<{ width: number; height: number }>((resolve, reject) => {
img.onload = () => resolve({ width: img.width, height: img.height });
img.onerror = reject;
img.src = URL.createObjectURL(file);
});
const resolution = await imageLoadPromise;
setImageResolution(resolution);
// Recommend placements based on resolution
const recommended = recommendPlacement(resolution.width, resolution.height);
setRecommendedPlacements(recommended);
// Upload the file
const res = await uploadFile(file);
// Update editing state with uploaded URL
setEditing((prev) => ({ ...(prev || {}), logo_url: res.url }));
// If no placement selected yet, auto-select the best recommendation
if (!editing?.placement && recommended.length > 0) {
const bestMatch = recommended[0];
setEditing((prev) => ({
...(prev || {}),
placement: bestMatch.value,
width: bestMatch.width,
height: bestMatch.height
}));
toast({
title: 'Obrázek nahrán',
description: `Rozlišení: ${resolution.width}×${resolution.height}. Doporučeno umístění: ${bestMatch.label}`,
status: 'success',
duration: 6000
});
} else {
toast({
title: 'Obrázek nahrán',
description: `Rozlišení: ${resolution.width}×${resolution.height}`,
status: 'success'
});
}
// Clean up object URL
URL.revokeObjectURL(img.src);
} catch (e: any) {
toast({ title: 'Nahrání selhalo', description: e?.message || 'Zkuste to znovu', status: 'error' });
} finally {
setUploadingImage(false);
}
};
const banners = data || [];
return (
<AdminLayout>
<Box>
<HStack justify="space-between" mb={4}>
<Heading size="lg">Bannery a reklamní plochy</Heading>
<Button leftIcon={<FiPlus />} colorScheme="blue" onClick={openCreate}>
Nový banner
</Button>
</HStack>
<Text color="gray.500" mb={6}>
Správa bannerů a reklamních ploch zobrazovaných na webu. Můžete přidávat, upravovat a odebírat bannery.
</Text>
<Box
bg={useColorModeValue('white', 'gray.800')}
borderWidth="1px"
borderRadius="lg"
overflowX="auto"
boxShadow="sm"
mb={6}
>
<Table size="sm">
<Thead>
<Tr>
<Th w="100px">Náhled</Th>
<Th>Název</Th>
<Th>Umístění</Th>
<Th>Rozměry</Th>
<Th w="100px">Aktivní</Th>
<Th w="160px">Akce</Th>
</Tr>
</Thead>
<Tbody>
{isLoading && (
<Tr><Td colSpan={6} textAlign="center"><Spinner size="sm" mr={2} />Načítání</Td></Tr>
)}
{!isLoading && banners.map((b) => {
const preset = getPreset((b as any).placement);
return (
<Tr key={b.id}>
<Td>
<Image src={assetUrl(b.logo_url) || '/logo192.png'} alt={b.name} boxSize="56px" objectFit="contain" bg={inputBg} borderRadius="md" />
</Td>
<Td>
<Text fontWeight="500">{b.name}</Text>
{b.website_url && (
<Text fontSize="xs" color="gray.500" noOfLines={1}>{b.website_url}</Text>
)}
</Td>
<Td>
{preset ? (
<VStack align="start" spacing={0}>
<Text fontSize="sm" fontWeight="500">{preset.label}</Text>
<Badge colorScheme="blue" fontSize="xs">{preset.position}</Badge>
</VStack>
) : (
<Text fontSize="xs" color="gray.500">-</Text>
)}
</Td>
<Td>
{(b as any).width && (b as any).height ? (
<Text fontSize="xs">{(b as any).width} × {(b as any).height}</Text>
) : '-'}
</Td>
<Td>
<Badge colorScheme={b.is_active ? 'green' : 'gray'}>
{b.is_active ? 'Ano' : 'Ne'}
</Badge>
</Td>
<Td>
<HStack>
<IconButton aria-label="Upravit" size="sm" icon={<FiEdit2 />} onClick={() => openEdit(b)} />
<IconButton aria-label="Smazat" size="sm" colorScheme="red" icon={<FiTrash2 />} onClick={() => { if (b.id != null) deleteMut.mutate(b.id); }} />
</HStack>
</Td>
</Tr>
);
})}
</Tbody>
</Table>
</Box>
<Modal isOpen={isOpen} onClose={closeModal} size="lg">
<ModalOverlay />
<ModalContent maxW="90vw" maxH="90vh" overflowY="auto">
<ModalHeader>{(editing as any)?.id ? 'Upravit banner' : 'Nový banner'}</ModalHeader>
<ModalCloseButton />
<ModalBody>
<VStack align="stretch" spacing={4}>
<FormControl isRequired>
<FormLabel>Název</FormLabel>
<Input value={editing?.name || ''} onChange={(e) => setEditing((prev) => ({ ...(prev as any), name: e.target.value }))} />
</FormControl>
<FormControl>
<FormLabel>Odkaz (po kliku)</FormLabel>
<Input type="url" value={editing?.website_url || ''} onChange={(e) => setEditing((prev) => ({ ...(prev as any), website_url: e.target.value }))} placeholder="https://partner.cz" />
</FormControl>
{/* Image resolution info */}
{imageResolution && (
<Alert status="info" borderRadius="md">
<AlertIcon />
<Box flex="1">
<AlertTitle fontSize="sm">Rozlišení obrázku: {imageResolution.width} × {imageResolution.height} px</AlertTitle>
<AlertDescription fontSize="xs">
Poměr stran: {(imageResolution.width / imageResolution.height).toFixed(2)}:1
</AlertDescription>
</Box>
</Alert>
)}
{/* Recommended placements */}
{recommendedPlacements.length > 0 && (
<Box p={3} bg={useColorModeValue('blue.50', 'blue.900')} borderRadius="md">
<Text fontSize="sm" fontWeight="600" mb={2} color={useColorModeValue('blue.700', 'blue.200')}>
<FiCheckCircle style={{ display: 'inline', marginRight: '6px' }} />
Doporučená umístění na základě rozlišení:
</Text>
<VStack align="stretch" spacing={1}>
{recommendedPlacements.map((preset, idx) => (
<HStack key={preset.value} justify="space-between" fontSize="xs">
<Text>
<Badge colorScheme={idx === 0 ? 'green' : 'blue'} mr={2}>
{idx === 0 ? 'Nejlepší' : `#${idx + 1}`}
</Badge>
{preset.label} ({preset.width}×{preset.height})
</Text>
{editing?.placement !== preset.value && (
<Button
size="xs"
variant="link"
colorScheme="blue"
onClick={() => {
setEditing(prev => ({
...prev,
placement: preset.value,
width: preset.width,
height: preset.height
}));
}}
>
Použít
</Button>
)}
</HStack>
))}
</VStack>
</Box>
)}
<FormControl isRequired>
<FormLabel>Umístění na webu</FormLabel>
<Select
value={(editing as any)?.placement || ''}
onChange={(e) => {
const placement = e.target.value;
const preset = getPreset(placement);
setEditing((prev) => ({
...(prev as any),
placement,
width: preset?.width,
height: preset?.height
}));
}}
>
<option value=""> vyberte umístění </option>
{BANNER_PRESETS.map(preset => (
<option key={preset.value} value={preset.value}>
{preset.label} ({preset.width}×{preset.height})
</option>
))}
</Select>
{editing?.placement && (() => {
const preset = getPreset((editing as any).placement);
return preset ? (
<Text fontSize="xs" color="gray.500" mt={1}>
{preset.description}
</Text>
) : null;
})()}
</FormControl>
{/* Placement dimensions display (read-only) */}
{editing?.placement && (() => {
const preset = getPreset((editing as any).placement);
return preset ? (
<Box p={3} bg={inputBg} borderRadius="md" borderWidth="1px" borderColor={borderColor}>
<HStack justify="space-between" mb={1}>
<Text fontSize="sm" fontWeight="600">Rozměry banneru:</Text>
<Badge colorScheme="blue">{preset.width} × {preset.height} px</Badge>
</HStack>
<Text fontSize="xs" color="gray.500">
Poměr stran: {preset.aspectRatio.toFixed(2)}:1 Pozice: {preset.position}
</Text>
</Box>
) : null;
})()}
<Divider />
<FormControl>
<FormLabel>Obrázek banneru</FormLabel>
<VStack align="stretch" spacing={3}>
{/* Preview */}
{editing?.logo_url && (() => {
const preset = getPreset((editing as any)?.placement);
const previewWidth = preset ? Math.min(preset.width, 600) : 300;
const previewHeight = preset ? (previewWidth / preset.aspectRatio) : 150;
return (
<Box>
<Text fontSize="xs" color="gray.500" mb={2}>Náhled banneru:</Text>
<Box
borderWidth="2px"
borderColor={borderColor}
borderRadius="md"
p={2}
bg={inputBg}
>
<Image
src={assetUrl(editing?.logo_url) || '/logo192.png'}
alt="banner preview"
width={`${previewWidth}px`}
height={`${previewHeight}px`}
objectFit="contain"
mx="auto"
display="block"
/>
</Box>
{preset && (
<Text fontSize="xs" color="gray.500" mt={1} textAlign="center">
Zobrazení v pozici: {preset.label}
</Text>
)}
</Box>
);
})()}
{/* Upload button */}
<HStack>
<Button
as="label"
type="button"
leftIcon={<FiUpload />}
colorScheme="blue"
variant="outline"
isLoading={uploadingImage}
loadingText="Nahrávání..."
>
{editing?.logo_url ? 'Změnit obrázek' : 'Nahrát obrázek'}
<Input
ref={fileInputRef}
type="file"
display="none"
accept="image/*"
onChange={async (e) => {
await onUpload(e.target.files?.[0]);
}}
/>
</Button>
{uploadingImage && <Spinner size="sm" />}
</HStack>
{!editing?.logo_url && (
<Alert status="warning" fontSize="xs">
<AlertIcon boxSize="12px" />
<Text fontSize="xs">Nahrajte obrázek pro automatické doporučení umístění</Text>
</Alert>
)}
</VStack>
</FormControl>
<FormControl display="flex" alignItems="center">
<FormLabel mb="0">Aktivní</FormLabel>
<Switch isChecked={!!editing?.is_active} onChange={(e) => setEditing((prev) => ({ ...(prev as any), is_active: e.target.checked }))} />
</FormControl>
</VStack>
</ModalBody>
<ModalFooter>
<Button variant="ghost" mr={3} onClick={closeModal}>Zrušit</Button>
<Button colorScheme="blue" onClick={onSubmit} isLoading={createMut.isLoading || updateMut.isLoading}>Uložit</Button>
</ModalFooter>
</ModalContent>
</Modal>
</Box>
</AdminLayout>
);
};
export default BannersAdminPage;
@@ -0,0 +1,286 @@
import {
Box,
Button,
Heading,
HStack,
IconButton,
Input,
Table,
Tbody,
Td,
Th,
Thead,
Tr,
useDisclosure,
useToast,
Modal,
ModalOverlay,
ModalContent,
ModalHeader,
ModalCloseButton,
ModalBody,
ModalFooter,
FormControl,
FormLabel,
VStack,
Text,
useColorModeValue,
Skeleton,
Alert,
AlertIcon,
Badge,
Textarea,
} from '@chakra-ui/react';
import { useState } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { FiEdit2, FiPlus, FiTrash2, FiTag } from 'react-icons/fi';
import AdminLayout from '../../layouts/AdminLayout';
import { PageHeader } from '../../components/admin/PageHeader';
import {
CategoryItem,
getCategories,
createCategory,
updateCategory,
deleteCategory,
} from '../../services/categories';
const CategoriesAdminPage = () => {
const toast = useToast();
const qc = useQueryClient();
const { data: categories, isLoading } = useQuery({ queryKey: ['admin-categories'], queryFn: getCategories });
const [editing, setEditing] = useState<Partial<CategoryItem> | null>(null);
const { isOpen, onOpen, onClose } = useDisclosure();
const bgCard = useColorModeValue('white', 'gray.800');
const borderColor = useColorModeValue('gray.200', 'gray.700');
const openCreate = () => {
setEditing({ name: '', description: '' });
onOpen();
};
const openEdit = (cat: CategoryItem) => {
setEditing({ ...cat });
onOpen();
};
const closeModal = () => {
setEditing(null);
onClose();
};
const createMut = useMutation({
mutationFn: (payload: any) => createCategory(payload),
onSuccess: () => {
toast({ title: 'Kategorie vytvořena', status: 'success', duration: 3000 });
qc.invalidateQueries({ queryKey: ['admin-categories'] });
closeModal();
},
onError: (e: any) =>
toast({
title: 'Vytvoření selhalo',
description: e?.response?.data?.chyba || e?.message || 'Chyba',
status: 'error',
duration: 5000,
}),
});
const updateMut = useMutation({
mutationFn: ({ id, payload }: { id: number | string; payload: any }) => updateCategory(id, payload),
onSuccess: () => {
toast({ title: 'Kategorie aktualizována', status: 'success', duration: 3000 });
qc.invalidateQueries({ queryKey: ['admin-categories'] });
closeModal();
},
onError: (e: any) =>
toast({
title: 'Aktualizace selhala',
description: e?.response?.data?.chyba || e?.message || 'Chyba',
status: 'error',
duration: 5000,
}),
});
const deleteMut = useMutation({
mutationFn: (id: number | string) => deleteCategory(id),
onSuccess: () => {
toast({ title: 'Kategorie smazána', status: 'success', duration: 3000 });
qc.invalidateQueries({ queryKey: ['admin-categories'] });
},
onError: (e: any) =>
toast({
title: 'Smazání selhalo',
description: e?.response?.data?.chyba || e?.response?.data?.detail || e?.message || 'Chyba',
status: 'error',
duration: 5000,
}),
});
const onSubmit = async () => {
if (!editing) return;
if (!editing.name?.trim()) {
toast({ title: 'Název je povinný', status: 'warning' });
return;
}
const payload = {
name: editing.name.trim(),
description: editing.description?.trim() || '',
};
if (editing.id != null) {
await updateMut.mutateAsync({ id: editing.id, payload });
} else {
await createMut.mutateAsync(payload);
}
};
const onDelete = async (cat: CategoryItem) => {
if (!window.confirm(`Opravdu chcete smazat kategorii "${cat.name}"?`)) return;
await deleteMut.mutateAsync(cat.id);
};
return (
<AdminLayout requireAdmin={false}>
<Box maxW="1200px" mx="auto">
<PageHeader
title="Kategorie článků"
description="Spravujte kategorie pro články. Kategorie lze přiřadit při vytváření nebo úpravě článku."
icon={FiTag}
action={{
label: 'Přidat kategorii',
icon: <FiPlus />,
onClick: openCreate,
colorScheme: 'blue',
}}
/>
{isLoading ? (
<VStack spacing={3}>
{[1, 2, 3].map((i) => (
<Skeleton key={i} height="60px" w="100%" borderRadius="md" />
))}
</VStack>
) : !categories || categories.length === 0 ? (
<Alert status="info" borderRadius="lg">
<AlertIcon />
<Box>
<Text fontWeight="semibold">Zatím nejsou žádné kategorie</Text>
<Text fontSize="sm">
Kategorie se vytvoří automaticky při psaní článků, nebo je můžete vytvořit ručně.
</Text>
</Box>
</Alert>
) : (
<Box
bg={bgCard}
borderWidth="1px"
borderColor={borderColor}
borderRadius="xl"
overflow="hidden"
shadow="md"
>
<Table variant="simple" size="md">
<Thead bg={useColorModeValue('gray.50', 'gray.700')}>
<Tr>
<Th width="40%">Název</Th>
<Th width="50%">Popis</Th>
<Th isNumeric>Akce</Th>
</Tr>
</Thead>
<Tbody>
{categories.map((cat) => (
<Tr
key={cat.id}
_hover={{ bg: useColorModeValue('gray.50', 'gray.700') }}
transition="background 0.2s"
>
<Td>
<HStack>
<Badge colorScheme="blue" fontSize="sm" px={3} py={1} borderRadius="full">
{cat.name}
</Badge>
</HStack>
</Td>
<Td>
<Text fontSize="sm" color="gray.600" noOfLines={2}>
{cat.description || <Text as="span" fontStyle="italic" color="gray.400">Bez popisu</Text>}
</Text>
</Td>
<Td isNumeric>
<HStack justify="flex-end" spacing={2}>
<IconButton
aria-label="Upravit"
icon={<FiEdit2 />}
size="sm"
colorScheme="blue"
variant="ghost"
onClick={() => openEdit(cat)}
/>
<IconButton
aria-label="Smazat"
icon={<FiTrash2 />}
size="sm"
colorScheme="red"
variant="ghost"
onClick={() => onDelete(cat)}
isLoading={deleteMut.isPending}
/>
</HStack>
</Td>
</Tr>
))}
</Tbody>
</Table>
</Box>
)}
{/* Create/Edit Modal */}
<Modal isOpen={isOpen} onClose={closeModal} size="lg">
<ModalOverlay />
<ModalContent>
<ModalHeader>
{editing?.id ? 'Upravit kategorii' : 'Nová kategorie'}
</ModalHeader>
<ModalCloseButton />
<ModalBody>
<VStack spacing={4}>
<FormControl isRequired>
<FormLabel>Název kategorie</FormLabel>
<Input
placeholder="např. Novinky, A-tým, Mládež..."
value={editing?.name || ''}
onChange={(e) => setEditing((prev) => ({ ...prev, name: e.target.value }))}
autoFocus
/>
</FormControl>
<FormControl>
<FormLabel>Popis (volitelné)</FormLabel>
<Textarea
placeholder="Krátký popis kategorie..."
value={editing?.description || ''}
onChange={(e) => setEditing((prev) => ({ ...prev, description: e.target.value }))}
rows={3}
/>
</FormControl>
</VStack>
</ModalBody>
<ModalFooter>
<Button variant="ghost" mr={3} onClick={closeModal}>
Zrušit
</Button>
<Button
colorScheme="blue"
onClick={onSubmit}
isLoading={createMut.isPending || updateMut.isPending}
>
{editing?.id ? 'Uložit' : 'Vytvořit'}
</Button>
</ModalFooter>
</ModalContent>
</Modal>
</Box>
</AdminLayout>
);
};
export default CategoriesAdminPage;
@@ -0,0 +1,722 @@
import React, { useEffect, useMemo, useState } from 'react';
import {
Box,
Button,
Flex,
FormControl,
FormLabel,
Heading,
HStack,
IconButton,
Input,
Select,
Spinner,
Table,
Tbody,
Td,
Text,
Th,
Thead,
Tr,
useDisclosure,
useToast,
VStack,
Badge,
Tooltip,
Alert,
AlertIcon,
useColorModeValue,
Divider,
} from '@chakra-ui/react';
import { FiPlus, FiTrash2, FiSave, FiRefreshCcw, FiDownload, FiEdit3, FiMove } from 'react-icons/fi';
import {
CompetitionAlias,
getCompetitionAliasesAdmin,
upsertCompetitionAlias,
deleteCompetitionAlias,
reorderCompetitionAliases,
} from '../../services/competitionAliases';
import AdminLayout from '../../layouts/AdminLayout';
import { PageHeader } from '../../components/admin/PageHeader';
const CompetitionAliasesAdminPage: React.FC = () => {
const cardBg = useColorModeValue('white', 'gray.800');
const borderColor = useColorModeValue('gray.200', 'gray.700');
const inputBg = useColorModeValue('white', 'gray.700');
const toast = useToast();
const [items, setItems] = useState<CompetitionAlias[]>([]);
const [loading, setLoading] = useState<boolean>(false);
const [newCode, setNewCode] = useState('');
const [newAlias, setNewAlias] = useState('');
const [editing, setEditing] = useState<Record<string, { alias: string }>>({});
const [draggedIndex, setDraggedIndex] = useState<number | null>(null);
const [reorderMode, setReorderMode] = useState<boolean>(false);
const load = async () => {
setLoading(true);
try {
const data = await getCompetitionAliasesAdmin();
setItems(data || []);
} catch (e: any) {
toast({ title: 'Chyba při načítání', description: e?.message || 'Nelze načíst aliasy', status: 'error' });
} finally {
setLoading(false);
}
};
useEffect(() => {
// Load current aliases, then silently try to import missing ones from FACR cache
load().then(async () => {
await importFromFacrAuto();
});
}, []);
const importFromFacr = async () => {
setLoading(true);
try {
// Attempt to fetch cache JSON served by backend/static
const resolveBackendUrl = (path: string) => {
try {
if (/^https?:\/\//i.test(path)) return path;
if (path.startsWith('/cache') || path.startsWith('/uploads')) {
const base = (process.env.REACT_APP_API_BASE_URL || process.env.REACT_APP_API_URL || 'http://localhost:8080/api/v1');
const u = new URL(base);
u.pathname = path;
return u.toString();
}
return path;
} catch {
return path;
}
};
const url = resolveBackendUrl('/cache/prefetch/facr_club_info.json');
const res = await fetch(url, { cache: 'no-cache' });
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data = await res.json();
const comps: Array<{ id?: string; code?: string; name?: string }> = Array.isArray(data?.competitions) ? data.competitions : [];
// Fetch fresh aliases to avoid using potentially stale state
const latest = await getCompetitionAliasesAdmin();
const existing = new Set((latest || []).map(i => i.code));
const missing = comps
.map(c => ({ code: String(c.code || c.id || '').trim(), name: String(c.name || '').trim() }))
.filter(c => c.code);
const newOnes = missing.filter(c => !existing.has(c.code));
if (!newOnes.length) {
toast({ title: 'Žádné nové soutěže', description: 'Všechny soutěže již mají alias.', status: 'info' });
return;
}
// Bulk upsert with alias defaulting to competition name
await Promise.all(newOnes.map(c => upsertCompetitionAlias(c.code, { alias: c.name || c.code, original_name: c.name || undefined })));
await load();
toast({ title: 'Import dokončen', description: `Přidáno: ${newOnes.length}`, status: 'success' });
} catch (e: any) {
toast({ title: 'Import selhal', description: e?.message || 'Nelze načíst FACR soutěže', status: 'error' });
} finally {
setLoading(false);
}
};
// Silent auto-import on mount: upserts only when missing; minimal toast
const importFromFacrAuto = async () => {
try {
const resolveBackendUrl = (path: string) => {
try {
if (/^https?:\/\//i.test(path)) return path;
if (path.startsWith('/cache') || path.startsWith('/uploads')) {
const base = (process.env.REACT_APP_API_BASE_URL || process.env.REACT_APP_API_URL || 'http://localhost:8080/api/v1');
const u = new URL(base);
u.pathname = path;
return u.toString();
}
return path;
} catch {
return path;
}
};
const url = resolveBackendUrl('/cache/prefetch/facr_club_info.json');
const res = await fetch(url, { cache: 'no-cache' });
if (!res.ok) return; // silent
const data = await res.json();
const comps: Array<{ id?: string; code?: string; name?: string }> = Array.isArray(data?.competitions) ? data.competitions : [];
const latest = await getCompetitionAliasesAdmin();
const existing = new Set((latest || []).map(i => i.code));
const missing = comps
.map(c => ({ code: String(c.code || c.id || '').trim(), name: String(c.name || '').trim() }))
.filter(c => c.code);
const newOnes = missing.filter(c => !existing.has(c.code));
if (!newOnes.length) return;
await Promise.all(newOnes.map(c => upsertCompetitionAlias(c.code, { alias: c.name || c.code, original_name: c.name || undefined })));
await load();
toast({ title: 'Alias soutěží doplněny', description: `Přidáno: ${newOnes.length}`, status: 'success', duration: 3000 });
} catch {
// silent on auto
}
};
const onAdd = async () => {
const code = newCode.trim();
const alias = newAlias.trim();
if (!code || !alias) {
toast({ title: 'Vyplňte code a alias', status: 'warning' });
return;
}
try {
const saved = await upsertCompetitionAlias(code, { alias });
setItems((prev) => {
const filtered = prev.filter((i) => i.code !== saved.code);
return [...filtered, saved].sort((a, b) => a.code.localeCompare(b.code));
});
setNewCode(''); setNewAlias('');
toast({ title: 'Alias uložen', status: 'success' });
} catch (e: any) {
toast({ title: 'Uložení selhalo', description: e?.message || 'Zkuste znovu', status: 'error' });
}
};
const onSave = async (code: string) => {
const data = editing[code];
if (!data) return;
if (!data.alias?.trim()) {
toast({ title: 'Alias je povinný', status: 'warning' });
return;
}
try {
// Preserve original_name if present on the existing item to avoid losing it on edit
const existing = items.find((i) => i.code === code);
const payload: { alias: string; original_name?: string } = { alias: data.alias.trim() };
if (existing?.original_name) payload.original_name = existing.original_name;
const saved = await upsertCompetitionAlias(code, payload);
setItems((prev) => prev.map((i) => (i.code === code ? saved : i)));
setEditing((prev) => { const n = { ...prev }; delete n[code]; return n; });
toast({ title: 'Uloženo', status: 'success' });
} catch (e: any) {
toast({ title: 'Uložení selhalo', description: e?.message || 'Zkuste znovu', status: 'error' });
}
};
const onDelete = async (code: string) => {
if (!window.confirm(`Smazat alias pro soutěž ${code}?`)) return;
try {
await deleteCompetitionAlias(code);
setItems((prev) => prev.filter((i) => i.code !== code));
setEditing((prev) => { const n = { ...prev }; delete n[code]; return n; });
toast({ title: 'Smazáno', status: 'success' });
} catch (e: any) {
toast({ title: 'Smazání selhalo', description: e?.message || 'Zkuste znovu', status: 'error' });
}
};
// In reorder mode, show items as-is (ordered by display_order from backend)
// In normal mode, sort by code for editing
const sorted = useMemo(() => {
if (!items) return [];
if (reorderMode) return items;
const normalizeOrder = (value?: number) => {
if (!value || value <= 0) return Number.MAX_SAFE_INTEGER;
return value;
};
return [...items].sort((a, b) => {
const orderA = normalizeOrder(a.display_order);
const orderB = normalizeOrder(b.display_order);
if (orderA !== orderB) return orderA - orderB;
return a.code.localeCompare(b.code);
});
}, [items, reorderMode]);
const onSaveAll = async () => {
const entries = Object.entries(editing);
if (!entries.length) {
toast({ title: 'Nic k uložení', status: 'info' });
return;
}
setLoading(true);
try {
// Validate
for (const [code, data] of entries) {
if (!data?.alias?.trim()) {
throw new Error(`Alias je povinný pro ${code}`);
}
}
// Save all in parallel
// When saving multiple edits, preserve original_name from existing items where available
const savedList = await Promise.all(
entries.map(([code, data]) => {
const existing = items.find((i) => i.code === code);
const payload: { alias: string; original_name?: string } = { alias: data.alias.trim() };
if (existing?.original_name) payload.original_name = existing.original_name;
return upsertCompetitionAlias(code, payload);
})
);
// Merge into items
setItems((prev) => {
const map = new Map(prev.map((i) => [i.code, i] as const));
for (const s of savedList) map.set(s.code, s);
return Array.from(map.values()).sort((a, b) => a.code.localeCompare(b.code));
});
setEditing({});
toast({ title: 'Vše uloženo', status: 'success' });
} catch (e: any) {
toast({ title: 'Hromadné uložení selhalo', description: e?.message || 'Zkuste znovu', status: 'error' });
} finally {
setLoading(false);
}
};
const hasChanges = Object.keys(editing).length > 0;
// Drag-and-drop handlers
const handleDragStart = (e: React.DragEvent<HTMLTableRowElement>, index: number) => {
setDraggedIndex(index);
e.dataTransfer.effectAllowed = 'move';
};
const handleDragOver = (e: React.DragEvent<HTMLTableRowElement>, index: number) => {
e.preventDefault();
if (draggedIndex === null || draggedIndex === index) return;
// Reorder items array
const newItems = [...items];
const draggedItem = newItems[draggedIndex];
newItems.splice(draggedIndex, 1);
newItems.splice(index, 0, draggedItem);
setItems(newItems);
setDraggedIndex(index);
};
const handleDragEnd = () => {
setDraggedIndex(null);
};
const saveReorder = async () => {
setLoading(true);
try {
// Assign display_order based on current position (1-indexed)
const reordered = items.map((item, idx) => ({
code: item.code,
display_order: idx + 1,
}));
await reorderCompetitionAliases(reordered);
toast({ title: 'Pořadí uloženo', status: 'success' });
setReorderMode(false);
await load();
} catch (e: any) {
toast({ title: 'Chyba při ukládání pořadí', description: e?.message || 'Zkuste znovu', status: 'error' });
} finally {
setLoading(false);
}
};
const cancelReorder = () => {
setReorderMode(false);
load(); // Reload to reset order
};
return (
<AdminLayout>
<Box maxW="1400px" mx="auto">
<PageHeader
title="Alias soutěží"
description="Spravujte zobrazované názvy soutěží. Můžete je importovat z cache (FACR) a upravit aliasy."
/>
{/* Action Bar */}
<Flex
align="center"
justify="space-between"
mb={6}
wrap="wrap"
gap={3}
bg={cardBg}
p={4}
borderRadius="lg"
shadow="sm"
borderWidth="1px"
borderColor="gray.200"
>
<HStack spacing={3}>
{!reorderMode ? (
<>
<Button
leftIcon={<FiRefreshCcw />}
onClick={load}
isLoading={loading}
variant="outline"
size="md"
_hover={{ bg: 'gray.50', borderColor: 'gray.400' }}
>
Obnovit
</Button>
<Button
leftIcon={<FiSave />}
onClick={onSaveAll}
isDisabled={!hasChanges}
isLoading={loading}
colorScheme="green"
size="md"
variant={hasChanges ? 'solid' : 'outline'}
_hover={hasChanges ? { bg: 'green.600' } : {}}
>
Uložit vše {hasChanges && <Badge ml={2} colorScheme="green" variant="solid">{Object.keys(editing).length}</Badge>}
</Button>
<Button
leftIcon={<FiMove />}
onClick={() => setReorderMode(true)}
isLoading={loading}
colorScheme="purple"
size="md"
variant="outline"
_hover={{ bg: 'purple.50', borderColor: 'purple.400' }}
>
Změnit pořadí
</Button>
</>
) : (
<>
<Button
leftIcon={<FiSave />}
onClick={saveReorder}
isLoading={loading}
colorScheme="green"
size="md"
>
Uložit pořadí
</Button>
<Button
onClick={cancelReorder}
isDisabled={loading}
variant="outline"
size="md"
>
Zrušit
</Button>
</>
)}
</HStack>
{!reorderMode && (
<Button
leftIcon={<FiDownload />}
onClick={importFromFacr}
isLoading={loading}
colorScheme="blue"
size="md"
shadow="sm"
>
Importovat ze soutěží
</Button>
)}
</Flex>
{/* Add New Section - Hidden in reorder mode */}
{!reorderMode && (
<Box
bg={cardBg}
borderWidth="1px"
borderColor="gray.200"
borderRadius="lg"
p={6}
mb={6}
shadow="sm"
_hover={{ shadow: 'md' }}
transition="all 0.2s"
>
<HStack mb={4} spacing={2}>
<Box bg="blue.500" p={2} borderRadius="md">
<FiPlus color="white" size={18} />
</Box>
<Heading size="md" color="gray.700">Přidat nový alias</Heading>
</HStack>
<Divider mb={4} />
<Flex gap={3} wrap="wrap" align="flex-end">
<VStack align="flex-start" spacing={2} flex="0 0 240px">
<Text fontSize="sm" fontWeight="medium" color="gray.600">Kód soutěže</Text>
<Input
placeholder="např. A1A"
value={newCode}
onChange={(e) => setNewCode(e.target.value.toUpperCase())}
size="md"
bg={useColorModeValue('gray.50', 'gray.900')}
borderColor="gray.300"
_hover={{ borderColor: 'blue.400', bg: 'white' }}
_focus={{ borderColor: 'blue.500', bg: 'white', shadow: 'sm' }}
fontFamily="mono"
fontWeight="semibold"
/>
</VStack>
<VStack align="flex-start" spacing={2} flex="1" minW="300px">
<Text fontSize="sm" fontWeight="medium" color="gray.600">Zobrazovaný název (alias)</Text>
<Input
placeholder="např. Krajský přebor"
value={newAlias}
onChange={(e) => setNewAlias(e.target.value)}
size="md"
bg={useColorModeValue('gray.50', 'gray.900')}
borderColor="gray.300"
_hover={{ borderColor: 'blue.400', bg: 'white' }}
_focus={{ borderColor: 'blue.500', bg: 'white', shadow: 'sm' }}
/>
</VStack>
<Button
leftIcon={<FiPlus />}
onClick={onAdd}
colorScheme="blue"
size="md"
px={8}
shadow="sm"
_hover={{ shadow: 'md', transform: 'translateY(-1px)' }}
transition="all 0.2s"
>
Přidat
</Button>
</Flex>
</Box>
)}
{/* Table Section */}
<Box
bg={cardBg}
borderWidth="1px"
borderColor="gray.200"
borderRadius="lg"
overflow="hidden"
shadow="sm"
>
<Box overflowX="auto">
<Table variant="simple" size="md">
<Thead bg={useColorModeValue('gray.50', 'gray.900')}>
<Tr>
{reorderMode && (
<Th width="60px" textTransform="uppercase" fontSize="xs" fontWeight="bold" color="gray.600" letterSpacing="wide">
Pořadí
</Th>
)}
<Th
width="200px"
textTransform="uppercase"
fontSize="xs"
fontWeight="bold"
color="gray.600"
letterSpacing="wide"
>
Kód
</Th>
{!reorderMode && (
<>
<Th
textTransform="uppercase"
fontSize="xs"
fontWeight="bold"
color="gray.600"
letterSpacing="wide"
>
Původní název
</Th>
<Th
textTransform="uppercase"
fontSize="xs"
fontWeight="bold"
color="gray.600"
letterSpacing="wide"
>
Alias
</Th>
<Th
isNumeric
textTransform="uppercase"
fontSize="xs"
fontWeight="bold"
color="gray.600"
letterSpacing="wide"
>
Akce
</Th>
</>
)}
{reorderMode && (
<Th
textTransform="uppercase"
fontSize="xs"
fontWeight="bold"
color="gray.600"
letterSpacing="wide"
>
Alias
</Th>
)}
</Tr>
</Thead>
<Tbody>
{sorted.map((it, idx) => {
const ed = editing[it.code] ?? { alias: it.alias };
const isEdited = editing[it.code] !== undefined;
if (reorderMode) {
// Reorder mode: simplified draggable row
return (
<Tr
key={it.code}
draggable
onDragStart={(e) => handleDragStart(e, idx)}
onDragOver={(e) => handleDragOver(e, idx)}
onDragEnd={handleDragEnd}
cursor="move"
bg={draggedIndex === idx ? 'blue.50' : undefined}
_hover={{ bg: 'gray.100' }}
transition="background 0.15s"
opacity={draggedIndex === idx ? 0.5 : 1}
>
<Td>
<HStack spacing={2}>
<Box as={FiMove} color="gray.500" />
<Text fontWeight="bold" color="gray.700">{idx + 1}</Text>
</HStack>
</Td>
<Td>
<Badge
colorScheme="blue"
variant="subtle"
fontFamily="mono"
fontSize="sm"
px={2}
py={1}
>
{it.code}
</Badge>
</Td>
<Td>
<Text fontSize="sm" fontWeight="medium">{it.alias}</Text>
</Td>
</Tr>
);
}
// Edit mode: full editing capabilities
return (
<Tr
key={it.code}
_hover={{ bg: 'gray.50' }}
transition="background 0.15s"
>
<Td>
<HStack>
<Badge
colorScheme="blue"
variant="subtle"
fontFamily="mono"
fontSize="sm"
px={2}
py={1}
>
{it.code}
</Badge>
{isEdited && (
<Box as={FiEdit3} color="orange.500" size={14} />
)}
</HStack>
</Td>
<Td>
<Text fontSize="sm" color="gray.600" fontWeight="medium">
{it.original_name || <Text as="span" color="gray.400"></Text>}
</Text>
</Td>
<Td>
<Input
size="md"
value={ed.alias}
onChange={(e) => setEditing((prev) => ({ ...prev, [it.code]: { alias: e.target.value } }))}
bg={isEdited ? 'orange.50' : 'white'}
borderColor={isEdited ? 'orange.300' : 'gray.200'}
_hover={{ borderColor: isEdited ? 'orange.400' : 'blue.300', bg: 'white' }}
_focus={{ borderColor: 'blue.500', bg: 'white', shadow: 'sm' }}
fontWeight="medium"
/>
</Td>
<Td isNumeric>
<HStack justify="flex-end" spacing={2}>
<IconButton
aria-label="Uložit"
icon={<FiSave />}
size="sm"
onClick={() => onSave(it.code)}
colorScheme="green"
variant="ghost"
_hover={{ bg: 'green.50' }}
/>
<IconButton
aria-label="Smazat"
icon={<FiTrash2 />}
size="sm"
colorScheme="red"
variant="ghost"
_hover={{ bg: 'red.50' }}
onClick={() => onDelete(it.code)}
/>
</HStack>
</Td>
</Tr>
);
})}
{sorted.length === 0 && (
<Tr>
<Td colSpan={4}>
<VStack py={12} spacing={3}>
<Box color="gray.300" fontSize="4xl">
<FiEdit3 />
</Box>
<Text color="gray.500" fontSize="md" fontWeight="medium">
Žádné aliasy zatím nejsou
</Text>
<Text color="gray.400" fontSize="sm">
Přidejte nový alias nebo importujte ze soutěží
</Text>
</VStack>
</Td>
</Tr>
)}
</Tbody>
</Table>
</Box>
</Box>
{/* Summary Footer */}
{sorted.length > 0 && (
<Box
mt={4}
p={4}
bg={useColorModeValue('gray.50', 'gray.900')}
borderRadius="md"
borderWidth="1px"
borderColor="gray.200"
>
<HStack justify="space-between" wrap="wrap">
<Text fontSize="sm" color="gray.600">
Celkem aliasů: <Badge colorScheme="blue" ml={1}>{sorted.length}</Badge>
</Text>
{hasChanges && (
<HStack spacing={2}>
<Badge colorScheme="orange" variant="subtle">
{Object.keys(editing).length} neuložených změn
</Badge>
<Button
size="sm"
colorScheme="green"
onClick={onSaveAll}
leftIcon={<FiSave />}
>
Uložit vše
</Button>
</HStack>
)}
</HStack>
</Box>
)}
</Box>
</AdminLayout>
);
};
export default CompetitionAliasesAdminPage;
@@ -0,0 +1,832 @@
import React, { useState, useEffect } from 'react';
import {
Box,
Button,
Table,
Thead,
Tbody,
Tr,
Th,
Td,
IconButton,
useToast,
Modal,
ModalOverlay,
ModalContent,
ModalHeader,
ModalCloseButton,
ModalBody,
ModalFooter,
FormControl,
FormLabel,
Input,
Textarea,
Switch,
Select,
Tabs,
TabList,
TabPanels,
Tab,
TabPanel,
Heading,
Text,
Badge,
HStack,
VStack,
useDisclosure,
AlertDialog,
AlertDialogBody,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogContent,
AlertDialogOverlay,
SimpleGrid,
Divider,
FormHelperText,
useColorModeValue,
} from '@chakra-ui/react';
import { FiEdit, FiTrash2, FiPlus, FiUser } from 'react-icons/fi';
import AdminLayout from '../../layouts/AdminLayout';
import {
getContacts,
createContact,
updateContact,
deleteContact,
Contact,
getContactCategories,
ContactCategory,
} from '../../services/contactInfo';
import api, { uploadImage } from '../../services/api';
import { getImageUrl } from '../../utils/imageUtils';
import { getAdminSettings, updateAdminSettings, AdminSettings, PublicSettings } from '../../services/settings';
import MapLinkImporter from '../../components/admin/MapLinkImporter';
import { MapCoordinates } from '../../utils/mapUrlParser';
import ContactMap from '../../components/home/ContactMap';
import MapStyleSelector from '../../components/admin/MapStyleSelector';
const ContactsAdminPage: React.FC = () => {
const cardBg = useColorModeValue('white', 'gray.800');
const borderColor = useColorModeValue('gray.200', 'gray.700');
const inputBg = useColorModeValue('white', 'gray.700');
const textSecondary = useColorModeValue('gray.600', 'gray.400');
const bgMain = useColorModeValue('gray.50', 'gray.900');
const tableBg = useColorModeValue('white', 'gray.800');
const hoverBg = useColorModeValue('gray.50', 'gray.700');
const infoBg = useColorModeValue('blue.50', 'blue.900');
const infoBorder = useColorModeValue('blue.200', 'blue.700');
const [contacts, setContacts] = useState<Contact[]>([]);
const [categories, setCategories] = useState<ContactCategory[]>([]);
const [loading, setLoading] = useState(false);
const [selectedContact, setSelectedContact] = useState<Contact | null>(null);
const [isContactModalOpen, setIsContactModalOpen] = useState(false);
const [deleteItem, setDeleteItem] = useState<{ type: 'contact'; id: number } | null>(null);
const cancelRef = React.useRef<HTMLButtonElement>(null);
const toast = useToast();
// Form states
const [contactForm, setContactForm] = useState({
name: '',
position: '',
email: '',
phone: '',
category_id: undefined as number | undefined,
image_url: '',
description: '',
display_order: 0,
is_active: true,
});
const [uploadingImage, setUploadingImage] = useState(false);
const [settings, setSettings] = useState<AdminSettings>({});
const [savingSettings, setSavingSettings] = useState(false);
useEffect(() => {
loadData();
loadSettings();
}, []);
const loadData = async () => {
setLoading(true);
try {
const [contactsData, categoriesData] = await Promise.all([
getContacts(),
getContactCategories(),
]);
setContacts(contactsData);
setCategories(categoriesData);
} catch (error) {
toast({
title: 'Chyba při načítání',
description: 'Nepodařilo se načíst kontakty a kategorie',
status: 'error',
duration: 3000,
});
} finally {
setLoading(false);
}
};
// Contact handlers
const openContactModal = (contact?: Contact) => {
if (contact) {
setSelectedContact(contact);
setContactForm({
name: contact.name,
position: contact.position,
email: contact.email,
phone: contact.phone,
category_id: contact.category_id,
image_url: contact.image_url || '',
description: contact.description || '',
display_order: contact.display_order,
is_active: contact.is_active,
});
} else {
setSelectedContact(null);
setContactForm({
name: '',
position: '',
email: '',
phone: '',
category_id: undefined,
image_url: '',
description: '',
display_order: contacts.length * 10,
is_active: true,
});
}
setIsContactModalOpen(true);
};
const loadSettings = async () => {
try {
const data = await getAdminSettings();
setSettings(data || {});
} catch (error) {
console.error('Failed to load settings:', error);
}
};
const handleSettingsChange = (key: keyof AdminSettings) => (e: React.ChangeEvent<HTMLInputElement>) => {
setSettings((prev) => ({ ...prev, [key]: e.target.value }));
};
const handleNumChange = (key: keyof AdminSettings) => (e: React.ChangeEvent<HTMLInputElement>) => {
const val = e.target.value;
const n = val === '' ? undefined : Number(val);
setSettings((prev) => ({ ...prev, [key]: (Number.isFinite(n as number) ? (n as any) : undefined) }));
};
const handleBoolChange = (key: keyof AdminSettings) => (e: React.ChangeEvent<HTMLInputElement>) => {
const checked = (e.target as any).checked as boolean;
setSettings((prev) => ({ ...prev, [key]: checked as any }));
};
const handleSelectChange = (key: keyof AdminSettings) => (e: React.ChangeEvent<HTMLSelectElement>) => {
setSettings((prev) => ({ ...prev, [key]: e.target.value }));
};
const handleSaveSettings = async () => {
setSavingSettings(true);
try {
// Extract and validate settings
const lat = typeof settings.location_latitude === 'number' ? settings.location_latitude : undefined;
const lng = typeof settings.location_longitude === 'number' ? settings.location_longitude : undefined;
const zoom = typeof settings.map_zoom_level === 'number' ? settings.map_zoom_level : undefined;
// Auto-enable map display if coordinates are set
const hasCoordinates = lat !== undefined && lng !== undefined;
// Build payload with proper types, filtering out undefined values
const payload: Partial<AdminSettings> = {};
if (settings.contact_address !== undefined) payload.contact_address = settings.contact_address;
if (settings.contact_city !== undefined) payload.contact_city = settings.contact_city;
if (settings.contact_zip !== undefined) payload.contact_zip = settings.contact_zip;
if (settings.contact_country !== undefined) payload.contact_country = settings.contact_country;
if (settings.contact_phone !== undefined) payload.contact_phone = settings.contact_phone;
if (settings.contact_email !== undefined) payload.contact_email = settings.contact_email;
if (lat !== undefined) payload.location_latitude = lat;
if (lng !== undefined) payload.location_longitude = lng;
if (zoom !== undefined) payload.map_zoom_level = zoom;
if (settings.map_style !== undefined) payload.map_style = settings.map_style;
payload.show_map_on_homepage = hasCoordinates;
await updateAdminSettings(payload);
toast({
title: 'Nastavení uloženo',
description: 'Kontaktní informace a mapa byly aktualizovány',
status: 'success',
duration: 3000,
});
} catch (error: any) {
const errorMsg = error?.response?.data?.chyba || error?.response?.data?.detail || error?.response?.data?.error || error?.message || 'Uložení nastavení se nezdařilo';
console.error('Settings save error:', error);
toast({
title: 'Chyba při ukládání',
description: errorMsg,
status: 'error',
duration: 5000,
isClosable: true,
});
} finally {
setSavingSettings(false);
}
};
const handleContactSubmit = async () => {
if (!contactForm.name.trim()) {
toast({
title: 'Chyba validace',
description: 'Jméno je povinné',
status: 'error',
duration: 3000,
});
return;
}
setLoading(true);
try {
if (selectedContact) {
await updateContact(selectedContact.id, contactForm);
toast({
title: 'Kontakt aktualizován',
status: 'success',
duration: 2000,
});
} else {
await createContact(contactForm);
toast({
title: 'Kontakt vytvořen',
status: 'success',
duration: 2000,
});
}
setIsContactModalOpen(false);
loadData();
} catch (error: any) {
toast({
title: 'Chyba',
description: error.response?.data?.error || 'Uložení kontaktu se nezdařilo',
status: 'error',
duration: 3000,
});
} finally {
setLoading(false);
}
};
const handleDeleteContact = async (id: number) => {
setLoading(true);
try {
await deleteContact(id);
toast({
title: 'Kontakt smazán',
status: 'success',
duration: 2000,
});
loadData();
} catch (error) {
toast({
title: 'Chyba',
description: 'Smazání kontaktu se nezdařilo',
status: 'error',
duration: 3000,
});
} finally {
setLoading(false);
setDeleteItem(null);
}
};
// Image upload handler
const handleImageUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
// Validate file type
if (!file.type.startsWith('image/')) {
toast({
title: 'Neplatný typ souboru',
description: 'Nahrajte prosím obrázkový soubor',
status: 'error',
duration: 3000,
});
return;
}
setUploadingImage(true);
try {
const formData = new FormData();
formData.append('file', file);
const result = await uploadImage(formData);
setContactForm({ ...contactForm, image_url: result.url });
toast({
title: 'Obrázek nahrán',
status: 'success',
duration: 2000,
});
} catch (error: any) {
toast({
title: 'Nahrávání selhalo',
description: error.response?.data?.error || 'Nahrání obrázku se nezdařilo',
status: 'error',
duration: 3000,
});
} finally {
setUploadingImage(false);
}
};
return (
<AdminLayout>
<Box p={6} bg={bgMain} minH="100vh">
<Heading size="lg" mb={6}>Správa kontaktů</Heading>
<Tabs colorScheme="blue">
<TabList>
<Tab>Kontakty</Tab>
<Tab>Mapa a adresa</Tab>
</TabList>
<TabPanels>
{/* Contacts Tab */}
<TabPanel>
<HStack justify="space-between" mb={4}>
<Text>Spravujte kontaktní osoby vašeho klubu</Text>
<Button
leftIcon={<FiPlus />}
colorScheme="blue"
onClick={() => openContactModal()}
>
Přidat kontakt
</Button>
</HStack>
<Box overflowX="auto" bg={tableBg} borderRadius="md" borderWidth="1px" borderColor={borderColor}>
<Table variant="simple">
<Thead>
<Tr>
<Th>Foto</Th>
<Th>Jméno</Th>
<Th>Pozice</Th>
<Th>Soutěž</Th>
<Th>Email</Th>
<Th>Telefon</Th>
<Th>Pořadí</Th>
<Th>Stav</Th>
<Th>Akce</Th>
</Tr>
</Thead>
<Tbody>
{contacts.map((contact) => (
<Tr key={contact.id}>
<Td>
{contact.image_url ? (
<Box
as="img"
src={getImageUrl(contact.image_url)}
alt={contact.name}
boxSize="40px"
objectFit="cover"
borderRadius="md"
/>
) : (
<Box
boxSize="40px"
bg="gray.200"
borderRadius="md"
display="flex"
alignItems="center"
justifyContent="center"
>
<FiUser />
</Box>
)}
</Td>
<Td fontWeight="bold">{contact.name}</Td>
<Td>{contact.position}</Td>
<Td>
{contact.category_id ? (
<Badge colorScheme="purple">{categories.find(c => c.id === contact.category_id)?.name || contact.category_id}</Badge>
) : (
<Text fontSize="sm" color="gray.500">Bez kategorie</Text>
)}
</Td>
<Td>{contact.email}</Td>
<Td>{contact.phone}</Td>
<Td>{contact.display_order}</Td>
<Td>
<Badge colorScheme={contact.is_active ? 'green' : 'red'}>
{contact.is_active ? 'Aktivní' : 'Neaktivní'}
</Badge>
</Td>
<Td>
<HStack spacing={2}>
<IconButton
aria-label="Upravit"
icon={<FiEdit />}
size="sm"
onClick={() => openContactModal(contact)}
/>
<IconButton
aria-label="Smazat"
icon={<FiTrash2 />}
size="sm"
colorScheme="red"
onClick={() => setDeleteItem({ type: 'contact', id: contact.id })}
/>
</HStack>
</Td>
</Tr>
))}
{contacts.length === 0 && (
<Tr>
<Td colSpan={9} textAlign="center" py={8}>
<Text color="gray.500">Zatím žádné kontakty. Přidejte svůj první kontakt!</Text>
</Td>
</Tr>
)}
</Tbody>
</Table>
</Box>
</TabPanel>
{/* Map & Address Tab */}
<TabPanel>
<VStack align="stretch" spacing={6}>
<Box bg={cardBg} p={6} borderRadius="lg" borderWidth="1px" borderColor={borderColor}>
<Heading size="md" mb={4}>Kontaktní údaje</Heading>
<SimpleGrid columns={{ base: 1, md: 2 }} spacing={4}>
<FormControl>
<FormLabel>Adresa</FormLabel>
<Input
bg={inputBg}
value={settings.contact_address || ''}
onChange={handleSettingsChange('contact_address' as keyof AdminSettings)}
placeholder="Ulice a č.p."
/>
</FormControl>
<FormControl>
<FormLabel>Město</FormLabel>
<Input
bg={inputBg}
value={settings.contact_city || ''}
onChange={handleSettingsChange('contact_city' as keyof AdminSettings)}
placeholder="Město"
/>
</FormControl>
<FormControl>
<FormLabel>PSČ</FormLabel>
<Input
bg={inputBg}
value={settings.contact_zip || ''}
onChange={handleSettingsChange('contact_zip' as keyof AdminSettings)}
placeholder="12345"
/>
</FormControl>
<FormControl>
<FormLabel>Země</FormLabel>
<Input
bg={inputBg}
value={settings.contact_country || ''}
onChange={handleSettingsChange('contact_country' as keyof AdminSettings)}
placeholder="Česká republika"
/>
</FormControl>
<FormControl>
<FormLabel>Telefon</FormLabel>
<Input
bg={inputBg}
value={settings.contact_phone || ''}
onChange={handleSettingsChange('contact_phone' as keyof AdminSettings)}
placeholder="+420 123 456 789"
/>
</FormControl>
<FormControl>
<FormLabel>E-mail</FormLabel>
<Input
bg={inputBg}
type="email"
value={settings.contact_email || ''}
onChange={handleSettingsChange('contact_email' as keyof AdminSettings)}
placeholder="kontakt@klub.cz"
/>
</FormControl>
</SimpleGrid>
</Box>
<Box bg={cardBg} p={6} borderRadius="lg" borderWidth="1px" borderColor={borderColor}>
<Heading size="md" mb={4}>Poloha na mapě</Heading>
<MapLinkImporter
currentLatitude={settings.location_latitude}
currentLongitude={settings.location_longitude}
currentZoom={settings.map_zoom_level}
mapStyle={settings.map_style || 'positron'}
clubPrimaryColor={settings.primary_color}
clubSecondaryColor={settings.accent_color}
clubName={settings.club_name}
onImport={(coords: MapCoordinates) => {
setSettings((prev) => ({
...prev,
location_latitude: coords.latitude,
location_longitude: coords.longitude,
map_zoom_level: coords.zoom,
// Auto-fill address fields if available
contact_address: coords.street || prev.contact_address,
contact_city: coords.city || prev.contact_city,
contact_zip: coords.zip || prev.contact_zip,
contact_country: coords.country || prev.contact_country,
}));
toast({
title: 'Souřadnice a adresa importovány',
description: `GPS poloha${coords.city ? ' a kontaktní údaje' : ''} byly načteny`,
status: 'success',
duration: 3000,
});
}}
/>
<Text fontSize="sm" color={textSecondary} mt={2} fontStyle="italic">
Mapa se automaticky zobrazí na titulní stránce při nastavení GPS souřadnic.
</Text>
</Box>
<Box bg={cardBg} p={6} borderRadius="lg" borderWidth="1px" borderColor={borderColor}>
<Heading size="md" mb={4}>Ruční nastavení souřadnic</Heading>
<SimpleGrid columns={{ base: 1, md: 3 }} spacing={4}>
<FormControl>
<FormLabel>Zeměpisná šířka (Latitude)</FormLabel>
<Input
bg={inputBg}
type="number"
step="0.000001"
value={settings.location_latitude ?? ''}
onChange={handleNumChange('location_latitude' as keyof AdminSettings)}
placeholder="50.0947"
/>
<FormHelperText fontSize="xs">Rozsah: -90 90</FormHelperText>
</FormControl>
<FormControl>
<FormLabel>Zeměpisná délka (Longitude)</FormLabel>
<Input
bg={inputBg}
type="number"
step="0.000001"
value={settings.location_longitude ?? ''}
onChange={handleNumChange('location_longitude' as keyof AdminSettings)}
placeholder="17.6997"
/>
<FormHelperText fontSize="xs">Rozsah: -180 180</FormHelperText>
</FormControl>
<FormControl>
<FormLabel>Úroveň přiblížení (Zoom)</FormLabel>
<Input
bg={inputBg}
type="number"
min="1"
max="20"
value={settings.map_zoom_level ?? 15}
onChange={handleNumChange('map_zoom_level' as keyof AdminSettings)}
placeholder="15"
/>
<FormHelperText fontSize="xs">Rozsah: 1-20 (vyšší = větší přiblížení)</FormHelperText>
</FormControl>
</SimpleGrid>
</Box>
<Box bg={cardBg} p={6} borderRadius="lg" borderWidth="1px" borderColor={borderColor}>
<MapStyleSelector
value={settings.map_style || 'positron'}
onChange={(value) => {
setSettings((prev) => ({ ...prev, map_style: value as PublicSettings['map_style'] }));
}}
clubPrimaryColor={settings.primary_color}
clubSecondaryColor={settings.accent_color}
showPreview={true}
/>
</Box>
{/* Live Map Preview with Current Coordinates */}
{settings.location_latitude && settings.location_longitude && (
<Box bg={cardBg} p={6} borderRadius="lg" borderWidth="1px" borderColor={borderColor}>
<VStack align="stretch" spacing={3}>
<HStack justify="space-between">
<Heading size="md">Náhled vaší mapy</Heading>
<Badge colorScheme="green">Aktuální poloha</Badge>
</HStack>
<Text fontSize="sm" color={textSecondary}>
Toto je náhled mapy s vaší aktuální polohou a vybraným stylem. Takto se zobrazí návštěvníkům na webu.
</Text>
<ContactMap
latitude={settings.location_latitude}
longitude={settings.location_longitude}
zoom={settings.map_zoom_level || 15}
address={`${settings.contact_address || ''}${settings.contact_city ? ', ' + settings.contact_city : ''}`}
clubName={settings.club_name}
mapStyle={settings.map_style || 'positron'}
clubPrimaryColor={settings.primary_color}
clubSecondaryColor={settings.accent_color}
height={400}
/>
</VStack>
</Box>
)}
<Box bg={infoBg} p={4} borderRadius="md" borderWidth="1px" borderColor={infoBorder}>
<HStack justify="space-between" align="center">
<VStack align="start" spacing={1}>
<Text fontSize="sm" fontWeight="semibold">
📍 Nezapomeňte uložit změny
</Text>
<Text fontSize="xs" color={textSecondary}>
Uložte nastavení, aby se změny projevily na webu.
</Text>
</VStack>
<Button
colorScheme="blue"
size="lg"
onClick={handleSaveSettings}
isLoading={savingSettings}
loadingText="Ukládám..."
>
Uložit nastavení
</Button>
</HStack>
</Box>
</VStack>
</TabPanel>
</TabPanels>
</Tabs>
{/* Contact Modal */}
<Modal isOpen={isContactModalOpen} onClose={() => setIsContactModalOpen(false)} size="xl">
<ModalOverlay />
<ModalContent>
<ModalHeader>{selectedContact ? 'Upravit kontakt' : 'Přidat kontakt'}</ModalHeader>
<ModalCloseButton />
<ModalBody>
<VStack spacing={4}>
<FormControl isRequired>
<FormLabel>Jméno</FormLabel>
<Input
value={contactForm.name}
onChange={(e) => setContactForm({ ...contactForm, name: e.target.value })}
placeholder="Jan Novák"
/>
</FormControl>
<FormControl>
<FormLabel>Pozice</FormLabel>
<Input
value={contactForm.position}
onChange={(e) => setContactForm({ ...contactForm, position: e.target.value })}
placeholder="Předseda, Hlavní trenér, atd."
/>
</FormControl>
<FormControl>
<FormLabel>Kategorie</FormLabel>
<Select
value={contactForm.category_id || ''}
onChange={(e) =>
setContactForm({
...contactForm,
category_id: e.target.value ? parseInt(e.target.value) : undefined,
})
}
>
<option value="">Bez přiřazení</option>
{categories.map((cat) => (
<option key={cat.id} value={cat.id}>
{cat.name}
</option>
))}
</Select>
<FormHelperText fontSize="xs">Přiřaďte kontakt ke konkrétní kategorii</FormHelperText>
</FormControl>
<FormControl>
<FormLabel>Email</FormLabel>
<Input
type="email"
value={contactForm.email}
onChange={(e) => setContactForm({ ...contactForm, email: e.target.value })}
placeholder="john@example.com"
/>
</FormControl>
<FormControl>
<FormLabel>Telefon</FormLabel>
<Input
value={contactForm.phone}
onChange={(e) => setContactForm({ ...contactForm, phone: e.target.value })}
placeholder="+420 123 456 789"
/>
</FormControl>
<FormControl>
<FormLabel>Fotografie</FormLabel>
<Input
type="file"
accept="image/*"
onChange={handleImageUpload}
disabled={uploadingImage}
/>
{contactForm.image_url && (
<Box mt={2}>
<img
src={getImageUrl(contactForm.image_url)}
alt="Náhled"
style={{ maxWidth: '200px', borderRadius: '8px' }}
/>
</Box>
)}
</FormControl>
<FormControl>
<FormLabel>Popis</FormLabel>
<Textarea
value={contactForm.description}
onChange={(e) => setContactForm({ ...contactForm, description: e.target.value })}
placeholder="Stručný popis nebo bio"
rows={3}
/>
</FormControl>
<FormControl>
<FormLabel>Pořadí zobrazení</FormLabel>
<Input
type="number"
value={contactForm.display_order}
onChange={(e) =>
setContactForm({ ...contactForm, display_order: parseInt(e.target.value) || 0 })
}
/>
</FormControl>
<FormControl display="flex" alignItems="center">
<FormLabel mb="0">Aktivní</FormLabel>
<Switch
isChecked={contactForm.is_active}
onChange={(e) => setContactForm({ ...contactForm, is_active: e.target.checked })}
/>
</FormControl>
</VStack>
</ModalBody>
<ModalFooter>
<Button variant="ghost" mr={3} onClick={() => setIsContactModalOpen(false)}>
Zrušit
</Button>
<Button colorScheme="blue" onClick={handleContactSubmit} isLoading={loading}>
{selectedContact ? 'Aktualizovat' : 'Vytvořit'}
</Button>
</ModalFooter>
</ModalContent>
</Modal>
{/* Delete Confirmation Dialog */}
<AlertDialog
isOpen={deleteItem !== null}
leastDestructiveRef={cancelRef}
onClose={() => setDeleteItem(null)}
>
<AlertDialogOverlay>
<AlertDialogContent>
<AlertDialogHeader fontSize="lg" fontWeight="bold">
Smazat kontakt
</AlertDialogHeader>
<AlertDialogBody>
Opravdu chcete smazat tento kontakt? Tuto akci nelze vrátit zpět.
</AlertDialogBody>
<AlertDialogFooter>
<Button ref={cancelRef} onClick={() => setDeleteItem(null)}>
Zrušit
</Button>
<Button
colorScheme="red"
onClick={() => {
if (deleteItem) {
handleDeleteContact(deleteItem.id);
}
}}
ml={3}
isLoading={loading}
>
Smazat
</Button>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialogOverlay>
</AlertDialog>
</Box>
</AdminLayout>
);
};
export default ContactsAdminPage;
@@ -0,0 +1,95 @@
import React, { useState, useEffect } from 'react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Users, Calendar, FileText } from 'lucide-react';
import { getAnalytics, AnalyticsData } from '@/services/analyticsService';
export default function DashboardPage() {
const [analytics, setAnalytics] = useState<AnalyticsData | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
const fetchAnalytics = async () => {
try {
const data = await getAnalytics();
setAnalytics(data);
} catch (err) {
setError('Nepodařilo se načíst analytická data');
console.error('Error fetching analytics:', err);
} finally {
setLoading(false);
}
};
fetchAnalytics();
}, []);
if (loading) return <div>Načítám data...</div>;
if (error) return <div className="text-red-500">{error}</div>;
return (
<div className="container mx-auto px-4 py-8">
{/* Header removed; Admin layout already provides the main title */}
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-3 mb-8">
<StatsCard
title="Uživatelé"
value={analytics?.users.total || 0}
description={`+${analytics?.users.new_this_week || 0} tento týden`}
icon={Users}
color="text-blue-500"
bgColor="bg-blue-50"
/>
<StatsCard
title="Události"
value={analytics?.events.total || 0}
description={`${analytics?.events.upcoming || 0} nadcházejících`}
icon={Calendar}
color="text-green-500"
bgColor="bg-green-50"
/>
<StatsCard
title="Články"
value={analytics?.articles.total || 0}
description={`${analytics?.articles.published || 0} publikováno`}
icon={FileText}
color="text-purple-500"
bgColor="bg-purple-50"
/>
</div>
{/* Add more dashboard sections here */}
</div>
);
}
interface StatsCardProps {
title: string;
value: string | number;
description: string;
icon: React.ComponentType<{ className?: string }>;
color: string;
bgColor: string;
}
function StatsCard({ title, value, description, icon: Icon, color, bgColor }: StatsCardProps) {
return (
<Card>
<CardHeader className="flex flex-row items-center justify-between pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">
{title}
</CardTitle>
<div className={`p-2 rounded-lg ${bgColor}`}>
<Icon className={`h-4 w-4 ${color}`} />
</div>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{value}</div>
<p className="text-xs text-muted-foreground">{description}</p>
</CardContent>
</Card>
);
}
+664
View File
@@ -0,0 +1,664 @@
import {
Box,
Button,
Heading,
HStack,
IconButton,
Image,
Input,
InputGroup,
InputLeftElement,
Modal,
ModalBody,
ModalCloseButton,
ModalContent,
ModalFooter,
ModalHeader,
ModalOverlay,
Table,
Tbody,
Td,
Th,
Thead,
Tr,
useDisclosure,
useToast,
VStack,
Text,
Badge,
Tabs,
TabList,
Tab,
TabPanels,
TabPanel,
useColorModeValue,
Tooltip,
Select,
Flex,
Spacer,
Alert,
AlertIcon,
AlertTitle,
AlertDescription,
Stack,
Link as ChakraLink,
Divider,
Code,
Icon,
} from '@chakra-ui/react';
import { useState, useMemo } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { FiTrash2, FiSearch, FiRefreshCw, FiCopy, FiAlertTriangle, FiExternalLink } from 'react-icons/fi';
import AdminLayout from '../../layouts/AdminLayout';
import {
FileInfo,
FileUsage,
DuplicateFiles,
getAllFiles,
getUnusedFiles,
getDuplicateFiles,
deleteFile,
scanAndSyncFiles,
formatFileSize,
getFileIcon,
} from '../../services/files';
const FilesAdminPage: React.FC = () => {
const toast = useToast();
const qc = useQueryClient();
const [search, setSearch] = useState('');
const [mimeFilter, setMimeFilter] = useState('');
const [selectedFile, setSelectedFile] = useState<FileInfo | null>(null);
const [deleteTarget, setDeleteTarget] = useState<FileInfo | null>(null);
const [forceDelete, setForceDelete] = useState(false);
const [scanResult, setScanResult] = useState<any>(null);
const { isOpen: isUsagesOpen, onOpen: onUsagesOpen, onClose: onUsagesClose } = useDisclosure();
const { isOpen: isDeleteOpen, onOpen: onDeleteOpen, onClose: onDeleteClose } = useDisclosure();
const { isOpen: isScanResultOpen, onOpen: onScanResultOpen, onClose: onScanResultClose } = useDisclosure();
const borderColor = useColorModeValue('gray.200', 'gray.600');
const bgHover = useColorModeValue('gray.50', 'gray.700');
// Fetch all files
const { data: allFiles = [], isLoading, refetch } = useQuery({
queryKey: ['admin-files', search, mimeFilter],
queryFn: () => getAllFiles({ search, mime_type: mimeFilter }),
});
// Fetch unused files
const { data: unusedFiles = [] } = useQuery({
queryKey: ['admin-files-unused'],
queryFn: getUnusedFiles,
});
// Fetch duplicate files
const { data: duplicateFiles = {} } = useQuery({
queryKey: ['admin-files-duplicates'],
queryFn: getDuplicateFiles,
});
// Delete mutation
const deleteMutation = useMutation({
mutationFn: ({ id, force }: { id: number; force: boolean }) => deleteFile(id, force),
onSuccess: () => {
toast({ title: 'Soubor smazán', status: 'success' });
qc.invalidateQueries({ queryKey: ['admin-files'] });
qc.invalidateQueries({ queryKey: ['admin-files-unused'] });
qc.invalidateQueries({ queryKey: ['admin-files-duplicates'] });
onDeleteClose();
setDeleteTarget(null);
setForceDelete(false);
},
onError: (error: any) => {
if (error.response?.status === 409) {
// File is in use - show warning
setForceDelete(false);
toast({
title: 'Soubor je používán',
description: 'Soubor je používán. Zkontrolujte použití a potvrďte smazání.',
status: 'warning',
duration: 5000,
});
} else {
toast({
title: 'Chyba při mazání',
description: error?.response?.data?.error || 'Nepodařilo se smazat soubor',
status: 'error',
});
}
},
});
// Scan mutation
const scanMutation = useMutation({
mutationFn: scanAndSyncFiles,
onSuccess: (data) => {
setScanResult(data);
onScanResultOpen();
qc.invalidateQueries({ queryKey: ['admin-files'] });
qc.invalidateQueries({ queryKey: ['admin-files-unused'] });
qc.invalidateQueries({ queryKey: ['admin-files-duplicates'] });
},
onError: () => {
toast({ title: 'Chyba při skenování', status: 'error' });
},
});
const handleDelete = (file: FileInfo) => {
setDeleteTarget(file);
setForceDelete(false);
onDeleteOpen();
};
const confirmDelete = () => {
if (deleteTarget) {
deleteMutation.mutate({ id: deleteTarget.id, force: forceDelete });
}
};
const handleViewUsages = (file: FileInfo) => {
setSelectedFile(file);
onUsagesOpen();
};
const copyToClipboard = (text: string) => {
navigator.clipboard.writeText(text);
toast({ title: 'Zkopírováno', status: 'success', duration: 2000 });
};
const getImageUrl = (url: string) => {
if (url.startsWith('http')) return url;
const apiUrl = process.env.REACT_APP_API_URL || 'http://localhost:8080/api/v1';
const origin = new URL(apiUrl).origin;
return `${origin}${url}`;
};
// Mime type options
const mimeTypes = useMemo(() => {
const types = new Set<string>();
allFiles.forEach(file => {
if (file.mime_type) {
const baseType = file.mime_type.split('/')[0];
types.add(baseType);
}
});
return Array.from(types);
}, [allFiles]);
const FileRow: React.FC<{ file: FileInfo; showUsageCount?: boolean }> = ({ file, showUsageCount = true }) => (
<Tr _hover={{ bg: bgHover }}>
<Td>
<HStack>
{file.mime_type?.startsWith('image/') ? (
<Image
src={getImageUrl(file.file_url)}
alt={file.filename}
boxSize="40px"
objectFit="cover"
borderRadius="md"
fallbackSrc="/logo192.png"
/>
) : (
<Icon as={getFileIcon(file.mime_type || '')} boxSize={6} color="blue.500" />
)}
<VStack align="start" spacing={0}>
<Text fontWeight="medium" fontSize="sm">{file.filename}</Text>
<Text fontSize="xs" color="gray.500">{file.file_path}</Text>
</VStack>
</HStack>
</Td>
<Td fontSize="sm">{formatFileSize(file.file_size)}</Td>
<Td fontSize="sm">
<Badge colorScheme={file.mime_type?.startsWith('image/') ? 'blue' : 'gray'}>
{file.mime_type}
</Badge>
</Td>
{showUsageCount && (
<Td>
<HStack>
<Badge colorScheme={file.usage_count > 0 ? 'green' : 'red'}>
{file.usage_count}
</Badge>
{file.usage_count > 0 && (
<IconButton
aria-label="Zobrazit použití"
icon={<FiExternalLink />}
size="xs"
variant="ghost"
onClick={() => handleViewUsages(file)}
/>
)}
</HStack>
</Td>
)}
<Td fontSize="sm">{new Date(file.created_at).toLocaleDateString('cs-CZ')}</Td>
<Td>
<HStack spacing={1}>
<Tooltip label="Kopírovat URL">
<IconButton
aria-label="Kopírovat URL"
icon={<FiCopy />}
size="sm"
variant="ghost"
onClick={() => copyToClipboard(file.file_url)}
/>
</Tooltip>
<Tooltip label="Smazat">
<IconButton
aria-label="Smazat"
icon={<FiTrash2 />}
size="sm"
variant="ghost"
colorScheme="red"
onClick={() => handleDelete(file)}
/>
</Tooltip>
</HStack>
</Td>
</Tr>
);
const duplicateGroups = Object.entries(duplicateFiles);
return (
<AdminLayout requireAdmin={true}>
<VStack align="stretch" spacing={6}>
<HStack justify="space-between">
<Heading size="lg">Správa souborů</Heading>
<Button
leftIcon={<FiRefreshCw />}
onClick={() => scanMutation.mutate()}
isLoading={scanMutation.isPending}
colorScheme="blue"
size="sm"
>
Skenovat soubory
</Button>
</HStack>
<Tabs colorScheme="blue" variant="enclosed">
<TabList>
<Tab>Všechny soubory ({allFiles.length})</Tab>
<Tab>Nepoužívané ({unusedFiles.length})</Tab>
<Tab>Duplicity ({duplicateGroups.length})</Tab>
</TabList>
<TabPanels>
{/* All Files Tab */}
<TabPanel>
<VStack align="stretch" spacing={4}>
{allFiles.length === 0 && !isLoading && (
<Alert status="info" borderRadius="md">
<AlertIcon />
<Box flex="1">
<AlertTitle>Žádné soubory v databázi</AlertTitle>
<AlertDescription>
Klikněte na tlačítko "Skenovat soubory" pro načtení souborů z uploads složky do databáze.
</AlertDescription>
</Box>
</Alert>
)}
<HStack spacing={4}>
<InputGroup maxW="400px">
<InputLeftElement pointerEvents="none">
<FiSearch />
</InputLeftElement>
<Input
placeholder="Hledat soubory..."
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
</InputGroup>
<Select
placeholder="Všechny typy"
maxW="200px"
value={mimeFilter}
onChange={(e) => setMimeFilter(e.target.value)}
>
{mimeTypes.map(type => (
<option key={type} value={type}>{type}</option>
))}
</Select>
<Spacer />
<Text fontSize="sm" color="gray.500">
Celkem: {allFiles.length} souborů
</Text>
</HStack>
<Box overflowX="auto" borderWidth="1px" borderRadius="md" borderColor={borderColor}>
<Table size="sm">
<Thead>
<Tr>
<Th>Soubor</Th>
<Th>Velikost</Th>
<Th>Typ</Th>
<Th>Použití</Th>
<Th>Vytvořeno</Th>
<Th>Akce</Th>
</Tr>
</Thead>
<Tbody>
{isLoading ? (
<Tr>
<Td colSpan={6} textAlign="center" py={8}>
Načítání...
</Td>
</Tr>
) : allFiles.length === 0 ? (
<Tr>
<Td colSpan={6} textAlign="center" py={8}>
Žádné soubory nenalezeny
</Td>
</Tr>
) : (
allFiles.map(file => <FileRow key={file.id} file={file} />)
)}
</Tbody>
</Table>
</Box>
</VStack>
</TabPanel>
{/* Unused Files Tab */}
<TabPanel>
<VStack align="stretch" spacing={4}>
<Alert status="info" borderRadius="md">
<AlertIcon />
<Box>
<AlertTitle>Nepoužívané soubory</AlertTitle>
<AlertDescription>
Tyto soubory nejsou použity v žádném článku, hráči, sponzorovi nebo jiné entitě.
</AlertDescription>
</Box>
</Alert>
<Box overflowX="auto" borderWidth="1px" borderRadius="md" borderColor={borderColor}>
<Table size="sm">
<Thead>
<Tr>
<Th>Soubor</Th>
<Th>Velikost</Th>
<Th>Typ</Th>
<Th>Vytvořeno</Th>
<Th>Akce</Th>
</Tr>
</Thead>
<Tbody>
{unusedFiles.length === 0 ? (
<Tr>
<Td colSpan={5} textAlign="center" py={8}>
Všechny soubory jsou používány
</Td>
</Tr>
) : (
unusedFiles.map(file => <FileRow key={file.id} file={file} showUsageCount={false} />)
)}
</Tbody>
</Table>
</Box>
</VStack>
</TabPanel>
{/* Duplicates Tab */}
<TabPanel>
<VStack align="stretch" spacing={4}>
<Alert status="warning" borderRadius="md">
<AlertIcon />
<Box>
<AlertTitle>Duplicitní soubory</AlertTitle>
<AlertDescription>
Tyto soubory mají identický obsah (MD5 hash). Můžete je smazat a aktualizovat odkazy.
</AlertDescription>
</Box>
</Alert>
{duplicateGroups.length === 0 ? (
<Box textAlign="center" py={8}>
<Text color="gray.500">Žádné duplicity nenalezeny</Text>
</Box>
) : (
duplicateGroups.map(([hash, files]) => (
<Box key={hash} borderWidth="1px" borderRadius="md" p={4} borderColor={borderColor}>
<VStack align="stretch" spacing={2}>
<HStack>
<FiAlertTriangle color="orange" />
<Text fontWeight="bold" fontSize="sm">
Duplicitní skupina ({files.length} souborů)
</Text>
<Code fontSize="xs">{hash.substring(0, 12)}...</Code>
</HStack>
<Divider />
<Table size="sm" variant="simple">
<Thead>
<Tr>
<Th>Soubor</Th>
<Th>Velikost</Th>
<Th>Použití</Th>
<Th>Vytvořeno</Th>
<Th>Akce</Th>
</Tr>
</Thead>
<Tbody>
{files.map(file => (
<FileRow key={file.id} file={file} />
))}
</Tbody>
</Table>
</VStack>
</Box>
))
)}
</VStack>
</TabPanel>
</TabPanels>
</Tabs>
</VStack>
{/* File Usages Modal */}
<Modal isOpen={isUsagesOpen} onClose={onUsagesClose} size="xl">
<ModalOverlay />
<ModalContent>
<ModalHeader>Použití souboru</ModalHeader>
<ModalCloseButton />
<ModalBody>
{selectedFile && (
<VStack align="stretch" spacing={4}>
<HStack>
{selectedFile.mime_type?.startsWith('image/') && (
<Image
src={getImageUrl(selectedFile.file_url)}
alt={selectedFile.filename}
maxH="100px"
borderRadius="md"
/>
)}
<VStack align="start" flex={1}>
<Text fontWeight="bold">{selectedFile.filename}</Text>
<Text fontSize="sm" color="gray.500">{selectedFile.file_path}</Text>
</VStack>
</HStack>
<Divider />
<VStack align="stretch" spacing={2}>
<Text fontWeight="bold">Používáno v ({selectedFile.usage_count}):</Text>
{selectedFile.usages && selectedFile.usages.length > 0 ? (
selectedFile.usages.map((usage) => (
<Box key={usage.id} p={3} borderWidth="1px" borderRadius="md">
<HStack justify="space-between">
<VStack align="start" spacing={0}>
<Badge colorScheme="purple">{usage.entity_type}</Badge>
<Text fontSize="sm" mt={1}>
{usage.entity_info?.title || usage.entity_info?.name || `ID: ${usage.entity_id}`}
</Text>
{usage.field_name && (
<Text fontSize="xs" color="gray.500">Pole: {usage.field_name}</Text>
)}
</VStack>
{usage.entity_info?.url && (
<ChakraLink href={usage.entity_info.url} isExternal>
<IconButton
aria-label="Otevřít"
icon={<FiExternalLink />}
size="sm"
variant="ghost"
/>
</ChakraLink>
)}
</HStack>
</Box>
))
) : (
<Text color="gray.500" fontSize="sm">Soubor není nikde použit</Text>
)}
</VStack>
</VStack>
)}
</ModalBody>
<ModalFooter>
<Button onClick={onUsagesClose}>Zavřít</Button>
</ModalFooter>
</ModalContent>
</Modal>
{/* Delete Confirmation Modal */}
<Modal isOpen={isDeleteOpen} onClose={onDeleteClose}>
<ModalOverlay />
<ModalContent>
<ModalHeader>Smazat soubor</ModalHeader>
<ModalCloseButton />
<ModalBody>
{deleteTarget && (
<VStack align="stretch" spacing={4}>
<Text>
Opravdu chcete smazat soubor <strong>{deleteTarget.filename}</strong>?
</Text>
{deleteTarget.usage_count > 0 && (
<Alert status="warning">
<AlertIcon />
<Box>
<AlertTitle>Pozor!</AlertTitle>
<AlertDescription>
Tento soubor je použit na {deleteTarget.usage_count} místech.
Smazáním může dojít k nefunkčnosti odkazů.
</AlertDescription>
</Box>
</Alert>
)}
</VStack>
)}
</ModalBody>
<ModalFooter>
<HStack spacing={3}>
<Button variant="ghost" onClick={onDeleteClose}>
Zrušit
</Button>
<Button
colorScheme="red"
onClick={confirmDelete}
isLoading={deleteMutation.isPending}
>
{(deleteTarget?.usage_count ?? 0) > 0 ? 'Přesto smazat' : 'Smazat'}
</Button>
</HStack>
</ModalFooter>
</ModalContent>
</Modal>
{/* Scan Results Modal */}
<Modal isOpen={isScanResultOpen} onClose={onScanResultClose} size="lg">
<ModalOverlay />
<ModalContent>
<ModalHeader>Výsledky skenování souborů</ModalHeader>
<ModalCloseButton />
<ModalBody>
{scanResult && (
<VStack align="stretch" spacing={4}>
<Alert status="success" borderRadius="md">
<AlertIcon />
<Box flex="1">
<AlertTitle>Skenování dokončeno</AlertTitle>
<AlertDescription>{scanResult.message}</AlertDescription>
</Box>
</Alert>
<Stack spacing={3}>
<HStack justify="space-between" p={3} borderWidth="1px" borderRadius="md">
<Text fontWeight="medium">Nalezených souborů:</Text>
<Badge colorScheme="blue" fontSize="md" px={3} py={1}>
{scanResult.found_files}
</Badge>
</HStack>
<HStack justify="space-between" p={3} borderWidth="1px" borderRadius="md">
<Text fontWeight="medium">Nově přidaných:</Text>
<Badge colorScheme="green" fontSize="md" px={3} py={1}>
{scanResult.new_files}
</Badge>
</HStack>
{scanResult.skipped_files !== undefined && scanResult.skipped_files > 0 && (
<HStack justify="space-between" p={3} borderWidth="1px" borderRadius="md">
<Text fontWeight="medium">Přeskočených (.gitkeep, atd.):</Text>
<Badge colorScheme="gray" fontSize="md" px={3} py={1}>
{scanResult.skipped_files}
</Badge>
</HStack>
)}
{scanResult.orphaned_files > 0 && (
<HStack justify="space-between" p={3} borderWidth="1px" borderRadius="md" borderColor="orange.300">
<Text fontWeight="medium">Osiřelých záznamů:</Text>
<Badge colorScheme="orange" fontSize="md" px={3} py={1}>
{scanResult.orphaned_files}
</Badge>
</HStack>
)}
</Stack>
{scanResult.new_files_list && scanResult.new_files_list.length > 0 && (
<Box>
<Text fontWeight="bold" mb={2}>Nově přidané soubory:</Text>
<Box maxH="200px" overflowY="auto" borderWidth="1px" borderRadius="md" p={3}>
<VStack align="stretch" spacing={1}>
{scanResult.new_files_list.map((filename: string, idx: number) => (
<Text key={idx} fontSize="sm" fontFamily="monospace">
{filename}
</Text>
))}
</VStack>
</Box>
</Box>
)}
{scanResult.orphaned_list && scanResult.orphaned_list.length > 0 && (
<Box>
<Text fontWeight="bold" mb={2} color="orange.500">Osiřelé záznamy (soubory v DB, ale ne na disku):</Text>
<Box maxH="200px" overflowY="auto" borderWidth="1px" borderRadius="md" p={3} borderColor="orange.300">
<VStack align="stretch" spacing={1}>
{scanResult.orphaned_list.map((filename: string, idx: number) => (
<Text key={idx} fontSize="sm" fontFamily="monospace" color="orange.600">
{filename}
</Text>
))}
</VStack>
</Box>
</Box>
)}
</VStack>
)}
</ModalBody>
<ModalFooter>
<Button colorScheme="blue" onClick={onScanResultClose}>
Zavřít
</Button>
</ModalFooter>
</ModalContent>
</Modal>
</AdminLayout>
);
};
export default FilesAdminPage;
@@ -0,0 +1,368 @@
import React, { useEffect, useState } from 'react';
import {
Box,
Container,
Heading,
Text,
Table,
Thead,
Tbody,
Tr,
Th,
Td,
Button,
HStack,
VStack,
Badge,
Spinner,
useToast,
Image,
Link,
Alert,
AlertIcon,
AlertTitle,
AlertDescription,
useColorModeValue,
} from '@chakra-ui/react';
import { RefreshCw, ExternalLink, Calendar, Image as ImageIcon, Eye } from 'lucide-react';
import AdminLayout from '../../components/layout/AdminLayout';
interface Album {
id: string;
title: string;
url: string;
date: string;
photos_count: number;
views_count?: number;
photos: Array<{
id: string;
page_url: string;
image_1500: string;
}>;
}
const resolveBackendUrl = (path: string) => {
try {
if (/^https?:\/\//i.test(path)) return path;
if (path.startsWith('/cache') || path.startsWith('/uploads') || path.startsWith('/api/')) {
const base = process.env.REACT_APP_API_BASE_URL || process.env.REACT_APP_API_URL || 'http://localhost:8080/api/v1';
const b = new URL(base);
const abs = new URL(path, `${b.protocol}//${b.host}`);
return abs.toString();
}
return path;
} catch {
return path;
}
};
const GalleryAdminPage: React.FC = () => {
const cardBg = useColorModeValue('white', 'gray.800');
const borderColor = useColorModeValue('gray.200', 'gray.700');
const textSecondary = useColorModeValue('gray.600', 'gray.400');
const [albums, setAlbums] = useState<Album[]>([]);
const [loading, setLoading] = useState(true);
const [refreshing, setRefreshing] = useState(false);
const [error, setError] = useState<string>('');
const toast = useToast();
const fetchAlbums = async () => {
setLoading(true);
setError('');
try {
// Try to load from both sources and combine them (similar to frontpage)
const [profileRes, albumsRes] = await Promise.allSettled([
fetch(resolveBackendUrl('/cache/prefetch/zonerama_profile.json'), { cache: 'no-cache' }),
fetch(resolveBackendUrl('/cache/prefetch/zonerama_albums.json'), { cache: 'no-cache' })
]);
let combinedAlbums: Album[] = [];
// Get profile albums (main source)
if (profileRes.status === 'fulfilled' && profileRes.value.ok) {
const profileData = await profileRes.value.json();
if (profileData.albums && Array.isArray(profileData.albums)) {
combinedAlbums = [...profileData.albums];
}
}
// Get additional albums (fallback source)
if (albumsRes.status === 'fulfilled' && albumsRes.value.ok) {
const albumsData = await albumsRes.value.json();
const blogAlbums = Array.isArray(albumsData) ? albumsData : [];
// Filter out empty albums and avoid duplicates
const validBlogAlbums = blogAlbums.filter((album: any) =>
album.id &&
album.title &&
!combinedAlbums.some(existing => existing.id === album.id)
);
combinedAlbums = [...combinedAlbums, ...validBlogAlbums];
}
setAlbums(combinedAlbums);
} catch (err: any) {
setError(err.message || 'Nepodařilo se načíst alba');
} finally {
setLoading(false);
}
};
const handleRefresh = async () => {
setRefreshing(true);
try {
const apiUrl = process.env.REACT_APP_API_URL || 'http://localhost:8080/api/v1';
const token = localStorage.getItem('token');
const response = await fetch(`${apiUrl}/admin/gallery/refresh`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json',
},
});
if (!response.ok) {
throw new Error('Chyba při obnově galerie');
}
toast({
title: 'Galerie obnovena',
description: 'Data z Zonerama byla úspěšně načtena',
status: 'success',
duration: 3000,
isClosable: true,
});
// Reload albums after refresh
await fetchAlbums();
} catch (err: any) {
toast({
title: 'Chyba',
description: err.message || 'Nepodařilo se obnovit galerii',
status: 'error',
duration: 5000,
isClosable: true,
});
} finally {
setRefreshing(false);
}
};
useEffect(() => {
fetchAlbums();
}, []);
const totalPhotos = albums.reduce((sum, album) => sum + album.photos_count, 0);
const totalViews = albums.reduce((sum, album) => sum + (album.views_count || 0), 0);
return (
<AdminLayout>
<Container maxW="7xl" py={8}>
<VStack align="stretch" spacing={6}>
{/* Header */}
<HStack justify="space-between" align="center" flexWrap="wrap">
<VStack align="start" spacing={1}>
<Heading size="xl">Správa galerie</Heading>
<Text color="gray.600">
Správa alb a fotografií ze Zonerama
</Text>
</VStack>
<Button
leftIcon={<RefreshCw size={18} />}
colorScheme="blue"
onClick={handleRefresh}
isLoading={refreshing}
loadingText="Obnova..."
>
Obnovit z Zonerama
</Button>
</HStack>
{/* Zonerama Info */}
<Alert status="info" borderRadius="md">
<AlertIcon />
<Box>
<AlertTitle>Zonerama integrace</AlertTitle>
<AlertDescription>
Alba jsou automaticky načítána ze Zonerama profilu. Klikněte na "Obnovit z Zonerama" pro synchronizaci s nejnovějšími daty.
</AlertDescription>
</Box>
</Alert>
{/* Statistics */}
{!loading && !error && albums.length > 0 && (
<HStack spacing={4} flexWrap="wrap">
<Badge colorScheme="purple" fontSize="md" p={3} borderRadius="md">
{albums.length} alb
</Badge>
<Badge colorScheme="blue" fontSize="md" p={3} borderRadius="md">
{totalPhotos} fotografií
</Badge>
<Badge colorScheme="green" fontSize="md" p={3} borderRadius="md">
{totalViews} zhlédnutí
</Badge>
</HStack>
)}
{/* Loading State */}
{loading && (
<VStack spacing={4} py={12}>
<Spinner size="xl" color="brand.primary" />
<Text color="gray.600">Načítám alba...</Text>
</VStack>
)}
{/* Error State */}
{error && !loading && (
<Alert status="error" borderRadius="md">
<AlertIcon />
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
{/* Empty State */}
{!loading && !error && albums.length === 0 && (
<Box
bg={cardBg}
borderWidth="1px"
borderRadius="lg"
p={12}
textAlign="center"
>
<VStack spacing={4}>
<ImageIcon size={48} color="gray" />
<Heading size="md" color="gray.600">
Zatím nejsou k dispozici žádná alba
</Heading>
<Text color="gray.500">
Klikněte na tlačítko "Obnovit z Zonerama" pro načtení alb.
</Text>
<Button
leftIcon={<RefreshCw size={18} />}
colorScheme="blue"
onClick={handleRefresh}
isLoading={refreshing}
>
Obnovit z Zonerama
</Button>
</VStack>
</Box>
)}
{/* Albums Table */}
{!loading && !error && albums.length > 0 && (
<Box
bg={cardBg}
borderWidth="1px"
borderRadius="lg"
overflow="hidden"
boxShadow="sm"
>
<Table variant="simple">
<Thead bg={useColorModeValue('gray.50', 'gray.900')}>
<Tr>
<Th width="100px">Náhled</Th>
<Th>Název</Th>
<Th width="120px">Datum</Th>
<Th width="100px" isNumeric>Fotky</Th>
<Th width="120px" isNumeric>Zhlédnutí</Th>
<Th width="180px">Akce</Th>
</Tr>
</Thead>
<Tbody>
{albums.map((album) => {
const coverPhoto = album.photos && album.photos.length > 0
? album.photos[0]
: null;
return (
<Tr key={album.id} _hover={{ bg: 'gray.50' }}>
<Td>
{coverPhoto ? (
<Image
src={coverPhoto.image_1500}
alt={album.title}
boxSize="60px"
objectFit="cover"
borderRadius="md"
/>
) : (
<Box
boxSize="60px"
bg="gray.200"
borderRadius="md"
display="flex"
alignItems="center"
justifyContent="center"
>
<ImageIcon size={24} color="gray" />
</Box>
)}
</Td>
<Td>
<Text fontWeight="600" color="gray.800" noOfLines={2}>
{album.title}
</Text>
</Td>
<Td>
<HStack spacing={1} fontSize="sm" color="gray.600">
<Calendar size={14} />
<Text>{album.date}</Text>
</HStack>
</Td>
<Td isNumeric>
<Badge colorScheme="blue">
{album.photos_count}
</Badge>
</Td>
<Td isNumeric>
<HStack spacing={1} justify="flex-end">
<Eye size={14} />
<Text fontSize="sm">{album.views_count || 0}</Text>
</HStack>
</Td>
<Td>
<HStack spacing={2}>
<Button
as={Link}
href={`/galerie/album/${album.id}`}
target="_blank"
size="sm"
colorScheme="purple"
variant="outline"
>
Náhled
</Button>
<Button
as={Link}
href={album.url}
target="_blank"
rel="noopener noreferrer"
size="sm"
colorScheme="blue"
variant="ghost"
rightIcon={<ExternalLink size={14} />}
>
Zonerama
</Button>
</HStack>
</Td>
</Tr>
);
})}
</Tbody>
</Table>
</Box>
)}
</VStack>
</Container>
</AdminLayout>
);
};
export default GalleryAdminPage;
@@ -0,0 +1,990 @@
import {
Box,
Heading,
Text,
Spinner,
Alert,
AlertIcon,
Table,
Thead,
Tbody,
Tr,
Th,
Td,
HStack,
Badge,
Button,
useToast,
Drawer,
DrawerOverlay,
DrawerContent,
DrawerHeader,
DrawerBody,
DrawerFooter,
FormControl,
FormLabel,
Input,
Stack,
InputGroup,
InputRightElement,
List,
ListItem,
FormErrorMessage,
Image,
useBreakpointValue,
Wrap,
WrapItem,
useColorModeValue,
Select
} from '@chakra-ui/react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { TeamLogo } from '../../components/common/TeamLogo';
import AdminLayout from '../../layouts/AdminLayout';
import { putMatchOverride, patchMatchOverride, searchClubs, uploadImage, fetchLogoAsBlob, uploadToLogaSportcreative } from '../../services/adminMatches';
import { getPublicSettings } from '../../services/settings';
import { useEffect, useMemo, useRef, useState } from 'react';
import { useSearchParams } from 'react-router-dom';
import { parse } from 'date-fns';
import { assetUrl } from '../../utils/url';
const MatchesAdminPage = () => {
const queryClient = useQueryClient();
const toast = useToast();
const [isOpen, setIsOpen] = useState(false);
const [focusSide, setFocusSide] = useState<'home' | 'away' | null>(null);
const [selected, setSelected] = useState<any | null>(null);
const [form, setForm] = useState({
home_name_override: '',
away_name_override: '',
venue_override: '',
date_time_override: '',
home_logo_url: '',
away_logo_url: '',
notes: '',
});
// External logo upload helpers/state
const [homeExternalTeamId, setHomeExternalTeamId] = useState<string>('');
const [awayExternalTeamId, setAwayExternalTeamId] = useState<string>('');
const [homeUploadedFile, setHomeUploadedFile] = useState<File | null>(null);
const [awayUploadedFile, setAwayUploadedFile] = useState<File | null>(null);
// Team search state
const [homeQuery, setHomeQuery] = useState('');
const [awayQuery, setAwayQuery] = useState('');
const [debouncedHome, setDebouncedHome] = useState('');
const [debouncedAway, setDebouncedAway] = useState('');
useEffect(() => {
const t = setTimeout(() => setDebouncedHome(homeQuery), 300);
return () => clearTimeout(t);
}, [homeQuery]);
useEffect(() => {
const t = setTimeout(() => setDebouncedAway(awayQuery), 300);
return () => clearTimeout(t);
}, [awayQuery]);
const { data: homeResults = [] } = useQuery({
queryKey: ['club-search-home', debouncedHome],
queryFn: () => searchClubs(debouncedHome),
enabled: debouncedHome.trim().length >= 2,
});
const { data: awayResults = [] } = useQuery({
queryKey: ['club-search-away', debouncedAway],
queryFn: () => searchClubs(debouncedAway),
enabled: debouncedAway.trim().length >= 2,
});
// Upload refs
const homeFileRef = useRef<HTMLInputElement | null>(null);
const awayFileRef = useRef<HTMLInputElement | null>(null);
const { data: matches = [], isLoading, error } = useQuery<any[], Error>({
queryKey: ['admin-matches-list-cache'],
queryFn: async () => {
// Read cached FACR club info
const apiUrl = process.env.REACT_APP_API_URL || 'http://localhost:8080/api/v1';
const origin = new URL(apiUrl).origin;
const url = `${origin}/cache/prefetch/facr_club_info.json`;
const res = await fetch(url, { headers: { 'Cache-Control': 'no-cache' } });
if (!res.ok) throw new Error(`Failed to load cache: ${res.status}`);
const json = await res.json();
const comps = Array.isArray(json?.competitions) ? json.competitions : [];
const items: any[] = comps.flatMap((c: any) =>
(Array.isArray(c.matches) ? c.matches : []).map((m: any) => ({ ...m, competitionName: c.name, competition_id: c.id }))
);
// Optional: stable sort by date ascending
const FACR_DATE_FMT = 'dd.MM.yyyy HH:mm';
items.sort((a, b) => {
const da = parse(String(a.date_time || a.date), FACR_DATE_FMT, new Date()).getTime();
const db = parse(String(b.date_time || b.date), FACR_DATE_FMT, new Date()).getTime();
return da - db;
});
return items.map((m: any) => ({
id: m.match_id,
date_time: m.date_time || m.date,
competitionName: m.competitionName,
competition_id: m.competition_id,
home: m.home || m.home_team,
home_id: m.home_id || m.home_team_id || m.home_team_facr_id,
away: m.away || m.away_team,
away_id: m.away_id || m.away_team_id || m.away_team_facr_id,
score: m.score,
venue: m.venue,
home_logo_url: m.home_logo_url,
away_logo_url: m.away_logo_url,
}));
},
});
// Filters
const [teamFilter, setTeamFilter] = useState('');
const [dateFrom, setDateFrom] = useState<string>(''); // YYYY-MM-DD
const [dateTo, setDateTo] = useState<string>(''); // YYYY-MM-DD
const [competitionFilter, setCompetitionFilter] = useState<string>('');
const [sideFilter, setSideFilter] = useState<'home' | 'away' | ''>('');
const normalizedTeam = teamFilter.trim().toLowerCase();
const FACR_DATE_FMT = 'dd.MM.yyyy HH:mm';
// Club name (for side filter)
const { data: publicSettings } = useQuery({
queryKey: ['public-settings'],
queryFn: getPublicSettings,
});
const { data: facrClubInfo } = useQuery({
queryKey: ['facr-club-info-name'],
queryFn: async () => {
const apiUrl = process.env.REACT_APP_API_URL || 'http://localhost:8080/api/v1';
const origin = new URL(apiUrl).origin;
const url = `${origin}/cache/prefetch/facr_club_info.json`;
const res = await fetch(url, { headers: { 'Cache-Control': 'no-cache' } });
if (!res.ok) return null;
return await res.json();
},
});
const clubName: string = (publicSettings as any)?.club_name || (facrClubInfo as any)?.name || '';
const normalize = (s: string) => String(s || '')
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
.replace(/\s+/g, ' ')
.trim()
.toLowerCase();
const stripPrefixes = (s: string) => {
let x = normalize(s);
// Common Czech club prefixes/words
x = x.replace(/\b(mestsky|m\.?f\.?k\.?|mfk|tj|sk|sokol|fotbalovy|fotbalový|fotbalovy\s+klub|fotbalovy\s+klub)\b/g, '').replace(/\s+/g, ' ').trim();
return x;
};
const clubNorm = normalize(clubName);
const clubStrip = stripPrefixes(clubName);
const teamMatchesClub = (team: string): boolean => {
const t = normalize(team);
const ts = stripPrefixes(team);
return !!clubNorm && (t.includes(clubNorm) || ts.includes(clubStrip) || t.endsWith(clubStrip) || clubStrip.endsWith(ts));
};
const competitionOptions = useMemo(() => {
const set = new Set<string>();
for (const m of matches) {
if (m.competitionName) set.add(String(m.competitionName));
}
return Array.from(set).sort((a, b) => a.localeCompare(b));
}, [matches]);
const filteredMatches = matches.filter((m: any) => {
// team filter
const teamOk = normalizedTeam
? (
sideFilter === 'home'
? [m.home, m.home_team].filter(Boolean).some((v: string) => String(v).toLowerCase().includes(normalizedTeam))
: sideFilter === 'away'
? [m.away, m.away_team].filter(Boolean).some((v: string) => String(v).toLowerCase().includes(normalizedTeam))
: [m.home, m.home_team, m.away, m.away_team]
.filter(Boolean)
.some((v: string) => String(v).toLowerCase().includes(normalizedTeam))
)
: true;
if (!teamOk) return false;
// competition filter
if (competitionFilter && String(m.competitionName || '') !== competitionFilter) return false;
// side filter based on club name
if (sideFilter && clubNorm) {
const homeName = String(m.home || m.home_team || '');
const awayName = String(m.away || m.away_team || '');
if (sideFilter === 'home' && !teamMatchesClub(homeName)) return false;
if (sideFilter === 'away' && !teamMatchesClub(awayName)) return false;
}
// date parse
const dtStr = String(m.date_time || m.date || '');
let ts = NaN;
try {
ts = parse(dtStr, FACR_DATE_FMT, new Date()).getTime();
} catch (_) {
const d2 = new Date(dtStr);
ts = d2.getTime();
}
if (isNaN(ts)) return true; // if can't parse, let it pass other filters
// date range filter
if (dateFrom) {
const fromTs = new Date(dateFrom + 'T00:00:00').getTime();
if (!isNaN(fromTs) && ts < fromTs) return false;
}
if (dateTo) {
const toTs = new Date(dateTo + 'T23:59:59').getTime();
if (!isNaN(toTs) && ts > toTs) return false;
}
return true;
});
// Pagination (Load more) + page size selector
const [pageSize, setPageSize] = useState(50);
const [limit, setLimit] = useState(50);
const [searchParams, setSearchParams] = useSearchParams();
// Initialize filters from URL on first load and when data changes (so comps are known)
useEffect(() => {
const spTeam = searchParams.get('team') || '';
const spFrom = searchParams.get('from') || '';
const spTo = searchParams.get('to') || '';
const spComp = searchParams.get('comp') || '';
const spVenue = searchParams.get('venue') || '';
const spSide = searchParams.get('side') || '';
const spSize = parseInt(searchParams.get('size') || '') || undefined;
const spLimit = parseInt(searchParams.get('limit') || '') || undefined;
if (spTeam) setTeamFilter(spTeam);
if (spFrom) setDateFrom(spFrom);
if (spTo) setDateTo(spTo);
if (spComp) setCompetitionFilter(spComp);
// venue filter removed
if (spSide === 'home' || spSide === 'away') setSideFilter(spSide);
if (spSize) {
setPageSize(spSize);
setLimit(spSize);
}
if (spLimit) setLimit(spLimit);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// Keep URL in sync when filters/pagination change
useEffect(() => {
const params: Record<string, string> = {};
if (teamFilter) params.team = teamFilter;
if (dateFrom) params.from = dateFrom;
if (dateTo) params.to = dateTo;
if (competitionFilter) params.comp = competitionFilter;
// venue filter removed
if (sideFilter) params.side = sideFilter;
if (pageSize !== 50) params.size = String(pageSize);
if (limit !== pageSize) params.limit = String(limit);
setSearchParams(params, { replace: true });
}, [teamFilter, dateFrom, dateTo, competitionFilter, sideFilter, pageSize, limit, setSearchParams]);
useEffect(() => {
// reset pagination on filter change
setLimit(pageSize);
}, [normalizedTeam, dateFrom, dateTo, competitionFilter, sideFilter, clubNorm, pageSize]);
const visibleMatches = filteredMatches.slice(0, limit);
// Date presets
const setThisWeek = () => {
const now = new Date();
const day = now.getDay(); // 0 Sun .. 6 Sat
const diffToMonday = (day === 0 ? -6 : 1 - day); // Monday start
const monday = new Date(now);
monday.setDate(now.getDate() + diffToMonday);
const sunday = new Date(monday);
sunday.setDate(monday.getDate() + 6);
const f = monday.toISOString().slice(0, 10);
const t = sunday.toISOString().slice(0, 10);
setDateFrom(f);
setDateTo(t);
};
const setNext30Days = () => {
const now = new Date();
const to = new Date(now);
to.setDate(now.getDate() + 30);
const f = now.toISOString().slice(0, 10);
const t = to.toISOString().slice(0, 10);
setDateFrom(f);
setDateTo(t);
};
// Export CSV of filtered results
const exportCsv = () => {
const rows = filteredMatches.map((m: any) => {
const date = m.date_time || m.date || '';
const comp = m.competitionName || '';
const home = m.home || m.home_team || '';
const away = m.away || m.away_team || '';
const score = m.score || (m.result_home != null && m.result_away != null ? `${m.result_home}:${m.result_away}` : '');
const venue = m.venue || '';
return { date, competition: comp, home, away, score, venue };
});
const headers = ['date', 'competition', 'home', 'away', 'score', 'venue'];
const escape = (v: any) => {
const s = String(v ?? '');
if (/[",\n]/.test(s)) return '"' + s.replace(/"/g, '""') + '"';
return s;
};
const csv = [headers.join(','), ...rows.map(r => headers.map(h => escape((r as any)[h])).join(','))].join('\n');
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'matches.csv';
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
};
// Datetime validation (RFC3339-ish)
const isDateInvalid = form.date_time_override.trim() !== '' && isNaN(Date.parse(form.date_time_override));
const saveMutation = useMutation({
mutationFn: async () => {
const externalMatchId: string = selected?.match_id || selected?.id;
if (!externalMatchId) throw new Error('Chybí match_id');
const payload: any = { ...form };
// normalize empty strings to null so backend can clear values
Object.keys(payload).forEach((k) => {
if (payload[k as keyof typeof payload] === '') payload[k as keyof typeof payload] = null;
});
// First store current overrides
await putMatchOverride(externalMatchId, payload);
// Best-effort upload to logoapi.sportcreative.eu for home/away
const results: { home?: { success: boolean; error?: string }; away?: { success: boolean; error?: string } } = {};
const processSide = async (
side: 'home' | 'away',
externalTeamId: string,
uploadedFile: File | null,
nameOverride: string,
logoUrl: string | null
) => {
try {
if (!externalTeamId) return { success: false, error: 'Chybí ID týmu' };
let file: File | Blob | null = uploadedFile;
if (!file && logoUrl) {
file = await fetchLogoAsBlob(logoUrl);
}
if (!file) return { success: false, error: 'Nelze získat soubor loga' };
const up = await uploadToLogaSportcreative(externalTeamId, file, {
filename: file instanceof File ? file.name : `${externalTeamId}.png`,
clubName: nameOverride || 'Neznámý klub',
clubType: 'football',
});
if (!up.success) return { success: false, error: up.error || 'Upload selhal' };
if (up.url) {
// Patch override to immediately use external URL
await patchMatchOverride(
externalMatchId,
side === 'home' ? { home_logo_url: up.url } : { away_logo_url: up.url }
);
}
return { success: true };
} catch (e: any) {
return { success: false, error: e?.message || 'Chyba při uploadu' };
}
};
if (homeExternalTeamId && (form.home_logo_url || homeUploadedFile)) {
results.home = await processSide('home', homeExternalTeamId, homeUploadedFile, form.home_name_override, form.home_logo_url);
}
if (awayExternalTeamId && (form.away_logo_url || awayUploadedFile)) {
results.away = await processSide('away', awayExternalTeamId, awayUploadedFile, form.away_name_override, form.away_logo_url);
}
return { ok: true, results };
},
onSuccess: (res: any) => {
const r = res?.results || {};
const parts: string[] = [];
if (r.home) parts.push(r.home.success ? 'Logo domácích nahráno' : `Domácí: ${r.home.error || 'chyba'}`);
if (r.away) parts.push(r.away.success ? 'Logo hostů nahráno' : `Hosté: ${r.away.error || 'chyba'}`);
const description = parts.length ? parts.join(' • ') : undefined;
toast({ title: 'Uloženo', description, status: 'success' });
setIsOpen(false);
setSelected(null);
setHomeUploadedFile(null);
setAwayUploadedFile(null);
// Invalidate the cache-backed list to refresh any merged overrides
queryClient.invalidateQueries({ queryKey: ['admin-matches-list-cache'] });
},
onError: (e: any) => {
toast({ title: 'Uložení selhalo', description: e?.message || 'Zkuste to znovu', status: 'error' });
},
});
const openEdit = (m: any, side?: 'home' | 'away') => {
setSelected(m);
// Convert FACR-style date (e.g., 25.08.2025 18:30) to RFC3339 for backend
const facrStr: string = m.date_time || m.date || '';
let iso = '';
if (facrStr) {
try {
const dt = parse(String(facrStr), 'dd.MM.yyyy HH:mm', new Date());
if (!isNaN(dt.getTime())) iso = dt.toISOString();
} catch (_) {
// If it's already ISO or another parseable format, keep as-is if valid
const d2 = new Date(facrStr);
if (!isNaN(d2.getTime())) iso = d2.toISOString();
}
}
setForm({
home_name_override: m.home || m.home_team || '',
away_name_override: m.away || m.away_team || '',
venue_override: m.venue || '',
date_time_override: iso,
home_logo_url: m.home_logo_url || '',
away_logo_url: m.away_logo_url || '',
notes: '',
});
setIsOpen(true);
setFocusSide(side ?? null);
// Reset external selections and uploaded files to avoid stale state
setHomeExternalTeamId('');
setAwayExternalTeamId('');
setHomeUploadedFile(null);
setAwayUploadedFile(null);
};
// Autofocus on the selected team input when drawer opens
const homeInputRef = useRef<HTMLInputElement | null>(null);
const awayInputRef = useRef<HTMLInputElement | null>(null);
const handleHomeInput = (e: React.ChangeEvent<HTMLInputElement>) => {
setHomeQuery(e.target.value);
};
const handleAwayInput = (e: React.ChangeEvent<HTMLInputElement>) => {
setAwayQuery(e.target.value);
};
useEffect(() => {
if (isOpen && focusSide) {
const t = setTimeout(() => {
if (focusSide === 'home') homeInputRef.current?.focus();
if (focusSide === 'away') awayInputRef.current?.focus();
}, 50);
return () => clearTimeout(t);
}
}, [isOpen, focusSide]);
const drawerSize = useBreakpointValue({ base: 'full', md: 'md' });
// Horizontal scroll affordance
const scrollRef = useRef<HTMLDivElement | null>(null);
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);
// 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);
};
// Drag-to-scroll handlers
const handleMouseDown = (e: React.MouseEvent<HTMLDivElement>) => {
if (!scrollRef.current) return;
setIsDragging(true);
setStartX(e.pageX - scrollRef.current.offsetLeft);
setScrollLeft(scrollRef.current.scrollLeft);
scrollRef.current.style.cursor = 'grabbing';
scrollRef.current.style.userSelect = 'none';
};
const handleMouseLeave = () => {
setIsDragging(false);
if (scrollRef.current) {
scrollRef.current.style.cursor = 'grab';
scrollRef.current.style.userSelect = 'auto';
}
};
const handleMouseUp = () => {
setIsDragging(false);
if (scrollRef.current) {
scrollRef.current.style.cursor = 'grab';
scrollRef.current.style.userSelect = 'auto';
}
};
const handleMouseMove = (e: React.MouseEvent<HTMLDivElement>) => {
if (!isDragging || !scrollRef.current) return;
e.preventDefault();
const x = e.pageX - scrollRef.current.offsetLeft;
const walk = (x - startX) * 2; // Scroll speed multiplier
scrollRef.current.scrollLeft = scrollLeft - walk;
};
// Utility to check if match is in the past
const isMatchPast = (dateTimeStr: string): boolean => {
if (!dateTimeStr) return false;
try {
const dt = parse(dateTimeStr, FACR_DATE_FMT, new Date());
if (!isNaN(dt.getTime())) {
return dt.getTime() < Date.now();
}
} catch (_) {
const d = new Date(dateTimeStr);
if (!isNaN(d.getTime())) {
return d.getTime() < Date.now();
}
}
return false;
};
useEffect(() => {
updateScrollShadow();
const onResize = () => updateScrollShadow();
window.addEventListener('resize', onResize);
return () => window.removeEventListener('resize', onResize);
}, []);
const headerBg = useColorModeValue('brand.primary', 'gray.700');
const headerText = useColorModeValue('text.onPrimary', 'white');
const cardBg = useColorModeValue('white', 'gray.800');
const borderColor = useColorModeValue('gray.200', 'gray.700');
return (
<AdminLayout requireAdmin={false}>
<Box>
<Box bg={headerBg} color={headerText} borderRadius="xl" p={6} mb={6} boxShadow="lg">
<Heading size="lg" mb={2}>Správa zápasů</Heading>
<Text opacity={0.9}>
Správa a úprava zápasů. Můžete upravovat informace o zápasech, včetně názvů týmů, termínů, log a dalších detailů.
</Text>
</Box>
{isLoading ? (
<HStack spacing={3} mb={4}>
<Spinner />
<Text>Načítám zápasy</Text>
</HStack>
) : error ? (
<Alert status="error" variant="left-accent" mb={4}>
<AlertIcon />
Nepodařilo se načíst zápasy.
</Alert>
) : (
<Box>
<Wrap mb={4} spacing={3} align="center">
<WrapItem minW="160px">
<Select size="sm" value={sideFilter} onChange={(e) => setSideFilter((e.target.value as any) || '')}>
<option value="">Všechny strany</option>
<option value="home">Domácí</option>
<option value="away">Hosté</option>
</Select>
</WrapItem>
<WrapItem flex={1} minW="220px">
<Input
placeholder="Filtrovat podle týmu…"
value={teamFilter}
onChange={(e) => setTeamFilter(e.target.value)}
size="sm"
/>
</WrapItem>
<WrapItem>
<HStack>
<Input
type="date"
size="sm"
value={dateFrom}
onChange={(e) => setDateFrom(e.target.value)}
/>
<Text color="gray.500" fontSize="sm"></Text>
<Input
type="date"
size="sm"
value={dateTo}
onChange={(e) => setDateTo(e.target.value)}
/>
</HStack>
</WrapItem>
<WrapItem>
<HStack>
<Button size="sm" variant="outline" onClick={setThisWeek} borderRadius="md" _hover={{ borderColor: 'brand.primary', color: 'brand.primary' }}>Tento týden</Button>
<Button size="sm" variant="outline" onClick={setNext30Days} borderRadius="md" _hover={{ borderColor: 'brand.primary', color: 'brand.primary' }}>Dalších 30 dní</Button>
</HStack>
</WrapItem>
<WrapItem minW="220px">
<Select size="sm" value={competitionFilter} onChange={(e) => setCompetitionFilter(e.target.value)}>
<option value="">Všechny soutěže</option>
{competitionOptions.map((c) => (
<option key={c} value={c}>{c}</option>
))}
</Select>
</WrapItem>
{(teamFilter || dateFrom || dateTo || competitionFilter || sideFilter) && (
<WrapItem>
<Button size="sm" variant="outline" colorScheme="red" onClick={() => { setTeamFilter(''); setDateFrom(''); setDateTo(''); setCompetitionFilter(''); setSideFilter(''); }} borderRadius="md">
Vymazat filtry
</Button>
</WrapItem>
)}
<WrapItem>
<HStack>
<Text fontSize="sm">Na stránku:</Text>
<Select size="sm" value={pageSize} onChange={(e) => setPageSize(parseInt(e.target.value) || 25)} width="auto">
<option value={25}>25</option>
<option value={50}>50</option>
<option value={100}>100</option>
<option value={200}>200</option>
</Select>
</HStack>
</WrapItem>
<WrapItem>
<Button size="sm" onClick={exportCsv} bg="brand.primary" color="text.onPrimary" _hover={{ filter: 'brightness(0.95)' }} borderRadius="md">Export CSV</Button>
</WrapItem>
<WrapItem>
<Text color="gray.500" fontSize="sm">
Zobrazeno {visibleMatches.length} / {filteredMatches.length}
</Text>
</WrapItem>
</Wrap>
{showScrollHint && (
<Text fontSize="xs" color="blue.600" fontWeight="600" mb={2}>
💡 Tip: Tabulku můžete posouvat tažením myší nebo touchem
</Text>
)}
<Box
ref={scrollRef}
overflowX="auto"
borderWidth="2px"
borderRadius="xl"
borderColor={borderColor}
w="full"
bg={cardBg}
boxShadow="md"
maxW="100%"
position="relative"
cursor="grab"
onMouseDown={handleMouseDown}
onMouseLeave={handleMouseLeave}
onMouseUp={handleMouseUp}
onMouseMove={handleMouseMove}
onScroll={(e) => {
updateScrollShadow();
if ((e.currentTarget as HTMLDivElement).scrollLeft > 0 && showScrollHint) setShowScrollHint(false);
}}
sx={{
WebkitOverflowScrolling: 'touch',
'th, td': { whiteSpace: 'nowrap' },
'::-webkit-scrollbar': { height: '12px' },
'::-webkit-scrollbar-thumb': {
background: '#3182ce',
borderRadius: '8px',
'&:hover': { background: '#2c5aa0' }
},
'::-webkit-scrollbar-track': {
background: '#e2e8f0',
borderRadius: '8px',
margin: '0 4px'
},
}}
>
{/* 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)')}
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)')}
zIndex={1}
/>
)}
<Table size="sm" sx={{ width: 'max-content' }}>
<Thead sx={{ position: 'sticky', top: 0, zIndex: 2, backgroundColor: headerBg, 'th': { bg: headerBg, color: headerText, fontWeight: 'bold', textTransform: 'uppercase', fontSize: 'xs', letterSpacing: '0.05em' } }}>
<Tr>
<Th minW="140px">Datum</Th>
<Th minW="200px">Soutěž</Th>
<Th minW="260px">Domácí</Th>
<Th minW="80px" textAlign="center">Skóre</Th>
<Th minW="260px">Hosté</Th>
<Th minW="220px">Místo</Th>
<Th minW="180px">Akce</Th>
</Tr>
</Thead>
<Tbody>
{filteredMatches.length === 0 ? (
<Tr>
<Td colSpan={6}>
<Text color="gray.500">Žádné zápasy k zobrazení.</Text>
</Td>
</Tr>
) : (
visibleMatches.map((m: any, idx: number) => {
const isPast = isMatchPast(m.date_time || m.date || '');
const hasScore = m.score || (m.result_home != null && m.result_away != null);
return (
<Tr
key={m.id ?? idx}
bg={isPast ? pastMatchBg : futureMatchBg}
_hover={{ bg: isPast ? pastMatchHoverBg : futureMatchHoverBg }}
opacity={isPast ? 0.85 : 1}
transition="all 0.2s"
>
<Td>
<HStack spacing={2}>
<Text>{m.date_time || m.date || ''}</Text>
{isPast && <Badge colorScheme="gray" fontSize="xs">Odehráno</Badge>}
{!isPast && <Badge colorScheme="green" fontSize="xs">Nadcházející</Badge>}
</HStack>
</Td>
<Td>
<HStack spacing={2}>
<Badge bg="brand.primary" color="text.onPrimary" borderRadius="md">{m.competitionName}</Badge>
</HStack>
</Td>
<Td>
<HStack spacing={2}>
<TeamLogo
teamId={m.home_id}
teamName={m.home || m.home_team || ''}
facrLogo={m.home_logo_url}
size="custom"
boxSize="24px"
/>
<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>
</HStack>
</Td>
<Td textAlign="center">
<Text fontWeight={hasScore ? 'bold' : 'normal'} color={hasScore ? 'blue.600' : 'gray.500'}>
{m.score || (m.result_home != null && m.result_away != null ? `${m.result_home}:${m.result_away}` : ':')}
</Text>
</Td>
<Td>
<HStack spacing={2}>
<TeamLogo
teamId={m.away_id}
teamName={m.away || m.away_team || ''}
facrLogo={m.away_logo_url}
size="custom"
boxSize="24px"
/>
<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>
</HStack>
</Td>
<Td>{m.venue || ''}</Td>
<Td>
<HStack spacing={2}>
<Button size="xs" onClick={() => openEdit(m)} bg="brand.primary" color="text.onPrimary" _hover={{ filter: 'brightness(0.95)' }} borderRadius="md">Upravit</Button>
</HStack>
</Td>
</Tr>
);
})
)}
</Tbody>
</Table>
</Box>
{filteredMatches.length > visibleMatches.length && (
<HStack justify="center" mt={6}>
<Button onClick={() => setLimit((n) => n + pageSize)} size="lg" bg="brand.primary" color="text.onPrimary" _hover={{ filter: 'brightness(0.95)' }} borderRadius="lg" px={8}>
Načíst další ({filteredMatches.length - visibleMatches.length} zápasů)
</Button>
</HStack>
)}
</Box>
)}
</Box>
{/* Edit Drawer */}
<Drawer isOpen={isOpen} placement="right" onClose={() => setIsOpen(false)} size={drawerSize}>
<DrawerOverlay />
<DrawerContent>
<DrawerHeader>Upravit zápas</DrawerHeader>
<DrawerBody>
{!selected ? (
<Text color="gray.500">Není vybrán žádný zápas.</Text>
) : (
<Stack spacing={4}>
<FormControl>
<FormLabel>Datum a čas (ISO)</FormLabel>
<Input
placeholder="YYYY-MM-DDTHH:mm:ss.sssZ"
value={form.date_time_override}
onChange={(e) => setForm((f) => ({ ...f, date_time_override: e.target.value }))}
/>
{isDateInvalid && (
<FormErrorMessage>Neplatný formát data/času</FormErrorMessage>
)}
</FormControl>
<FormControl>
<FormLabel>Místo</FormLabel>
<Input
placeholder="Místo konání"
value={form.venue_override}
onChange={(e) => setForm((f) => ({ ...f, venue_override: e.target.value }))}
/>
</FormControl>
{/* Home team */}
<FormControl>
<FormLabel>Domácí tým (název)</FormLabel>
<InputGroup>
<Input
ref={homeInputRef}
placeholder="Zadejte název týmu"
value={form.home_name_override}
onChange={(e) => {
setForm((f) => ({ ...f, home_name_override: e.target.value }));
handleHomeInput(e);
}}
/>
<InputRightElement width="4.5rem">
<Button h="1.75rem" size="sm" onClick={() => homeFileRef.current?.click()}>Logo</Button>
</InputRightElement>
</InputGroup>
<input
type="file"
accept="image/*"
hidden
ref={homeFileRef}
onChange={async (e) => {
const file = e.target.files?.[0];
if (!file) return;
try {
const up = await uploadImage(file);
setForm((f) => ({ ...f, home_logo_url: up.url }));
setHomeUploadedFile(file);
toast({ title: 'Logo nahráno (domácí)', status: 'success' });
} catch (err: any) {
toast({ title: 'Nahrání se nezdařilo', description: err?.message || '', status: 'error' });
} finally {
if (homeFileRef.current) homeFileRef.current.value = '' as any;
}
}}
/>
{homeResults.length > 0 && (
<Box mt={2} borderWidth="1px" borderRadius="md" p={2} maxH="180px" overflowY="auto">
<List spacing={1}>
{homeResults.map((r: any) => (
<ListItem key={r.id}>
<Button size="xs" variant="ghost" onClick={() => {
setForm((f) => ({ ...f, home_name_override: r.name, home_logo_url: r.logo_url || f.home_logo_url }));
setHomeQuery(r.name);
setHomeExternalTeamId(String(r.id || ''));
}}>
{r.name}
</Button>
</ListItem>
))}
</List>
</Box>
)}
{form.home_logo_url && (
<HStack mt={2} spacing={3}>
<Image src={form.home_logo_url} alt="home logo" boxSize="28px" objectFit="contain" />
<Button size="xs" variant="outline" onClick={() => setForm((f) => ({ ...f, home_logo_url: '' }))}>Odebrat logo</Button>
</HStack>
)}
</FormControl>
{/* Away team */}
<FormControl>
<FormLabel>Hostující tým (název)</FormLabel>
<InputGroup>
<Input
ref={awayInputRef}
placeholder="Zadejte název týmu"
value={form.away_name_override}
onChange={(e) => {
setForm((f) => ({ ...f, away_name_override: e.target.value }));
handleAwayInput(e);
}}
/>
<InputRightElement width="4.5rem">
<Button h="1.75rem" size="sm" onClick={() => awayFileRef.current?.click()}>Logo</Button>
</InputRightElement>
</InputGroup>
<input
type="file"
accept="image/*"
hidden
ref={awayFileRef}
onChange={async (e) => {
const file = e.target.files?.[0];
if (!file) return;
try {
const up = await uploadImage(file);
setForm((f) => ({ ...f, away_logo_url: up.url }));
setAwayUploadedFile(file);
toast({ title: 'Logo nahráno (hosté)', status: 'success' });
} catch (err: any) {
toast({ title: 'Nahrání se nezdařilo', description: err?.message || '', status: 'error' });
} finally {
if (awayFileRef.current) awayFileRef.current.value = '' as any;
}
}}
/>
{awayResults.length > 0 && (
<Box mt={2} borderWidth="1px" borderRadius="md" p={2} maxH="180px" overflowY="auto">
<List spacing={1}>
{awayResults.map((r: any) => (
<ListItem key={r.id}>
<Button size="xs" variant="ghost" onClick={() => {
setForm((f) => ({ ...f, away_name_override: r.name, away_logo_url: r.logo_url || f.away_logo_url }));
setAwayQuery(r.name);
setAwayExternalTeamId(String(r.id || ''));
}}>
{r.name}
</Button>
</ListItem>
))}
</List>
</Box>
)}
{form.away_logo_url && (
<HStack mt={2} spacing={3}>
<Image src={form.away_logo_url} alt="away logo" boxSize="28px" objectFit="contain" />
<Button size="xs" variant="outline" onClick={() => setForm((f) => ({ ...f, away_logo_url: '' }))}>Odebrat logo</Button>
</HStack>
)}
</FormControl>
<FormControl>
<FormLabel>Poznámka</FormLabel>
<Input
placeholder="Libovolná poznámka (interní)"
value={form.notes}
onChange={(e) => setForm((f) => ({ ...f, notes: e.target.value }))}
/>
</FormControl>
</Stack>
)}
</DrawerBody>
<DrawerFooter>
<HStack spacing={3}>
<Button variant="outline" onClick={() => setIsOpen(false)}>Zavřít</Button>
<Button colorScheme="blue" isLoading={saveMutation.isPending} onClick={() => saveMutation.mutate()} isDisabled={isDateInvalid}>
Uložit změny
</Button>
</HStack>
</DrawerFooter>
</DrawerContent>
</Drawer>
</AdminLayout>
);
};
export default MatchesAdminPage;
+599
View File
@@ -0,0 +1,599 @@
import {
Box,
Button,
Heading,
HStack,
IconButton,
Image,
Input,
InputGroup,
InputLeftElement,
Modal,
ModalBody,
ModalCloseButton,
ModalContent,
ModalFooter,
ModalHeader,
ModalOverlay,
SimpleGrid,
useDisclosure,
useToast,
VStack,
Text,
Badge,
Tabs,
TabList,
Tab,
TabPanels,
TabPanel,
useColorModeValue,
Tooltip,
Select,
Flex,
Spacer,
Stack,
AspectRatio,
Divider,
Code,
Skeleton,
} from '@chakra-ui/react';
import { useState, useMemo } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { FiTrash2, FiSearch, FiRefreshCw, FiCopy, FiUpload, FiImage, FiVideo, FiFile, FiDownload, FiExternalLink } from 'react-icons/fi';
import AdminLayout from '../../layouts/AdminLayout';
import {
FileInfo,
getAllFiles,
deleteFile,
scanAndSyncFiles,
formatFileSize,
} from '../../services/files';
import { uploadFile } from '../../services/articles';
const MediaAdminPage: React.FC = () => {
const toast = useToast();
const qc = useQueryClient();
const [search, setSearch] = useState('');
const [typeFilter, setTypeFilter] = useState<'all' | 'images' | 'videos' | 'documents'>('all');
const [selectedFile, setSelectedFile] = useState<FileInfo | null>(null);
const [deleteTarget, setDeleteTarget] = useState<FileInfo | null>(null);
const [uploadFiles, setUploadFiles] = useState<FileList | null>(null);
const [uploading, setUploading] = useState(false);
const { isOpen: isDetailOpen, onOpen: onDetailOpen, onClose: onDetailClose } = useDisclosure();
const { isOpen: isDeleteOpen, onOpen: onDeleteOpen, onClose: onDeleteClose } = useDisclosure();
const { isOpen: isUploadOpen, onOpen: onUploadOpen, onClose: onUploadClose } = useDisclosure();
const borderColor = useColorModeValue('gray.200', 'gray.600');
const bgHover = useColorModeValue('gray.50', 'gray.700');
const cardBg = useColorModeValue('white', 'gray.800');
// Build MIME filter based on type
const mimeFilter = useMemo(() => {
if (typeFilter === 'images') return 'image/';
if (typeFilter === 'videos') return 'video/';
if (typeFilter === 'documents') return 'application/';
return '';
}, [typeFilter]);
// Fetch all files
const { data: allFiles = [], isLoading, refetch } = useQuery({
queryKey: ['admin-media-files', search, mimeFilter],
queryFn: () => getAllFiles({ search, mime_type: mimeFilter }),
});
// Filter files by type
const filteredFiles = useMemo(() => {
return allFiles.filter(file => {
if (search && !file.filename?.toLowerCase().includes(search.toLowerCase())) {
return false;
}
return true;
});
}, [allFiles, search]);
// Separate by media type
const imageFiles = filteredFiles.filter(f => f.mime_type?.startsWith('image/'));
const videoFiles = filteredFiles.filter(f => f.mime_type?.startsWith('video/'));
const documentFiles = filteredFiles.filter(f =>
f.mime_type?.startsWith('application/') || f.mime_type?.startsWith('text/')
);
// Delete mutation
const deleteMutation = useMutation({
mutationFn: ({ id, force }: { id: number; force: boolean }) => deleteFile(id, force),
onSuccess: () => {
toast({ title: 'Soubor smazán', status: 'success' });
qc.invalidateQueries({ queryKey: ['admin-media-files'] });
onDeleteClose();
setDeleteTarget(null);
},
onError: (error: any) => {
toast({
title: 'Chyba při mazání',
description: error?.response?.data?.error || 'Nepodařilo se smazat soubor',
status: 'error',
});
},
});
// Scan mutation
const scanMutation = useMutation({
mutationFn: scanAndSyncFiles,
onSuccess: (data) => {
toast({
title: 'Skenování dokončeno',
description: `Přidáno: ${data.new_files || 0}, Smazáno: ${data.orphaned_files || 0}`,
status: 'success'
});
qc.invalidateQueries({ queryKey: ['admin-media-files'] });
},
onError: () => {
toast({ title: 'Chyba při skenování', status: 'error' });
},
});
const handleDelete = (file: FileInfo) => {
setDeleteTarget(file);
onDeleteOpen();
};
const confirmDelete = () => {
if (deleteTarget) {
deleteMutation.mutate({ id: deleteTarget.id, force: false });
}
};
const handleViewDetails = (file: FileInfo) => {
setSelectedFile(file);
onDetailOpen();
};
const copyToClipboard = (text: string) => {
navigator.clipboard.writeText(text);
toast({ title: 'Zkopírováno do schránky', status: 'success', duration: 2000 });
};
const handleUpload = async () => {
if (!uploadFiles || uploadFiles.length === 0) return;
setUploading(true);
try {
const promises = Array.from(uploadFiles).map(file => uploadFile(file));
await Promise.all(promises);
toast({
title: 'Soubory nahrány',
description: `Úspěšně nahráno ${uploadFiles.length} souborů`,
status: 'success'
});
qc.invalidateQueries({ queryKey: ['admin-media-files'] });
onUploadClose();
setUploadFiles(null);
} catch (error) {
toast({ title: 'Chyba při nahrávání', status: 'error' });
} finally {
setUploading(false);
}
};
const getFileUrl = (file: FileInfo) => {
if (file.url.startsWith('http')) return file.url;
const base = window.location.origin;
return `${base}${file.url}`;
};
const MediaCard = ({ file }: { file: FileInfo }) => {
const isImage = file.mime_type?.startsWith('image/');
const isVideo = file.mime_type?.startsWith('video/');
return (
<Box
bg={cardBg}
borderRadius="lg"
borderWidth="1px"
borderColor={borderColor}
overflow="hidden"
cursor="pointer"
onClick={() => handleViewDetails(file)}
_hover={{ shadow: 'md', transform: 'translateY(-2px)' }}
transition="all 0.2s"
>
<AspectRatio ratio={16 / 9}>
<Box bg="gray.100" display="flex" alignItems="center" justifyContent="center">
{isImage ? (
<Image
src={getFileUrl(file)}
alt={file.filename}
objectFit="cover"
w="100%"
h="100%"
/>
) : isVideo ? (
<Box fontSize="48px" color="gray.400">
<FiVideo />
</Box>
) : (
<Box fontSize="48px" color="gray.400">
<FiFile />
</Box>
)}
</Box>
</AspectRatio>
<VStack align="stretch" p={3} spacing={2}>
<Text fontSize="sm" fontWeight="semibold" noOfLines={1}>
{file.filename}
</Text>
<HStack justify="space-between" fontSize="xs" color="gray.500">
<Text>{formatFileSize(file.size || 0)}</Text>
<Badge colorScheme="blue" fontSize="9px">
{file.mime_type?.split('/')[0]}
</Badge>
</HStack>
<HStack spacing={1}>
<Tooltip label="Kopírovat URL">
<IconButton
aria-label="Copy URL"
icon={<FiCopy />}
size="xs"
variant="ghost"
onClick={(e) => {
e.stopPropagation();
copyToClipboard(getFileUrl(file));
}}
/>
</Tooltip>
<Tooltip label="Smazat">
<IconButton
aria-label="Delete"
icon={<FiTrash2 />}
size="xs"
variant="ghost"
colorScheme="red"
onClick={(e) => {
e.stopPropagation();
handleDelete(file);
}}
/>
</Tooltip>
</HStack>
</VStack>
</Box>
);
};
return (
<AdminLayout requireAdmin={false}>
<Box>
<Flex justify="space-between" align="center" mb={6}>
<Box>
<Heading size="lg" mb={1}>Média</Heading>
<Text color="gray.500">Správa obrázků, videí a dalších souborů</Text>
</Box>
<HStack>
<Button
leftIcon={<FiRefreshCw />}
onClick={() => scanMutation.mutate()}
isLoading={scanMutation.isPending}
size="sm"
>
Skenovat
</Button>
<Button
leftIcon={<FiUpload />}
colorScheme="blue"
onClick={onUploadOpen}
size="sm"
>
Nahrát
</Button>
</HStack>
</Flex>
{/* Stats */}
<HStack spacing={4} mb={6}>
<Box p={4} bg={cardBg} borderRadius="lg" borderWidth="1px" borderColor={borderColor}>
<HStack>
<FiImage />
<VStack align="start" spacing={0}>
<Text fontSize="2xl" fontWeight="bold">{imageFiles.length}</Text>
<Text fontSize="xs" color="gray.500">Obrázků</Text>
</VStack>
</HStack>
</Box>
<Box p={4} bg={cardBg} borderRadius="lg" borderWidth="1px" borderColor={borderColor}>
<HStack>
<FiVideo />
<VStack align="start" spacing={0}>
<Text fontSize="2xl" fontWeight="bold">{videoFiles.length}</Text>
<Text fontSize="xs" color="gray.500">Videí</Text>
</VStack>
</HStack>
</Box>
<Box p={4} bg={cardBg} borderRadius="lg" borderWidth="1px" borderColor={borderColor}>
<HStack>
<FiFile />
<VStack align="start" spacing={0}>
<Text fontSize="2xl" fontWeight="bold">{documentFiles.length}</Text>
<Text fontSize="xs" color="gray.500">Dokumentů</Text>
</VStack>
</HStack>
</Box>
</HStack>
{/* Filters */}
<HStack mb={6} spacing={4}>
<InputGroup maxW="400px">
<InputLeftElement pointerEvents="none">
<FiSearch color="gray" />
</InputLeftElement>
<Input
placeholder="Hledat soubory..."
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
</InputGroup>
<Select
maxW="200px"
value={typeFilter}
onChange={(e) => setTypeFilter(e.target.value as any)}
>
<option value="all">Všechny typy</option>
<option value="images">Pouze obrázky</option>
<option value="videos">Pouze videa</option>
<option value="documents">Pouze dokumenty</option>
</Select>
</HStack>
{/* Tabs */}
<Tabs>
<TabList>
<Tab>Všechny ({filteredFiles.length})</Tab>
<Tab>Obrázky ({imageFiles.length})</Tab>
<Tab>Videa ({videoFiles.length})</Tab>
<Tab>Dokumenty ({documentFiles.length})</Tab>
</TabList>
<TabPanels>
{/* All Files */}
<TabPanel px={0}>
{isLoading ? (
<SimpleGrid columns={{ base: 1, md: 3, lg: 4 }} spacing={4}>
{[...Array(8)].map((_, i) => (
<Skeleton key={i} height="250px" borderRadius="lg" />
))}
</SimpleGrid>
) : filteredFiles.length === 0 ? (
<Box textAlign="center" py={12}>
<Text color="gray.500">Žádné soubory</Text>
</Box>
) : (
<SimpleGrid columns={{ base: 1, md: 3, lg: 4 }} spacing={4}>
{filteredFiles.map(file => (
<MediaCard key={file.id} file={file} />
))}
</SimpleGrid>
)}
</TabPanel>
{/* Images */}
<TabPanel px={0}>
{imageFiles.length === 0 ? (
<Box textAlign="center" py={12}>
<Text color="gray.500">Žádné obrázky</Text>
</Box>
) : (
<SimpleGrid columns={{ base: 1, md: 3, lg: 4 }} spacing={4}>
{imageFiles.map(file => (
<MediaCard key={file.id} file={file} />
))}
</SimpleGrid>
)}
</TabPanel>
{/* Videos */}
<TabPanel px={0}>
{videoFiles.length === 0 ? (
<Box textAlign="center" py={12}>
<Text color="gray.500">Žádná videa</Text>
</Box>
) : (
<SimpleGrid columns={{ base: 1, md: 3, lg: 4 }} spacing={4}>
{videoFiles.map(file => (
<MediaCard key={file.id} file={file} />
))}
</SimpleGrid>
)}
</TabPanel>
{/* Documents */}
<TabPanel px={0}>
{documentFiles.length === 0 ? (
<Box textAlign="center" py={12}>
<Text color="gray.500">Žádné dokumenty</Text>
</Box>
) : (
<SimpleGrid columns={{ base: 1, md: 3, lg: 4 }} spacing={4}>
{documentFiles.map(file => (
<MediaCard key={file.id} file={file} />
))}
</SimpleGrid>
)}
</TabPanel>
</TabPanels>
</Tabs>
{/* File Details Modal */}
<Modal isOpen={isDetailOpen} onClose={onDetailClose} size="2xl">
<ModalOverlay />
<ModalContent>
<ModalHeader>Detail souboru</ModalHeader>
<ModalCloseButton />
<ModalBody>
{selectedFile && (
<VStack align="stretch" spacing={4}>
{selectedFile.mime_type?.startsWith('image/') && (
<Image
src={getFileUrl(selectedFile)}
alt={selectedFile.filename}
borderRadius="lg"
maxH="400px"
objectFit="contain"
/>
)}
<Divider />
<Stack spacing={2}>
<HStack justify="space-between">
<Text fontWeight="semibold">Název:</Text>
<Text>{selectedFile.filename}</Text>
</HStack>
<HStack justify="space-between">
<Text fontWeight="semibold">Velikost:</Text>
<Text>{formatFileSize(selectedFile.size || 0)}</Text>
</HStack>
<HStack justify="space-between">
<Text fontWeight="semibold">Typ:</Text>
<Badge>{selectedFile.mime_type}</Badge>
</HStack>
<HStack justify="space-between">
<Text fontWeight="semibold">Vytvořeno:</Text>
<Text fontSize="sm">
{selectedFile.created_at ? new Date(selectedFile.created_at).toLocaleString('cs-CZ') : 'N/A'}
</Text>
</HStack>
</Stack>
<Divider />
<Box>
<Text fontWeight="semibold" mb={2}>URL:</Text>
<HStack>
<Code flex={1} p={2} fontSize="xs" borderRadius="md">
{getFileUrl(selectedFile)}
</Code>
<IconButton
aria-label="Copy URL"
icon={<FiCopy />}
size="sm"
onClick={() => copyToClipboard(getFileUrl(selectedFile))}
/>
</HStack>
</Box>
</VStack>
)}
</ModalBody>
<ModalFooter>
<HStack spacing={2}>
{selectedFile && (
<Button
as="a"
href={getFileUrl(selectedFile)}
target="_blank"
leftIcon={<FiExternalLink />}
size="sm"
>
Otevřít
</Button>
)}
<Button
colorScheme="red"
leftIcon={<FiTrash2 />}
onClick={() => {
if (selectedFile) {
onDetailClose();
handleDelete(selectedFile);
}
}}
size="sm"
>
Smazat
</Button>
<Button onClick={onDetailClose} size="sm">Zavřít</Button>
</HStack>
</ModalFooter>
</ModalContent>
</Modal>
{/* Delete Confirmation Modal */}
<Modal isOpen={isDeleteOpen} onClose={onDeleteClose}>
<ModalOverlay />
<ModalContent>
<ModalHeader>Smazat soubor</ModalHeader>
<ModalCloseButton />
<ModalBody>
<Text>
Opravdu chcete smazat soubor <strong>{deleteTarget?.filename}</strong>?
</Text>
<Text mt={2} fontSize="sm" color="gray.500">
Tato akce je nevratná.
</Text>
</ModalBody>
<ModalFooter>
<HStack spacing={2}>
<Button onClick={onDeleteClose} size="sm">Zrušit</Button>
<Button
colorScheme="red"
onClick={confirmDelete}
isLoading={deleteMutation.isPending}
size="sm"
>
Smazat
</Button>
</HStack>
</ModalFooter>
</ModalContent>
</Modal>
{/* Upload Modal */}
<Modal isOpen={isUploadOpen} onClose={onUploadClose}>
<ModalOverlay />
<ModalContent>
<ModalHeader>Nahrát soubory</ModalHeader>
<ModalCloseButton />
<ModalBody>
<VStack align="stretch" spacing={4}>
<Text fontSize="sm" color="gray.500">
Vyberte jeden nebo více souborů k nahrání.
</Text>
<Input
type="file"
multiple
accept="image/*,video/*,application/pdf"
onChange={(e) => setUploadFiles(e.target.files)}
p={1}
/>
{uploadFiles && uploadFiles.length > 0 && (
<Box>
<Text fontSize="sm" fontWeight="semibold" mb={2}>
Vybrané soubory ({uploadFiles.length}):
</Text>
<VStack align="stretch" spacing={1}>
{Array.from(uploadFiles).map((file, i) => (
<Text key={i} fontSize="sm">
{file.name} ({formatFileSize(file.size)})
</Text>
))}
</VStack>
</Box>
)}
</VStack>
</ModalBody>
<ModalFooter>
<HStack spacing={2}>
<Button onClick={onUploadClose} size="sm">Zrušit</Button>
<Button
colorScheme="blue"
onClick={handleUpload}
isLoading={uploading}
isDisabled={!uploadFiles || uploadFiles.length === 0}
size="sm"
>
Nahrát
</Button>
</HStack>
</ModalFooter>
</ModalContent>
</Modal>
</Box>
</AdminLayout>
);
};
export default MediaAdminPage;
@@ -0,0 +1,508 @@
import { useState, useMemo } from 'react';
import {
Box,
Button,
Checkbox,
Flex,
Heading,
IconButton,
Menu,
MenuButton,
MenuItem,
MenuList,
Select,
Stack,
Table,
Tbody,
Td,
Text,
Th,
Thead,
Tr,
useDisclosure,
useToast,
Badge,
InputGroup,
InputLeftElement,
Input,
HStack,
useColorModeValue,
Modal,
ModalOverlay,
ModalContent,
ModalHeader,
ModalBody,
ModalFooter,
ModalCloseButton,
FormControl,
FormLabel,
VStack
} from '@chakra-ui/react';
import { AddIcon, DeleteIcon, EmailIcon, Search2Icon, StarIcon, ArrowForwardIcon } from '@chakra-ui/icons';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { format } from 'date-fns';
import { cs } from 'date-fns/locale';
import AdminLayout from '../../layouts/AdminLayout';
import {
getContactMessages,
markAsRead,
deleteMessage,
deleteMultipleMessages,
forwardAllMessages,
ContactMessage
} from '../../services/admin/contactMessages';
import Pagination from '../../components/common/Pagination';
import MessageDetailModal from '../../components/admin/MessageDetailModal';
import ConfirmationDialog from '../../components/common/ConfirmationDialog';
export default function MessagesAdminPage() {
const cardBg = useColorModeValue('white', 'gray.800');
const borderColor = useColorModeValue('gray.200', 'gray.700');
const inputBg = useColorModeValue('white', 'gray.700');
const [selectedMessages, setSelectedMessages] = useState<string[]>([]);
const [searchTerm, setSearchTerm] = useState('');
const [statusFilter, setStatusFilter] = useState<'all' | 'read' | 'unread'>('all');
const [sortBy, setSortBy] = useState<{ field: string; order: 'asc' | 'desc' }>({
field: 'createdAt',
order: 'desc',
});
const [pagination, setPagination] = useState({
page: 1,
limit: 10,
});
const {
isOpen: isDetailOpen,
onOpen: onDetailOpen,
onClose: onDetailClose
} = useDisclosure();
const {
isOpen: isDeleteOpen,
onOpen: onDeleteOpen,
onClose: onDeleteClose
} = useDisclosure();
const {
isOpen: isForwardAllOpen,
onOpen: onForwardAllOpen,
onClose: onForwardAllClose
} = useDisclosure();
const [forwardAllEmail, setForwardAllEmail] = useState('');
const [selectedMessage, setSelectedMessage] = useState<ContactMessage | null>(null);
const toast = useToast();
const queryClient = useQueryClient();
const { data, isLoading, isError } = useQuery({
queryKey: ['admin', 'contact-messages', { ...pagination, searchTerm, statusFilter, sortBy }],
queryFn: () =>
getContactMessages({
page: pagination.page,
limit: pagination.limit,
search: searchTerm,
isRead: statusFilter === 'all' ? undefined : statusFilter === 'read',
sortBy: sortBy.field,
sortOrder: sortBy.order,
}),
keepPreviousData: true,
});
const markAsReadMutation = useMutation({
mutationFn: markAsRead,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['admin', 'contact-messages'] });
toast({
title: 'Zpráva označena jako přečtená',
status: 'success',
duration: 3000,
isClosable: true,
});
},
});
const deleteMessageMutation = useMutation({
mutationFn: deleteMessage,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['admin', 'contact-messages'] });
toast({
title: 'Zpráva smazána',
status: 'success',
duration: 3000,
isClosable: true,
});
},
});
const deleteMultipleMutation = useMutation({
mutationFn: deleteMultipleMessages,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['admin', 'contact-messages'] });
setSelectedMessages([]);
toast({
title: 'Vybrané zprávy byly smazány',
status: 'success',
duration: 3000,
isClosable: true,
});
},
});
const forwardAllMutation = useMutation({
mutationFn: forwardAllMessages,
onSuccess: (data) => {
toast({
title: 'Zprávy se přeposílají',
description: data.message || 'Všechny zprávy budou přeposlány na zadaný e-mail',
status: 'success',
duration: 5000,
isClosable: true,
});
setForwardAllEmail('');
onForwardAllClose();
},
onError: () => {
toast({
title: 'Chyba',
description: 'Nepodařilo se přeposlat zprávy',
status: 'error',
duration: 3000,
isClosable: true,
});
},
});
const handleViewMessage = (message: ContactMessage) => {
setSelectedMessage(message);
if (!message.isRead) {
markAsReadMutation.mutate(message.id);
}
onDetailOpen();
};
const handleDeleteClick = (message: ContactMessage) => {
setSelectedMessage(message);
onDeleteOpen();
};
const handleDeleteConfirm = () => {
if (selectedMessage) {
deleteMessageMutation.mutate(selectedMessage.id);
}
onDeleteClose();
};
const handleBulkDelete = () => {
if (selectedMessages.length > 0) {
deleteMultipleMutation.mutate(selectedMessages);
}
};
const handleForwardAll = () => {
if (!forwardAllEmail || !forwardAllEmail.includes('@')) {
toast({
title: 'Chyba',
description: 'Zadejte platnou e-mailovou adresu',
status: 'error',
duration: 3000,
});
return;
}
forwardAllMutation.mutate(forwardAllEmail);
};
const handleSelectAll = (e: React.ChangeEvent<HTMLInputElement>) => {
if (e.target.checked) {
setSelectedMessages(data?.data.map((msg) => msg.id) || []);
} else {
setSelectedMessages([]);
}
};
const handleSelectMessage = (id: string, isSelected: boolean) => {
if (isSelected) {
setSelectedMessages((prev) => [...prev, id]);
} else {
setSelectedMessages((prev) => prev.filter((msgId) => msgId !== id));
}
};
const handleSort = (field: string) => {
setSortBy((prev) => ({
field,
order: prev.field === field && prev.order === 'asc' ? 'desc' : 'asc',
}));
};
const formatDate = (dateString: string) => {
return format(new Date(dateString), 'd. M. yyyy HH:mm', { locale: cs });
};
const getSortIcon = (field: string) => {
if (sortBy.field !== field) return null;
return sortBy.order === 'asc' ? '↑' : '↓';
};
return (
<AdminLayout>
<Box p={6}>
<Flex justify="space-between" align="center" mb={6}>
<Box>
<Heading size="lg" mb={2}>
Příchozí zprávy
</Heading>
<Text color="gray.600">
Spravujte příchozí zprávy z kontaktního formuláře
</Text>
</Box>
<HStack spacing={3} flexWrap="wrap">
<Button
colorScheme="teal"
variant="outline"
leftIcon={<ArrowForwardIcon />}
onClick={onForwardAllOpen}
size={{ base: "sm", md: "md" }}
>
Přeposlat vše
</Button>
{selectedMessages.length > 0 && (
<Button
colorScheme="red"
variant="outline"
leftIcon={<DeleteIcon />}
onClick={handleBulkDelete}
isLoading={deleteMultipleMutation.isLoading}
size={{ base: "sm", md: "md" }}
>
Smazat vybrané ({selectedMessages.length})
</Button>
)}
</HStack>
</Flex>
<Box bg={cardBg} borderRadius="lg" boxShadow="sm" p={4} mb={6}>
<Flex mb={4} gap={4} flexWrap="wrap">
<InputGroup maxW="md">
<InputLeftElement pointerEvents="none">
<Search2Icon color="gray.400" />
</InputLeftElement>
<Input
placeholder="Hledat v zprávách..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
/>
</InputGroup>
<Select
value={statusFilter}
onChange={(e) => setStatusFilter(e.target.value as any)}
maxW="200px"
>
<option value="all">Všechny zprávy</option>
<option value="unread">Nepřečtené</option>
<option value="read">Přečtené</option>
</Select>
</Flex>
<Box overflowX="auto">
<Table variant="simple">
<Thead>
<Tr>
<Th w="40px">
<Checkbox
isChecked={selectedMessages.length > 0 && selectedMessages.length === data?.data.length}
onChange={handleSelectAll}
/>
</Th>
<Th
cursor="pointer"
onClick={() => handleSort('name')}
_hover={{ textDecoration: 'underline' }}
>
Jméno {getSortIcon('name')}
</Th>
<Th
cursor="pointer"
onClick={() => handleSort('email')}
_hover={{ textDecoration: 'underline' }}
>
E-mail {getSortIcon('email')}
</Th>
<Th>Předmět</Th>
<Th>Zdroj</Th>
<Th
cursor="pointer"
onClick={() => handleSort('createdAt')}
_hover={{ textDecoration: 'underline' }}
>
Datum {getSortIcon('createdAt')}
</Th>
<Th>Stav</Th>
<Th w="120px">Akce</Th>
</Tr>
</Thead>
<Tbody>
{isLoading ? (
<Tr>
<Td colSpan={8} textAlign="center" py={8}>
Načítání...
</Td>
</Tr>
) : isError ? (
<Tr>
<Td colSpan={8} textAlign="center" py={8} color="red.500">
Chyba při načítání zpráv
</Td>
</Tr>
) : data?.data.length === 0 ? (
<Tr>
<Td colSpan={8} textAlign="center" py={8} color="gray.500">
Žádné zprávy nenalezeny
</Td>
</Tr>
) : (
data?.data.map((message) => (
<Tr
key={message.id}
bg={!message.isRead ? 'blue.50' : 'transparent'}
_hover={{ bg: !message.isRead ? 'blue.50' : 'gray.50' }}
>
<Td>
<Checkbox
isChecked={selectedMessages.includes(message.id)}
onChange={(e) => handleSelectMessage(message.id, e.target.checked)}
/>
</Td>
<Td fontWeight={!message.isRead ? 'semibold' : 'normal'}>
{message.name}
</Td>
<Td>{message.email}</Td>
<Td maxW="200px" isTruncated title={message.subject || 'Bez předmětu'}>
{message.subject || '—'}
</Td>
<Td>
{message.source === 'sponsor' ? (
<Badge colorScheme="purple">Sponzor</Badge>
) : (
<Badge colorScheme="gray">Kontakt</Badge>
)}
</Td>
<Td whiteSpace="nowrap">
{formatDate(message.createdAt)}
</Td>
<Td>
{message.isRead ? (
<Badge colorScheme="green">Přečteno</Badge>
) : (
<Badge colorScheme="blue">Nová zpráva</Badge>
)}
</Td>
<Td>
<HStack spacing={2}>
<IconButton
aria-label="Zobrazit zprávu"
icon={<EmailIcon />}
size="sm"
colorScheme="blue"
variant="ghost"
onClick={() => handleViewMessage(message)}
/>
<IconButton
aria-label="Smazat zprávu"
icon={<DeleteIcon />}
size="sm"
colorScheme="red"
variant="ghost"
onClick={() => handleDeleteClick(message)}
isLoading={
deleteMessageMutation.isLoading &&
deleteMessageMutation.variables === message.id
}
/>
</HStack>
</Td>
</Tr>
))
)}
</Tbody>
</Table>
</Box>
{data && data.total > 0 && (
<Flex justify="space-between" mt={4} alignItems="center">
<Text color="gray.600" fontSize="sm">
Zobrazeno {data.data.length} z {data.total} zpráv
</Text>
<Pagination
currentPage={pagination.page}
totalPages={data.totalPages || 1}
onPageChange={(page) => setPagination((p) => ({ ...p, page }))}
/>
</Flex>
)}
</Box>
</Box>
{selectedMessage && (
<MessageDetailModal
isOpen={isDetailOpen}
onClose={onDetailClose}
message={selectedMessage}
onDelete={() => {
onDetailClose();
handleDeleteClick(selectedMessage);
}}
onMarkAsRead={() => markAsReadMutation.mutate(selectedMessage.id)}
/>
)}
<ConfirmationDialog
isOpen={isDeleteOpen}
onClose={onDeleteClose}
onConfirm={handleDeleteConfirm}
title="Smazat zprávu"
message="Opravdu chcete smazat tuto zprávu? Tato akce je nevratná."
confirmText="Smazat"
cancelText="Zrušit"
isDanger
isLoading={deleteMessageMutation.isLoading}
/>
<Modal isOpen={isForwardAllOpen} onClose={onForwardAllClose}>
<ModalOverlay />
<ModalContent>
<ModalHeader>Přeposlat všechny zprávy</ModalHeader>
<ModalCloseButton />
<ModalBody>
<VStack spacing={4}>
<Text>
Všechny příchozí zprávy budou přeposlány na zadanou e-mailovou adresu.
</Text>
<FormControl isRequired>
<FormLabel>E-mailová adresa</FormLabel>
<Input
type="email"
placeholder="prijemce@email.cz"
value={forwardAllEmail}
onChange={(e) => setForwardAllEmail(e.target.value)}
/>
</FormControl>
</VStack>
</ModalBody>
<ModalFooter>
<Button variant="ghost" mr={3} onClick={onForwardAllClose}>
Zrušit
</Button>
<Button
colorScheme="teal"
onClick={handleForwardAll}
isLoading={forwardAllMutation.isLoading}
>
Přeposlat
</Button>
</ModalFooter>
</ModalContent>
</Modal>
</AdminLayout>
);
}
@@ -0,0 +1,113 @@
import React, { useEffect, useMemo, useRef, useState } from 'react';
import { Box, Button, Center, HStack, Heading, Image, SimpleGrid, Text, useColorModeValue, useToast, VStack } from '@chakra-ui/react';
import AdminLayout from '@/layouts/AdminLayout';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { getAdminScoreboard, updateAdminScoreboard, ScoreboardState } from '@/services/scoreboard';
const MobileScoreboardControlPage: React.FC = () => {
const toast = useToast();
const qc = useQueryClient();
const cardBg = useColorModeValue('white', 'gray.800');
const borderCol = useColorModeValue('gray.200', 'gray.700');
const { data: state, isLoading } = useQuery<ScoreboardState>({
queryKey: ['admin-scoreboard-mobile'],
queryFn: getAdminScoreboard,
refetchInterval: 5000,
staleTime: 3000,
});
const setPartial = async (patch: Partial<ScoreboardState>) => {
try {
await updateAdminScoreboard(patch);
await qc.invalidateQueries({ queryKey: ['admin-scoreboard-mobile'] });
} catch (e) {
toast({ title: 'Uložení selhalo', status: 'error' });
}
};
// Simple local match timer (upwards). Does not persist to backend; overlay remains score-only.
const [running, setRunning] = useState(false);
const [elapsed, setElapsed] = useState(0); // seconds
const startRef = useRef<number | null>(null);
useEffect(() => {
let raf: number;
const tick = () => {
if (running) {
const now = Date.now();
const base = startRef.current ?? now;
startRef.current = base;
setElapsed(Math.floor((now - base) / 1000));
}
raf = requestAnimationFrame(tick);
};
raf = requestAnimationFrame(tick);
return () => cancelAnimationFrame(raf);
}, [running]);
const resetTimer = () => { setRunning(false); setElapsed(0); startRef.current = null; };
const mmss = useMemo(() => {
const mm = Math.floor(elapsed / 60);
const ss = elapsed % 60;
return `${String(mm).padStart(2, '0')}:${String(ss).padStart(2, '0')}`;
}, [elapsed]);
if (isLoading || !state) {
return (
<AdminLayout>
<Center minH="50vh">Načítání</Center>
</AdminLayout>
);
}
return (
<AdminLayout>
<Box p={3}>
<Heading size="md" mb={3}>Mobilní ovládání tabule</Heading>
<VStack align="stretch" spacing={3}>
<Box borderWidth="1px" borderColor={borderCol} bg={cardBg} borderRadius="lg" p={3}>
<SimpleGrid columns={3} spacing={2} alignItems="center">
<VStack spacing={2}>
{state.homeLogo ? <Image src={state.homeLogo} alt="DOM" boxSize="64px" objectFit="contain" /> : null}
<Text fontWeight="bold" textAlign="center">{state.homeShort || 'DOM'}</Text>
<HStack>
<Button size="lg" onClick={() => setPartial({ homeScore: Math.max(0, (state.homeScore || 0) - 1) })}></Button>
<Button size="lg" colorScheme="green" onClick={() => setPartial({ homeScore: (state.homeScore || 0) + 1 })}>+</Button>
</HStack>
</VStack>
<VStack spacing={2}>
<Text fontSize="5xl" fontWeight="black">{state.homeScore} : {state.awayScore}</Text>
<HStack>
<Button onClick={() => setRunning((r) => !r)}>{running ? 'Stop' : 'Start'}</Button>
<Button variant="outline" onClick={resetTimer}>Reset</Button>
</HStack>
<Text fontSize="2xl" fontFamily="mono">{mmss}</Text>
</VStack>
<VStack spacing={2}>
{state.awayLogo ? <Image src={state.awayLogo} alt="HOS" boxSize="64px" objectFit="contain" /> : null}
<Text fontWeight="bold" textAlign="center">{state.awayShort || 'HOS'}</Text>
<HStack>
<Button size="lg" onClick={() => setPartial({ awayScore: Math.max(0, (state.awayScore || 0) - 1) })}></Button>
<Button size="lg" colorScheme="green" onClick={() => setPartial({ awayScore: (state.awayScore || 0) + 1 })}>+</Button>
</HStack>
</VStack>
</SimpleGrid>
</Box>
<Box borderWidth="1px" borderColor={borderCol} bg={cardBg} borderRadius="lg" p={3}>
<HStack justify="space-between">
<Text>Vybraný zápas</Text>
<Text fontWeight="bold">{state.externalMatchId ? state.externalMatchId : '—'}</Text>
</HStack>
<HStack mt={2} spacing={2}>
<Button onClick={() => setPartial({ active: true })} colorScheme="blue">Aktivovat</Button>
<Button variant="outline" onClick={() => setPartial({ active: false })}>Deaktivovat</Button>
<Button variant="ghost" onClick={() => setPartial({ homeScore: 0, awayScore: 0 })}>Reset skóre</Button>
</HStack>
</Box>
</VStack>
</Box>
</AdminLayout>
);
};
export default MobileScoreboardControlPage;
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,970 @@
import React, { useEffect, useState } from 'react';
import {
Box,
Button,
Flex,
FormControl,
FormLabel,
FormHelperText,
Heading,
IconButton,
Table,
Tbody,
Td,
Text,
Th,
Thead,
Tr,
useDisclosure,
useToast,
Badge,
Input,
InputGroup,
InputLeftElement,
HStack,
VStack,
Textarea,
Modal,
ModalOverlay,
ModalContent,
ModalHeader,
ModalFooter,
ModalBody,
ModalCloseButton,
useBreakpointValue,
Switch,
Tooltip,
Spinner,
InputRightElement,
Checkbox,
Select,
Tabs,
TabList,
Tab,
TabPanels,
TabPanel,
useColorModeValue,
} from '@chakra-ui/react';
import { AddIcon, DeleteIcon, EmailIcon, SearchIcon } from '@chakra-ui/icons';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { format } from 'date-fns';
import { cs } from 'date-fns/locale';
import AdminLayout from '../../layouts/AdminLayout';
import {
getNewsletterSubscribers,
sendNewsletter,
sendNewsletterTest,
deleteSubscriber,
toggleSubscriberStatus,
NewsletterSubscriber,
NewsletterSendData,
getNewsletterStatus,
sendNewsletterTestAdvanced,
NewsletterTestType,
sendNewsletterDigest,
setNewsletterAutomation,
previewNewsletter,
DigestType,
} from '../../services/admin/newsletter';
import { getAdminSettings, updateAdminSettings, AdminSettings } from '../../services/settings';
import { adminSendSmtpTest, AdminSmtpTestPayload } from '../../services/admin/newsletter';
import { getRecentEmailStats, EmailStatRow, getEmailEventsForLog, EmailEventRow } from '../../services/admin/newsletter';
export default function NewsletterAdminPage() {
const cardBg = useColorModeValue('white', 'gray.800');
const borderColor = useColorModeValue('gray.200', 'gray.700');
const inputBg = useColorModeValue('white', 'gray.700');
const textSecondary = useColorModeValue('gray.600', 'gray.400');
const hoverBg = useColorModeValue('gray.50', 'gray.700');
const [searchTerm, setSearchTerm] = useState('');
const [selectedSubscribers, setSelectedSubscribers] = useState<number[]>([]);
const [newsletterData, setNewsletterData] = useState<NewsletterSendData>({
subject: '',
content: '',
});
// SMTP test (admin)
const [smtpHost, setSmtpHost] = useState('');
const [smtpPort, setSmtpPort] = useState<number | ''>('');
const [smtpUser, setSmtpUser] = useState('');
const [smtpPass, setSmtpPass] = useState('');
const [smtpFrom, setSmtpFrom] = useState('');
const [smtpTo, setSmtpTo] = useState('');
const [smtpTLS, setSmtpTLS] = useState(true);
const [smtpSubject, setSmtpSubject] = useState('SMTP Test');
const [smtpBody, setSmtpBody] = useState('<p>Toto je testovací email (admin SMTP debug).</p>');
const [showSmtpPass, setShowSmtpPass] = useState(false);
const adminSmtpTestMutation = useMutation({
mutationFn: async () => {
const payload: AdminSmtpTestPayload = {
host: smtpHost.trim(),
port: typeof smtpPort === 'number' ? smtpPort : 0,
username: smtpUser || undefined,
password: smtpPass || undefined,
from: smtpFrom.trim(),
to: smtpTo.trim(),
subject: smtpSubject || undefined,
body: smtpBody || undefined,
use_tls: smtpTLS,
};
return adminSendSmtpTest(payload);
},
onSuccess: (res: any) => {
if (res?.ok) {
toast({ title: 'SMTP test úspěšný', description: res?.message || 'Test email sent', status: 'success' });
} else {
toast({ title: 'SMTP test selhal', description: res?.error || 'Chyba při odeslání testu', status: 'error' });
}
},
onError: (err: any) => {
toast({ title: 'Chyba požadavku', description: err?.response?.data?.error || err?.message, status: 'error' });
}
});
const [testEmail, setTestEmail] = useState<string>('');
const [testEmails, setTestEmails] = useState<string>('');
const [testType, setTestType] = useState<NewsletterTestType>('newsletter');
const [prefsModalOpen, setPrefsModalOpen] = useState(false);
const [editingSubscriber, setEditingSubscriber] = useState<NewsletterSubscriber | null>(null);
const [editingPrefs, setEditingPrefs] = useState<Record<string, boolean>>({});
// Send mode: custom body or digest template
const [sendMode, setSendMode] = useState<'custom' | DigestType>('custom');
const [competitions, setCompetitions] = useState<string>('');
const [previewSubject, setPreviewSubject] = useState<string>('');
const [previewHtml, setPreviewHtml] = useState<string>('');
const [previewLoading, setPreviewLoading] = useState<boolean>(false);
const { isOpen, onOpen, onClose } = useDisclosure();
const testModal = useDisclosure();
const smtpModal = useDisclosure();
const toast = useToast();
const queryClient = useQueryClient();
const isMobile = useBreakpointValue({ base: true, md: false });
// Admin settings (for scheduling)
const settingsQuery = useQuery({
queryKey: ['admin', 'settings'],
queryFn: getAdminSettings,
});
const settings = settingsQuery.data as AdminSettings | undefined;
const [enableWeekly, setEnableWeekly] = useState<boolean>(!!settings?.enable_weekly);
const [enableMatchReminders, setEnableMatchReminders] = useState<boolean>(!!settings?.enable_match_reminders);
const [enableResults, setEnableResults] = useState<boolean>(!!settings?.enable_results);
const [weeklyDay, setWeeklyDay] = useState<AdminSettings['newsletter_weekly_day']>(settings?.newsletter_weekly_day || 'sun');
const [weeklyHour, setWeeklyHour] = useState<number>(typeof settings?.newsletter_weekly_hour === 'number' ? (settings!.newsletter_weekly_hour as number) : 18);
const [reminderLead, setReminderLead] = useState<number>(typeof settings?.newsletter_reminder_lead_hours === 'number' ? (settings!.newsletter_reminder_lead_hours as number) : 48);
const [quietStart, setQuietStart] = useState<number>(typeof settings?.newsletter_quiet_start === 'number' ? (settings!.newsletter_quiet_start as number) : 22);
const [quietEnd, setQuietEnd] = useState<number>(typeof settings?.newsletter_quiet_end === 'number' ? (settings!.newsletter_quiet_end as number) : 7);
// Sync local state when settings load
useEffect(() => {
if (!settings) return;
setEnableWeekly(!!settings.enable_weekly);
setEnableMatchReminders(!!settings.enable_match_reminders);
setEnableResults(!!settings.enable_results);
setWeeklyDay(settings.newsletter_weekly_day || 'sun');
setWeeklyHour(typeof settings.newsletter_weekly_hour === 'number' ? settings.newsletter_weekly_hour! : 18);
setReminderLead(typeof settings.newsletter_reminder_lead_hours === 'number' ? settings.newsletter_reminder_lead_hours! : 48);
setQuietStart(typeof settings.newsletter_quiet_start === 'number' ? settings.newsletter_quiet_start! : 22);
setQuietEnd(typeof settings.newsletter_quiet_end === 'number' ? settings.newsletter_quiet_end! : 7);
}, [settings]);
const saveScheduleMutation = useMutation({
mutationFn: () => updateAdminSettings({
enable_weekly: enableWeekly,
enable_match_reminders: enableMatchReminders,
enable_results: enableResults,
newsletter_weekly_day: weeklyDay,
newsletter_weekly_hour: weeklyHour,
newsletter_reminder_lead_hours: reminderLead,
newsletter_quiet_start: quietStart,
newsletter_quiet_end: quietEnd,
}),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['admin', 'settings'] });
toast({ title: 'Nastavení rozesílek uloženo', status: 'success' });
},
onError: (err: any) => {
toast({ title: 'Chyba při ukládání', description: err?.response?.data?.error || err?.message, status: 'error' });
}
});
// Basic client-side sanitizer for preview (defense-in-depth; backend should still sanitize)
const sanitizeHtml = (html: string): string => {
try {
// Remove script/style/iframe tags entirely
const withoutDangerousTags = html.replace(/<\s*(script|style|iframe)[^>]*>[\s\S]*?<\s*\/\s*\1\s*>/gi, '');
// Remove event handlers like onClick, onError, etc.
const withoutEvents = withoutDangerousTags.replace(/ on[a-zA-Z]+\s*=\s*"[^"]*"/g, '').replace(/ on[a-zA-Z]+\s*=\s*'[^']*'/g, '').replace(/ on[a-zA-Z]+\s*=\s*[^\s>]+/g, '');
// Neutralize javascript:, data: URLs in href/src
const neutralizedUrls = withoutEvents.replace(/(href|src)\s*=\s*(["'])\s*(javascript:|data:)/gi, '$1=$2#');
return neutralizedUrls;
} catch {
return '';
}
};
// Fetch subscribers
const { data: subscribers = [], isLoading } = useQuery({
queryKey: ['admin', 'newsletter-subscribers'],
queryFn: getNewsletterSubscribers,
});
// Filter subscribers based on search term
const filteredSubscribers = subscribers.filter((subscriber) =>
subscriber.email.toLowerCase().includes(searchTerm.toLowerCase())
);
// Toggle subscriber status
const toggleStatusMutation = useMutation({
mutationFn: ({ id, isActive }: { id: number; isActive: boolean }) =>
toggleSubscriberStatus(id, isActive),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['admin', 'newsletter-subscribers'] });
toast({
title: 'Stav odběratele byl aktualizován',
status: 'success',
duration: 3000,
isClosable: true,
});
},
});
// Delete subscriber
const deleteMutation = useMutation({
mutationFn: deleteSubscriber,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['admin', 'newsletter-subscribers'] });
setSelectedSubscribers([]);
toast({
title: 'Odběratel byl smazán',
status: 'success',
duration: 3000,
isClosable: true,
});
},
});
const updatePrefsMutation = useMutation({
mutationFn: ({ id, prefs }: { id: number; prefs: Record<string, boolean> }) =>
// lazy import to avoid cyclic imports
import('../../services/admin/newsletter').then((m) => m.updateSubscriberPreferences(id, prefs)),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['admin', 'newsletter-subscribers'] });
setPrefsModalOpen(false);
toast({ title: 'Preference uloženy', status: 'success' });
},
onError: (err: any) => {
toast({ title: 'Chyba při ukládání preferencí', description: err?.response?.data?.error || err?.message, status: 'error' });
},
});
// Send newsletter
const sendNewsletterMutation = useMutation({
mutationFn: sendNewsletter,
onSuccess: () => {
onClose();
setNewsletterData({ subject: '', content: '' });
toast({
title: 'Newsletter byl odeslán',
status: 'success',
duration: 5000,
isClosable: true,
});
},
onError: (error: any) => {
toast({
title: 'Chyba při odesílání newsletteru',
description: error?.response?.data?.error || error?.response?.data?.message || error?.message || 'Došlo k chybě',
status: 'error',
duration: 5000,
isClosable: true,
});
},
});
// Send test newsletter
const sendTestMutation = useMutation({
mutationFn: () => {
const emails = testEmails
.split(',')
.map((s) => s.trim())
.filter(Boolean);
if (emails.length > 0) {
return sendNewsletterTestAdvanced({ emails, type: testType });
}
if (testEmail) {
return sendNewsletterTestAdvanced({ email: testEmail, type: testType });
}
return sendNewsletterTestAdvanced({ type: testType });
},
onSuccess: (data: { message: string; recipient?: string; recipients?: string[]; type: string }) => {
toast({
title: 'Test odeslán',
description:
data?.recipients && data.recipients.length > 0
? `E-mail byl odeslán na ${data.recipients.join(', ')}`
: data?.recipient
? `E-mail byl odeslán na ${data.recipient}`
: 'Testovací e-mail byl odeslán',
status: 'success',
duration: 4000,
isClosable: true,
});
setTestEmail('');
setTestEmails('');
testModal.onClose();
},
onError: (error: any) => {
toast({
title: 'Chyba při odesílání testu',
description: error?.response?.data?.error || error?.response?.data?.message || error?.message || 'Došlo k chybě',
status: 'error',
duration: 5000,
isClosable: true,
});
},
});
const handleSelectAll = (e: React.ChangeEvent<HTMLInputElement>) => {
if (e.target.checked) {
setSelectedSubscribers(filteredSubscribers.map((s) => s.id));
} else {
setSelectedSubscribers([]);
}
};
const handleSelectSubscriber = (id: number, isSelected: boolean) => {
if (isSelected) {
setSelectedSubscribers((prev) => [...prev, id]);
} else {
setSelectedSubscribers((prev) => prev.filter((subId) => subId !== id));
}
};
const handleDeleteSelected = () => {
if (window.confirm('Opravdu chcete smazat vybrané odběratele?')) {
Promise.all(selectedSubscribers.map((id) => deleteMutation.mutateAsync(id)));
}
};
const handleSendNewsletter = () => {
sendNewsletterMutation.mutate(newsletterData);
};
const formatDate = (dateString: string) => {
return format(new Date(dateString), 'd. M. yyyy HH:mm', { locale: cs });
};
// Newsletter status
const { data: statusData } = useQuery({
queryKey: ['admin', 'newsletter-status'],
queryFn: getNewsletterStatus,
});
const automationToggle = useMutation({
mutationFn: (enabled: boolean) => setNewsletterAutomation(enabled),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['admin', 'newsletter-status'] }),
});
// Recent delivery stats
const { data: recentStats = [], isLoading: statsLoading } = useQuery<EmailStatRow[]>({
queryKey: ['admin', 'newsletter-stats-recent'],
queryFn: getRecentEmailStats,
});
// Events modal state
const [eventsModalOpen, setEventsModalOpen] = useState(false);
const [activeLog, setActiveLog] = useState<EmailStatRow | null>(null);
const eventsQuery = useQuery<EmailEventRow[]>({
queryKey: ['admin', 'email-events', activeLog?.id],
queryFn: () => getEmailEventsForLog(activeLog?.id || 0),
enabled: !!activeLog?.id && eventsModalOpen,
});
const [eventFilter, setEventFilter] = useState<{ open: boolean; click: boolean; spam: boolean; unsubscribe: boolean }>({ open: true, click: true, spam: true, unsubscribe: true });
const filteredEvents = (eventsQuery.data || []).filter((ev) => eventFilter[(ev.event_type as 'open'|'click'|'spam'|'unsubscribe')] ?? true);
const exportCSV = () => {
// Prevent CSV injection by prefixing dangerous leading characters
const safeCSV = (value: any) => {
const s = String(value ?? '');
return /^[=+\-@]/.test(s) ? `'${s}` : s;
};
const rows = (eventsQuery.data || []).map((ev) => ({
id: ev.id,
created_at: ev.created_at,
event_type: ev.event_type,
url: ev.meta?.url ?? '',
ua: ev.meta?.ua ?? '',
ip: ev.meta?.ip ?? '',
}));
const header = ['id','created_at','event_type','url','ua','ip'];
const lines = [
header.join(','),
...rows.map(r => [
safeCSV(r.id),
safeCSV(r.created_at),
safeCSV(r.event_type),
safeCSV(JSON.stringify(r.url)),
safeCSV(JSON.stringify(r.ua)),
safeCSV(JSON.stringify(r.ip))
].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 = `email_events_${activeLog?.id}.csv`;
a.click();
URL.revokeObjectURL(url);
};
return (
<AdminLayout>
<Box p={6}>
<Flex justify="space-between" align="center" mb={6} flexWrap="wrap" gap={4}>
<Box>
<Heading size="lg" mb={2}>
Správa newsletteru
</Heading>
<Text color="gray.600">
Spravujte odběratele newsletteru a rozesílejte hromadné zprávy
</Text>
</Box>
<HStack spacing={3}>
{selectedSubscribers.length > 0 && (
<Button
colorScheme="red"
variant="outline"
leftIcon={<DeleteIcon />}
onClick={handleDeleteSelected}
isLoading={deleteMutation.isLoading}
>
Smazat vybrané ({selectedSubscribers.length})
</Button>
)}
<Button
variant="outline"
onClick={testModal.onOpen}
>
Odeslat testovací e-mail
</Button>
<Button variant="outline" onClick={smtpModal.onOpen}>Otestovat SMTP</Button>
<Button
colorScheme="blue"
leftIcon={<EmailIcon />}
onClick={onOpen}
isDisabled={subscribers.length === 0}
>
Odeslat newsletter
</Button>
</HStack>
</Flex>
{/* Tabs layout */}
<Tabs colorScheme="blue" isFitted>
<TabList>
<Tab>Stav</Tab>
<Tab>Plánování</Tab>
<Tab>Statistika</Tab>
</TabList>
<TabPanels>
<TabPanel p={0}>
{/* Status panel */}
<Box bg={cardBg} borderRadius="lg" boxShadow="sm" p={4} mb={6}>
<Heading size="md" mb={3}>Stav rozesílek</Heading>
<HStack spacing={6} wrap="wrap">
<Text><b>Odběratelů:</b> {statusData?.total_subscribers ?? '—'}</Text>
<Text><b>Aktivních:</b> {statusData?.active_subscribers ?? '—'}</Text>
<Text><b>Další běh:</b> {statusData?.next_approximate ? format(new Date(statusData.next_approximate), 'd. M. yyyy HH:mm', { locale: cs }) : '—'}</Text>
<Text><b>Interval:</b> {statusData?.interval_minutes ? `${statusData.interval_minutes} min` : '—'}</Text>
<HStack>
<Switch size="sm" isChecked={!!statusData?.newsletter_enabled} onChange={(e)=> automationToggle.mutate(e.target.checked)} />
<Text>Automatické rozesílky</Text>
</HStack>
</HStack>
{statusData?.next_approximate ? (
<Text color="gray.600" fontSize="sm" mt={2}>
Další automatický newsletter za {(() => {
const diff = new Date(statusData.next_approximate).getTime() - Date.now();
if (diff <= 0) return 'méně než minutu';
const mins = Math.floor(diff / 60000);
const hrs = Math.floor(mins / 60);
const rem = mins % 60;
return hrs > 0 ? `${hrs} h ${rem} min` : `${mins} min`;
})()}
</Text>
) : null}
{statusData?.sample_recipients?.length ? (
<Box mt={3}>
<Text color="gray.600" fontSize="sm">Ukázka příjemců ({statusData.sample_recipients.length}): {statusData.sample_recipients.join(', ')}</Text>
</Box>
) : null}
</Box>
</TabPanel>
<TabPanel p={0}>
{/* Scheduling controls */}
<Box bg={cardBg} borderRadius="lg" boxShadow="sm" p={4} mb={6}>
<Heading size="md" mb={3}>Plánování rozesílek</Heading>
<Text color="gray.600" mb={4}>Nastavte, kdy se automaticky posílají jednotlivé typy newsletterů.</Text>
{settingsQuery.isLoading ? (
<HStack color="gray.600"><Spinner size="sm" /> <Text>Načítám nastavení</Text></HStack>
) : (
<VStack align="stretch" spacing={4}>
<HStack justify="space-between">
<Text fontWeight="600">Týdenní přehled</Text>
<Switch isChecked={enableWeekly} onChange={(e)=> setEnableWeekly(e.target.checked)} />
</HStack>
<HStack spacing={3}>
<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>
</Select>
</FormControl>
<FormControl maxW="160px">
<FormLabel>Hodina</FormLabel>
<Input type="number" min={0} max={23} value={weeklyHour} onChange={(e)=> setWeeklyHour(Math.max(0, Math.min(23, Number(e.target.value)||0)))} />
</FormControl>
</HStack>
</VStack>
)}
</Box>
</TabPanel>
{/* Analytics tab */}
<TabPanel p={0}>
<Box bg={cardBg} borderRadius="lg" boxShadow="sm" p={4} mb={6}>
<Heading size="md" mb={3}>Dodání a interakce</Heading>
{statsLoading ? (
<HStack color="gray.600"><Spinner size="sm" /> <Text>Načítám statistiky</Text></HStack>
) : recentStats.length === 0 ? (
<Text color="gray.600">Zatím nejsou k dispozici žádné záznamy.</Text>
) : (
<Box overflowX="auto">
<Table size="sm" variant="simple">
<Thead>
<Tr>
<Th>Čas</Th>
<Th>Předmět</Th>
<Th>Příjemce</Th>
<Th isNumeric>Otevření</Th>
<Th isNumeric>Kliknutí</Th>
<Th isNumeric>Spam</Th>
<Th isNumeric>Odhlášení</Th>
<Th>Stav</Th>
<Th>Detail</Th>
</Tr>
</Thead>
<Tbody>
{recentStats.slice(0, 50).map((row) => (
<Tr key={row.id}>
<Td>{format(new Date(row.created_at), 'd. M. yyyy HH:mm', { locale: cs })}</Td>
<Td>{row.subject}</Td>
<Td>{row.recipient}</Td>
<Td isNumeric>{row.opens}</Td>
<Td isNumeric>{row.clicks}</Td>
<Td isNumeric>{row.spam}</Td>
<Td isNumeric>{row.unsubs}</Td>
<Td>
<Badge colorScheme={row.status === 'failed' ? 'red' : 'green'}>{row.status}</Badge>
</Td>
<Td>
<Button size="xs" variant="outline" onClick={() => { setActiveLog(row); setEventsModalOpen(true); }}>Detail</Button>
</Td>
</Tr>
))}
</Tbody>
</Table>
</Box>
)}
</Box>
</TabPanel>
</TabPanels>
</Tabs>
{/* Subscribers table */}
<Box bg={cardBg} borderRadius="lg" boxShadow="sm" p={4} mb={6}>
<InputGroup maxW="md" mb={4}>
<InputLeftElement pointerEvents="none">
<SearchIcon color="gray.400" />
</InputLeftElement>
<Input
placeholder="Hledat podle e-mailu..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
/>
</InputGroup>
<Box overflowX="auto">
<Table variant="simple">
<Thead>
<Tr>
<Th w="40px">
<input
type="checkbox"
onChange={handleSelectAll}
checked={
filteredSubscribers.length > 0 &&
selectedSubscribers.length === filteredSubscribers.length
}
/>
</Th>
<Th>E-mail</Th>
<Th>Stav</Th>
<Th>Datum registrace</Th>
<Th>Poslední změna</Th>
<Th w="120px">Akce</Th>
</Tr>
</Thead>
<Tbody>
{isLoading ? (
<Tr>
<Td colSpan={6} textAlign="center" py={8}>
Načítání...
</Td>
</Tr>
) : filteredSubscribers.length === 0 ? (
<Tr>
<Td colSpan={6} textAlign="center" py={8} color="gray.500">
{searchTerm ? 'Žádní odběratelé nebyli nalezeni' : 'Žádní odběratelé'}
</Td>
</Tr>
) : (
filteredSubscribers.map((subscriber) => (
<Tr key={subscriber.id}>
<Td>
<input
type="checkbox"
checked={selectedSubscribers.includes(subscriber.id)}
onChange={(e) =>
handleSelectSubscriber(subscriber.id, e.target.checked)
}
/>
</Td>
<Td>{subscriber.email}</Td>
<Td>
<Badge
colorScheme={subscriber.is_active ? 'green' : 'gray'}
>
{subscriber.is_active ? 'Aktivní' : 'Neaktivní'}
</Badge>
</Td>
<Td>
{(() => {
const prefs = (subscriber as any).preferences;
if (prefs && Object.keys(prefs).length > 0) {
return Object.keys(prefs).map((k) =>
prefs[k] ? <Badge key={k} mr={1} colorScheme="blue">{k}</Badge> : null
);
}
return <Text color="gray.500">Žádné</Text>;
})()}
</Td>
<Td>{formatDate(subscriber.created_at)}</Td>
<Td>{formatDate(subscriber.updated_at)}</Td>
<Td>
<HStack spacing={2}>
<Tooltip label={subscriber.is_active ? 'Deaktivovat' : 'Aktivovat'}>
<span>
<Switch
colorScheme="green"
isChecked={subscriber.is_active}
onChange={(e) =>
toggleStatusMutation.mutate({
id: subscriber.id,
isActive: e.target.checked,
})
}
isDisabled={toggleStatusMutation.isLoading}
/>
</span>
</Tooltip>
<Tooltip label="Smazat">
<span>
<IconButton
aria-label="Smazat odběratele"
icon={<DeleteIcon />}
size="sm"
colorScheme="red"
variant="ghost"
onClick={() => {
if (window.confirm('Opravdu chcete smazat tohoto odběratele?')) {
deleteMutation.mutate(subscriber.id);
}
}}
isLoading={
deleteMutation.isLoading &&
(deleteMutation as any).variables === subscriber.id
}
/>
</span>
</Tooltip>
<Tooltip label="Upravit preference">
<Button
size="sm"
variant="ghost"
onClick={() => {
setEditingSubscriber(subscriber);
setEditingPrefs((subscriber as any).preferences || {});
setPrefsModalOpen(true);
}}
>
Upravit
</Button>
</Tooltip>
</HStack>
</Td>
</Tr>
))
)}
</Tbody>
</Table>
</Box>
</Box>
</Box>
{/* Send Newsletter Modal */}
<Modal isOpen={isOpen} onClose={onClose} size="xl">
<ModalOverlay />
<ModalContent maxW="90vw" maxH="90vh" overflowY="auto">
<ModalHeader>Odeslat newsletter</ModalHeader>
<ModalCloseButton />
<ModalBody>
<VStack spacing={4} align="stretch">
<Text>
Newsletter bude odeslán všem aktivním odběratelům ({subscribers.filter(s => s.is_active).length}).
</Text>
<FormControl>
<FormLabel>Typ</FormLabel>
<Select value={sendMode} onChange={(e)=> setSendMode(e.target.value as any)}>
<option value="custom">Vlastní obsah</option>
<option value="matches">Šablona: Zápasy</option>
<option value="scores">Šablona: Výsledky</option>
<option value="events">Šablona: Akce</option>
<option value="blogs">Šablona: Novinky</option>
<option value="weekly">Šablona: Týdenní přehled</option>
</Select>
<FormHelperText>Zvolte mezi vlastním obsahem nebo automatickou šablonou.</FormHelperText>
</FormControl>
{sendMode === 'custom' && (
<>
<FormControl isRequired>
<FormLabel>Předmět</FormLabel>
<Input
placeholder="Předmět zprávy"
value={newsletterData.subject}
onChange={(e) =>
setNewsletterData({ ...newsletterData, subject: e.target.value })
}
/>
</FormControl>
<FormControl isRequired>
<FormLabel>Obsah zprávy</FormLabel>
<Textarea
placeholder="Zde napište obsah newsletteru..."
value={newsletterData.content}
onChange={(e) =>
setNewsletterData({ ...newsletterData, content: e.target.value })
}
rows={10}
/>
<FormHelperText>
Pro formátování textu můžete použít HTML značky.
</FormHelperText>
</FormControl>
</>
)}
{sendMode !== 'custom' && (
<>
<FormControl>
<FormLabel>Filtr soutěží (volitelné)</FormLabel>
<Input placeholder="NAPŘ. KP, I.A, I.B" value={competitions} onChange={(e)=> setCompetitions(e.target.value)} />
<FormHelperText>Čárkou oddělený seznam kódů soutěží.</FormHelperText>
</FormControl>
<HStack>
<Button variant="outline" onClick={async ()=>{
setPreviewLoading(true);
try {
const prefs: any = {};
if (sendMode === 'weekly') { prefs.blogs = true; prefs.events = true; prefs.matches = true; prefs.scores = true; } else { (prefs as any)[sendMode] = true; }
if (competitions.trim()) { prefs.competitions = competitions.trim(); }
const res = await previewNewsletter({ preferences: prefs });
setPreviewSubject(res.subject);
setPreviewHtml(res.html);
} finally {
setPreviewLoading(false);
}
}}>Náhled šablony</Button>
{previewLoading && <Spinner size="sm" />}
{previewSubject && <Badge colorScheme="blue">{previewSubject}</Badge>}
</HStack>
<Box mt={2} p={3} bg={useColorModeValue('gray.50', 'gray.900')} borderRadius="md" borderWidth="1px">
<Box
bg={cardBg}
p={3}
borderRadius="md"
borderWidth="1px"
dangerouslySetInnerHTML={{ __html: sanitizeHtml(previewHtml || '<em>Náhled se zobrazí zde</em>') }}
/>
</Box>
</>
)}
<Box mt={4} p={4} bg={useColorModeValue('gray.50', 'gray.900')} borderRadius="md">
<Text fontWeight="bold" mb={2}>Náhled:</Text>
<Box
border="1px"
borderColor="gray.200"
p={4}
borderRadius="md"
bg={cardBg}
dangerouslySetInnerHTML={{ __html: sanitizeHtml(sendMode === 'custom' ? (newsletterData.content || '<em>Náhled se zobrazí zde</em>') : (previewHtml || '<em>Náhled se zobrazí zde</em>')) }}
/>
</Box>
</VStack>
</ModalBody>
<ModalFooter>
<Button variant="ghost" mr={3} onClick={onClose}>
Zrušit
</Button>
{sendMode === 'custom' ? (
<Button
colorScheme="blue"
onClick={handleSendNewsletter}
isLoading={sendNewsletterMutation.isLoading}
isDisabled={!newsletterData.subject || !newsletterData.content}
leftIcon={<EmailIcon />}
>
Odeslat newsletter
</Button>
) : (
<Button
colorScheme="blue"
onClick={async ()=>{
try {
await sendNewsletterDigest(sendMode as DigestType, competitions.trim() || undefined);
toast({ title: 'Digest odeslán', status: 'success' });
onClose();
setPreviewHtml(''); setPreviewSubject(''); setCompetitions(''); setSendMode('custom');
} catch (e: any) {
toast({ title: 'Chyba při odeslání', description: e?.response?.data?.error || e?.message, status: 'error' });
}
}}
leftIcon={<EmailIcon />}
>
Odeslat digest
</Button>
)}
</ModalFooter>
</ModalContent>
</Modal>
{/* Test Email Modal */}
<Modal isOpen={testModal.isOpen} onClose={testModal.onClose} size="md">
<ModalOverlay />
<ModalContent maxW="90vw" maxH="90vh" overflowY="auto">
<ModalHeader>Odeslat testovací e-mail</ModalHeader>
<ModalCloseButton />
<ModalBody>
<VStack spacing={4} align="stretch">
<Text color="gray.600">
Testovací e-mail bude odeslán na zadanou adresu. Pokud pole necháte prázdné, odešleme na výchozí administrátorský e-mail.
</Text>
<FormControl>
<FormLabel>E-mail příjemce (volitelně)</FormLabel>
<Input
type="email"
placeholder="např. admin@priklad.cz"
value={testEmail}
onChange={(e) => setTestEmail(e.target.value)}
/>
</FormControl>
<FormControl>
<FormLabel>Více e-mailů (oddělené čárkou)</FormLabel>
<Input
placeholder="user1@priklad.cz, user2@priklad.cz"
value={testEmails}
onChange={(e) => setTestEmails(e.target.value)}
/>
<FormHelperText>Pokud vyplníte, použije se toto pole přednostně.</FormHelperText>
</FormControl>
<FormControl>
<FormLabel>Typ testu</FormLabel>
<select value={testType} onChange={(e) => setTestType(e.target.value as NewsletterTestType)}>
<option value="newsletter">Newsletter</option>
<option value="welcome">Uvítací</option>
<option value="welcome_back">Uvítací (návrat)</option>
<option value="blogs">Blogy (digest)</option>
<option value="events">Události (digest)</option>
<option value="matches">Zápasy (digest)</option>
<option value="scores">Výsledky (digest)</option>
<option value="weekly">Týdenní přehled</option>
</select>
</FormControl>
</VStack>
</ModalBody>
<ModalFooter>
<Button variant="ghost" mr={3} onClick={testModal.onClose}>
Zavřít
</Button>
<Button colorScheme="blue" onClick={() => sendTestMutation.mutate()} isLoading={sendTestMutation.isLoading}>
Odeslat test
</Button>
</ModalFooter>
</ModalContent>
</Modal>
{/* Preferences Modal */}
<Modal isOpen={prefsModalOpen} onClose={() => setPrefsModalOpen(false)} size="md">
<ModalOverlay />
<ModalContent>
<ModalHeader>Upravit preference</ModalHeader>
<ModalCloseButton />
<ModalBody>
<VStack spacing={4} align="stretch">
<FormControl>
<FormLabel>Týdenní přehled</FormLabel>
<Switch isChecked={!!editingPrefs['weekly']} onChange={(e) => setEditingPrefs({ ...editingPrefs, weekly: e.target.checked })} />
</FormControl>
<FormControl>
<FormLabel>Nadcházející zápasy</FormLabel>
<Switch isChecked={!!editingPrefs['matches']} onChange={(e) => setEditingPrefs({ ...editingPrefs, matches: e.target.checked })} />
</FormControl>
<FormControl>
<FormLabel>Blog</FormLabel>
<Switch isChecked={!!editingPrefs['blogs']} onChange={(e) => setEditingPrefs({ ...editingPrefs, blogs: e.target.checked })} />
</FormControl>
</VStack>
</ModalBody>
<ModalFooter>
<Button variant="ghost" mr={3} onClick={() => setPrefsModalOpen(false)}>Zrušit</Button>
<Button
colorScheme="blue"
onClick={() =>
editingSubscriber
? updatePrefsMutation.mutate({ id: editingSubscriber.id, prefs: editingPrefs })
: undefined
}
isLoading={updatePrefsMutation.isLoading}
isDisabled={!editingSubscriber}
>
Uložit
</Button>
</ModalFooter>
</ModalContent>
</Modal>
</AdminLayout>
);
}
@@ -0,0 +1,585 @@
import React, { useState, useRef, useEffect, useMemo } from 'react';
import {
Box,
Button,
FormControl,
FormLabel,
Heading,
HStack,
IconButton,
Image,
Input,
Modal,
ModalBody,
ModalCloseButton,
ModalContent,
ModalFooter,
ModalHeader,
ModalOverlay,
Select,
Spinner,
Switch,
Table,
Tbody,
Td,
Textarea,
Th,
Thead,
Tr,
useColorModeValue,
useDisclosure,
useToast,
VStack,
SimpleGrid,
NumberInput,
NumberInputField,
} from '@chakra-ui/react';
import { FiEdit2, FiTrash2, FiPlus, FiUpload } from 'react-icons/fi';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import AdminLayout from '../../layouts/AdminLayout';
import { Player, getPlayers, createPlayer, updatePlayer, deletePlayer } from '../../services/players';
import { uploadFile } from '../../services/articles';
import { translateNationality } from '../../utils/nationality';
type Editing = Partial<Player> & { id?: number };
const PlayersAdminPage: React.FC = () => {
const cardBg = useColorModeValue('white', 'gray.800');
const borderColor = useColorModeValue('gray.200', 'gray.700');
const inputBg = useColorModeValue('white', 'gray.700');
const toast = useToast();
const normalizeImageUrl = (url?: string) => {
if (!url || url === '') return '/logo192.png';
// If it's already absolute, return as-is
if (/^https?:\/\//i.test(url)) return url;
// If it's an uploads path, prefix with API origin
const apiUrl = process.env.REACT_APP_API_URL || 'http://localhost:8080/api/v1';
const origin = new URL(apiUrl).origin;
if (url.startsWith('/uploads/')) return `${origin}${url}`;
// Fallback: treat as relative to origin
return `${origin}${url.startsWith('/') ? '' : '/'}${url}`;
};
// Hoisted helper: convert country code to flag emoji
function countryCodeToEmoji(cc: string) {
if (!cc) return '';
return cc.toUpperCase().replace(/./g, (char) => String.fromCodePoint(127397 + char.charCodeAt(0)));
}
// Countries for nationality dropdown
const [countries, setCountries] = useState<Array<{ name: string; cca2: string; emoji: string }>>([]);
// Search query for filtering nationality dropdown
const [countryQuery, setCountryQuery] = useState<string>("");
const countrySearchRef = useRef<HTMLInputElement | null>(null);
const countrySelectRef = useRef<HTMLSelectElement | null>(null);
// Simple fuzzy match scoring: higher is better
function fuzzyScore(text: string, query: string): number {
if (!query) return 0;
const t = text.toLowerCase();
const q = query.toLowerCase();
// Exact and prefix bonuses
if (t === q) return 1000;
if (t.startsWith(q)) return 800 - (t.length - q.length);
// Subsequence score
let ti = 0, qi = 0, score = 0, streak = 0;
while (ti < t.length && qi < q.length) {
if (t[ti] === q[qi]) {
streak += 1;
score += 10 + streak; // reward consecutive matches
qi++;
} else {
streak = 0;
}
ti++;
}
// Small penalty for distance
if (qi === q.length) {
return score - Math.abs(t.length - q.length);
}
return -Infinity; // not all chars matched in order
}
const filteredCountries = useMemo(() => {
const q = countryQuery.trim();
if (!q) return countries;
// Score by name and cca2 and take the better score
const scored = countries
.map((c) => ({
c,
s: Math.max(
fuzzyScore(c.name, q),
fuzzyScore(c.cca2, q)
),
}))
.filter((x) => x.s > -Infinity)
.sort((a, b) => b.s - a.s || a.c.name.localeCompare(b.c.name, 'cs', { sensitivity: 'base' }))
.map((x) => x.c);
return scored;
}, [countries, countryQuery]);
useEffect(() => {
let mounted = true;
(async () => {
try {
// Try v3.1 API first
let json: any[] | null = null;
const tryUrls = [
'https://restcountries.com/v3.1/all?fields=name,cca2',
// fallback: v2 API
'https://restcountries.com/v2/all?fields=name,alpha2Code',
// minimal fallback without fields (may be heavier, but used only if above fail)
'https://restcountries.com/v3.1/all',
];
let lastErr: any = null;
for (const url of tryUrls) {
try {
const res = await fetch(url);
if (!res.ok) throw new Error(`countries ${res.status}`);
const data = await res.json();
if (Array.isArray(data)) { json = data; break; }
} catch (e) {
lastErr = e;
}
}
if (!mounted) return;
if (!json) throw lastErr || new Error('countries fetch failed');
const list = (json || []).map((c: any) => {
const name = c?.name?.common || c?.name || '';
const cca2 = c?.cca2 || c?.alpha2Code || '';
return {
name,
cca2,
emoji: countryCodeToEmoji(cca2),
};
}).filter((c: any) => c.name && c.cca2).sort((a: any, b: any) => a.name.localeCompare(b.name, 'cs', { sensitivity: 'base' }));
setCountries(list);
} catch (err) {
// Hard fallback list for most common nationalities used locally
const fallback = [
{ name: 'Czechia', cca2: 'CZ', emoji: countryCodeToEmoji('CZ') },
{ name: 'Slovakia', cca2: 'SK', emoji: countryCodeToEmoji('SK') },
{ name: 'Poland', cca2: 'PL', emoji: countryCodeToEmoji('PL') },
{ name: 'Germany', cca2: 'DE', emoji: countryCodeToEmoji('DE') },
{ name: 'Austria', cca2: 'AT', emoji: countryCodeToEmoji('AT') },
{ name: 'Ukraine', cca2: 'UA', emoji: countryCodeToEmoji('UA') },
{ name: 'France', cca2: 'FR', emoji: countryCodeToEmoji('FR') },
{ name: 'Spain', cca2: 'ES', emoji: countryCodeToEmoji('ES') },
{ name: 'Italy', cca2: 'IT', emoji: countryCodeToEmoji('IT') },
{ name: 'England', cca2: 'GB', emoji: countryCodeToEmoji('GB') },
];
setCountries(fallback);
}
})();
return () => { mounted = false; };
}, []);
// Persist countryQuery across sessions
useEffect(() => {
try {
const saved = localStorage.getItem('playersAdmin.countryQuery');
if (saved) setCountryQuery(saved);
} catch {}
}, []);
useEffect(() => {
try {
localStorage.setItem('playersAdmin.countryQuery', countryQuery);
} catch {}
}, [countryQuery]);
// Helper: compress image client-side, resize to max dimension and convert to JPEG to reduce size
const compressAndUpload = async (file: File) => {
const img = await readFileAsImage(file);
const maxDim = 1400;
let { width, height } = img;
if (width > maxDim || height > maxDim) {
const ratio = Math.min(maxDim / width, maxDim / height);
width = Math.round(width * ratio);
height = Math.round(height * ratio);
}
const canvas = document.createElement('canvas');
canvas.width = width;
canvas.height = height;
const ctx = canvas.getContext('2d')!;
ctx.drawImage(img, 0, 0, width, height);
// Use JPEG for good compression; backend can accept jpeg (serving as png not mandatory)
const blob: Blob | null = await new Promise((resolve) => canvas.toBlob(resolve as any, 'image/jpeg', 0.78));
if (!blob) throw new Error('compression failed');
const f = new File([blob], file.name.replace(/\.[^/.]+$/, '') + '.jpg', { type: 'image/jpeg' });
return await uploadFile(f);
};
// Hoisted helper: read file into HTMLImageElement
function readFileAsImage(file: File) {
return new Promise<HTMLImageElement>((resolve, reject) => {
const fr = new FileReader();
fr.onload = () => {
const img = new window.Image();
img.onload = () => resolve(img);
img.onerror = reject;
img.src = fr.result as string;
};
fr.onerror = reject;
fr.readAsDataURL(file);
});
}
const qc = useQueryClient();
const { data, isLoading } = useQuery({ queryKey: ['admin-players'], queryFn: getPlayers });
const [editing, setEditing] = useState<Editing | null>(null);
const { isOpen, onOpen, onClose } = useDisclosure();
// 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: '' });
const openCreate = () => { setEditing({ first_name: '', last_name: '', is_active: true, email: '', phone: '' } as any); setDobFromDateStr(''); onOpen(); };
const openEdit = (p: Player) => { setEditing({ ...p }); setDobFromDateStr(p.date_of_birth || ''); onOpen(); };
const closeModal = () => { setEditing(null); onClose(); };
const createMut = useMutation({
mutationFn: (payload: any) => createPlayer(payload),
onSuccess: (created: any) => {
try {
qc.setQueryData(['admin-players'], (old: any) => {
const list = Array.isArray(old) ? old : (old?.data || []);
const newList = [created, ...list];
if (old && old.data) return { ...old, data: newList };
return newList;
});
} catch (e) {}
toast({ title: 'Hráč vytvořen', status: 'success' });
qc.invalidateQueries({ queryKey: ['admin-players'] });
closeModal();
},
onError: (e: any) => {
const status = e?.response?.status;
const msg = e?.response?.data?.chyba || e?.response?.data?.error || e?.message || 'Chyba';
toast({ title: 'Vytvoření selhalo', description: status ? `HTTP ${status}: ${msg}` : msg, status: 'error' });
},
});
const updateMut = useMutation({
mutationFn: ({ id, payload }: { id: number | string; payload: any }) => updatePlayer(id, payload),
onSuccess: () => { toast({ title: 'Hráč upraven', status: 'success' }); qc.invalidateQueries({ queryKey: ['admin-players'] }); closeModal(); },
onError: (e: any) => {
const status = e?.response?.status;
const msg = e?.response?.data?.chyba || e?.response?.data?.error || e?.message || 'Chyba';
toast({ title: 'Aktualizace selhala', description: status ? `HTTP ${status}: ${msg}` : msg, status: 'error' });
},
});
const deleteMut = useMutation({
mutationFn: (id: number) => deletePlayer(id),
onSuccess: () => { toast({ title: 'Hráč smazán', status: 'success' }); qc.invalidateQueries({ queryKey: ['admin-players'] }); },
onError: (e: any) => {
const status = e?.response?.status;
const msg = e?.response?.data?.chyba || e?.response?.data?.error || e?.message || 'Chyba';
toast({ title: 'Smazání selhalo', description: status ? `HTTP ${status}: ${msg}` : msg, status: 'error' });
},
});
const onSubmit = async () => {
if (!editing) return;
const fn = (editing.first_name || '').trim();
const ln = (editing.last_name || '').trim();
if (!fn || !ln) {
toast({ title: 'Jméno a příjmení jsou povinné', status: 'warning' });
return;
}
// Build payload by including only present values to satisfy backend validation
const payload: any = {
first_name: fn,
last_name: ln,
};
if (editing.date_of_birth) payload.date_of_birth = editing.date_of_birth;
if (editing.position) payload.position = editing.position;
if (typeof editing.jersey_number === 'number' && Number.isFinite(editing.jersey_number) && editing.jersey_number > 0) payload.jersey_number = editing.jersey_number;
if (editing.nationality) payload.nationality = editing.nationality;
if (typeof editing.height === 'number' && editing.height > 0) payload.height = editing.height;
if (typeof editing.weight === 'number' && editing.weight > 0) payload.weight = editing.weight;
if (editing.image_url) payload.image_url = editing.image_url;
if (typeof editing.is_active === 'boolean') payload.is_active = editing.is_active;
const email = ((editing as any).email || '').trim();
const phone = ((editing as any).phone || '').trim();
if (email) payload.email = email;
if (phone) payload.phone = phone;
try {
if ((editing as any).id != null) {
await updateMut.mutateAsync({ id: (editing as any).id, payload });
} else {
await createMut.mutateAsync(payload);
}
} catch (err) {
// handled by mutation
}
};
return (
<AdminLayout requireAdmin={false}>
<Heading size="lg" mb={4}>Hráči</Heading>
<HStack mb={4}>
<Button leftIcon={<FiPlus />} colorScheme="blue" onClick={openCreate}>Nový hráč</Button>
</HStack>
<Box bg={cardBg} borderWidth="1px" borderRadius="md" overflowX="auto">
<Table size="sm">
<Thead>
<Tr>
<Th w="80px">Fotka</Th>
<Th>Jméno</Th>
<Th>Pozice</Th>
<Th>Národnost</Th>
<Th w="120px">Číslo</Th>
<Th w="120px">Aktivní</Th>
<Th w="160px">Akce</Th>
</Tr>
</Thead>
<Tbody>
{isLoading && (<Tr><Td colSpan={7}>Načítám...</Td></Tr>)}
{!isLoading && (data || []).map((p) => (
<Tr key={p.id}>
<Td>
<Image src={normalizeImageUrl(p.image_url)} alt={p.first_name} boxSize="48px" objectFit="cover" borderRadius="md" />
</Td>
<Td>{p.first_name} {p.last_name}</Td>
<Td>{p.position || '-'}</Td>
<Td>{p.nationality ? translateNationality(p.nationality) : '-'}</Td>
<Td>{p.jersey_number ?? '-'}</Td>
<Td><Switch isChecked={!!p.is_active} onChange={() => { if (p.id != null) updateMut.mutate({ id: p.id, payload: { is_active: !p.is_active } }); }} /></Td>
<Td>
<HStack>
<IconButton aria-label="Upravit" size="sm" icon={<FiEdit2 />} onClick={() => openEdit(p)} />
<IconButton aria-label="Smazat" size="sm" colorScheme="red" icon={<FiTrash2 />} onClick={async () => { if (!confirm('Opravdu smazat záznam?')) return; if (p.id != null) await deleteMut.mutateAsync(p.id); }} />
</HStack>
</Td>
</Tr>
))}
</Tbody>
</Table>
</Box>
<Modal isOpen={isOpen} onClose={closeModal} size="lg">
<ModalOverlay />
<ModalContent maxW="90vw" maxH="90vh" overflowY="auto">
<ModalHeader>{(editing as any)?.id ? 'Upravit hráče' : 'Nový hráč'}</ModalHeader>
<ModalCloseButton />
<ModalBody>
<VStack align="stretch" spacing={4}>
<SimpleGrid columns={[1, 2]} spacing={4}>
<FormControl isRequired>
<FormLabel>Jméno</FormLabel>
<Input value={editing?.first_name || ''} onChange={(e) => setEditing((p) => ({ ...(p as any), first_name: e.target.value }))} />
</FormControl>
<FormControl isRequired>
<FormLabel>Příjmení</FormLabel>
<Input value={editing?.last_name || ''} onChange={(e) => setEditing((p) => ({ ...(p as any), last_name: e.target.value }))} />
</FormControl>
{/* Custom DOB picker: day / month / year (timezone-safe) */}
<FormControl>
<FormLabel>Datum narození</FormLabel>
<HStack>
<Select value={dobParts.day} onChange={(e) => updateDobPart('day', e.target.value)}>
<option value="">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>
{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>
{Array.from({ length: 80 }).map((_, i) => { const y = new Date().getFullYear() - i; return <option key={y} value={String(y)}>{y}</option>; })}
</Select>
</HStack>
<Box mt={2} fontSize="sm" color="gray.500">
{formatDobPreview(dobParts)}
</Box>
</FormControl>
<FormControl>
<FormLabel>Pozice</FormLabel>
<Select value={editing?.position || ''} onChange={(e) => setEditing((p) => ({ ...(p as any), position: e.target.value }))}>
<option value=""> vyberte pozici </option>
<option value="Brankář">Brankář</option>
<option value="Obránce">Obránce</option>
<option value="Záložník">Záložník</option>
<option value="Útočník">Útočník</option>
<option value="Univerzál">Univerzál</option>
</Select>
</FormControl>
<FormControl>
<FormLabel>Číslo dresu</FormLabel>
<NumberInput value={editing?.jersey_number ?? 0} onChange={(_, v) => setEditing((p) => ({ ...(p as any), jersey_number: Number.isFinite(v) ? v : 0 }))}>
<NumberInputField />
</NumberInput>
</FormControl>
<FormControl>
<FormLabel>Národnost</FormLabel>
<VStack align="stretch" spacing={2}>
<Input
placeholder="Hledat zemi (např. cz, czechia)…"
value={countryQuery}
onChange={(e) => setCountryQuery(e.target.value)}
ref={countrySearchRef}
onKeyDown={(e) => {
if (e.key === 'ArrowDown') {
e.preventDefault();
countrySelectRef.current?.focus();
} else if (e.key === 'Escape') {
setCountryQuery('');
} else if (e.key === 'Enter') {
if (filteredCountries.length === 1) {
const only = filteredCountries[0];
setEditing((p) => ({ ...(p as any), nationality: only.name }));
countrySelectRef.current?.focus();
}
}
}}
/>
<Select
placeholder="— vyberte zemi —"
value={editing?.nationality || ''}
onChange={(e) => setEditing((p) => ({ ...(p as any), nationality: e.target.value }))}
ref={countrySelectRef}
onKeyDown={(e) => {
if (e.key === 'ArrowUp' && (e.target as HTMLSelectElement).selectedIndex === 0) {
// Move back to search
e.preventDefault();
countrySearchRef.current?.focus();
}
}}
>
{filteredCountries.map((c) => (
<option key={c.cca2} value={c.name}>
{c.emoji} {translateNationality(c.name)} ({c.name})
</option>
))}
</Select>
</VStack>
</FormControl>
<FormControl>
<FormLabel>Výška (cm)</FormLabel>
<NumberInput value={editing?.height ?? 0} onChange={(_, v) => setEditing((p) => ({ ...(p as any), height: Number.isFinite(v) ? v : 0 }))}>
<NumberInputField />
</NumberInput>
</FormControl>
<FormControl>
<FormLabel>Váha (kg)</FormLabel>
<NumberInput value={editing?.weight ?? 0} onChange={(_, v) => setEditing((p) => ({ ...(p as any), weight: Number.isFinite(v) ? v : 0 }))}>
<NumberInputField />
</NumberInput>
</FormControl>
{/* Optional contact info (not shown publicly) */}
<FormControl>
<FormLabel>Email (nepovinné)</FormLabel>
<Input type="email" value={(editing as any)?.email || ''} onChange={(e) => setEditing((p) => ({ ...(p as any), email: e.target.value }))} />
</FormControl>
<FormControl>
<FormLabel>Telefon (nepovinné)</FormLabel>
<Input type="tel" value={(editing as any)?.phone || ''} onChange={(e) => setEditing((p) => ({ ...(p as any), phone: e.target.value }))} />
</FormControl>
</SimpleGrid>
<FormControl>
<FormLabel>Fotka</FormLabel>
<HStack>
<Image src={normalizeImageUrl(editing?.image_url)} alt="photo" boxSize="56px" objectFit="cover" borderRadius="md" />
<Button as="label" type="button" leftIcon={<FiUpload />}>Nahrát
<Input
type="file"
display="none"
accept="image/*,image/svg+xml"
onChange={async (e) => {
e.preventDefault();
e.stopPropagation();
const f = e.target.files?.[0];
if (!f) return;
try {
const res = f.type === 'image/svg+xml' ? await uploadFile(f) : await compressAndUpload(f);
// ensure stored url is relative or absolute path expected by API
setEditing((p) => ({ ...(p as any), image_url: res.url }));
// reset the input so the same file can be selected again if needed
(e.target as HTMLInputElement).value = '';
} catch (err) {
toast({ title: 'Nahrání selhalo', status: 'error' });
}
}}
/>
</Button>
</HStack>
</FormControl>
<FormControl display="flex" alignItems="center">
<FormLabel mb="0">Aktivní</FormLabel>
<Switch isChecked={!!editing?.is_active} onChange={(e) => setEditing((p) => ({ ...(p as any), is_active: e.target.checked }))} />
</FormControl>
</VStack>
</ModalBody>
<ModalFooter>
<Button variant="ghost" mr={3} onClick={closeModal}>Zrušit</Button>
<Button colorScheme="blue" onClick={onSubmit} isLoading={createMut.isLoading || updateMut.isLoading}>Uložit</Button>
</ModalFooter>
</ModalContent>
</Modal>
</AdminLayout>
);
// Helpers for DOB to avoid timezone shifts
function parseYmd(s?: string | null): { y: number; m: number; d: number } | null {
if (!s) return null;
// Accept plain date 'YYYY-MM-DD' or ISO datetime starting with that date
const m = String(s).match(/^(\d{4})-(\d{2})-(\d{2})(?:$|T)/);
if (!m) return null;
const y = Number(m[1]);
const mm = Number(m[2]);
const dd = Number(m[3]);
if (!y || !mm || !dd) return null;
return { y, m: mm, d: dd };
}
function setDobFromDateStr(s?: string) {
const p = parseYmd(s || null);
if (!p) { setDobParts({ day: '', month: '', year: '' }); return; }
setDobParts({ day: String(p.d), month: String(p.m), year: String(p.y) });
}
function formatDobPreview(parts: { day: string; month: string; year: string }): string {
const dd = parts.day ? parts.day.toString().padStart(2, '0') : '__';
const mm = parts.month ? parts.month.toString().padStart(2, '0') : '__';
const yyyy = parts.year || '____';
return `${dd}.${mm}.${yyyy}`;
}
// Update DOB parts and, when complete, compose YYYY-MM-DD. Clamp day to month length.
function updateDobPart(part: 'day'|'month'|'year', value: string) {
setDobParts((prev) => {
let next = { ...prev, [part]: value } as { day: string; month: string; year: string };
// Clamp day if month/year present
const y = Number(next.year || '0');
const m = Number(next.month || '0');
const d = Number(next.day || '0');
if (y && m && d) {
const maxDay = new Date(y, m, 0).getDate();
if (d > maxDay) {
next.day = String(maxDay);
}
}
// Apply to editing only when all three are set
const yy = Number(next.year || '0');
const mm = Number(next.month || '0');
const dd = Number(next.day || '0');
if (yy && mm && dd) {
const ymd = `${yy}-${String(mm).padStart(2, '0')}-${String(dd).padStart(2, '0')}`;
setEditing((p) => ({ ...(p as any), date_of_birth: ymd }));
} else {
setEditing((p) => ({ ...(p as any), date_of_birth: '' }));
}
return next;
});
}
};
export default PlayersAdminPage;
+883
View File
@@ -0,0 +1,883 @@
import React, { useState, useEffect } from 'react';
import {
Box,
Button,
Container,
Heading,
HStack,
VStack,
Table,
Thead,
Tbody,
Tr,
Th,
Td,
Badge,
IconButton,
useDisclosure,
Modal,
ModalOverlay,
ModalContent,
ModalHeader,
ModalFooter,
ModalBody,
ModalCloseButton,
FormControl,
FormLabel,
Input,
Textarea,
Select,
Switch,
useToast,
Spinner,
Text,
Alert,
AlertIcon,
Tabs,
TabList,
TabPanels,
Tab,
TabPanel,
NumberInput,
NumberInputField,
NumberInputStepper,
NumberIncrementStepper,
NumberDecrementStepper,
Menu,
MenuButton,
MenuList,
MenuItem,
Stat,
StatLabel,
StatNumber,
StatHelpText,
SimpleGrid,
Card,
CardBody,
Image,
AspectRatio,
} from '@chakra-ui/react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { AddIcon, EditIcon, DeleteIcon, ChevronDownIcon, ViewIcon, CheckIcon, CloseIcon } from '@chakra-ui/icons';
import AdminLayout from '../../layouts/AdminLayout';
import {
getAdminPolls,
createPoll,
updatePoll,
deletePoll,
getPollStats,
Poll,
CreatePollRequest,
UpdatePollRequest,
PollStats,
} from '../../services/polls';
import { getCachedYouTube, YouTubeVideo } from '../../services/youtube';
const PollsAdminPage: React.FC = () => {
const toast = useToast();
const queryClient = useQueryClient();
const { isOpen, onOpen, onClose } = useDisclosure();
const { isOpen: isStatsOpen, onOpen: onStatsOpen, onClose: onStatsClose } = useDisclosure();
const [statusFilter, setStatusFilter] = useState<string>('');
const [editingPoll, setEditingPoll] = useState<Poll | null>(null);
const [selectedPollStats, setSelectedPollStats] = useState<PollStats | null>(null);
// Video selector state
const [clubVideos, setClubVideos] = useState<YouTubeVideo[]>([]);
const [loadingVideos, setLoadingVideos] = useState(false);
// Form state
const [formData, setFormData] = useState<CreatePollRequest>({
title: '',
description: '',
type: 'single',
status: 'draft',
allow_multiple: false,
max_choices: 1,
show_results: 'after_vote',
require_auth: false,
allow_guest_vote: true,
featured: false,
options: [
{ text: '', display_order: 0 },
{ text: '', display_order: 1 },
],
});
// Fetch polls
const { data: polls, isLoading } = useQuery({
queryKey: ['admin-polls', statusFilter],
queryFn: () => getAdminPolls(statusFilter ? { status: statusFilter } : undefined),
});
// Create mutation
const createMutation = useMutation({
mutationFn: createPoll,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['admin-polls'] });
toast({
title: 'Anketa vytvořena',
status: 'success',
duration: 3000,
});
onClose();
resetForm();
},
onError: (error: any) => {
toast({
title: 'Chyba',
description: error.response?.data?.error || 'Nepodařilo se vytvořit anketu',
status: 'error',
duration: 5000,
});
},
});
// Update mutation
const updateMutation = useMutation({
mutationFn: ({ id, data }: { id: number; data: UpdatePollRequest }) =>
updatePoll(id, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['admin-polls'] });
toast({
title: 'Anketa aktualizována',
status: 'success',
duration: 3000,
});
onClose();
resetForm();
},
onError: (error: any) => {
toast({
title: 'Chyba',
description: error.response?.data?.error || 'Nepodařilo se aktualizovat anketu',
status: 'error',
duration: 5000,
});
},
});
// Delete mutation
const deleteMutation = useMutation({
mutationFn: deletePoll,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['admin-polls'] });
toast({
title: 'Anketa smazána',
status: 'success',
duration: 3000,
});
},
onError: (error: any) => {
toast({
title: 'Chyba',
description: error.response?.data?.error || 'Nepodařilo se smazat anketu',
status: 'error',
duration: 5000,
});
},
});
// Stats query
const { data: statsData, isLoading: isLoadingStats } = useQuery({
queryKey: ['poll-stats', selectedPollStats?.poll?.id],
queryFn: () => getPollStats(selectedPollStats!.poll.id),
enabled: !!selectedPollStats?.poll?.id,
});
const resetForm = () => {
setFormData({
title: '',
description: '',
type: 'single',
status: 'draft',
allow_multiple: false,
max_choices: 1,
show_results: 'after_vote',
require_auth: false,
allow_guest_vote: true,
featured: false,
options: [
{ text: '', display_order: 0 },
{ text: '', display_order: 1 },
],
});
setEditingPoll(null);
};
const handleOpenCreate = () => {
resetForm();
onOpen();
};
const handleOpenEdit = (poll: Poll) => {
setEditingPoll(poll);
setFormData({
title: poll.title,
description: poll.description,
type: poll.type,
status: poll.status,
start_date: poll.start_date,
end_date: poll.end_date,
allow_multiple: poll.allow_multiple,
max_choices: poll.max_choices,
show_results: poll.show_results,
require_auth: poll.require_auth,
allow_guest_vote: poll.allow_guest_vote,
featured: poll.featured,
category_id: poll.category_id,
related_match_id: poll.related_match_id,
related_article_id: poll.related_article_id,
related_event_id: poll.related_event_id,
related_video_url: poll.related_video_url,
image_url: poll.image_url,
options: poll.options.map(opt => ({
text: opt.text,
description: opt.description,
image_url: opt.image_url,
display_order: opt.display_order,
player_id: opt.player_id,
})),
});
onOpen();
};
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 (editingPoll) {
updateMutation.mutate({ id: editingPoll.id, data: formData });
} else {
createMutation.mutate(formData);
}
};
const handleDelete = (id: number) => {
if (window.confirm('Opravdu chcete smazat tuto anketu?')) {
deleteMutation.mutate(id);
}
};
const handleViewStats = async (poll: Poll) => {
try {
const stats = await getPollStats(poll.id);
setSelectedPollStats(stats);
onStatsOpen();
} catch (error: any) {
toast({
title: 'Chyba',
description: 'Nepodařilo se načíst statistiky',
status: 'error',
duration: 3000,
});
}
};
const addOption = () => {
setFormData({
...formData,
options: [
...formData.options,
{ text: '', display_order: formData.options.length },
],
});
};
const removeOption = (index: number) => {
if (formData.options.length > 2) {
setFormData({
...formData,
options: formData.options.filter((_, i) => i !== index),
});
}
};
const updateOption = (index: number, field: string, value: any) => {
const newOptions = [...formData.options];
newOptions[index] = { ...newOptions[index], [field]: value };
setFormData({ ...formData, options: newOptions });
};
// Load club videos when modal opens
useEffect(() => {
if (isOpen && clubVideos.length === 0) {
setLoadingVideos(true);
getCachedYouTube()
.then((data) => {
if (data?.videos) {
setClubVideos(data.videos);
}
})
.catch(() => {
toast({
title: 'Chyba',
description: 'Nepodařilo se načíst videa',
status: 'error',
duration: 3000,
});
})
.finally(() => {
setLoadingVideos(false);
});
}
}, [isOpen, clubVideos.length, toast]);
const getStatusBadge = (status: string) => {
const colorMap: Record<string, string> = {
draft: 'gray',
active: 'green',
closed: 'orange',
archived: 'red',
};
return <Badge colorScheme={colorMap[status] || 'gray'}>{status}</Badge>;
};
if (isLoading) {
return (
<AdminLayout>
<Container maxW="7xl" py={8}>
<VStack spacing={4}>
<Spinner size="xl" />
<Text>Načítání anket...</Text>
</VStack>
</Container>
</AdminLayout>
);
}
return (
<AdminLayout>
<Container maxW="7xl" py={8}>
<VStack spacing={6} align="stretch">
<HStack justify="space-between">
<Heading size="lg">Správa anket</Heading>
<Button leftIcon={<AddIcon />} colorScheme="blue" onClick={handleOpenCreate}>
Nová anketa
</Button>
</HStack>
<Alert status="info">
<AlertIcon />
<Box>
<Text fontWeight="bold">Ankety a hlasování</Text>
<Text fontSize="sm">
Vytvořte interaktivní ankety pro fanoušky - Hráč zápasu, předpověď výsledku, designové ankety a další.
</Text>
</Box>
</Alert>
<HStack>
<Select
value={statusFilter}
onChange={(e) => setStatusFilter(e.target.value)}
maxW="200px"
>
<option value="">Všechny stavy</option>
<option value="draft">Koncepty</option>
<option value="active">Aktivní</option>
<option value="closed">Uzavřené</option>
<option value="archived">Archivované</option>
</Select>
</HStack>
<Box overflowX="auto">
<Table variant="simple">
<Thead>
<Tr>
<Th>Název</Th>
<Th>Typ</Th>
<Th>Stav</Th>
<Th>Počet hlasů</Th>
<Th>Vytvořeno</Th>
<Th>Akce</Th>
</Tr>
</Thead>
<Tbody>
{polls?.map((poll) => (
<Tr key={poll.id}>
<Td>
<VStack align="start" spacing={0}>
<Text fontWeight="bold">{poll.title}</Text>
{poll.featured && <Badge colorScheme="purple">Zvýrazněná</Badge>}
</VStack>
</Td>
<Td>{poll.type}</Td>
<Td>{getStatusBadge(poll.status)}</Td>
<Td>{poll.total_votes}</Td>
<Td>{new Date(poll.created_at).toLocaleDateString('cs-CZ')}</Td>
<Td>
<HStack spacing={2}>
<IconButton
aria-label="Statistiky"
icon={<ViewIcon />}
size="sm"
onClick={() => handleViewStats(poll)}
/>
<IconButton
aria-label="Upravit"
icon={<EditIcon />}
size="sm"
onClick={() => handleOpenEdit(poll)}
/>
<IconButton
aria-label="Smazat"
icon={<DeleteIcon />}
size="sm"
colorScheme="red"
onClick={() => handleDelete(poll.id)}
/>
</HStack>
</Td>
</Tr>
))}
</Tbody>
</Table>
</Box>
{polls?.length === 0 && (
<Alert status="info">
<AlertIcon />
Zatím nemáte žádné ankety. Vytvořte první anketu pomocí tlačítka výše.
</Alert>
)}
</VStack>
{/* Create/Edit Modal */}
<Modal isOpen={isOpen} onClose={onClose} size="4xl">
<ModalOverlay />
<ModalContent>
<ModalHeader>
{editingPoll ? 'Upravit anketu' : 'Nová anketa'}
</ModalHeader>
<ModalCloseButton />
<ModalBody>
<Tabs>
<TabList>
<Tab>Základní</Tab>
<Tab>Možnosti</Tab>
<Tab>Nastavení</Tab>
</TabList>
<TabPanels>
{/* Basic Info Tab */}
<TabPanel>
<VStack spacing={4}>
<FormControl isRequired>
<FormLabel>Název ankety</FormLabel>
<Input
value={formData.title}
onChange={(e) =>
setFormData({ ...formData, title: e.target.value })
}
placeholder="Např. Hráč zápasu"
/>
</FormControl>
<FormControl>
<FormLabel>Popis</FormLabel>
<Textarea
value={formData.description}
onChange={(e) =>
setFormData({ ...formData, description: e.target.value })
}
placeholder="Volitelný popis ankety"
/>
</FormControl>
<SimpleGrid columns={2} spacing={4} w="full">
<FormControl>
<FormLabel>Typ</FormLabel>
<Select
value={formData.type}
onChange={(e) =>
setFormData({ ...formData, type: e.target.value as any })
}
>
<option value="single">Jedna volba</option>
<option value="multiple">Více voleb</option>
<option value="rating">Hodnocení</option>
</Select>
</FormControl>
<FormControl>
<FormLabel>Stav</FormLabel>
<Select
value={formData.status}
onChange={(e) =>
setFormData({ ...formData, status: e.target.value as any })
}
>
<option value="draft">Koncept</option>
<option value="active">Aktivní</option>
<option value="closed">Uzavřená</option>
<option value="archived">Archivovaná</option>
</Select>
</FormControl>
</SimpleGrid>
<SimpleGrid columns={2} spacing={4} w="full">
<FormControl>
<FormLabel>Datum zahájení</FormLabel>
<Input
type="datetime-local"
value={formData.start_date || ''}
onChange={(e) =>
setFormData({ ...formData, start_date: e.target.value })
}
/>
</FormControl>
<FormControl>
<FormLabel>Datum ukončení</FormLabel>
<Input
type="datetime-local"
value={formData.end_date || ''}
onChange={(e) =>
setFormData({ ...formData, end_date: e.target.value })
}
/>
</FormControl>
</SimpleGrid>
<FormControl>
<FormLabel>Video z klubového kanálu (volitelné)</FormLabel>
<Text fontSize="sm" color="gray.500" mb={2}>
Připojte anketu k videu z vašeho YouTube kanálu
</Text>
{loadingVideos ? (
<HStack>
<Spinner size="sm" />
<Text fontSize="sm">Načítání videí...</Text>
</HStack>
) : clubVideos.length === 0 ? (
<Alert status="info" size="sm">
<AlertIcon />
<Text fontSize="sm">Žádná videa nenalezena. Přidejte videa v Nastavení Videa.</Text>
</Alert>
) : (
<VStack spacing={3} align="stretch">
<Select
value={formData.related_video_url || ''}
onChange={(e) =>
setFormData({ ...formData, related_video_url: e.target.value })
}
placeholder="Vyberte video (nebo nechte prázdné)"
>
{clubVideos.map((video) => (
<option
key={video.video_id}
value={`https://www.youtube.com/watch?v=${video.video_id}`}
>
{video.title} {video.published_text ? `(${video.published_text})` : ''}
</option>
))}
</Select>
{formData.related_video_url && (
<Box borderWidth="1px" borderRadius="md" p={2}>
<Text fontSize="sm" fontWeight="semibold" mb={2}>Náhled vybraného videa:</Text>
{(() => {
const selectedVideo = clubVideos.find(
v => `https://www.youtube.com/watch?v=${v.video_id}` === formData.related_video_url
);
if (selectedVideo) {
return (
<HStack spacing={3}>
<Image
src={selectedVideo.thumbnail_url}
alt={selectedVideo.title}
boxSize="120px"
objectFit="cover"
borderRadius="md"
/>
<VStack align="start" spacing={1}>
<Text fontSize="sm" fontWeight="medium">{selectedVideo.title}</Text>
{selectedVideo.published_text && (
<Badge size="sm">{selectedVideo.published_text}</Badge>
)}
</VStack>
</HStack>
);
}
return null;
})()}
</Box>
)}
</VStack>
)}
<Text fontSize="xs" color="gray.500" mt={2}>
Poznámka: Propojení s články a aktivitami se nastavuje přímo v editoru článku/aktivity.
</Text>
</FormControl>
</VStack>
</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"
/>
</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>
))}
<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.allow_multiple && (
<FormControl>
<FormLabel>Max. počet voleb</FormLabel>
<NumberInput
value={formData.max_choices}
min={1}
max={formData.options.length}
onChange={(_, num) =>
setFormData({ ...formData, max_choices: num })
}
>
<NumberInputField />
<NumberInputStepper>
<NumberIncrementStepper />
<NumberDecrementStepper />
</NumberInputStepper>
</NumberInput>
</FormControl>
)}
<FormControl>
<FormLabel>Zobrazení výsledků</FormLabel>
<Select
value={formData.show_results}
onChange={(e) =>
setFormData({ ...formData, show_results: e.target.value as any })
}
>
<option value="always">Vždy</option>
<option value="after_vote">Po hlasování</option>
<option value="after_end">Po ukončení</option>
<option value="never">Nikdy (pouze admin)</option>
</Select>
</FormControl>
<FormControl display="flex" alignItems="center">
<FormLabel mb="0">Vyžadovat přihlášení</FormLabel>
<Switch
isChecked={formData.require_auth}
onChange={(e) =>
setFormData({ ...formData, require_auth: e.target.checked })
}
/>
</FormControl>
<FormControl display="flex" alignItems="center">
<FormLabel mb="0">Povolit hlasování hostů</FormLabel>
<Switch
isChecked={formData.allow_guest_vote}
onChange={(e) =>
setFormData({ ...formData, allow_guest_vote: e.target.checked })
}
/>
</FormControl>
<FormControl display="flex" alignItems="center">
<FormLabel mb="0">Zvýraznit na hlavní stránce</FormLabel>
<Switch
isChecked={formData.featured}
onChange={(e) =>
setFormData({ ...formData, featured: e.target.checked })
}
/>
</FormControl>
</VStack>
</TabPanel>
</TabPanels>
</Tabs>
</ModalBody>
<ModalFooter>
<Button variant="ghost" mr={3} onClick={onClose}>
Zrušit
</Button>
<Button
colorScheme="blue"
onClick={handleSave}
isLoading={createMutation.isPending || updateMutation.isPending}
>
{editingPoll ? 'Uložit' : 'Vytvořit'}
</Button>
</ModalFooter>
</ModalContent>
</Modal>
{/* Stats Modal */}
<Modal isOpen={isStatsOpen} onClose={onStatsClose} size="4xl">
<ModalOverlay />
<ModalContent>
<ModalHeader>Statistiky ankety</ModalHeader>
<ModalCloseButton />
<ModalBody>
{isLoadingStats ? (
<VStack py={8}>
<Spinner size="xl" />
<Text>Načítání statistik...</Text>
</VStack>
) : statsData ? (
<VStack spacing={6} align="stretch">
<SimpleGrid columns={3} spacing={4}>
<Stat>
<StatLabel>Celkem hlasů</StatLabel>
<StatNumber>{statsData.poll.total_votes}</StatNumber>
</Stat>
<Stat>
<StatLabel>Přihlášení uživatelé</StatLabel>
<StatNumber>{statsData.authenticated_votes}</StatNumber>
<StatHelpText>
{statsData.poll.total_votes > 0
? `${Math.round((statsData.authenticated_votes / statsData.poll.total_votes) * 100)}%`
: '0%'}
</StatHelpText>
</Stat>
<Stat>
<StatLabel>Hosté</StatLabel>
<StatNumber>{statsData.guest_votes}</StatNumber>
<StatHelpText>
{statsData.poll.total_votes > 0
? `${Math.round((statsData.guest_votes / statsData.poll.total_votes) * 100)}%`
: '0%'}
</StatHelpText>
</Stat>
</SimpleGrid>
<Box>
<Heading size="sm" mb={4}>
Výsledky
</Heading>
<VStack spacing={2} align="stretch">
{statsData.poll.options.map((option) => {
const percentage =
statsData.poll.total_votes > 0
? (option.vote_count / statsData.poll.total_votes) * 100
: 0;
return (
<Box key={option.id}>
<HStack justify="space-between" mb={1}>
<Text fontWeight="medium">{option.text}</Text>
<Text fontSize="sm" color="gray.500">
{option.vote_count} hlasů ({percentage.toFixed(1)}%)
</Text>
</HStack>
<Box
w="full"
h="8px"
bg="gray.200"
borderRadius="full"
overflow="hidden"
>
<Box
h="full"
bg="blue.500"
w={`${percentage}%`}
transition="width 0.3s"
/>
</Box>
</Box>
);
})}
</VStack>
</Box>
{statsData.votes_by_day.length > 0 && (
<Box>
<Heading size="sm" mb={4}>
Hlasy podle dnů
</Heading>
<VStack spacing={2} align="stretch">
{statsData.votes_by_day.map((day) => (
<HStack key={day.date} justify="space-between">
<Text>{new Date(day.date).toLocaleDateString('cs-CZ')}</Text>
<Badge>{day.count} hlasů</Badge>
</HStack>
))}
</VStack>
</Box>
)}
</VStack>
) : null}
</ModalBody>
<ModalFooter>
<Button onClick={onStatsClose}>Zavřít</Button>
</ModalFooter>
</ModalContent>
</Modal>
</Container>
</AdminLayout>
);
};
export default PollsAdminPage;
@@ -0,0 +1,254 @@
import React from 'react';
import {
Box,
Heading,
Text,
Stack,
Button,
Stat,
StatLabel,
StatNumber,
StatHelpText,
SimpleGrid,
useToast,
Card,
CardHeader,
CardBody,
Badge,
Divider,
Modal,
ModalOverlay,
ModalContent,
ModalHeader,
ModalCloseButton,
ModalBody,
ModalFooter,
Textarea,
HStack,
VStack,
} from '@chakra-ui/react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { getPrefetchStatus, triggerPrefetch, PrefetchStatus } from '../../services/admin/prefetch';
import AdminLayout from '../../layouts/AdminLayout';
import api, { API_URL } from '../../services/api';
const PrefetchAdminPage: React.FC = () => {
const toast = useToast();
const qc = useQueryClient();
// RAW cache viewer state
const [rawOpen, setRawOpen] = React.useState<boolean>(false);
const [rawSel, setRawSel] = React.useState<string>('');
const [rawLoading, setRawLoading] = React.useState<boolean>(false);
const [rawError, setRawError] = React.useState<string | null>(null);
const [rawText, setRawText] = React.useState<string>('');
// Load list of available cache files (admin)
const { data: rawList, isError: rawListError, error: rawListErrorMsg } = useQuery<{ files: Array<{ label: string; path: string; size_bytes?: number; mod_time?: string }>}>({
queryKey: ['admin', 'cache', 'list'],
queryFn: async () => {
const res = await api.get('/admin/cache/list');
return res.data;
},
staleTime: 30_000,
retry: 1,
});
const fetchRaw = async (path: string) => {
setRawLoading(true);
setRawError(null);
setRawText('');
try {
const res = await api.get(`/admin/cache/file?path=${encodeURIComponent(path)}`, {
transformResponse: [(data) => data], // Get raw text response
});
const txt = res.data;
try {
const obj = JSON.parse(txt);
setRawText(JSON.stringify(obj, null, 2));
} catch {
setRawText(txt);
}
} catch (e: any) {
setRawError(e?.message || 'Nelze načíst data');
} finally {
setRawLoading(false);
}
};
const { data: status, isLoading, isFetching } = useQuery<PrefetchStatus>({
queryKey: ['admin', 'prefetch', 'status'],
queryFn: getPrefetchStatus,
refetchInterval: 30_000, // keep page live
});
const trigger = useMutation({
mutationFn: triggerPrefetch,
onSuccess: async () => {
toast({ title: 'Prefetch spuštěn', status: 'success' });
await qc.invalidateQueries({ queryKey: ['admin', 'prefetch', 'status'] });
},
onError: (err: any) => {
toast({ title: 'Spuštění prefetch selhalo', description: String(err?.message || err), status: 'error' });
},
});
const last = status?.lastUpdated ? new Date(status.lastUpdated) : null;
const next = status?.nextApproximate ? new Date(status.nextApproximate) : null;
return (
<AdminLayout>
<Box>
<Heading size="lg" mb={4}>Prefetch & Cache</Heading>
<Text color="gray.600" mb={6}>
Na pozadí běží úloha, která pravidelně stahuje JSON snapshoty z veřejných API pro rychlejší načítání stránek. Zde uvidíte aktuální plán a můžete spustit ruční stažení.
</Text>
<SimpleGrid columns={{ base: 1, md: 3 }} spacing={4} mb={6}>
<Card>
<CardHeader><Text fontWeight="bold">Režim</Text></CardHeader>
<CardBody>
<Stat>
<StatLabel>Aktuální režim</StatLabel>
<StatNumber>
{status?.fastMode ? (
<Badge colorScheme="green">Rychlý (během zápasu)</Badge>
) : (
<Badge>Normální</Badge>
)}
</StatNumber>
<StatHelpText>V době konání zápasů se automaticky přepne do rychlého režimu.</StatHelpText>
</Stat>
</CardBody>
</Card>
<Card>
<CardHeader><Text fontWeight="bold">Poslední aktualizace</Text></CardHeader>
<CardBody>
<Stat>
<StatLabel>Poslední prefetch</StatLabel>
<StatNumber fontSize="lg">
{last ? last.toLocaleString() : 'Unknown'}
</StatNumber>
<StatHelpText>{isFetching ? 'Obnovuji…' : 'Aktuální'}</StatHelpText>
</Stat>
</CardBody>
</Card>
<Card>
<CardHeader><Text fontWeight="bold">Další spuštění</Text></CardHeader>
<CardBody>
<Stat>
<StatLabel>Přibližně</StatLabel>
<StatNumber fontSize="lg">
{next ? next.toLocaleString() : '—'}
</StatNumber>
<StatHelpText>
Interval: {status?.intervalMinutes ?? 30} min
</StatHelpText>
</Stat>
</CardBody>
</Card>
</SimpleGrid>
<Card>
<CardHeader>
<Stack direction={{ base: 'column', sm: 'row' }} align="center" justify="space-between">
<Text fontWeight="bold">Ovládání</Text>
<Stack direction="row" spacing={3}>
<Button
colorScheme="blue"
isLoading={trigger.isLoading}
onClick={() => trigger.mutate()}
>
Spustit stažení
</Button>
<Button variant="outline" onClick={() => qc.invalidateQueries({ queryKey: ['admin', 'prefetch', 'status'] })}>
Obnovit stav
</Button>
<Button
variant="outline"
onClick={() => {
setRawOpen(true);
const first = rawList?.files?.[0];
if (first) {
setRawSel(first.path);
fetchRaw(first.path);
} else if (rawListError) {
setRawError('Nelze načíst seznam souborů');
} else if (!rawList?.files || rawList.files.length === 0) {
setRawError('Žádné cache soubory nebyly nalezeny');
}
}}
isDisabled={rawListError && !rawList}
>
Zobrazit RAW data
</Button>
</Stack>
</Stack>
</CardHeader>
<Divider />
<CardBody>
<Text color="gray.600">
Ruční spuštění zahájí na pozadí obnovu všech veřejných endpointů a zdrojů FAČR. Nezablokuje uživatelské rozhraní.
</Text>
</CardBody>
</Card>
{/* RAW viewer modal */}
<Modal isOpen={rawOpen} onClose={() => setRawOpen(false)} size="6xl" scrollBehavior="inside">
<ModalOverlay />
<ModalContent>
<ModalHeader>RAW data (prefetch & cache)</ModalHeader>
<ModalCloseButton />
<ModalBody>
{rawListError ? (
<Box p={4} textAlign="center">
<Text color="red.500" mb={2}>Chyba při načítání seznamu souborů</Text>
<Text color="gray.500" fontSize="sm">{String(rawListErrorMsg)}</Text>
</Box>
) : !rawList?.files || rawList.files.length === 0 ? (
<Box p={4} textAlign="center">
<Text color="gray.500">Žádné cache soubory nebyly nalezeny</Text>
<Text fontSize="sm" color="gray.400" mt={2}>Zkuste spustit prefetch stažení nejprve</Text>
</Box>
) : (
<SimpleGrid columns={{ base: 1, md: 4 }} spacing={4}>
<VStack align="stretch" spacing={2} gridColumn={{ base: '1', md: 'span 1' }}>
{rawList.files.map((f) => (
<Button
key={f.path}
variant={rawSel === f.path ? 'solid' : 'outline'}
onClick={() => { setRawSel(f.path); fetchRaw(f.path); }}
justifyContent="flex-start"
size="sm"
>
<Text noOfLines={1} fontSize="xs" textAlign="left" w="full">
{f.label}
</Text>
</Button>
))}
</VStack>
<Box gridColumn={{ base: '1', md: 'span 3' }}>
<HStack justify="space-between" mb={2}>
<Text fontWeight="semibold" fontSize="sm" noOfLines={1}>{rawSel || 'Vyberte soubor'}</Text>
<HStack>
<Button size="sm" variant="ghost" onClick={() => fetchRaw(rawSel)} isLoading={rawLoading} isDisabled={!rawSel}>Obnovit</Button>
<Button size="sm" as="a" href={`${API_URL}/admin/cache/file?path=${encodeURIComponent(rawSel)}`} target="_blank" rel="noreferrer" isDisabled={!rawSel}>Otevřít v nové záložce</Button>
</HStack>
</HStack>
{rawError && <Box color="red.500" mb={2} p={2} bg="red.50" borderRadius="md">{rawError}</Box>}
<Textarea value={rawText} onChange={() => {}} readOnly fontFamily="mono" rows={24} fontSize="xs" />
</Box>
</SimpleGrid>
)}
</ModalBody>
<ModalFooter>
<Button onClick={() => setRawOpen(false)}>Zavřít</Button>
</ModalFooter>
</ModalContent>
</Modal>
</Box>
</AdminLayout>
);
};
export default PrefetchAdminPage;
@@ -0,0 +1,605 @@
import React, { useEffect, useMemo, useState } from 'react';
import {
Box,
Button,
FormControl,
FormLabel,
Input,
NumberInput,
NumberInputField,
Select,
SimpleGrid,
HStack,
VStack,
Heading,
Divider,
Image,
useToast,
Text,
Switch,
Badge,
Tabs,
TabList,
TabPanels,
Tab,
TabPanel,
useColorModeValue,
} from '@chakra-ui/react';
import { CopyIcon } from '@chakra-ui/icons';
import AdminLayout from '@/layouts/AdminLayout';
import ScoreboardPreview from '@/components/scoreboard/ScoreboardPreview';
import {
getScoreboardState,
saveScoreboardState,
updateAdminScoreboard,
ScoreboardState,
deriveShort,
derivePrimaryFromLogo,
ScoreboardTheme,
startTimer,
pauseTimer,
resetTimer,
} from '@/services/scoreboard';
import { useFacrApi } from '@/hooks/useFacrApi';
import { SearchResult } from '@/services/facr/types';
import { API_URL } from '@/services/api';
import { useQuery } from '@tanstack/react-query';
import { AdminMatch, fetchAdminMatches } from '@/services/adminMatches';
import { getFacrClubInfoCache } from '@/services/facr/cache';
const themes: ScoreboardTheme[] = ['classic', 'pill', 'var1', 'var2', 'var3', 'var4'];
const resolveLogoUrl = (u?: string | null) => {
if (!u) return undefined;
if (u.startsWith('/uploads') || u.startsWith('/dist') || u.startsWith('/api/')) return u;
if (/^https?:\/\//i.test(u)) {
const base = (API_URL || '').replace(/\/$/, '');
return `${base}/proxy/image?url=${encodeURIComponent(u)}`;
}
return u;
};
const ScoreboardAdminPage: React.FC = () => {
const cardBg = useColorModeValue('white', 'gray.800');
const borderColor = useColorModeValue('gray.200', 'gray.700');
const inputBg = useColorModeValue('white', 'gray.700');
const [state, setState] = useState<ScoreboardState | null>(null);
const [loading, setLoading] = useState(true);
const toast = useToast();
const [activeTab, setActiveTab] = useState<number>(0); // 0 upcoming, 1 recent
// Club search inline (home/away target)
const [clubQuery, setClubQuery] = useState('');
const [assignTo, setAssignTo] = useState<'home' | 'away'>('home');
const { searchClubs, searchResults, searchLoading, searchError } = useFacrApi();
useEffect(() => {
(async () => {
const s = await getScoreboardState();
setState(s);
setLoading(false);
})();
}, []);
// Poll while timer is running to reflect live time
useEffect(() => {
if (!state?.running) return;
let mounted = true;
const id = setInterval(async () => {
try {
const s = await getScoreboardState();
if (mounted) setState(s);
} catch {}
}, 1000);
return () => {
mounted = false;
clearInterval(id);
};
}, [state?.running]);
// Load matches for linking
const { data: adminMatches = [] } = useQuery<AdminMatch[]>({
queryKey: ['admin-matches'],
queryFn: fetchAdminMatches,
staleTime: 60_000,
});
// Load competitions/matches from cached FACR blob
const { data: facrCache } = useQuery<any>({
queryKey: ['facr-club-info-cache'],
queryFn: getFacrClubInfoCache,
staleTime: 120_000,
refetchInterval: 120_000,
});
// Normalize matches from FACR cache to AdminMatch-ish shape
const facrMatches: AdminMatch[] = useMemo(() => {
const out: AdminMatch[] = [];
try {
const comps = facrCache?.competitions || [];
for (const c of comps) {
const cname = String(c?.name || c?.code || '').trim();
const matches = c?.matches || [];
for (const m of matches) {
out.push({
match_id: m?.match_id || m?.id || '',
id: m?.match_id || m?.id || '',
home: m?.home || '',
away: m?.away || '',
date_time: m?.date_time || m?.kickoff || '',
home_logo_url: m?.home_logo_url || '',
away_logo_url: m?.away_logo_url || '',
competition: cname,
league: cname,
});
}
}
} catch {}
return out;
}, [facrCache]);
// Build competition list from both sources
const competitions = React.useMemo(() => {
const set = new Set<string>();
for (const m of adminMatches) {
const comp = String(m.competition || m.league || '').trim();
if (comp) set.add(comp);
}
for (const m of facrMatches) {
const comp = String((m as any).competition || (m as any).league || '').trim();
if (comp) set.add(comp);
}
return ['Vše', ...Array.from(set)];
}, [adminMatches, facrMatches]);
const [compFilter, setCompFilter] = useState<string>('Vše');
const [matchQuery, setMatchQuery] = useState('');
const parsedMatches = React.useMemo(() => {
const parseTs = (m: AdminMatch) => {
const dt = String(m.date_time || m.date || '').slice(0, 16);
// Accept RFC3339 or "YYYY-MM-DD HH:MM"
let t = Date.parse(dt);
if (isNaN(t) && dt.includes(' ')) {
t = Date.parse(dt.replace(' ', 'T') + ':00');
}
return t;
};
const now = Date.now();
let list: AdminMatch[] = [...(adminMatches || []), ...facrMatches];
if (compFilter && compFilter !== 'Vše') {
list = list.filter((m) => String((m as any).competition || (m as any).league || '').trim() === compFilter);
}
if (matchQuery.trim()) {
const q = matchQuery.trim().toLowerCase();
list = list.filter((m) =>
String(m.home || '').toLowerCase().includes(q) ||
String(m.away || '').toLowerCase().includes(q) ||
String((m as any).competition || (m as any).league || '').toLowerCase().includes(q)
);
}
const withTs = list.map((m) => ({ m, ts: parseTs(m) }));
const upcoming = withTs
.filter((x) => typeof x.ts === 'number' && !isNaN(x.ts) && x.ts >= now)
.sort((a, b) => a.ts - b.ts)
.map((x) => x.m);
const recent = withTs
.filter((x) => typeof x.ts === 'number' && !isNaN(x.ts) && x.ts < now)
.sort((a, b) => b.ts - a.ts)
.map((x) => x.m);
return { upcoming, recent };
}, [adminMatches, facrMatches, compFilter, matchQuery]);
useEffect(() => {
const q = clubQuery.trim();
if (!q) return;
const t = setTimeout(() => {
searchClubs(q).catch(() => {});
}, 400);
return () => clearTimeout(t);
}, [clubQuery, searchClubs]);
const setPartial = async (patch: Partial<ScoreboardState>) => {
const next = await saveScoreboardState(patch);
setState(next);
};
const applyMatch = async (m: AdminMatch) => {
if (!state) return;
// Populate names, logos and short codes
const homeName = String(m.home || m.home_team || '').trim();
const awayName = String(m.away || m.away_team || '').trim();
const homeLogo = resolveLogoUrl(m.home_logo_url || '') || '';
const awayLogo = resolveLogoUrl(m.away_logo_url || '') || '';
const updates: Partial<ScoreboardState> = {
homeName,
awayName,
homeShort: deriveShort(homeName),
awayShort: deriveShort(awayName),
homeLogo: homeLogo || state.homeLogo,
awayLogo: awayLogo || state.awayLogo,
externalMatchId: String(m.match_id || m.id || ''),
};
// Try to detect colors from logos
const [cHome, cAway] = await Promise.all([
derivePrimaryFromLogo(homeLogo || state.homeLogo),
derivePrimaryFromLogo(awayLogo || state.awayLogo),
]);
if (cHome) updates.primaryColor = cHome;
if (cAway) updates.secondaryColor = cAway;
const next = await saveScoreboardState(updates);
setState(next);
toast({ title: 'Zápas vybrán', description: 'Základní údaje byly předvyplněny.', status: 'success' });
};
const applyClub = async (club: SearchResult) => {
const logo = resolveLogoUrl(club.logo_url) || undefined;
const color = await derivePrimaryFromLogo(logo || undefined);
if (assignTo === 'home') {
await setPartial({
homeName: club.name || 'DOMÁCÍ',
homeShort: deriveShort(club.name || ''),
homeLogo: logo,
primaryColor: color || state?.primaryColor,
});
} else {
await setPartial({
awayName: club.name || 'HOSTÉ',
awayShort: deriveShort(club.name || ''),
awayLogo: logo,
secondaryColor: color || state?.secondaryColor,
});
}
toast({ title: `Nastaveno pro ${assignTo === 'home' ? 'domácí' : 'hosty'}`, status: 'success' });
};
if (loading || !state) return (
<AdminLayout>
<Box>Načítání</Box>
</AdminLayout>
);
return (
<AdminLayout>
<Box>
<Heading size="lg" mb={2}>Tabule (Scoreboard)</Heading>
<Text color="gray.500" mb={2}>Napojte tabuli na konkrétní zápas a ovládejte skóre v reálném čase. Pokud je tabule aktivní a propojená se zápasem, propisujeme živé skóre i do veřejné homepage.</Text>
<HStack spacing={3} mb={4}>
<Badge colorScheme="green">OBS</Badge>
{(() => { const href = (typeof window !== 'undefined' ? window.location.origin.replace(/\/$/, '') : '') + '/overlay/scoreboard'; return (
<>
<Button as="a" href={href} target="_blank" rel="noreferrer">Otevřít overlay</Button>
<HStack spacing={2}>
<Text fontSize="sm" color="gray.500">Veřejná URL pro OBS: {href}</Text>
<Button size="sm" leftIcon={<CopyIcon />} onClick={() => { try { navigator.clipboard.writeText(href); } catch (_) {} }}>Kopírovat</Button>
</HStack>
</>
); })()}
</HStack>
{/* Match linking (initial element) */}
<Box borderWidth="1px" borderRadius="lg" p={4} bg={cardBg} mb={6}>
<Heading size="md" mb={3}>Napojení na zápas (FAČR)</Heading>
<SimpleGrid columns={{ base: 1, md: 3 }} spacing={3} mb={3}>
<FormControl>
<FormLabel>Soutěž</FormLabel>
<Select value={compFilter} onChange={(e) => setCompFilter(e.target.value)}>
{competitions.map((c) => (<option key={c} value={c}>{c}</option>))}
</Select>
</FormControl>
<FormControl>
<FormLabel>Hledat zápas</FormLabel>
<Input placeholder="Hledat podle názvu týmu nebo soutěže" value={matchQuery} onChange={(e)=>setMatchQuery(e.target.value)} />
</FormControl>
<FormControl display="flex" alignItems="center">
<FormLabel mb={0}>Tabule aktivní</FormLabel>
<Switch isChecked={!!state?.active} onChange={async (e) => {
const next = await saveScoreboardState({ active: e.target.checked });
setState(next);
}} />
{state?.externalMatchId ? <Badge ml={3} colorScheme="blue">Zápas: {state.externalMatchId}</Badge> : <Badge ml={3}>Bez zápasu</Badge>}
</FormControl>
</SimpleGrid>
<Tabs index={activeTab} onChange={setActiveTab} variant="enclosed" size="sm">
<TabList>
<Tab>Nadcházející</Tab>
<Tab>Nedávné</Tab>
</TabList>
<TabPanels>
<TabPanel px={0}>
<VStack align="stretch" spacing={2} maxH="320px" overflowY="auto">
{parsedMatches.upcoming.map((m) => (
<HStack key={`${m.match_id || m.id}`} spacing={3} p={2} borderWidth="1px" borderRadius="md" _hover={{ bg: 'gray.50' }}>
{m.home_logo_url ? <Image src={resolveLogoUrl(m.home_logo_url)} alt={String(m.home)} boxSize="28px" objectFit="contain" /> : null}
<Text fontWeight="semibold">{String(m.home)} vs {String(m.away)}</Text>
<Badge ml="auto">{String(m.date_time || m.date || '').slice(0,16).replace('T',' ')}</Badge>
<Button size="sm" onClick={() => applyMatch(m)}>Vybrat zápas</Button>
</HStack>
))}
</VStack>
</TabPanel>
<TabPanel px={0}>
<VStack align="stretch" spacing={2} maxH="320px" overflowY="auto">
{parsedMatches.recent.map((m) => (
<HStack key={`${m.match_id || m.id}`} spacing={3} p={2} borderWidth="1px" borderRadius="md" _hover={{ bg: 'gray.50' }}>
{m.home_logo_url ? <Image src={resolveLogoUrl(m.home_logo_url)} alt={String(m.home)} boxSize="28px" objectFit="contain" /> : null}
<Text fontWeight="semibold">{String(m.home)} vs {String(m.away)}</Text>
<Badge ml="auto" colorScheme="purple">{String(m.date_time || m.date || '').slice(0,16).replace('T',' ')}</Badge>
<Button size="sm" variant="outline" onClick={() => applyMatch(m)}>Vybrat zápas</Button>
</HStack>
))}
</VStack>
</TabPanel>
</TabPanels>
</Tabs>
<HStack mt={3}>
<Button
colorScheme="blue"
onClick={async () => {
if (!state?.externalMatchId) { toast({ title: 'Zvolte zápas', description: 'Nejprve vyberte zápas ze seznamu výše.', status: 'warning' }); return; }
const next = await saveScoreboardState({ active: true });
setState(next);
toast({ title: 'Tabule aktivována', description: 'Živá data se budou propsat i na homepage.', status: 'success' });
}}
>Aktivovat pro vybraný zápas</Button>
<Button
variant="ghost"
onClick={async () => {
const next = await saveScoreboardState({ externalMatchId: '', active: false });
setState(next);
toast({ title: 'Odpojeno', description: 'Tabule odpojena od zápasu.', status: 'info' });
}}
>Odpojit od zápasu</Button>
</HStack>
</Box>
{/* Live preview */}
<Box display="flex" justifyContent="center" mb={6}>
<ScoreboardPreview state={state} />
</Box>
<SimpleGrid columns={{ base: 1, md: 2 }} spacing={6}>
<Box>
<Heading size="md" mb={3}>Základní údaje</Heading>
<SimpleGrid columns={2} spacing={4}>
<FormControl>
<FormLabel>Domácí tým</FormLabel>
<Input
value={state.homeName}
onChange={async (e) => {
const v = e.target.value;
await setPartial({ homeName: v, homeShort: state.homeShort?.trim() ? state.homeShort : deriveShort(v) });
}}
/>
</FormControl>
<FormControl>
<FormLabel>Zkratka domácích</FormLabel>
<Input
value={state.homeShort || ''}
maxLength={3}
onChange={async (e) => {
await setPartial({ homeShort: e.target.value.toUpperCase().slice(0,3) });
}}
/>
</FormControl>
<FormControl>
<FormLabel>Hostující tým</FormLabel>
<Input
value={state.awayName}
onChange={async (e) => {
const v = e.target.value;
await setPartial({ awayName: v, awayShort: state.awayShort?.trim() ? state.awayShort : deriveShort(v) });
}}
/>
</FormControl>
<FormControl>
<FormLabel>Zkratka hostů</FormLabel>
<Input
value={state.awayShort || ''}
maxLength={3}
onChange={async (e) => {
await setPartial({ awayShort: e.target.value.toUpperCase().slice(0,3) });
}}
/>
</FormControl>
<FormControl>
<FormLabel>Logo domácích (URL)</FormLabel>
<Input
value={state.homeLogo || ''}
onChange={async (e) => {
await setPartial({ homeLogo: e.target.value });
}}
/>
</FormControl>
<FormControl>
<FormLabel>Logo hostů (URL)</FormLabel>
<Input
value={state.awayLogo || ''}
onChange={async (e) => {
await setPartial({ awayLogo: e.target.value });
}}
/>
</FormControl>
<FormControl>
<FormLabel>Skóre domácích</FormLabel>
<NumberInput value={state.homeScore} min={0} onChange={async (_, n) => setPartial({ homeScore: Number.isFinite(n) ? n : 0 })}>
<NumberInputField />
</NumberInput>
</FormControl>
<FormControl>
<FormLabel>Skóre hostů</FormLabel>
<NumberInput value={state.awayScore} min={0} onChange={async (_, n) => setPartial({ awayScore: Number.isFinite(n) ? n : 0 })}>
<NumberInputField />
</NumberInput>
</FormControl>
<FormControl>
<FormLabel>Délka poločasu (min)</FormLabel>
<NumberInput value={state.halfLength} min={1} max={60} onChange={async (_, n) => setPartial({ halfLength: Number.isFinite(n) ? n : 45 })}>
<NumberInputField />
</NumberInput>
</FormControl>
<FormControl>
<FormLabel>Styl</FormLabel>
<Select value={state.theme} onChange={async (e) => setPartial({ theme: e.target.value as ScoreboardTheme })}>
{themes.map((t) => (
<option key={t} value={t}>{t}</option>
))}
</Select>
</FormControl>
</SimpleGrid>
</Box>
<Box>
<Heading size="md" mb={3}>Barvy a vyhledání klubu</Heading>
<SimpleGrid columns={2} spacing={4}>
<FormControl>
<FormLabel>Barva domácích</FormLabel>
<Input type="color" value={state.primaryColor || '#1e3a8a'} onChange={async (e) => setPartial({ primaryColor: e.target.value })} />
</FormControl>
<FormControl>
<FormLabel>Barva hostů</FormLabel>
<Input type="color" value={state.secondaryColor || '#2563eb'} onChange={async (e) => setPartial({ secondaryColor: e.target.value })} />
</FormControl>
</SimpleGrid>
<Divider my={4} />
<HStack spacing={3} align="flex-start">
<Select value={assignTo} onChange={(e) => setAssignTo((e.target.value as 'home'|'away') || 'home')} maxW="160px">
<option value="home">Nastavit domácí</option>
<option value="away">Nastavit hosty</option>
</Select>
<Input placeholder="Hledat klub (FAČR)" value={clubQuery} onChange={(e) => setClubQuery(e.target.value)} />
<Button isLoading={searchLoading} onClick={() => clubQuery.trim() && searchClubs(clubQuery.trim())}>Hledat</Button>
</HStack>
{searchError ? (
<Text color="red.500" mt={2}>Chyba vyhledávání: {searchError.message}</Text>
) : null}
<VStack align="stretch" spacing={2} mt={3} maxH="260px" overflowY="auto">
{searchResults?.slice(0, 8).map((r) => (
<HStack key={`${r.club_type}-${r.club_id}`} spacing={3} p={2} borderWidth="1px" borderRadius="md" _hover={{ bg: 'gray.50' }} cursor="pointer" onClick={() => applyClub(r)}>
{r.logo_url ? <Image src={resolveLogoUrl(r.logo_url)} alt={r.name} boxSize="28px" objectFit="contain" /> : null}
<Box>
<Text fontWeight="medium">{r.name}</Text>
<Text fontSize="sm" color="gray.500">{r.club_type}</Text>
</Box>
</HStack>
))}
</VStack>
</Box>
</SimpleGrid>
<Divider my={6} />
<HStack spacing={3}>
<Button onClick={() => setPartial({ homeScore: (state.homeScore || 0) + 1 })}>+ Gól DOM</Button>
<Button onClick={() => setPartial({ homeScore: Math.max(0, (state.homeScore || 0) - 1) })}> Gól DOM</Button>
<Button onClick={() => setPartial({ awayScore: (state.awayScore || 0) + 1 })}>+ Gól HOS</Button>
<Button onClick={() => setPartial({ awayScore: Math.max(0, (state.awayScore || 0) - 1) })}> Gól HOS</Button>
<Button variant="outline" onClick={() => setPartial({ homeScore: 0, awayScore: 0 })}>Reset skóre</Button>
</HStack>
<Divider my={6} />
{/* Timer controls */}
<Box borderWidth="1px" borderRadius="lg" p={4} bg={cardBg} mb={6}>
<Heading size="md" mb={3}>Časovač</Heading>
<HStack spacing={4} align="center" flexWrap="wrap">
<Text fontSize="4xl" fontFamily="mono" minW="120px">{state.timer || '00:00'}</Text>
{state.running ? (
<Badge colorScheme="green">Běží</Badge>
) : (
<Badge>Stojí</Badge>
)}
<HStack>
<Button colorScheme="green" onClick={async () => {
await startTimer();
const s = await getScoreboardState();
setState(s);
}}>Start</Button>
<Button onClick={async () => {
await pauseTimer();
const s = await getScoreboardState();
setState(s);
}}>Pauza</Button>
<Button variant="outline" onClick={async () => {
await resetTimer();
const s = await getScoreboardState();
setState(s);
}}>Reset</Button>
</HStack>
</HStack>
<HStack mt={3} spacing={3} align="center">
<FormControl maxW="160px" isDisabled={!!state.running}>
<FormLabel>Nastavit čas (MM:SS)</FormLabel>
<Input
value={state.timer || '00:00'}
onChange={async (e) => {
const v = e.target.value.trim();
// allow edit only when not running
if (!state.running) {
const next = await saveScoreboardState({ timer: v });
setState(next);
}
}}
/>
</FormControl>
</HStack>
</Box>
<Heading size="md" mb={3}>Import / Export</Heading>
<HStack spacing={4} align="center" flexWrap="wrap">
<Button
onClick={() => {
try {
const blob = new Blob([JSON.stringify(state, null, 2)], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'scoreboard-state.json';
a.click();
URL.revokeObjectURL(url);
} catch (e) {
toast({ title: 'Export selhal', status: 'error' });
}
}}
>Exportovat JSON</Button>
<Button as="label">
Importovat JSON
<Input type="file" accept="application/json" display="none" onChange={async (e) => {
try {
const f = e.target.files?.[0];
if (!f) return;
const text = await f.text();
const data = JSON.parse(text);
// Patch via admin API when possible
await updateAdminScoreboard(data);
setState(await getScoreboardState());
toast({ title: 'Import dokončen', status: 'success' });
} catch (err: any) {
toast({ title: 'Import selhal', description: err?.message || 'Neplatný soubor', status: 'error' });
} finally {
try { if (e.target) (e.target as HTMLInputElement).value = ''; } catch {}
}
}} />
</Button>
</HStack>
<Divider my={8} />
<Heading size="md" mb={2}>Návod k použití (CZ)</Heading>
<VStack align="start" spacing={2} color="gray.700">
<Text>1) V horní části vyberte soutěž, případně vyhledejte zápas podle názvu týmu. Klikněte na Vybrat zápas.</Text>
<Text>2) Pole Domácí/Hosté, loga a zkratky se předvyplní. Barvy se pokusíme odhadnout z log.</Text>
<Text>3) Zapněte přepínač Tabule aktivní nebo použijte tlačítko Aktivovat pro vybraný zápas.</Text>
<Text>4) Skóre upravujte tlačítky níže nebo ručním zadáním. Změny se ukládají průběžně.</Text>
<Text>5) Pokud je tabule aktivní a propojená se zápasem, živé skóre se propisuje do veřejné homepage (nejbližší zápas) a do JSON cache.</Text>
<Text>6) Přepněte vzhled tabule (styl) dle potřeby. Pro přenos do streamu použijte veřejnou adresu /overlay/scoreboard.</Text>
<Text>Tip: Při importu JSON použijte předchozí export, formát je kompatibilní.</Text>
</VStack>
</Box>
</AdminLayout>
);
};
export default ScoreboardAdminPage;
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,313 @@
import {
Box,
Button,
Heading,
HStack,
IconButton,
Image,
Input,
Link,
Modal,
ModalBody,
ModalCloseButton,
ModalContent,
ModalFooter,
ModalHeader,
ModalOverlay,
Table,
Tbody,
Td,
Th,
Thead,
Tr,
FormControl,
FormLabel,
Switch,
useDisclosure,
useToast,
VStack,
useColorModeValue,
Text,
Select,
NumberInput,
NumberInputField,
Badge,
} from '@chakra-ui/react';
import { useState } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { FiEdit2, FiPlus, FiTrash2, FiUpload, FiExternalLink } from 'react-icons/fi';
import AdminLayout from '../../layouts/AdminLayout';
import { Sponsor, getSponsors, createSponsor, updateSponsor, deleteSponsor } from '../../services/sponsors';
import { uploadFile } from '../../services/articles';
const SponsorsAdminPage: React.FC = () => {
const cardBg = useColorModeValue('white', 'gray.800');
const borderColor = useColorModeValue('gray.200', 'gray.700');
const inputBg = useColorModeValue('white', 'gray.700');
const normalizeImageUrl = (url?: string) => {
if (!url || url === '') return '/logo192.png';
if (/^https?:\/\//i.test(url)) return url;
const apiUrl = process.env.REACT_APP_API_URL || 'http://localhost:8080/api/v1';
const origin = new URL(apiUrl).origin;
if (url.startsWith('/uploads/')) return `${origin}${url}`;
return `${origin}${url.startsWith('/') ? '' : '/'}${url}`;
};
const toast = useToast();
const qc = useQueryClient();
const { data, isLoading } = useQuery({ queryKey: ['admin-sponsors'], queryFn: getSponsors });
const [editing, setEditing] = useState<Partial<Sponsor> | null>(null);
const { isOpen, onOpen, onClose } = useDisclosure();
const openCreate = () => { setEditing({ name: '', is_active: true, tier: 'standard', display_order: 0 }); onOpen(); };
const openEdit = (s: Sponsor) => { setEditing({ ...s }); onOpen(); };
const closeModal = () => { setEditing(null); onClose(); };
const createMut = useMutation({
mutationFn: (payload: any) => createSponsor(payload),
onSuccess: (created: any) => {
// If backend returned the created sponsor, merge into cache for immediate UI update
try {
qc.setQueryData(['admin-sponsors'], (old: any) => {
const list = Array.isArray(old) ? old : (old?.data || []);
const newList = [created, ...list];
// if cache shape is { data, total, page }, attempt to preserve
if (old && old.data) return { ...old, data: newList };
return newList;
});
} catch (e) {
// ignore cache update errors
}
toast({ title: 'Sponzor vytvořen', status: 'success' });
qc.invalidateQueries({ queryKey: ['admin-sponsors'] });
closeModal();
},
onError: (e: any) => toast({ title: 'Vytvoření selhalo', description: e?.response?.data?.chyba || 'Chyba', status: 'error' }),
});
const updateMut = useMutation({
mutationFn: ({ id, payload }: { id: number | string; payload: any }) => updateSponsor(id, payload),
onSuccess: (updated: any) => {
// Optimistically merge returned sponsor into cache to avoid transient undefined fields
try {
qc.setQueryData(['admin-sponsors'], (old: any) => {
const list = Array.isArray(old) ? old : (old?.data || []);
const newList = (list || []).map((it: any) => (it?.id === updated?.id ? { ...it, ...updated } : it));
if (old && old.data) return { ...old, data: newList };
return newList;
});
} catch {}
toast({ title: 'Sponzor aktualizován', status: 'success' });
qc.invalidateQueries({ queryKey: ['admin-sponsors'] });
closeModal();
},
onError: (e: any) => toast({ title: 'Aktualizace selhala', description: e?.response?.data?.chyba || 'Chyba', status: 'error' }),
});
const deleteMut = useMutation({
mutationFn: (id: number) => deleteSponsor(id),
onSuccess: () => { toast({ title: 'Sponzor smazán', status: 'success' }); qc.invalidateQueries({ queryKey: ['admin-sponsors'] }); },
onError: (e: any) => toast({ title: 'Smazání selhalo', description: e?.response?.data?.chyba || 'Chyba', status: 'error' }),
});
const onSubmit = async () => {
if (!editing) return;
const payload = {
name: editing.name || '',
website_url: editing.website_url || '',
logo_url: editing.logo_url || '',
is_active: editing.is_active ?? true,
tier: editing.tier || 'standard',
display_order: editing.display_order ?? 0,
};
if ((editing as any).id != null) {
await updateMut.mutateAsync({ id: (editing as any).id, payload });
} else {
await createMut.mutateAsync(payload);
}
};
const onUpload = async (file?: File | null) => {
if (!file) return;
try {
const res = await uploadFile(file);
const apiUrl = process.env.REACT_APP_API_URL || 'http://localhost:8080/api/v1';
const apiOrigin = new URL(apiUrl).origin;
// If backend returned an absolute URL pointing to the dev host (same origin as app), rewrite to API origin
let url = res.url || '';
try {
const parsed = new URL(url, window.location.origin);
const appOrigin = window.location.origin;
if (parsed.origin === appOrigin) {
// replace with API origin while keeping path
url = apiOrigin + parsed.pathname + parsed.search + parsed.hash;
}
} catch (e) {
// ignore
}
setEditing((prev) => ({ ...(prev || {}), logo_url: url }));
toast({ title: 'Logo nahráno', status: 'success' });
} catch (err) {
toast({ title: 'Nahrání loga selhalo', status: 'error' });
}
};
const toggleActive = async (s: Sponsor) => {
if (s.id == null) return;
try {
await updateMut.mutateAsync({ id: s.id, payload: { is_active: !s.is_active } });
toast({ title: 'Stav sponzora aktualizován', status: 'success' });
} catch (err) {
toast({ title: 'Aktualizace selhala', status: 'error' });
}
};
return (
<AdminLayout requireAdmin={false}>
<Box>
<HStack justify="space-between" mb={4}>
<Heading size="lg">Správa sponzorů</Heading>
<Button leftIcon={<FiPlus />} colorScheme="blue" onClick={openCreate}>
Nový sponzor
</Button>
</HStack>
<Text color="gray.500" mb={6}>
Správa sponzorů a partnerů klubu. Můžete přidávat, upravovat a odebírat sponzory, kteří se zobrazují na webu.
</Text>
<Box
bg={useColorModeValue('white', 'gray.800')}
borderWidth="1px"
borderRadius="lg"
overflowX="auto"
boxShadow="sm"
mb={6}
>
<Table size="sm">
<Thead>
<Tr>
<Th w="80px">Logo</Th>
<Th>Název</Th>
<Th>Úroveň</Th>
<Th>Pořadí</Th>
<Th>Web</Th>
<Th w="120px">Aktivní</Th>
<Th w="160px">Akce</Th>
</Tr>
</Thead>
<Tbody>
{isLoading && (<Tr><Td colSpan={7}>Načítám...</Td></Tr>)}
{!isLoading && (data || []).map((s) => (
<Tr key={s.id}>
<Td>
<Image src={normalizeImageUrl(s.logo_url)} alt={s.name} boxSize="48px" objectFit="contain" />
</Td>
<Td>{s.name}</Td>
<Td>
<Badge colorScheme={s.tier === 'general' ? 'green' : 'blue'}>
{s.tier === 'general' ? 'Hlavní partner' : 'Partner'}
</Badge>
</Td>
<Td>{s.display_order ?? 0}</Td>
<Td>
{s.website_url ? (
<HStack>
<Link href={s.website_url} color="blue.500" isExternal fontSize="sm">{s.website_url}</Link>
<FiExternalLink />
</HStack>
) : '-'}
</Td>
<Td>
<Switch isChecked={!!s.is_active} onChange={() => toggleActive(s)} />
</Td>
<Td>
<HStack spacing={2}>
<IconButton
aria-label="Upravit"
icon={<FiEdit2 />}
size="sm"
onClick={() => openEdit(s)}
/>
<IconButton
aria-label="Smazat"
icon={<FiTrash2 />}
size="sm"
colorScheme="red"
variant="outline"
onClick={async () => {
if (!window.confirm(`Opravdu chcete smazat sponzora "${s.name}"?`)) return;
try {
await deleteMut.mutateAsync(s.id as number);
toast({ title: 'Sponzor smazán', status: 'success' });
} catch (err) {
toast({ title: 'Smazání sponzora selhalo', status: 'error' });
}
}}
/>
</HStack>
</Td>
</Tr>
))}
</Tbody>
</Table>
</Box>
<Modal isOpen={isOpen} onClose={closeModal} size="lg">
<ModalOverlay />
<ModalContent>
<ModalHeader>{(editing as any)?.id ? 'Upravit sponzora' : 'Nový sponzor'}</ModalHeader>
<ModalCloseButton />
<ModalBody>
<VStack align="stretch" spacing={4}>
<FormControl isRequired>
<FormLabel>Název</FormLabel>
<Input value={editing?.name || ''} onChange={(e) => setEditing((p) => ({ ...(p as any), name: e.target.value }))} />
</FormControl>
<FormControl>
<FormLabel>Web (URL)</FormLabel>
<Input value={editing?.website_url || ''} onChange={(e) => setEditing((p) => ({ ...(p as any), website_url: e.target.value }))} />
</FormControl>
<FormControl>
<FormLabel>Logo</FormLabel>
<HStack>
<Image src={normalizeImageUrl(editing?.logo_url)} alt="logo" boxSize="56px" objectFit="contain" />
<Button as="label" type="button" leftIcon={<FiUpload />}>Upload
<Input type="file" display="none" accept="image/*" onChange={async (e) => {
const file = e.target.files?.[0];
await onUpload(file);
// reset input to allow selecting the same file again
(e.target as HTMLInputElement).value = '';
}} />
</Button>
</HStack>
</FormControl>
<FormControl>
<FormLabel>Úroveň partnera</FormLabel>
<Select value={editing?.tier || 'standard'} onChange={(e) => setEditing((p) => ({ ...(p as any), tier: e.target.value }))}>
<option value="general">Hlavní partner</option>
<option value="standard">Partner</option>
</Select>
</FormControl>
<FormControl>
<FormLabel>Pořadí zobrazení</FormLabel>
<NumberInput value={editing?.display_order ?? 0} onChange={(val) => setEditing((p) => ({ ...(p as any), display_order: parseInt(val) || 0 }))} min={0}>
<NumberInputField />
</NumberInput>
<Text fontSize="xs" color="gray.500" mt={1}>Menší číslo = vyšší pozice</Text>
</FormControl>
<FormControl display="flex" alignItems="center">
<FormLabel mb="0">Aktivní</FormLabel>
<Switch isChecked={!!editing?.is_active} onChange={(e) => setEditing((p) => ({ ...(p as any), is_active: e.target.checked }))} />
</FormControl>
</VStack>
</ModalBody>
<ModalFooter>
<Button variant="ghost" mr={3} onClick={closeModal}>Zrušit</Button>
<Button colorScheme="blue" onClick={onSubmit} isLoading={createMut.isLoading || updateMut.isLoading}>Uložit</Button>
</ModalFooter>
</ModalContent>
</Modal>
</Box>
</AdminLayout>
);
};
export default SponsorsAdminPage;
@@ -0,0 +1,158 @@
import { Heading, Text, Box, Spinner, Alert, AlertIcon, Table, Thead, Tbody, Tr, Th, Td, VStack, Select, HStack, Badge } from '@chakra-ui/react';
import { useQuery } from '@tanstack/react-query';
import { useMemo, useState } from 'react';
import AdminLayout from '../../layouts/AdminLayout';
import { assetUrl } from '../../utils/url';
import { TeamLogo } from '../../components/common/TeamLogo';
type TableRow = {
rank?: string;
team?: string;
team_id?: string;
team_logo_url?: string;
played?: string;
wins?: string;
draws?: string;
losses?: string;
score?: string;
points?: string;
};
const StandingsAdminPage: React.FC = () => {
const { data, isLoading, error } = useQuery<any>({
queryKey: ['facr-tables-cache'],
queryFn: async () => {
const apiUrl = process.env.REACT_APP_API_URL || 'http://localhost:8080/api/v1';
const origin = new URL(apiUrl).origin;
const url = `${origin}/cache/prefetch/facr_tables.json`;
const res = await fetch(url, { headers: { 'Cache-Control': 'no-cache' } });
if (!res.ok) throw new Error(`Failed to load cache: ${res.status}`);
return res.json();
},
staleTime: 5 * 60 * 1000,
});
const competitions: any[] = Array.isArray(data?.competitions) ? data!.competitions : [];
// Optional category/code switcher based on competition code
const [code, setCode] = useState<string>('');
const options = useMemo(() => {
const items = competitions.map((c) => ({ code: c.code, name: c.name }));
const unique = new Map(items.map((i) => [i.code, i]));
return Array.from(unique.values());
}, [competitions]);
const filtered = code ? competitions.filter((c) => c.code === code) : competitions;
return (
<AdminLayout>
<Heading size="lg" mb={2}>Standings (FAČR)</Heading>
<Text mb={4}>Read-only view of standings from FACR cache. Fast and offline-friendly.</Text>
{isLoading && (
<VStack align="start" spacing={3} mb={6}>
<Spinner />
<Text>Načítám tabulky</Text>
</VStack>
)}
{Boolean(error) && (
<Alert status="error" variant="left-accent" mb={4}>
<AlertIcon />
Nepodařilo se načíst data z cache.
</Alert>
)}
{!isLoading && !error && (
<HStack mb={4} spacing={3} align="center">
<Text color="gray.600">Soutěž:</Text>
<Select value={code} onChange={(e) => setCode(e.target.value)} maxW="260px" size="sm">
<option value="">Vše</option>
{options.map((o) => (
<option key={o.code} value={o.code}>{o.code} {o.name}</option>
))}
</Select>
<Badge colorScheme="gray" variant="subtle">{filtered.length} soutěží</Badge>
</HStack>
)}
{!isLoading && !error && filtered.map((comp) => {
const rows: TableRow[] = comp?.table?.overall || [];
return (
<Box key={comp.id} mb={8}>
<Heading size="md" mb={3}>{comp.name}</Heading>
<Box
overflowX="auto"
borderWidth="1px"
borderRadius="md"
w="full"
maxW="100%"
sx={{
WebkitOverflowScrolling: 'touch',
'th, td': { whiteSpace: 'nowrap' },
}}
>
<Table size="sm" sx={{ width: 'max-content' }}>
<Thead>
<Tr>
<Th>#</Th>
<Th>Tým</Th>
<Th isNumeric>Z</Th>
<Th isNumeric>V</Th>
<Th isNumeric>R</Th>
<Th isNumeric>P</Th>
<Th isNumeric>Skóre</Th>
<Th isNumeric>Body</Th>
</Tr>
</Thead>
<Tbody>
{rows.map((r, idx) => (
<Tr key={`${comp.id}-${idx}`}>
<Td width="40px">{r.rank}</Td>
<Td>
<HStack spacing={2} align="center">
<TeamLogo
teamId={r.team_id}
teamName={r.team}
facrLogo={r.team_logo_url}
size="small"
alt={r.team}
objectFit="contain"
fallbackIcon={
<Box
w="20px"
h="20px"
bg="gray.200"
borderRadius="md"
display="flex"
alignItems="center"
justifyContent="center"
color="gray.400"
fontSize="xs"
fontWeight="bold"
>
{r.team?.substring(0, 2).toUpperCase() || '??'}
</Box>
}
/>
<Text as="span">{r.team}</Text>
</HStack>
</Td>
<Td isNumeric>{r.played}</Td>
<Td isNumeric>{r.wins}</Td>
<Td isNumeric>{r.draws}</Td>
<Td isNumeric>{r.losses}</Td>
<Td isNumeric>{r.score}</Td>
<Td isNumeric fontWeight="bold">{r.points}</Td>
</Tr>
))}
</Tbody>
</Table>
</Box>
</Box>
);
})}
</AdminLayout>
);
};
export default StandingsAdminPage;
+692
View File
@@ -0,0 +1,692 @@
import { batchFetchLogosFromSportLogosAPI } from '../../utils/sportLogosAPI';
import {
Heading,
Text,
Box,
Spinner,
Alert,
AlertIcon,
Table,
Thead,
Tbody,
Tr,
Th,
Td,
VStack,
Button,
HStack,
Drawer,
DrawerOverlay,
DrawerContent,
DrawerHeader,
DrawerBody,
DrawerFooter,
FormControl,
FormLabel,
Input,
InputGroup,
InputRightElement,
List,
ListItem,
Badge,
useToast,
SimpleGrid,
useColorModeValue,
ButtonGroup,
Tag,
TagLabel,
Tooltip,
Image,
Tabs,
TabList,
Tab,
TabPanels,
TabPanel,
Flex,
Wrap
} from '@chakra-ui/react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import AdminLayout from '../../layouts/AdminLayout';
import { searchClubs, uploadImage, putTeamLogoOverride, fetchTeamLogoOverrides, fetchLogoAsBlob, uploadToLogaSportcreative } from '../../services/adminMatches';
import { getFacrTablesCache } from '../../services/facr/cache';
import { assetUrl } from '../../utils/url';
import { useEffect, useMemo, useRef, useState } from 'react';
import { TeamLogo } from '../../components/common/TeamLogo';
type TableRow = {
rank?: string;
team?: string;
team_id?: string;
team_logo_url?: string;
played?: string;
wins?: string;
draws?: string;
losses?: string;
score?: string;
points?: string;
};
const TeamsAdminPage = () => {
const toast = useToast();
const queryClient = useQueryClient();
const { data, isLoading, error } = useQuery<any>({
queryKey: ['facr-tables-cache'],
queryFn: () => getFacrTablesCache(),
staleTime: 5 * 60 * 1000,
});
const competitions: any[] = Array.isArray(data?.competitions) ? data!.competitions : [];
// Backend origin (used to resolve relative URLs like /uploads/...)
const apiUrl = process.env.REACT_APP_API_URL || 'http://localhost:8080/api/v1';
const backendOrigin = new URL(apiUrl).origin;
// Load public/admin overrides map to apply on cache-fed view
const { data: overrides = {} } = useQuery({
queryKey: ['teamLogoOverrides'],
queryFn: fetchTeamLogoOverrides,
staleTime: 5 * 60 * 1000,
});
// Fetch logos from logoapi.sportcreative.eu for all teams
const [sportLogosMap, setSportLogosMap] = useState<Record<string, string>>({});
const [sportLogosLoading, setSportLogosLoading] = useState(false);
useEffect(() => {
if (!competitions || competitions.length === 0) return;
// Extract all unique team IDs from all competitions
const teamIds = new Set<string>();
for (const comp of competitions) {
const rows: TableRow[] = comp?.table?.overall || [];
for (const r of rows) {
if (r.team_id) teamIds.add(r.team_id);
}
}
if (teamIds.size === 0) return;
// Fetch logos from logoapi.sportcreative.eu
setSportLogosLoading(true);
batchFetchLogosFromSportLogosAPI(Array.from(teamIds))
.then((map) => setSportLogosMap(map))
.catch((err) => console.error('Failed to fetch sport logos:', err))
.finally(() => setSportLogosLoading(false));
}, [competitions]);
const normalize = (s: string) => {
let out = String(s || '');
// Normalize diacritics and case
out = out
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
.toLowerCase();
// 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, '');
// Remove organization phrases/prefixes anywhere (keep core locality/name)
const orgPhrases = [
'fotbalovy klub',
'sportovni klub',
'telovychovna jednota',
'skolni sportovni klub',
'fotbal',
'futsal',
];
for (const phrase of orgPhrases) {
const re = new RegExp(`(^|\b)${phrase}(\b|$)`, 'g');
out = out.replace(re, ' ');
}
// Remove common short prefixes (tokens) like FC, FK, MFK, TJ, SK, SFC, AFK at word boundaries
out = out.replace(/\b(1\.)?\s*(sfc|afc|fc|fk|mfk|tj|sk|afk)\b\.?/g, ' ');
// Remove punctuation except hyphen
out = out.replace(/[\.,!;:()\[\]{}]/g, ' ');
// Collapse multiple spaces and trim
out = out.replace(/\s+/g, ' ').trim();
return out;
};
const byName: Record<string, string> = (overrides as any)?.by_name || {};
const byNameNormalized = useMemo(() => {
const idx: Record<string, string> = {};
for (const k of Object.keys(byName)) {
idx[normalize(k)] = byName[k];
}
return idx;
}, [byName]);
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 2: Try exact match from local overrides
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
if (original) {
return original;
}
// Final fallback: empty logo
return '/dist/img/logo-club-empty.svg';
};
// 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)
const [selectedCompIndex, setSelectedCompIndex] = useState<number>(0);
// Text filter for club/team name
const [filter, setFilter] = useState('');
const [filterDebounced, setFilterDebounced] = useState('');
useEffect(() => {
const t = setTimeout(() => setFilterDebounced(filter), 250);
return () => clearTimeout(t);
}, [filter]);
// Build a deduplicated list of teams across all competitions
type TeamAggregate = {
key: string; // normalized key
name: string; // representative name
logo: string;
variants: string[]; // all raw names found
};
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);
if (!map[key]) {
map[key] = { key, name: teamName, logo, variants: [teamName] };
} else {
map[key].variants.push(teamName);
// 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;
}
}
}
}
// Sort by representative name
return Object.values(map).sort((a, b) => a.name.localeCompare(b.name, 'cs', { sensitivity: 'base' }));
}, [competitions, getLogo]);
// Fast lookup from normalized name to variant list
const variantsByKey = useMemo(() => {
const m: Record<string, string[]> = {};
for (const t of allTeamsUnique) {
m[t.key] = Array.from(new Set(t.variants));
}
return m;
}, [allTeamsUnique]);
// Drawer state for editing a team's logo/name
const [isOpen, setIsOpen] = useState(false);
const [selected, setSelected] = useState<{ compId?: string; teamName: string; teamLogoUrl?: string; variantNames?: string[] } | null>(null);
const [form, setForm] = useState({
external_team_id: '',
team_name: '',
logo_url: '',
});
const fileRef = useRef<HTMLInputElement | null>(null);
const [uploadedFile, setUploadedFile] = useState<File | null>(null);
const [externalUploadStatus, setExternalUploadStatus] = useState<'idle' | 'uploading' | 'success' | 'error'>('idle');
const [externalUploadError, setExternalUploadError] = useState<string | null>(null);
// Club search
const [query, setQuery] = useState('');
const [debounced, setDebounced] = useState('');
useEffect(() => {
const t = setTimeout(() => setDebounced(query), 300);
return () => clearTimeout(t);
}, [query]);
const { data: searchResults = [] } = useQuery({
queryKey: ['club-search', debounced],
queryFn: () => searchClubs(debounced),
enabled: debounced.trim().length >= 2,
});
const onOpenEdit = (teamName: string, teamLogoUrl?: string, variantNames?: string[]) => {
// If variants not explicitly provided (e.g., from table view), compute from normalized key
let v = variantNames;
if (!v || v.length === 0) {
const key = normalize(teamName || '');
v = variantsByKey[key] || [];
}
setSelected({ teamName, teamLogoUrl, variantNames: v });
setForm({ external_team_id: '', team_name: teamName || '', logo_url: teamLogoUrl || '' });
setQuery(teamName || '');
setIsOpen(true);
};
const onSave = useMutation({
mutationFn: async () => {
if (!form.external_team_id) {
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();
// 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);
// Save override for each variant name so editing one updates all duplicates
await Promise.all(
names.map((n) => putTeamLogoOverride(form.external_team_id, n, logoUrl))
);
// Also upload to logoapi.sportcreative.eu (non-blocking, best-effort)
if (logoUrl) {
setExternalUploadStatus('uploading');
setExternalUploadError(null);
try {
let logoFileToUpload: File | Blob | null = uploadedFile;
// If no file was uploaded but we have a logo URL, fetch it as blob
if (!logoFileToUpload && logoUrl) {
logoFileToUpload = await fetchLogoAsBlob(logoUrl);
}
if (logoFileToUpload) {
// Upload to the logo service (loga.sportcreative.eu)
const logaResult = await uploadToLogaSportcreative(
form.external_team_id,
logoFileToUpload,
{
filename: `${form.external_team_id}.${logoFileToUpload instanceof File ? logoFileToUpload.name.split('.').pop() : 'png'}`,
clubName: form.team_name || selected?.teamName || 'Neznámý klub'
}
);
if (logaResult.success) {
setExternalUploadStatus('success');
// Use the URL from loga.sportcreative.eu
if (logaResult.url) {
logoUrl = logaResult.url;
}
} else {
setExternalUploadStatus('error');
setExternalUploadError(logaResult.error || 'Nepodařilo se nahrát logo');
}
} else {
setExternalUploadStatus('error');
setExternalUploadError('Could not fetch logo file');
}
} catch (error: any) {
setExternalUploadStatus('error');
setExternalUploadError(error?.message || 'Upload failed');
}
}
return true;
},
onSuccess: () => {
let description = 'Změna byla aplikována na všechny duplicitní varianty.';
if (externalUploadStatus === 'success') {
description += ' Logo bylo úspěšně nahráno.';
} else if (externalUploadStatus === 'error') {
description += ` ⚠️ ${externalUploadError || 'Nepodařilo se nahrát logo'}`;
}
toast({
title: 'Uloženo',
description,
status: externalUploadStatus === 'error' ? 'warning' : 'success',
duration: 6000,
isClosable: true,
});
setIsOpen(false);
setSelected(null);
setUploadedFile(null);
setExternalUploadStatus('idle');
setExternalUploadError(null);
queryClient.invalidateQueries({ queryKey: ['facr-tables-cache'] });
queryClient.invalidateQueries({ queryKey: ['teamLogoOverrides'] });
},
onError: (e: any) => {
toast({ title: 'Uložení selhalo', description: e?.message || 'Zkuste to znovu', status: 'error' });
},
});
return (
<AdminLayout requireAdmin={false}>
<Box>
<HStack justify="space-between" mb={4}>
<Heading size="lg">Správa týmů</Heading>
</HStack>
<Text color="gray.500" mb={4} fontSize="sm">
Přehled tabulek z FAČR. Kompaktní zobrazení s rychlým přepínáním soutěží.
</Text>
<Flex mb={4} gap={3} align="center" wrap="wrap">
<ButtonGroup isAttached size="sm" variant="outline">
<Button
colorScheme={viewMode === 'table' ? 'blue' : 'gray'}
variant={viewMode === 'table' ? 'solid' : 'outline'}
onClick={() => setViewMode('table')}
size="sm"
>
Tabulky
</Button>
<Button
colorScheme={viewMode === 'grid' ? 'blue' : 'gray'}
variant={viewMode === 'grid' ? 'solid' : 'outline'}
onClick={() => setViewMode('grid')}
size="sm"
>
Mřížka
</Button>
</ButtonGroup>
<InputGroup maxW={{ base: '100%', md: '300px' }} size="sm">
<Input placeholder="Filtrovat podle názvu týmu…" value={filter} onChange={(e) => setFilter(e.target.value)} />
{filter && (
<InputRightElement width="3rem">
<Button size="xs" variant="ghost" onClick={() => setFilter('')}>Vymazat</Button>
</InputRightElement>
)}
</InputGroup>
{sportLogosLoading && (
<Badge colorScheme="blue" fontSize="xs">
<HStack spacing={1}>
<Spinner size="xs" />
<Text>Načítám loga z logoapi.sportcreative.eu...</Text>
</HStack>
</Badge>
)}
{!sportLogosLoading && Object.keys(sportLogosMap).length > 0 && (
<Badge colorScheme="green" fontSize="xs">
{Object.keys(sportLogosMap).length} log z logoapi.sportcreative.eu
</Badge>
)}
</Flex>
{isLoading && (
<VStack align="start" spacing={3} mb={6}>
<Spinner />
<Text>Načítám tabulky</Text>
</VStack>
)}
{Boolean(error) && (
<Alert status="error" variant="left-accent" mb={4}>
<AlertIcon />
Nepodařilo se načíst data z cache.
</Alert>
)}
{!isLoading && !error && viewMode === 'table' && (
<Box>
<Tabs
index={selectedCompIndex}
onChange={setSelectedCompIndex}
variant="soft-rounded"
colorScheme="blue"
size="sm"
>
<TabList mb={4} overflowX="auto" overflowY="hidden" flexWrap="nowrap" pb={2}>
{competitions.map((comp, idx) => (
<Tab
key={comp.id}
fontSize="xs"
px={3}
py={1.5}
minW="fit-content"
whiteSpace="nowrap"
>
{comp.name}
</Tab>
))}
</TabList>
<TabPanels>
{competitions.map((comp) => {
const rows: TableRow[] = comp?.table?.overall || [];
const f = normalize(filterDebounced);
const rowsFiltered = f ? rows.filter((r) => normalize(r.team || '').includes(f)) : rows;
return (
<TabPanel key={comp.id} p={0}>
<Box
overflowX="auto"
borderWidth="1px"
borderRadius="md"
w="full"
sx={{
WebkitOverflowScrolling: 'touch',
}}
>
<Table size="sm" variant="simple">
<Thead bg={useColorModeValue('gray.50', 'gray.700')}>
<Tr>
<Th w="40px" fontSize="xs" py={2}>#</Th>
<Th fontSize="xs" py={2}>Tým</Th>
<Th isNumeric w="45px" fontSize="xs" py={2}>Z</Th>
<Th isNumeric w="45px" fontSize="xs" py={2}>V</Th>
<Th isNumeric w="45px" fontSize="xs" py={2}>R</Th>
<Th isNumeric w="45px" fontSize="xs" py={2}>P</Th>
<Th isNumeric w="70px" fontSize="xs" py={2}>Skóre</Th>
<Th isNumeric w="50px" fontSize="xs" py={2}>Body</Th>
<Th w="90px" fontSize="xs" py={2}>Akce</Th>
</Tr>
</Thead>
<Tbody>
{rowsFiltered.map((r, idx) => (
<Tr key={`${comp.id}-${idx}`} _hover={{ bg: useColorModeValue('gray.50', 'gray.700') }}>
<Td py={1.5} fontSize="xs">{r.rank}</Td>
<Td py={1.5}>
<HStack spacing={2} align="center">
<TeamLogo
teamId={(r as any).team_id}
teamName={r.team}
facrLogo={r.team_logo_url}
size="small"
alt={r.team}
objectFit="contain"
/>
<Text fontSize="xs" noOfLines={1}>{r.team}</Text>
</HStack>
</Td>
<Td isNumeric py={1.5} fontSize="xs">{r.played}</Td>
<Td isNumeric py={1.5} fontSize="xs">{r.wins}</Td>
<Td isNumeric py={1.5} fontSize="xs">{r.draws}</Td>
<Td isNumeric py={1.5} fontSize="xs">{r.losses}</Td>
<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>
</Td>
</Tr>
))}
{rowsFiltered.length === 0 && (
<Tr>
<Td colSpan={9} py={4}>
<Text color="gray.500" fontSize="sm" textAlign="center">Žádný tým neodpovídá filtru.</Text>
</Td>
</Tr>
)}
</Tbody>
</Table>
</Box>
</TabPanel>
);
})}
</TabPanels>
</Tabs>
</Box>
)}
{!isLoading && !error && viewMode === 'grid' && (
<Box>
{(() => {
const f = normalize(filterDebounced);
const source = f
? allTeamsUnique.filter((t) => normalize(t.name).includes(f) || (t.variants || []).some(v => normalize(v).includes(f)))
: allTeamsUnique;
return (
<SimpleGrid columns={{ base: 2, sm: 3, md: 4, lg: 6 }} spacing={3}>
{source.map((t) => (
<Box
key={t.key}
borderWidth="1px"
borderRadius="md"
p={2.5}
_hover={{ boxShadow: 'md', borderColor: 'blue.300' }}
transition="all 0.2s"
>
<VStack align="stretch" spacing={2}>
<HStack justify="space-between" align="start">
<HStack spacing={2} flex={1} minW={0}>
<Image src={t.logo} alt={t.name} boxSize="24px" objectFit="contain" flexShrink={0} />
<Text fontSize="xs" noOfLines={2} fontWeight="medium">{t.name}</Text>
</HStack>
{t.variants.length > 1 && (
<Tooltip label={`Varianty: ${Array.from(new Set(t.variants)).join(', ')}`} hasArrow>
<Tag size="sm" colorScheme="purple" variant="subtle" fontSize="xs">
<TagLabel>{t.variants.length}</TagLabel>
</Tag>
</Tooltip>
)}
</HStack>
<Button size="xs" fontSize="xs" onClick={() => onOpenEdit(t.name, t.logo, t.variants)} w="full">Upravit</Button>
</VStack>
</Box>
))}
{source.length === 0 && (
<Box gridColumn={{ base: 'span 2', sm: 'span 3', md: 'span 4', lg: 'span 6' }}>
<Text color="gray.500" fontSize="sm" textAlign="center" py={8}>Žádný tým neodpovídá filtru.</Text>
</Box>
)}
</SimpleGrid>
);
})()}
</Box>
)}
<Drawer isOpen={isOpen} placement="right" onClose={() => { setIsOpen(false); setSelected(null); }} size="md">
<DrawerOverlay />
<DrawerContent>
<DrawerHeader>Upravit logo týmu</DrawerHeader>
<DrawerBody>
<VStack align="stretch" spacing={4}>
<FormControl>
<FormLabel>Hledat tým (FAČR)</FormLabel>
<InputGroup>
<Input value={query} onChange={(e) => { setQuery(e.target.value); }} placeholder="Zadejte název týmu" />
<InputRightElement width="8rem">
<Button size="xs" onClick={() => fileRef.current?.click()}>Nahrát logo</Button>
</InputRightElement>
</InputGroup>
{searchResults.length > 0 && (
<Box
mt={4}
bg={useColorModeValue('white', 'gray.800')}
borderWidth="1px"
borderRadius="lg"
overflowX="auto"
boxShadow="sm"
mb={6}
>
<List spacing={0}>
{searchResults.map((r: any) => (
<ListItem
key={r.id}
px={3}
py={2}
_hover={{ bg: 'gray.50', cursor: 'pointer' }}
onClick={() => {
setForm((f) => ({ ...f, external_team_id: r.id, team_name: r.name, logo_url: r.logo_url || f.logo_url }));
setQuery(r.name);
}}
>
<HStack justify="space-between" spacing={3}>
<HStack spacing={3} maxW="80%">
{r.logo_url ? (
<Image src={r.logo_url} alt={r.name} boxSize="20px" objectFit="contain" />
) : (
<Badge colorScheme="gray">bez loga</Badge>
)}
<Text noOfLines={1}>{r.name}</Text>
</HStack>
{r.logo_url && <Badge colorScheme="green">logo</Badge>}
</HStack>
</ListItem>
))}
</List>
</Box>
)}
<input ref={fileRef} type="file" accept="image/png,image/svg+xml,image/jpeg,application/pdf" style={{ display: 'none' }} onChange={async (e) => {
const file = e.target.files?.[0];
if (!file) return;
try {
const { url } = await uploadImage(file);
setForm((f) => ({ ...f, logo_url: url }));
setUploadedFile(file); // Store for later upload to external API
toast({ title: 'Logo nahráno', status: 'success' });
} catch (err: any) {
toast({ title: 'Nahrávání selhalo', description: err?.message || 'Zkuste to znovu', status: 'error' });
} finally {
if (fileRef.current) fileRef.current.value = '' as any;
}
}} />
</FormControl>
<FormControl>
<FormLabel>Název týmu</FormLabel>
<Input value={form.team_name} onChange={(e) => setForm({ ...form, team_name: e.target.value })} />
</FormControl>
<FormControl>
<FormLabel>Logo URL</FormLabel>
<Input value={form.logo_url} onChange={(e) => setForm({ ...form, logo_url: e.target.value })} />
</FormControl>
{selected?.variantNames && selected.variantNames.length > 1 && (
<Alert status="info">
<AlertIcon />
Upravíte také duplicitní názvy: {Array.from(new Set(selected.variantNames)).join(', ')}
</Alert>
)}
{form.logo_url && (
<Alert status="info" variant="left-accent">
<AlertIcon />
<VStack align="start" spacing={1}>
<Text fontSize="sm" fontWeight="medium">Logo bude automaticky nahráno na logoapi.sportcreative.eu</Text>
<Text fontSize="xs" color="gray.600">Toto poskytuje zálohu a sdílení log mezi aplikacemi.</Text>
</VStack>
</Alert>
)}
</VStack>
</DrawerBody>
<DrawerFooter>
<HStack>
<Button variant="outline" onClick={() => { setIsOpen(false); setSelected(null); }}>Zrušit</Button>
<Button colorScheme="blue" isLoading={onSave.isPending} onClick={() => onSave.mutate()}>Uložit</Button>
</HStack>
</DrawerFooter>
</DrawerContent>
</Drawer>
</Box>
</AdminLayout>
);
};
export default TeamsAdminPage;
+430
View File
@@ -0,0 +1,430 @@
import { useState, useEffect } from 'react';
import {
Box,
Table,
Thead,
Tbody,
Tr,
Th,
Td,
Button,
useDisclosure,
useToast,
Badge,
IconButton,
Menu,
MenuButton,
MenuList,
MenuItem,
useColorModeValue,
Modal,
ModalOverlay,
ModalContent,
ModalHeader,
ModalFooter,
ModalBody,
ModalCloseButton,
FormControl,
FormLabel,
Input,
Select,
VStack,
HStack,
Text,
Switch,
FormHelperText,
Heading,
Spinner,
Alert,
AlertIcon,
AlertTitle,
AlertDescription,
} from '@chakra-ui/react';
import { AddIcon, DeleteIcon, EditIcon, HamburgerIcon } from '@chakra-ui/icons';
import { useAuth } from '../../contexts/AuthContext';
import api from '../../services/api';
import AdminLayout from '../../layouts/AdminLayout';
interface User {
id: string;
email: string;
name: string;
role: 'admin' | 'editor';
isActive: boolean;
createdAt: string;
}
const UsersAdminPage = () => {
const [users, setUsers] = useState<User[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [isSubmitting, setIsSubmitting] = useState(false);
const { isOpen, onOpen, onClose } = useDisclosure();
const [selectedUser, setSelectedUser] = useState<User | null>(null);
const [formData, setFormData] = useState({
name: '',
email: '',
password: '',
currentPassword: '',
role: 'editor' as 'admin' | 'editor',
isActive: true,
});
const toast = useToast();
const bgColor = useColorModeValue('white', 'gray.800');
const borderColor = useColorModeValue('gray.200', 'gray.700');
const { user: authUser } = useAuth();
const fetchUsers = async () => {
try {
const response = await api.get('/admin/users');
setUsers(response.data);
} catch (error) {
console.error('Error fetching users:', error);
toast({
title: 'Error',
description: 'Failed to fetch users',
status: 'error',
duration: 5000,
isClosable: true,
});
} finally {
setIsLoading(false);
}
};
useEffect(() => {
fetchUsers();
}, []);
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>) => {
const { name, value } = e.target;
setFormData(prev => ({
...prev,
[name]: name === 'isActive' ? (e.target as HTMLInputElement).checked : value
}));
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setIsSubmitting(true);
try {
if (selectedUser) {
// Update existing user
// If editing an admin account, require current password confirmation
const payload: any = {
name: formData.name,
email: formData.email,
role: formData.role,
isActive: formData.isActive,
};
if (selectedUser.role === 'admin') {
if (!formData.currentPassword) {
throw new Error('Pro úpravu administrátorského účtu zadejte prosím současné heslo.');
}
payload.current_password = formData.currentPassword;
}
await api.put(`/admin/users/${selectedUser.id}`, payload);
toast({
title: 'Success',
description: 'User updated successfully',
status: 'success',
duration: 3000,
isClosable: true,
});
} else {
// Create new user
await api.post('/admin/users', formData);
toast({
title: 'Success',
description: 'User created successfully',
status: 'success',
duration: 3000,
isClosable: true,
});
}
onClose();
fetchUsers();
resetForm();
} catch (error: any) {
console.error('Error saving user:', error);
toast({
title: 'Error',
description: error.response?.data?.error || error.response?.data?.message || 'Failed to save user',
status: 'error',
duration: 5000,
isClosable: true,
});
} finally {
setIsSubmitting(false);
}
};
const handleDelete = async (userId: string) => {
const u = users.find(x => x.id === userId);
if (u?.role === 'admin') {
toast({ title: 'Zakázáno', description: 'Admin uživatele nelze smazat.', status: 'warning' });
return;
}
if (authUser && String(authUser.id) === String(userId)) {
toast({ title: 'Zakázáno', description: 'Nemůžete smazat sám sebe.', status: 'warning' });
return;
}
if (window.confirm('Are you sure you want to delete this user?')) {
try {
await api.delete(`/admin/users/${userId}`);
toast({
title: 'Success',
description: 'User deleted successfully',
status: 'success',
duration: 3000,
isClosable: true,
});
fetchUsers();
} catch (error: any) {
console.error('Error deleting user:', error);
toast({
title: 'Error',
description: error.response?.data?.error || error.response?.data?.message || 'Failed to delete user',
status: 'error',
duration: 5000,
isClosable: true,
});
}
}
};
const resetForm = () => {
setFormData({
name: '',
email: '',
password: '',
currentPassword: '',
role: 'editor',
isActive: true,
});
setSelectedUser(null);
};
const openEditModal = (user: User) => {
setSelectedUser(user);
setFormData({
name: user.name,
email: user.email,
password: '',
currentPassword: '',
role: user.role,
isActive: user.isActive,
});
onOpen();
};
const openCreateModal = () => {
resetForm();
onOpen();
};
return (
<AdminLayout>
<Box>
<HStack justify="space-between" mb={4}>
<Heading size="lg">Správa uživatelů</Heading>
<Button leftIcon={<AddIcon />} colorScheme="blue" onClick={openCreateModal}>
Přidat uživatele
</Button>
</HStack>
<Text color="gray.500" mb={6}>
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' : '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`);
toast({ title: 'Hotovo', description: 'Instrukce pro obnovení hesla byly odeslány.', status: 'success' });
} 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,
});
}
}}>
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>
</Tr>
))}
</Tbody>
</Table>
</Box>
{/* Add/Edit User Modal */}
<Modal isOpen={isOpen} onClose={onClose} size="lg">
<ModalOverlay />
<ModalContent as="form" onSubmit={handleSubmit}>
<ModalHeader>
{selectedUser ? 'Edit User' : 'Add New User'}
</ModalHeader>
<ModalCloseButton />
<ModalBody pb={6}>
<VStack spacing={4}>
<FormControl isRequired>
<FormLabel>Full Name</FormLabel>
<Input
name="name"
value={formData.name}
onChange={handleInputChange}
placeholder="Enter full name"
/>
</FormControl>
<FormControl isRequired>
<FormLabel>Email</FormLabel>
<Input
type="email"
name="email"
value={formData.email}
onChange={handleInputChange}
placeholder="Enter email"
/>
</FormControl>
{!selectedUser && (
<FormControl isRequired={!selectedUser}>
<FormLabel>Password</FormLabel>
<Input
type="password"
name="password"
value={formData.password}
onChange={handleInputChange}
placeholder="Enter password"
minLength={8}
/>
<FormHelperText>
Password must be at least 8 characters long
</FormHelperText>
</FormControl>
)}
{selectedUser && selectedUser.role === 'admin' && (
<FormControl>
<FormLabel>Současné heslo (potvrzení)</FormLabel>
<Input
type="password"
name="currentPassword"
value={formData.currentPassword}
onChange={handleInputChange}
placeholder="Zadejte současné heslo administrátora"
/>
<FormHelperText>Pro úpravu administrátorského účtu je nutné zadat současné heslo.</FormHelperText>
</FormControl>
)}
<FormControl>
<FormLabel>Role</FormLabel>
<Select
name="role"
value={formData.role}
onChange={handleInputChange}
>
<option value="editor" disabled={!!selectedUser && selectedUser.role === 'admin'}>Editor</option>
<option value="admin">Admin</option>
</Select>
{selectedUser?.role === 'admin' && (
<FormHelperText>Administrátorský účet nelze degradovat na editora přes tuto obrazovku.</FormHelperText>
)}
</FormControl>
<FormControl display="flex" alignItems="center">
<FormLabel mb="0" mr={2}>
Active
</FormLabel>
<Switch
name="isActive"
isChecked={formData.isActive}
onChange={handleInputChange}
colorScheme="blue"
/>
</FormControl>
</VStack>
</ModalBody>
<ModalFooter>
<Button variant="ghost" mr={3} onClick={onClose}>
Cancel
</Button>
<Button
colorScheme="blue"
type="submit"
isLoading={isSubmitting}
loadingText="Saving..."
>
{selectedUser ? 'Update' : 'Create'} User
</Button>
</ModalFooter>
</ModalContent>
</Modal>
</Box>
</AdminLayout>
);
};
export default UsersAdminPage;