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
@@ -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;