Files
MyClub/frontend/src/pages/admin/AdminActivitiesPage.tsx
T
Tomas Dvorak 16e4533202 dev day #75
2025-10-29 21:20:16 +01:00

1064 lines
50 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, FiLink } 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 FilePreview from '../../components/common/FilePreview';
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 SaveStatusIndicator from '../../components/common/SaveStatusIndicator';
import DraftRecoveryModal from '../../components/common/DraftRecoveryModal';
import { useAutoSave, loadDraft, getDraftMetadata } from '../../hooks/useAutoSave';
import { FiVideo, FiYoutube } from 'react-icons/fi';
import ThumbnailPreview from '../../components/common/ThumbnailPreview';
import { assetUrl } from '../../utils/url';
import { createShortLink } from '../../services/shortlinks';
const types: Array<{ value: Event['type']; label: string }> = [
{ value: 'match', label: 'Zápas' },
{ 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 [showDraftRecovery, setShowDraftRecovery] = useState(false);
const [draftKey, setDraftKey] = useState<string>('');
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');
// Auto-save hook - saves draft automatically
const { saveStatus, lastSaved, forceSave, clearDraft } = useAutoSave({
data: editing || {},
storageKey: draftKey,
onSave: async (data) => {
// If event has ID, update it
if (data.id) {
return await updateEvent(data.id, data);
}
// If no ID and has title, create as draft
if (data.title?.trim() && data.start_time) {
const created = await createEvent(data);
// Update editing state with new ID
if (created?.id) {
setEditing(prev => ({ ...prev, id: created.id } as any));
}
return created;
}
// Don't save if no title or start time
return {};
},
debounceMs: 2000,
enabled: isOpen && editing !== null,
});
const { data, isLoading } = useQuery({
queryKey: ['admin-events'],
queryFn: () => getEvents(),
});
const events = data || [];
// Localized label for event type
const typeLabel = (t?: string) => {
const v = String(t || '').trim() as any;
const found = types.find((x) => x.value === v);
return found ? found.label : 'Jiné';
};
// Load club YouTube videos
useEffect(() => {
(async () => {
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 = () => {
// Check for existing draft
const key = 'draft-activity-new';
setDraftKey(key);
const metadata = getDraftMetadata(key);
if (metadata && metadata.age < 1440) {
// Show recovery modal
setShowDraftRecovery(true);
} else {
// No draft, start fresh
setEditing({ title: '', description: '', type: 'other', is_public: true } as any);
setLocationLat(undefined);
setLocationLng(undefined);
onOpen();
}
};
const openEdit = (ev: Event) => {
// Set unique draft key for this event
const key = `draft-activity-${ev.id}`;
setDraftKey(key);
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();
};
// Draft recovery handlers
const handleRecoverDraft = () => {
const draft = loadDraft<Partial<Event>>(draftKey);
if (draft) {
setEditing(draft);
// Restore location if present
if ((draft as any)?.latitude && (draft as any)?.longitude) {
setLocationLat((draft as any).latitude);
setLocationLng((draft as any).longitude);
}
onOpen();
}
setShowDraftRecovery(false);
};
const handleDiscardDraft = () => {
clearDraft();
setEditing({ title: '', description: '', type: 'other', is_public: true } as any);
setLocationLat(undefined);
setLocationLng(undefined);
setShowDraftRecovery(false);
onOpen();
};
const handleDeleteOnly = () => {
clearDraft();
setShowDraftRecovery(false);
// Don't open the modal - just delete and close
};
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[] = [];
const clubName = String(settingsQ?.data?.club_name || '').trim();
if (clubName) lines.push(`Klub: ${clubName}`);
if (e.type) lines.push(`Typ: ${e.type}`);
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 constraints = 'Nevkládej datum ani místo (lokalitu) do textu. Neuváděj konkrétní čas nebo adresu.';
const prompt = `${safeUserPrompt}\n\nPiš ${toneText}, česky, s důrazem na jasnost a pozvánku k účasti. ${constraints}\nDetaily:\n${base}`.trim();
const { data } = await api.post('/ai/blog/generate', {
prompt,
audience: clubName ? `Fanoušci klubu ${clubName}, oznámení/pozvánka` : '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={assetUrl(settingsQ.data?.club_logo_url) || assetUrl('/dist/img/logo-club-empty.svg') || '/dist/img/logo-club-empty.svg'}
alt="No image"
boxSize="48px"
objectFit="contain"
opacity={0.3}
/>
)}
</Td>
<Td>{ev.title}</Td>
<Td>{typeLabel(ev.type as any)}</Td>
<Td>{new Date(ev.start_time).toLocaleString()}</Td>
<Td>{ev.end_time ? new Date(ev.end_time).toLocaleString() : '-'}</Td>
<Td>{ev.location || '-'}</Td>
<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)} />
<IconButton
aria-label="Zkrátit odkaz"
size="sm"
icon={<FiLink />}
title="Zkrátit odkaz pro sdílení"
onClick={async () => {
try {
const origin = window.location.origin;
const target = `${origin}/aktivita/${ev.id}`;
const res = await createShortLink({ target_url: target, title: ev.title, source_type: 'event', source_id: ev.id as any });
await navigator.clipboard.writeText(res.short_url);
toast({ title: 'Zkrácený odkaz zkopírován', description: res.short_url, status: 'success', duration: 4000 });
} catch (e: any) {
toast({ title: 'Vytvoření odkazu selhalo', description: e?.message || 'Zkuste to znovu', status: 'error' });
}
}}
/>
</HStack>
</Td>
</Tr>
))}
</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>
<HStack justify="space-between" align="start" w="full" pr={8}>
<VStack align="start" spacing={1} flex={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>
<SaveStatusIndicator status={saveStatus} lastSaved={lastSaved} compact />
</HStack>
</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 colSpan={3} p={2}>
<HStack justify="space-between" w="full">
<Box flex={1}>
<FilePreview
url={att.url}
name={att.name}
mimeType={att.mime_type}
size={att.size}
/>
</Box>
<Button
size="xs"
variant="outline"
colorScheme="red"
flexShrink={0}
ml={2}
onClick={() => setEditing(prev => ({ ...(prev as any), attachments: ((prev as any).attachments || []).filter((_: any, i: number) => i !== idx) }))}
>
Odebrat
</Button>
</HStack>
</Td>
</Tr>
))}
</Tbody>
</Table>
) : (
<Box color="gray.500">Žádné přílohy</Box>
)}
</Box>
</FormControl>
</HStack>
{/* Poll Section */}
<Box mt={6} pt={4} borderTopWidth="1px" borderColor={borderColor}>
<Heading size="sm" mb={3}>Anketa</Heading>
{editing?.id ? (
<PollLinker eventId={editing.id} />
) : (
<Box bg={useColorModeValue('blue.50', 'blue.900')} p={4} borderRadius="md" borderWidth="1px" borderColor="blue.200">
<Text fontSize="sm" color={useColorModeValue('blue.700', 'blue.200')}>
💡 Nejprve uložte aktivitu, poté budete moci vytvořit nebo připojit anketu přímo zde.
</Text>
</Box>
)}
</Box>
</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>
{/* Draft Recovery Modal */}
<DraftRecoveryModal
isOpen={showDraftRecovery}
onClose={() => setShowDraftRecovery(false)}
onRecover={handleRecoverDraft}
onDiscard={handleDiscardDraft}
onDeleteOnly={handleDeleteOnly}
draftAge={getDraftMetadata(draftKey)?.age || null}
entityType="aktivitu"
/>
</Box>
</AdminLayout>
);
};
export default AdminActivitiesPage;