Files
MyClub/frontend/src/pages/admin/AdminActivitiesPage.tsx
T
Tomáš Dvořák 96ff7895a6 dev day #63
2025-10-17 11:15:09 +02:00

926 lines
44 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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';
import ThumbnailPreview from '../../components/common/ThumbnailPreview';
import { assetUrl } from '../../utils/url';
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áhled</Th>
<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={8}>Načítání</Td></Tr>
)}
{!isLoading && events.map(ev => (
<Tr key={ev.id}>
<Td>
{(ev as any).image_url ? (
<ThumbnailPreview
src={assetUrl((ev as any).image_url) || (ev as any).image_url}
alt={ev.title}
size="48px"
previewSize="350px"
/>
) : (
<ChakraImage
src={settingsQ.data?.club_logo_url || '/dist/img/logo-club-empty.svg'}
alt="No image"
boxSize="48px"
objectFit="contain"
opacity={0.3}
/>
)}
</Td>
<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;