mirror of
https://github.com/Dvorinka/MyClubServer.git
synced 2026-06-04 18:52:56 +00:00
upload
This commit is contained in:
@@ -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:00–19: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;
|
||||
Reference in New Issue
Block a user