mirror of
https://github.com/Dvorinka/MyClubServer.git
synced 2026-06-04 02:32:57 +00:00
1064 lines
50 KiB
TypeScript
1064 lines
50 KiB
TypeScript
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: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 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;
|