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 | null>(null); const [showDraftRecovery, setShowDraftRecovery] = useState(false); const [draftKey, setDraftKey] = useState(''); const [aiPrompt, setAiPrompt] = useState(''); const [aiLoading, setAiLoading] = useState(false); const [aiTone, setAiTone] = useState<'informative'|'friendly'|'formal'>('friendly'); const [aiOverwrite, setAiOverwrite] = useState(true); // Location coordinates for map preview const [locationLat, setLocationLat] = useState(undefined); const [locationLng, setLocationLng] = useState(undefined); // YouTube videos from club channel const [clubVideos, setClubVideos] = useState([]); 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>(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) => 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 }) => 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>([]); const [aliasesMap, setAliasesMap] = useState>({}); 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 = {}; 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(''); const [startTime, setStartTime] = useState(''); const [endDate, setEndDate] = useState(''); const [endTime, setEndTime] = useState(''); // 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 => { 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((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 = { 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 ( Aktivity (Události) {isLoading && ( )} {!isLoading && events.map(ev => ( ))}
Náhled Název Typ Začátek Konec Místo Veřejná Akce
Načítání…
{(ev as any).image_url ? ( ) : ( )} {ev.title} {typeLabel(ev.type as any)} {new Date(ev.start_time).toLocaleString()} {ev.end_time ? new Date(ev.end_time).toLocaleString() : '-'} {ev.location || '-'} {ev.is_public ? 'Ano' : 'Ne'} } onClick={() => openEdit(ev)} /> } onClick={() => deleteMut.mutate(ev.id)} /> } 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' }); } }} />
{/* Modal */} {(editing as any)?.id ? 'Upravit aktivitu' : 'Nová aktivita'} Plánujte klubové akce, sdílejte s fanoušky a týmem. AI generování Beta Zadejte instrukce pro AI nebo klikněte na předlohy níže. AI může vytvořit nebo doplnit titulek a popis události.