mirror of
https://github.com/Dvorinka/MyClubServer.git
synced 2026-06-04 02:32:57 +00:00
upload
This commit is contained in:
@@ -0,0 +1,396 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
FormControl,
|
||||
FormLabel,
|
||||
Input,
|
||||
Textarea,
|
||||
Select,
|
||||
VStack,
|
||||
HStack,
|
||||
Heading,
|
||||
Text,
|
||||
useToast,
|
||||
Tabs,
|
||||
TabList,
|
||||
TabPanels,
|
||||
Tab,
|
||||
TabPanel,
|
||||
SimpleGrid,
|
||||
Icon,
|
||||
Badge,
|
||||
} from '@chakra-ui/react';
|
||||
import { FiSave, FiEye, FiCode, FiLayout, FiZap } from 'react-icons/fi';
|
||||
import AdminLayout from '../../layouts/AdminLayout';
|
||||
import RichTextEditor from '../../components/common/RichTextEditor';
|
||||
import api from '../../services/api';
|
||||
import { generateAboutAI } from '../../services/ai';
|
||||
|
||||
type AboutPageData = {
|
||||
id?: number;
|
||||
title: string;
|
||||
subtitle: string;
|
||||
style: 'default' | 'modern' | 'timeline' | 'custom';
|
||||
content: string;
|
||||
sections: string;
|
||||
seo_title: string;
|
||||
seo_description: string;
|
||||
};
|
||||
|
||||
const AboutAdminPage: React.FC = () => {
|
||||
const toast = useToast();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [aiGenerating, setAiGenerating] = useState(false);
|
||||
const [aiPrompt, setAiPrompt] = useState('');
|
||||
const [aiAudience, setAiAudience] = useState('Fanoušci klubu');
|
||||
const [data, setData] = useState<AboutPageData>({
|
||||
title: '',
|
||||
subtitle: '',
|
||||
style: 'default',
|
||||
content: '',
|
||||
sections: '[]',
|
||||
seo_title: '',
|
||||
seo_description: '',
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
loadData();
|
||||
}, []);
|
||||
|
||||
const loadData = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await api.get('/admin/about');
|
||||
if (res.data) {
|
||||
setData(res.data);
|
||||
}
|
||||
} catch (e: any) {
|
||||
toast({ title: 'Chyba', description: 'Nepodařilo se načíst data', status: 'error' });
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!data.title.trim()) {
|
||||
toast({ title: 'Chyba', description: 'Vyplňte název stránky', status: 'warning' });
|
||||
return;
|
||||
}
|
||||
|
||||
setSaving(true);
|
||||
try {
|
||||
await api.put('/admin/about', data);
|
||||
toast({ title: 'Uloženo', description: 'Stránka O klubu byla uložena', status: 'success' });
|
||||
await loadData();
|
||||
} catch (e: any) {
|
||||
toast({
|
||||
title: 'Chyba',
|
||||
description: e?.response?.data?.error || 'Nepodařilo se uložit',
|
||||
status: 'error',
|
||||
});
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!confirm('Opravdu smazat stránku O klubu?')) return;
|
||||
try {
|
||||
await api.delete('/admin/about');
|
||||
toast({ title: 'Smazáno', description: 'Stránka byla smazána', status: 'info' });
|
||||
setData({
|
||||
title: '',
|
||||
subtitle: '',
|
||||
style: 'default',
|
||||
content: '',
|
||||
sections: '[]',
|
||||
seo_title: '',
|
||||
seo_description: '',
|
||||
});
|
||||
} catch (e: any) {
|
||||
toast({ title: 'Chyba', description: 'Nepodařilo se smazat', status: 'error' });
|
||||
}
|
||||
};
|
||||
|
||||
const handleGenerateAI = async () => {
|
||||
if (!aiPrompt.trim()) {
|
||||
toast({ title: 'Chyba', description: 'Zadejte poznámky pro AI', status: 'warning' });
|
||||
return;
|
||||
}
|
||||
|
||||
setAiGenerating(true);
|
||||
try {
|
||||
const result = await generateAboutAI({
|
||||
prompt: aiPrompt,
|
||||
audience: aiAudience,
|
||||
style: data.style,
|
||||
});
|
||||
setData((prev) => ({
|
||||
...prev,
|
||||
content: result.html || prev.content,
|
||||
title: result.title || prev.title,
|
||||
subtitle: result.subtitle || prev.subtitle,
|
||||
seo_title: result.seo_title || prev.seo_title,
|
||||
seo_description: result.seo_description || prev.seo_description,
|
||||
}));
|
||||
toast({
|
||||
title: 'Hotovo!',
|
||||
description: 'Obsah byl vygenerován pomocí AI',
|
||||
status: 'success',
|
||||
});
|
||||
} catch (e: any) {
|
||||
toast({
|
||||
title: 'Chyba AI',
|
||||
description: e?.response?.data?.error || 'Nepodařilo se vygenerovat obsah',
|
||||
status: 'error',
|
||||
});
|
||||
} finally {
|
||||
setAiGenerating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const styleDescriptions: Record<string, { name: string; desc: string }> = {
|
||||
default: { name: 'Výchozí', desc: 'Jednoduchý layout s titulkem a obsahem' },
|
||||
modern: { name: 'Moderní', desc: 'Vizuálně atraktivní s hero obrázkem a sekcemi' },
|
||||
timeline: { name: 'Timeline', desc: 'Historie klubu na časové ose' },
|
||||
custom: { name: 'Vlastní HTML', desc: 'Plná kontrola nad obsahem a stylingem' },
|
||||
};
|
||||
|
||||
return (
|
||||
<AdminLayout requireAdmin={false}>
|
||||
<Box>
|
||||
<HStack justify="space-between" mb={4}>
|
||||
<Heading size="lg">Stránka O klubu</Heading>
|
||||
<HStack>
|
||||
<Button
|
||||
leftIcon={<FiSave />}
|
||||
colorScheme="brand"
|
||||
onClick={handleSave}
|
||||
isLoading={saving}
|
||||
>
|
||||
Uložit
|
||||
</Button>
|
||||
</HStack>
|
||||
</HStack>
|
||||
|
||||
<Text color="gray.600" mb={6}>
|
||||
Vytvořte nebo upravte stránku O klubu. Dostupná na <strong>/o-klubu</strong>.
|
||||
</Text>
|
||||
|
||||
<Tabs variant="enclosed" colorScheme="brand">
|
||||
<TabList>
|
||||
<Tab><Icon as={FiLayout} mr={2} /> Obsah</Tab>
|
||||
<Tab><Icon as={FiCode} mr={2} /> Styl</Tab>
|
||||
<Tab><Icon as={FiEye} mr={2} /> SEO</Tab>
|
||||
</TabList>
|
||||
|
||||
<TabPanels>
|
||||
{/* Content Tab */}
|
||||
<TabPanel>
|
||||
<VStack align="stretch" spacing={4}>
|
||||
<FormControl isRequired>
|
||||
<FormLabel>Název stránky</FormLabel>
|
||||
<Input
|
||||
value={data.title}
|
||||
onChange={(e) => setData((prev) => ({ ...prev, title: e.target.value }))}
|
||||
placeholder="O naší klubu"
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
<FormControl>
|
||||
<FormLabel>Podtitulek</FormLabel>
|
||||
<Input
|
||||
value={data.subtitle}
|
||||
onChange={(e) => setData((prev) => ({ ...prev, subtitle: e.target.value }))}
|
||||
placeholder="Naše historie, hodnoty a komunita"
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
{/* Hero image removed: About page uses club logo and name from settings */}
|
||||
|
||||
<Box
|
||||
borderWidth="1px"
|
||||
borderRadius="md"
|
||||
p={4}
|
||||
bg="gray.50"
|
||||
>
|
||||
<HStack justify="space-between" mb={3} align="flex-start">
|
||||
<Box>
|
||||
<Heading size="sm" mb={1}>AI generátor obsahu</Heading>
|
||||
<Text fontSize="sm" color="gray.600">
|
||||
Napište krátké poznámky (např. historie, úspěchy, hodnoty) a nechte AI připravit návrh stránky.
|
||||
</Text>
|
||||
</Box>
|
||||
<Button
|
||||
leftIcon={<FiZap />}
|
||||
colorScheme="purple"
|
||||
variant="solid"
|
||||
onClick={handleGenerateAI}
|
||||
isLoading={aiGenerating}
|
||||
>
|
||||
Vygenerovat
|
||||
</Button>
|
||||
</HStack>
|
||||
<VStack align="stretch" spacing={3}>
|
||||
<FormControl>
|
||||
<FormLabel>Poznámky pro AI</FormLabel>
|
||||
<Textarea
|
||||
value={aiPrompt}
|
||||
onChange={(e) => setAiPrompt(e.target.value)}
|
||||
placeholder="Popište historii klubu, současné cíle, přístup k mládeži..."
|
||||
rows={4}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormControl>
|
||||
<FormLabel>Cílové publikum</FormLabel>
|
||||
<Input
|
||||
value={aiAudience}
|
||||
onChange={(e) => setAiAudience(e.target.value)}
|
||||
placeholder="Fanoušci klubu"
|
||||
/>
|
||||
</FormControl>
|
||||
</VStack>
|
||||
</Box>
|
||||
|
||||
<FormControl>
|
||||
<FormLabel>Obsah stránky</FormLabel>
|
||||
<RichTextEditor
|
||||
value={data.content}
|
||||
onChange={(value) => setData((prev) => ({ ...prev, content: value }))}
|
||||
height="400px"
|
||||
toolbar="full"
|
||||
showImageResize={true}
|
||||
placeholder="Zadejte obsah stránky O nás..."
|
||||
/>
|
||||
</FormControl>
|
||||
</VStack>
|
||||
</TabPanel>
|
||||
|
||||
{/* Style Tab */}
|
||||
<TabPanel>
|
||||
<VStack align="stretch" spacing={4}>
|
||||
<FormControl>
|
||||
<FormLabel>Styl stránky</FormLabel>
|
||||
<Select
|
||||
value={data.style}
|
||||
onChange={(e) =>
|
||||
setData((prev) => ({ ...prev, style: e.target.value as any }))
|
||||
}
|
||||
>
|
||||
{Object.entries(styleDescriptions).map(([key, { name, desc }]) => (
|
||||
<option key={key} value={key}>
|
||||
{name}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
<Text fontSize="sm" color="gray.600" mt={2}>
|
||||
{styleDescriptions[data.style]?.desc}
|
||||
</Text>
|
||||
</FormControl>
|
||||
|
||||
<Box
|
||||
p={4}
|
||||
borderWidth="1px"
|
||||
borderRadius="md"
|
||||
bg="blue.50"
|
||||
borderColor="blue.200"
|
||||
>
|
||||
<Heading size="sm" mb={2}>Vybraný styl: {styleDescriptions[data.style]?.name}</Heading>
|
||||
<SimpleGrid columns={{ base: 1, md: 2 }} spacing={3} mb={3}>
|
||||
<Box>
|
||||
<Badge colorScheme="blue" mb={1}>Výchozí</Badge>
|
||||
<Text fontSize="sm">Jednoduchý layout, ideální pro text</Text>
|
||||
</Box>
|
||||
<Box>
|
||||
<Badge colorScheme="purple" mb={1}>Moderní</Badge>
|
||||
<Text fontSize="sm">Hero obrázek + sekce karet</Text>
|
||||
</Box>
|
||||
<Box>
|
||||
<Badge colorScheme="green" mb={1}>Timeline</Badge>
|
||||
<Text fontSize="sm">Historie klubu chronologicky</Text>
|
||||
</Box>
|
||||
<Box>
|
||||
<Badge colorScheme="orange" mb={1}>Vlastní</Badge>
|
||||
<Text fontSize="sm">Plná kontrola přes HTML/CSS</Text>
|
||||
</Box>
|
||||
</SimpleGrid>
|
||||
</Box>
|
||||
|
||||
{data.style === 'custom' && (
|
||||
<Box
|
||||
p={3}
|
||||
bg="yellow.50"
|
||||
borderWidth="1px"
|
||||
borderColor="yellow.300"
|
||||
borderRadius="md"
|
||||
>
|
||||
<Text fontSize="sm" fontWeight="semibold" mb={1}>
|
||||
Vlastní HTML režim
|
||||
</Text>
|
||||
<Text fontSize="sm">
|
||||
V editoru můžete použít jakékoliv HTML a CSS. Ujistěte se, že je kód validní.
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
</VStack>
|
||||
</TabPanel>
|
||||
|
||||
{/* SEO Tab */}
|
||||
<TabPanel>
|
||||
<VStack align="stretch" spacing={4}>
|
||||
<FormControl>
|
||||
<FormLabel>SEO Titulek</FormLabel>
|
||||
<Input
|
||||
value={data.seo_title}
|
||||
onChange={(e) => setData((prev) => ({ ...prev, seo_title: e.target.value }))}
|
||||
placeholder="Pokud prázdné, použije se název stránky"
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
<FormControl>
|
||||
<FormLabel>SEO Popis</FormLabel>
|
||||
<Textarea
|
||||
value={data.seo_description}
|
||||
onChange={(e) =>
|
||||
setData((prev) => ({ ...prev, seo_description: e.target.value }))
|
||||
}
|
||||
placeholder="Krátký popis pro vyhledávače (doporučeno 150-160 znaků)"
|
||||
rows={4}
|
||||
/>
|
||||
<Text fontSize="sm" color="gray.600" mt={1}>
|
||||
Délka: {data.seo_description.length} znaků
|
||||
</Text>
|
||||
</FormControl>
|
||||
</VStack>
|
||||
</TabPanel>
|
||||
</TabPanels>
|
||||
</Tabs>
|
||||
|
||||
<Box mt={6} pt={4} borderTopWidth="1px">
|
||||
<HStack justify="space-between">
|
||||
<Button colorScheme="red" variant="outline" onClick={handleDelete}>
|
||||
Smazat stránku
|
||||
</Button>
|
||||
<HStack>
|
||||
<Button as="a" href="/o-klubu" target="_blank" variant="outline">
|
||||
Náhled
|
||||
</Button>
|
||||
<Button
|
||||
leftIcon={<FiSave />}
|
||||
colorScheme="brand"
|
||||
onClick={handleSave}
|
||||
isLoading={saving}
|
||||
>
|
||||
Uložit změny
|
||||
</Button>
|
||||
</HStack>
|
||||
</HStack>
|
||||
</Box>
|
||||
</Box>
|
||||
</AdminLayout>
|
||||
);
|
||||
};
|
||||
|
||||
export default AboutAdminPage;
|
||||
@@ -0,0 +1,904 @@
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import AdminLayout from '../../layouts/AdminLayout';
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
ButtonGroup,
|
||||
FormControl,
|
||||
FormLabel,
|
||||
HStack,
|
||||
Heading,
|
||||
IconButton,
|
||||
Input,
|
||||
Modal,
|
||||
ModalBody,
|
||||
ModalCloseButton,
|
||||
ModalContent,
|
||||
ModalFooter,
|
||||
ModalHeader,
|
||||
ModalOverlay,
|
||||
Select,
|
||||
Switch,
|
||||
Table,
|
||||
Tbody,
|
||||
Td,
|
||||
Textarea,
|
||||
Th,
|
||||
Thead,
|
||||
Tr,
|
||||
useDisclosure,
|
||||
useToast,
|
||||
SimpleGrid,
|
||||
Text,
|
||||
VStack,
|
||||
Tag,
|
||||
TagLabel,
|
||||
Tooltip,
|
||||
Badge,
|
||||
Wrap,
|
||||
WrapItem,
|
||||
useColorModeValue,
|
||||
Image as ChakraImage,
|
||||
} from '@chakra-ui/react';
|
||||
import { FiEdit2, FiPlus, FiTrash2 } from 'react-icons/fi';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { Event } from '../../types/event';
|
||||
import { uploadFile } from '../../services/articles';
|
||||
import { createEvent, deleteEvent, getEvents, updateEvent } from '../../services/eventService';
|
||||
import { api } from '../../services/api';
|
||||
// Removed react-datepicker to prevent crash; using native date/time inputs instead
|
||||
import { getPublicSettings } from '../../services/settings';
|
||||
import PollLinker from '../../components/admin/PollLinker';
|
||||
import { facrApi } from '../../services/facr/facrApi';
|
||||
import { getCompetitionAliasesPublic } from '../../services/competitionAliases';
|
||||
import MapLinkImporter from '../../components/admin/MapLinkImporter';
|
||||
import { MapCoordinates } from '../../utils/mapUrlParser';
|
||||
import ContactMap from '../../components/home/ContactMap';
|
||||
import RichTextEditor from '../../components/common/RichTextEditor';
|
||||
import { getCachedYouTube, YouTubeVideo } from '../../services/youtube';
|
||||
import { FiVideo, FiYoutube, FiLink } from 'react-icons/fi';
|
||||
|
||||
const types: Array<{ value: Event['type']; label: string }> = [
|
||||
{ value: 'match', label: 'Zápas' },
|
||||
{ value: 'training', label: 'Trénink' },
|
||||
{ value: 'meeting', label: 'Schůzka' },
|
||||
{ value: 'other', label: 'Jiné' },
|
||||
];
|
||||
|
||||
const AdminActivitiesPage: React.FC = () => {
|
||||
const cardBg = useColorModeValue('white', 'gray.800');
|
||||
const borderColor = useColorModeValue('gray.200', 'gray.700');
|
||||
const inputBg = useColorModeValue('white', 'gray.700');
|
||||
const textSecondary = useColorModeValue('gray.600', 'gray.400');
|
||||
const toast = useToast();
|
||||
const qc = useQueryClient();
|
||||
const { isOpen, onOpen, onClose } = useDisclosure();
|
||||
const [editing, setEditing] = useState<Partial<Event> | null>(null);
|
||||
const [aiPrompt, setAiPrompt] = useState<string>('');
|
||||
const [aiLoading, setAiLoading] = useState<boolean>(false);
|
||||
const [aiTone, setAiTone] = useState<'informative'|'friendly'|'formal'>('friendly');
|
||||
const [aiOverwrite, setAiOverwrite] = useState<boolean>(true);
|
||||
// Location coordinates for map preview
|
||||
const [locationLat, setLocationLat] = useState<number | undefined>(undefined);
|
||||
const [locationLng, setLocationLng] = useState<number | undefined>(undefined);
|
||||
// YouTube videos from club channel
|
||||
const [clubVideos, setClubVideos] = useState<YouTubeVideo[]>([]);
|
||||
const [youtubeTab, setYoutubeTab] = useState<'club' | 'custom'>('club');
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ['admin-events'],
|
||||
queryFn: () => getEvents(),
|
||||
});
|
||||
const events = data || [];
|
||||
|
||||
// Load club YouTube videos
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
try {
|
||||
const ytData = await getCachedYouTube();
|
||||
if (ytData?.videos) {
|
||||
setClubVideos(ytData.videos.slice(0, 20)); // Limit to 20 most recent
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to load YouTube videos:', err);
|
||||
}
|
||||
})();
|
||||
}, []);
|
||||
|
||||
// Settings for logo fetch (for placeholder image)
|
||||
const settingsQ = useQuery({
|
||||
queryKey: ['public-settings'],
|
||||
queryFn: getPublicSettings,
|
||||
staleTime: 5 * 60_000,
|
||||
});
|
||||
|
||||
const openCreate = () => {
|
||||
setEditing({ title: '', description: '', type: 'other', is_public: true } as any);
|
||||
setLocationLat(undefined);
|
||||
setLocationLng(undefined);
|
||||
onOpen();
|
||||
};
|
||||
const openEdit = (ev: Event) => {
|
||||
setEditing({ ...ev });
|
||||
// Initialize map coordinates from event
|
||||
if ((ev as any).latitude && (ev as any).longitude) {
|
||||
setLocationLat((ev as any).latitude);
|
||||
setLocationLng((ev as any).longitude);
|
||||
} else {
|
||||
setLocationLat(undefined);
|
||||
setLocationLng(undefined);
|
||||
}
|
||||
onOpen();
|
||||
};
|
||||
const closeModal = () => {
|
||||
setEditing(null);
|
||||
setLocationLat(undefined);
|
||||
setLocationLng(undefined);
|
||||
onClose();
|
||||
};
|
||||
|
||||
const createMut = useMutation({
|
||||
mutationFn: (payload: Partial<Event>) => createEvent(payload),
|
||||
onSuccess: () => { toast({ title: 'Událost vytvořena', status: 'success' }); qc.invalidateQueries({ queryKey: ['admin-events'] }); closeModal(); },
|
||||
onError: (e: any) => toast({ title: 'Chyba při vytváření', description: e?.message || 'Došlo k chybě', status: 'error' }),
|
||||
});
|
||||
const updateMut = useMutation({
|
||||
mutationFn: ({ id, payload }: { id: number|string; payload: Partial<Event> }) => updateEvent(id, payload),
|
||||
onSuccess: () => { toast({ title: 'Událost upravena', status: 'success' }); qc.invalidateQueries({ queryKey: ['admin-events'] }); closeModal(); },
|
||||
onError: (e: any) => toast({ title: 'Chyba při ukládání', description: e?.message || 'Došlo k chybě', status: 'error' }),
|
||||
});
|
||||
const deleteMut = useMutation({
|
||||
mutationFn: (id: number) => deleteEvent(id),
|
||||
onSuccess: () => { toast({ title: 'Smazáno', status: 'success' }); qc.invalidateQueries({ queryKey: ['admin-events'] }); },
|
||||
onError: (e: any) => toast({ title: 'Smazání selhalo', description: e?.message || 'Došlo k chybě', status: 'error' }),
|
||||
});
|
||||
|
||||
// Competitions list for category selection (like Articles)
|
||||
const [competitions, setCompetitions] = useState<Array<{ code?: string; name: string }>>([]);
|
||||
const [aliasesMap, setAliasesMap] = useState<Record<string, string>>({});
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
try {
|
||||
const settings = await getPublicSettings();
|
||||
const clubId = (settings as any)?.club_id || '';
|
||||
const clubType = ((settings as any)?.club_type || 'football') as 'football' | 'futsal';
|
||||
const comps: Array<{ code?: string; name: string }> = [];
|
||||
if (clubId) {
|
||||
try {
|
||||
const club = await facrApi.getClub(String(clubId), clubType);
|
||||
const arr = Array.isArray((club as any)?.competitions) ? (club as any).competitions : [];
|
||||
arr.forEach((c: any) => comps.push({ code: c.code, name: c.name || c.code }));
|
||||
} catch {}
|
||||
}
|
||||
let amap: Record<string, string> = {};
|
||||
try {
|
||||
const list = await getCompetitionAliasesPublic();
|
||||
list.forEach((a) => { if (a.code && a.alias) amap[a.code] = a.alias; });
|
||||
} catch {}
|
||||
const withAliases = comps.map((c) => ({ code: c.code, name: (c.code && amap[c.code]) ? amap[c.code] : c.name }));
|
||||
setAliasesMap(amap);
|
||||
setCompetitions(withAliases);
|
||||
} catch {}
|
||||
})();
|
||||
}, []);
|
||||
|
||||
// AI generation for activity title/description (uses blog generator to produce quality Czech copy)
|
||||
const generateWithAI = async () => {
|
||||
try {
|
||||
setAiLoading(true);
|
||||
const e = editing || {};
|
||||
// Build a helpful Czech prompt including known fields
|
||||
const lines: string[] = [];
|
||||
if (e.type) lines.push(`Typ: ${e.type}`);
|
||||
if (e.location) lines.push(`Místo: ${e.location}`);
|
||||
if (e.start_time) {
|
||||
try { lines.push(`Začátek: ${new Date(e.start_time as any).toLocaleString('cs-CZ')}`); } catch {}
|
||||
}
|
||||
if (e.end_time) {
|
||||
try { lines.push(`Konec: ${new Date(e.end_time as any).toLocaleString('cs-CZ')}`); } catch {}
|
||||
}
|
||||
if (e.description) lines.push(`Poznámky: ${e.description}`);
|
||||
const base = lines.join('\n');
|
||||
const toneText = aiTone === 'informative' ? 'informativním a věcným stylem' : aiTone === 'formal' ? 'formálním a profesionálním stylem' : 'přátelským, pozitivním a lákavým stylem';
|
||||
const safeUserPrompt = (aiPrompt || 'Vytvoř krátké oznámení pro fanoušky o klubové aktivitě.').trim();
|
||||
const prompt = `${safeUserPrompt}\n\nPiš ${toneText}, česky, s důrazem na jasnost a pozvánku k účasti.\nDetaily:\n${base}`.trim();
|
||||
const { data } = await api.post('/ai/blog/generate', {
|
||||
prompt,
|
||||
audience: 'Fanoušci klubu, oznámení/pozvánka',
|
||||
min_words: 120,
|
||||
});
|
||||
|
||||
// Handle potential JSON string response from AI (defensive parsing)
|
||||
let parsedData = data;
|
||||
if (typeof data === 'string') {
|
||||
try {
|
||||
parsedData = JSON.parse(data);
|
||||
} catch {
|
||||
throw new Error('AI vrátila neplatný formát odpovědi');
|
||||
}
|
||||
}
|
||||
|
||||
const title = String(parsedData?.title || '').trim();
|
||||
const html = String(parsedData?.html || '').trim();
|
||||
if (!title && !html) throw new Error('AI nevrátila obsah');
|
||||
|
||||
setEditing(prev => ({
|
||||
...(prev || {}),
|
||||
title: aiOverwrite ? (title || (prev?.title || '')) : (prev?.title || title || ''),
|
||||
description: aiOverwrite ? (html || (prev?.description || '')) : `${(prev?.description || '')}${(prev?.description ? '\n\n' : '')}${html}`,
|
||||
}));
|
||||
toast({ title: 'Vygenerováno pomocí AI', status: 'success', duration: 3000 });
|
||||
} catch (e: any) {
|
||||
console.error('AI generation error:', e);
|
||||
toast({ title: 'AI generování selhalo', description: e?.response?.data?.error || e?.message || 'Zkuste doplnit více detailů a opakovat.', status: 'error', duration: 5000 });
|
||||
} finally {
|
||||
setAiLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
// Date/time segmented inputs state
|
||||
const [startDate, setStartDate] = useState<string>('');
|
||||
const [startTime, setStartTime] = useState<string>('');
|
||||
const [endDate, setEndDate] = useState<string>('');
|
||||
const [endTime, setEndTime] = useState<string>('');
|
||||
|
||||
// Sync segmented date/time when editing changes
|
||||
useEffect(() => {
|
||||
const s = editing?.start_time ? new Date(editing.start_time as any) : null;
|
||||
if (s) {
|
||||
const yyyy = s.getFullYear();
|
||||
const mm = String(s.getMonth() + 1).padStart(2, '0');
|
||||
const dd = String(s.getDate()).padStart(2, '0');
|
||||
const hh = String(s.getHours()).padStart(2, '0');
|
||||
const min = String(s.getMinutes()).padStart(2, '0');
|
||||
setStartDate(`${yyyy}-${mm}-${dd}`);
|
||||
setStartTime(`${hh}:${min}`);
|
||||
} else {
|
||||
setStartDate(''); setStartTime('');
|
||||
}
|
||||
const e = editing?.end_time ? new Date(editing.end_time as any) : null;
|
||||
if (e) {
|
||||
const yyyy = e.getFullYear();
|
||||
const mm = String(e.getMonth() + 1).padStart(2, '0');
|
||||
const dd = String(e.getDate()).padStart(2, '0');
|
||||
const hh = String(e.getHours()).padStart(2, '0');
|
||||
const min = String(e.getMinutes()).padStart(2, '0');
|
||||
setEndDate(`${yyyy}-${mm}-${dd}`);
|
||||
setEndTime(`${hh}:${min}`);
|
||||
} else {
|
||||
setEndDate(''); setEndTime('');
|
||||
}
|
||||
}, [editing?.start_time, editing?.end_time]);
|
||||
|
||||
const toISO = (d?: string, t?: string) => {
|
||||
if (!d || !t) return null as any;
|
||||
const val = new Date(`${d}T${t}:00`);
|
||||
if (isNaN(val.getTime())) return null as any;
|
||||
return val.toISOString() as any;
|
||||
};
|
||||
|
||||
const missingTitle = !String(editing?.title || '').trim();
|
||||
const missingStart = !startDate || !startTime;
|
||||
|
||||
const ensurePlaceholderImage = async (): Promise<string | undefined> => {
|
||||
try {
|
||||
const logoUrl = String(settingsQ.data?.club_logo_url || '/dist/img/logo-club-empty.svg');
|
||||
const canvas = document.createElement('canvas');
|
||||
const W = 1200, H = 630;
|
||||
canvas.width = W; canvas.height = H;
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) return undefined;
|
||||
// White background
|
||||
ctx.fillStyle = '#ffffff';
|
||||
ctx.fillRect(0, 0, W, H);
|
||||
// Border for visibility
|
||||
ctx.strokeStyle = '#000000';
|
||||
ctx.lineWidth = 6;
|
||||
ctx.strokeRect(3, 3, W - 6, H - 6);
|
||||
// Draw club logo centered
|
||||
const img = new Image();
|
||||
img.crossOrigin = 'anonymous';
|
||||
const loadImg = () => new Promise<HTMLImageElement>((resolve, reject) => {
|
||||
img.onload = () => resolve(img);
|
||||
img.onerror = reject;
|
||||
img.src = logoUrl;
|
||||
});
|
||||
try { await loadImg(); } catch { /* fallback */ }
|
||||
const boxW = Math.round(W * 0.5);
|
||||
const boxH = Math.round(H * 0.5);
|
||||
const x = Math.round((W - boxW) / 2);
|
||||
const y = Math.round((H - boxH) / 2);
|
||||
try { ctx.drawImage(img, x, y, boxW, boxH); } catch {}
|
||||
// Export and upload
|
||||
const blob: Blob | null = await new Promise(resolve => canvas.toBlob(b => resolve(b), 'image/jpeg', 0.92));
|
||||
if (!blob) return undefined;
|
||||
const file = new File([blob], 'activity-cover.jpg', { type: 'image/jpeg' });
|
||||
const res = await uploadFile(file);
|
||||
return (res as any)?.url || undefined;
|
||||
} catch { return undefined; }
|
||||
};
|
||||
|
||||
const onSubmit = async () => {
|
||||
if (!editing) return;
|
||||
// Validation to avoid 400s
|
||||
if (missingTitle) { toast({ title: 'Zadejte název', status: 'warning' }); return; }
|
||||
if (missingStart) { toast({ title: 'Vyplňte datum a čas začátku', status: 'warning' }); return; }
|
||||
const startISO = toISO(startDate, startTime);
|
||||
const endISO = endDate && endTime ? toISO(endDate, endTime) : null;
|
||||
if (endISO && startISO && new Date(endISO).getTime() < new Date(startISO).getTime()) {
|
||||
toast({ title: 'Konec musí být po začátku', status: 'warning' });
|
||||
return;
|
||||
}
|
||||
|
||||
// Auto-generate image if missing
|
||||
let imageUrl = (editing as any).image_url as string | undefined;
|
||||
if (!imageUrl) {
|
||||
imageUrl = await ensurePlaceholderImage();
|
||||
if (imageUrl) setEditing(prev => ({ ...(prev || {}), image_url: imageUrl }));
|
||||
}
|
||||
|
||||
const payload: Partial<Event> = {
|
||||
title: (editing.title || '').trim(),
|
||||
description: (editing.description || '').trim(),
|
||||
start_time: startISO as any,
|
||||
end_time: (endISO as any) || null,
|
||||
location: (editing.location || '').trim(),
|
||||
type: (editing.type || 'other') as any,
|
||||
is_public: !!editing.is_public,
|
||||
image_url: imageUrl || undefined,
|
||||
file_url: (editing as any).file_url || undefined,
|
||||
category_name: (editing as any)?.category_name || undefined,
|
||||
attachments: Array.isArray((editing as any)?.attachments) ? ((editing as any).attachments as any[]).map((a: any) => ({ name: a.name, url: a.url, mime_type: a.mime_type, size: a.size })) : undefined,
|
||||
youtube_url: (editing as any)?.youtube_url || undefined,
|
||||
latitude: (editing as any)?.latitude || locationLat || undefined,
|
||||
longitude: (editing as any)?.longitude || locationLng || undefined,
|
||||
};
|
||||
|
||||
if ((editing as any).id) {
|
||||
await updateMut.mutateAsync({ id: (editing as any).id, payload });
|
||||
} else {
|
||||
await createMut.mutateAsync(payload);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<AdminLayout requireAdmin={false}>
|
||||
<Box>
|
||||
<HStack justify="space-between" mb={4}>
|
||||
<Heading size="lg">Aktivity (Události)</Heading>
|
||||
<Button leftIcon={<FiPlus />} colorScheme="blue" onClick={openCreate}>Nová aktivita</Button>
|
||||
</HStack>
|
||||
<Box bg={cardBg} borderWidth="1px" borderRadius="lg" overflowX="auto" boxShadow="sm" mb={6}>
|
||||
<Table size="sm">
|
||||
<Thead>
|
||||
<Tr>
|
||||
<Th>Název</Th>
|
||||
<Th>Typ</Th>
|
||||
<Th>Začátek</Th>
|
||||
<Th>Konec</Th>
|
||||
<Th>Místo</Th>
|
||||
<Th>Veřejná</Th>
|
||||
<Th w="140px">Akce</Th>
|
||||
</Tr>
|
||||
</Thead>
|
||||
<Tbody>
|
||||
{isLoading && (
|
||||
<Tr><Td colSpan={7}>Načítání…</Td></Tr>
|
||||
)}
|
||||
{!isLoading && events.map(ev => (
|
||||
<Tr key={ev.id}>
|
||||
<Td>{ev.title}</Td>
|
||||
<Td>{ev.type}</Td>
|
||||
<Td>{new Date(ev.start_time).toLocaleString()}</Td>
|
||||
<Td>{ev.end_time ? new Date(ev.end_time).toLocaleString() : '-'}</Td>
|
||||
<Td>{ev.location || '-'}</Td>
|
||||
<Td>{ev.is_public ? 'Ano' : 'Ne'}</Td>
|
||||
<Td>
|
||||
<HStack>
|
||||
<IconButton aria-label="Upravit" size="sm" icon={<FiEdit2 />} onClick={() => openEdit(ev)} />
|
||||
<IconButton aria-label="Smazat" size="sm" colorScheme="red" icon={<FiTrash2 />} onClick={() => deleteMut.mutate(ev.id)} />
|
||||
</HStack>
|
||||
</Td>
|
||||
</Tr>
|
||||
))}
|
||||
</Tbody>
|
||||
</Table>
|
||||
</Box>
|
||||
|
||||
{/* Modal */}
|
||||
<Modal isOpen={isOpen} onClose={closeModal} isCentered scrollBehavior="inside">
|
||||
<ModalOverlay backdropFilter="blur(3px)" />
|
||||
<ModalContent maxW={{ base: '96vw', md: '920px' }} maxH={{ base: '90vh', md: '86vh' }} borderRadius="2xl" overflow="hidden" boxShadow="2xl">
|
||||
<ModalHeader>
|
||||
<VStack align="start" spacing={1}>
|
||||
<Heading size="md">{(editing as any)?.id ? 'Upravit aktivitu' : 'Nová aktivita'}</Heading>
|
||||
<Text fontSize="sm" color="gray.500">Plánujte klubové akce, sdílejte s fanoušky a týmem.</Text>
|
||||
</VStack>
|
||||
</ModalHeader>
|
||||
<ModalCloseButton />
|
||||
<ModalBody overflowY="auto" maxH={{ base: '76vh', md: '70vh' }}>
|
||||
<Box borderWidth="1px" borderRadius="lg" p={4} mb={5} bg={useColorModeValue('gray.50', 'gray.900')}>
|
||||
<HStack justify="space-between" align="start" mb={2}>
|
||||
<Heading size="sm">AI generování</Heading>
|
||||
<Badge colorScheme="purple" variant="subtle">Beta</Badge>
|
||||
</HStack>
|
||||
<Text fontSize="sm" color="gray.600" mb={2}>Zadejte instrukce pro AI nebo klikněte na předlohy níže. AI může vytvořit nebo doplnit titulek a popis události.</Text>
|
||||
<Textarea
|
||||
placeholder="Např.: Trénink A-týmu, hřiště TJ Dvorce, 18:00–19:30, pro všechny hráče."
|
||||
value={aiPrompt}
|
||||
onChange={(e)=> setAiPrompt(e.target.value)}
|
||||
rows={3}
|
||||
bg={cardBg}
|
||||
/>
|
||||
<Wrap spacing={2} mt={2}>
|
||||
{[
|
||||
'Pozvánka na trénink se zaměřením na kondici',
|
||||
'Oznámení přátelského zápasu pro fanoušky',
|
||||
'Rodičovská schůzka mládeže – stručný program',
|
||||
].map((t, i) => (
|
||||
<WrapItem key={i}>
|
||||
<Tag size="sm" variant="subtle" colorScheme="blue" _hover={{ cursor: 'pointer', opacity: 0.9 }} onClick={() => setAiPrompt(t)}>
|
||||
<TagLabel>{t}</TagLabel>
|
||||
</Tag>
|
||||
</WrapItem>
|
||||
))}
|
||||
</Wrap>
|
||||
<HStack mt={3} spacing={3} align="center">
|
||||
<FormControl maxW="220px">
|
||||
<FormLabel mb={1}>Tón</FormLabel>
|
||||
<Select size="sm" value={aiTone} onChange={(e)=> setAiTone(e.target.value as any)}>
|
||||
<option value="friendly">Přátelský</option>
|
||||
<option value="informative">Informační</option>
|
||||
<option value="formal">Formální</option>
|
||||
</Select>
|
||||
</FormControl>
|
||||
<FormControl display="flex" alignItems="center">
|
||||
<FormLabel mb="0">Přepsat existující obsah</FormLabel>
|
||||
<Switch isChecked={aiOverwrite} onChange={(e)=> setAiOverwrite(e.target.checked)} />
|
||||
</FormControl>
|
||||
<Tooltip label="AI doplní titul a popis podle zadaných informací." hasArrow>
|
||||
<Button onClick={generateWithAI} isLoading={aiLoading} leftIcon={<FiPlus />} bg="brand.primary" color="text.onPrimary" _hover={{ filter: 'brightness(0.95)' }}>
|
||||
AI text
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</HStack>
|
||||
</Box>
|
||||
<FormControl isRequired mb={3}>
|
||||
<FormLabel>Název</FormLabel>
|
||||
<Input value={editing?.title || ''} onChange={(e) => setEditing(prev => ({ ...(prev || {}), title: e.target.value }))} />
|
||||
{missingTitle && (
|
||||
<Text fontSize="sm" color="orange.500" mt={1}>Vyplňte název aktivity.</Text>
|
||||
)}
|
||||
</FormControl>
|
||||
|
||||
<FormControl mb={3}>
|
||||
<FormLabel>Popis (Rich Text Editor)</FormLabel>
|
||||
<RichTextEditor
|
||||
value={editing?.description || ''}
|
||||
onChange={(value) => setEditing(prev => ({ ...(prev || {}), description: value }))}
|
||||
height="300px"
|
||||
toolbar="full"
|
||||
showImageResize={true}
|
||||
placeholder="Zadejte popis události..."
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
<Box mb={3}>
|
||||
<FormLabel>YouTube Video (volitelné)</FormLabel>
|
||||
<Box borderWidth="1px" borderRadius="md" p={3} bg={useColorModeValue('gray.50', 'gray.900')}>
|
||||
<HStack mb={3} spacing={2}>
|
||||
<Button
|
||||
size="sm"
|
||||
leftIcon={<FiYoutube />}
|
||||
onClick={() => setYoutubeTab('club')}
|
||||
variant={youtubeTab === 'club' ? 'solid' : 'outline'}
|
||||
colorScheme={youtubeTab === 'club' ? 'red' : 'gray'}
|
||||
>
|
||||
Z kanálu klubu ({clubVideos.length})
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
leftIcon={<FiLink />}
|
||||
onClick={() => setYoutubeTab('custom')}
|
||||
variant={youtubeTab === 'custom' ? 'solid' : 'outline'}
|
||||
colorScheme={youtubeTab === 'custom' ? 'blue' : 'gray'}
|
||||
>
|
||||
Vlastní odkaz
|
||||
</Button>
|
||||
</HStack>
|
||||
|
||||
{youtubeTab === 'club' && (
|
||||
<Box>
|
||||
{clubVideos.length === 0 ? (
|
||||
<Text fontSize="sm" color="gray.500">
|
||||
Žádná videa z kanálu klubu. Nastavte YouTube kanál v Nastavení.
|
||||
</Text>
|
||||
) : (
|
||||
<VStack align="stretch" spacing={2} maxH="300px" overflowY="auto">
|
||||
{clubVideos.map((video) => {
|
||||
const videoUrl = `https://www.youtube.com/watch?v=${video.video_id}`;
|
||||
const isSelected = (editing as any)?.youtube_url?.includes(video.video_id);
|
||||
return (
|
||||
<HStack
|
||||
key={video.video_id}
|
||||
p={2}
|
||||
borderWidth="1px"
|
||||
borderRadius="md"
|
||||
borderColor={isSelected ? 'red.500' : borderColor}
|
||||
bg={isSelected ? useColorModeValue('red.50', 'red.900') : cardBg}
|
||||
cursor="pointer"
|
||||
_hover={{ borderColor: 'red.300', bg: useColorModeValue('red.50', 'red.900') }}
|
||||
onClick={() => {
|
||||
setEditing(prev => ({ ...(prev || {}), youtube_url: videoUrl } as any));
|
||||
toast({
|
||||
title: 'Video vybráno',
|
||||
description: video.title,
|
||||
status: 'success',
|
||||
duration: 2000,
|
||||
});
|
||||
}}
|
||||
>
|
||||
<ChakraImage
|
||||
src={video.thumbnail_url}
|
||||
alt={video.title}
|
||||
boxSize="60px"
|
||||
objectFit="cover"
|
||||
borderRadius="md"
|
||||
/>
|
||||
<VStack align="start" flex={1} spacing={0}>
|
||||
<Text fontSize="sm" fontWeight="medium" noOfLines={2}>
|
||||
{video.title}
|
||||
</Text>
|
||||
<HStack fontSize="xs" color="gray.500">
|
||||
{video.published_text && <Text>{video.published_text}</Text>}
|
||||
{video.views_text && <Text>• {video.views_text}</Text>}
|
||||
</HStack>
|
||||
</VStack>
|
||||
{isSelected && (
|
||||
<Badge colorScheme="red">Vybráno</Badge>
|
||||
)}
|
||||
</HStack>
|
||||
);
|
||||
})}
|
||||
</VStack>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{youtubeTab === 'custom' && (
|
||||
<VStack align="stretch" spacing={2}>
|
||||
<Input
|
||||
value={(editing as any)?.youtube_url || ''}
|
||||
onChange={(e) => setEditing(prev => ({ ...(prev || {}), youtube_url: e.target.value } as any))}
|
||||
placeholder="https://www.youtube.com/watch?v=... nebo https://youtu.be/..."
|
||||
/>
|
||||
<Text fontSize="xs" color="gray.500">
|
||||
Vložte odkaz na jakékoliv YouTube video (nemusí být z vašeho kanálu).
|
||||
</Text>
|
||||
</VStack>
|
||||
)}
|
||||
|
||||
{(editing as any)?.youtube_url && (
|
||||
<HStack mt={2} spacing={2}>
|
||||
<Badge colorScheme="green" fontSize="xs">Video nastaveno</Badge>
|
||||
<Button
|
||||
size="xs"
|
||||
variant="ghost"
|
||||
colorScheme="red"
|
||||
onClick={() => setEditing(prev => ({ ...(prev || {}), youtube_url: '' } as any))}
|
||||
>
|
||||
Zrušit video
|
||||
</Button>
|
||||
</HStack>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
<Box mt={3}>
|
||||
<Text fontWeight="bold" mb={2}>Datum a čas</Text>
|
||||
<SimpleGrid columns={{ base: 1, md: 2 }} spacing={3}>
|
||||
<FormControl isRequired>
|
||||
<FormLabel>Začátek</FormLabel>
|
||||
<HStack>
|
||||
<Input type="date" value={startDate} onChange={(e) => {
|
||||
const v = e.target.value; setStartDate(v);
|
||||
const iso = toISO(v, startTime);
|
||||
setEditing(prev => ({ ...(prev || {}), start_time: iso || (prev as any)?.start_time }));
|
||||
}} />
|
||||
<Input type="time" step="900" value={startTime} onChange={(e) => {
|
||||
const v = e.target.value; setStartTime(v);
|
||||
const iso = toISO(startDate, v);
|
||||
// Auto-shift end if needed
|
||||
setEditing(prev => {
|
||||
const nextStart = iso;
|
||||
const prevEnd = prev?.end_time ? new Date(prev.end_time as any) : null;
|
||||
let nextEndISO = prev?.end_time as any;
|
||||
if (prevEnd && nextStart && prevEnd.getTime() < new Date(nextStart).getTime()) {
|
||||
nextEndISO = new Date(new Date(nextStart).getTime() + 60*60*1000).toISOString() as any;
|
||||
}
|
||||
return ({ ...(prev || {}), start_time: nextStart, end_time: nextEndISO });
|
||||
});
|
||||
}} />
|
||||
</HStack>
|
||||
{missingStart && (
|
||||
<Text fontSize="sm" color="orange.500" mt={1}>Vyplňte datum i čas začátku.</Text>
|
||||
)}
|
||||
</FormControl>
|
||||
<FormControl>
|
||||
<FormLabel>Konec (volitelné)</FormLabel>
|
||||
<HStack>
|
||||
<Input type="date" value={endDate} onChange={(e) => {
|
||||
const v = e.target.value; setEndDate(v);
|
||||
const iso = endTime ? toISO(v, endTime) : null;
|
||||
setEditing(prev => ({ ...(prev || {}), end_time: iso as any }));
|
||||
}} />
|
||||
<Input type="time" step="900" value={endTime} onChange={(e) => {
|
||||
const v = e.target.value; setEndTime(v);
|
||||
const iso = endDate ? toISO(endDate, v) : null;
|
||||
setEditing(prev => ({ ...(prev || {}), end_time: iso as any }));
|
||||
}} />
|
||||
</HStack>
|
||||
<HStack mt={2}>
|
||||
<Button size="sm" variant="outline" onClick={() => setEditing(prev => {
|
||||
if (!prev?.start_time) return prev || {};
|
||||
const start = new Date(prev.start_time as any).getTime();
|
||||
const end = new Date(start + 60*60*1000).toISOString();
|
||||
return ({ ...(prev || {}), end_time: end });
|
||||
})}>+60m</Button>
|
||||
<Button size="sm" variant="outline" onClick={() => setEditing(prev => {
|
||||
if (!prev?.start_time) return prev || {};
|
||||
const start = new Date(prev.start_time as any).getTime();
|
||||
const end = new Date(start + 90*60*1000).toISOString();
|
||||
return ({ ...(prev || {}), end_time: end });
|
||||
})}>+90m</Button>
|
||||
<Button size="sm" variant="outline" onClick={() => setEditing(prev => {
|
||||
if (!prev?.start_time) return prev || {};
|
||||
const start = new Date(prev.start_time as any).getTime();
|
||||
const end = new Date(start + 120*60*1000).toISOString();
|
||||
return ({ ...(prev || {}), end_time: end });
|
||||
})}>+120m</Button>
|
||||
<Button size="sm" variant="ghost" onClick={() => { setEndDate(''); setEndTime(''); setEditing(prev => ({ ...(prev || {}), end_time: null })); }}>Zrušit</Button>
|
||||
</HStack>
|
||||
</FormControl>
|
||||
</SimpleGrid>
|
||||
{/* Quick presets for start time */}
|
||||
<HStack mt={2} spacing={2}>
|
||||
<Button size="sm" variant="outline" onClick={() => setEditing(prev => {
|
||||
const now = new Date();
|
||||
const m = now.getMinutes();
|
||||
const rounded = new Date(now.getTime() + ((15 - (m % 15)) % 15) * 60000);
|
||||
const yyyy = rounded.getFullYear();
|
||||
const mm = String(rounded.getMonth() + 1).padStart(2, '0');
|
||||
const dd = String(rounded.getDate()).padStart(2, '0');
|
||||
const hh = String(rounded.getHours()).padStart(2, '0');
|
||||
const min = String(rounded.getMinutes()).padStart(2, '0');
|
||||
setStartDate(`${yyyy}-${mm}-${dd}`);
|
||||
setStartTime(`${hh}:${min}`);
|
||||
return ({ ...(prev || {}), start_time: rounded.toISOString() as any });
|
||||
})}>Nyní (zaokrouhlit 15m)</Button>
|
||||
<Button size="sm" variant="outline" onClick={() => setEditing(prev => {
|
||||
const d = new Date(); d.setHours(18,0,0,0);
|
||||
const yyyy = d.getFullYear();
|
||||
const mm = String(d.getMonth() + 1).padStart(2, '0');
|
||||
const dd = String(d.getDate()).padStart(2, '0');
|
||||
setStartDate(`${yyyy}-${mm}-${dd}`);
|
||||
setStartTime(`18:00`);
|
||||
return ({ ...(prev || {}), start_time: d.toISOString() as any });
|
||||
})}>Dnes 18:00</Button>
|
||||
<Button size="sm" variant="outline" onClick={() => setEditing(prev => {
|
||||
const d = new Date(); d.setDate(d.getDate()+1); d.setHours(18,0,0,0);
|
||||
const yyyy = d.getFullYear();
|
||||
const mm = String(d.getMonth() + 1).padStart(2, '0');
|
||||
const dd = String(d.getDate()).padStart(2, '0');
|
||||
setStartDate(`${yyyy}-${mm}-${dd}`);
|
||||
setStartTime(`18:00`);
|
||||
return ({ ...(prev || {}), start_time: d.toISOString() as any });
|
||||
})}>Zítra 18:00</Button>
|
||||
<Tooltip label="Rychlé nastavení zítřejšího rána" hasArrow>
|
||||
<Button size="sm" variant="outline" onClick={() => setEditing(prev => {
|
||||
const d = new Date(); d.setDate(d.getDate()+1); d.setHours(9,0,0,0);
|
||||
const yyyy = d.getFullYear();
|
||||
const mm = String(d.getMonth() + 1).padStart(2, '0');
|
||||
const dd = String(d.getDate()).padStart(2, '0');
|
||||
setStartDate(`${yyyy}-${mm}-${dd}`);
|
||||
setStartTime(`09:00`);
|
||||
return ({ ...(prev || {}), start_time: d.toISOString() as any });
|
||||
})}>Zítra 9:00</Button>
|
||||
</Tooltip>
|
||||
</HStack>
|
||||
<Text mt={2} fontSize="xs" color="gray.500">Časová zóna: {Intl.DateTimeFormat().resolvedOptions().timeZone}</Text>
|
||||
</Box>
|
||||
<Box mt={4}>
|
||||
<Heading size="sm" mb={3}>Místo konání</Heading>
|
||||
|
||||
{/* MapLinkImporter */}
|
||||
<Box bg={useColorModeValue('gray.50', 'gray.900')} p={4} borderRadius="md" borderWidth="1px" mb={3}>
|
||||
<Text fontSize="sm" fontWeight="semibold" mb={2}>Importovat z odkazu na mapu</Text>
|
||||
<MapLinkImporter
|
||||
currentLatitude={locationLat}
|
||||
currentLongitude={locationLng}
|
||||
mapStyle={settingsQ.data?.map_style || 'positron'}
|
||||
clubPrimaryColor={settingsQ.data?.primary_color}
|
||||
clubSecondaryColor={settingsQ.data?.accent_color}
|
||||
clubName={editing?.title || editing?.location || 'Místo události'}
|
||||
onImport={(coords: MapCoordinates) => {
|
||||
setLocationLat(coords.latitude);
|
||||
setLocationLng(coords.longitude);
|
||||
|
||||
// Build location string from address data
|
||||
let locationString = '';
|
||||
if (coords.address) {
|
||||
// Use full address if available
|
||||
locationString = coords.address;
|
||||
} else {
|
||||
// Build address from parts
|
||||
const parts: string[] = [];
|
||||
if (coords.street) parts.push(coords.street);
|
||||
if (coords.city) parts.push(coords.city);
|
||||
if (coords.zip) parts.push(coords.zip);
|
||||
locationString = parts.join(', ');
|
||||
}
|
||||
|
||||
// Save coordinates and location to the event
|
||||
setEditing(prev => ({
|
||||
...(prev || {}),
|
||||
latitude: coords.latitude,
|
||||
longitude: coords.longitude,
|
||||
location: locationString || prev?.location || '',
|
||||
} as any));
|
||||
|
||||
toast({
|
||||
title: 'Místo importováno',
|
||||
description: locationString || `Lat: ${coords.latitude.toFixed(6)}, Lng: ${coords.longitude.toFixed(6)}`,
|
||||
status: 'success',
|
||||
duration: 3000,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
<HStack spacing={3} align="start" mt={3}>
|
||||
<FormControl flex={2}>
|
||||
<FormLabel>Název místa / Adresa</FormLabel>
|
||||
<Input
|
||||
value={editing?.location || ''}
|
||||
onChange={(e) => setEditing(prev => ({ ...(prev || {}), location: e.target.value }))}
|
||||
placeholder="např. Sportovní hala TJ Sokol"
|
||||
/>
|
||||
<Text fontSize="xs" color="gray.500" mt={1}>Zobrazí se návštěvníkům (s možnou mapou)</Text>
|
||||
</FormControl>
|
||||
<FormControl flex={1}>
|
||||
<FormLabel>Typ</FormLabel>
|
||||
<Select value={(editing?.type as any) || 'other'} onChange={(e) => setEditing(prev => ({ ...(prev || {}), type: e.target.value as any }))}>
|
||||
{types.map(t => (<option key={t.value} value={t.value}>{t.label}</option>))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
</HStack>
|
||||
</Box>
|
||||
<FormControl mt={3}>
|
||||
<FormLabel>Kategorie (soutěž)</FormLabel>
|
||||
<Select
|
||||
placeholder="Vyberte kategorii (volitelné)"
|
||||
value={(editing as any)?.category_name || ''}
|
||||
onChange={(e) => setEditing(prev => ({ ...(prev || {}), category_name: e.target.value } as any))}
|
||||
>
|
||||
{competitions.map((c, idx) => (
|
||||
<option key={(c.code || c.name) + '_' + idx} value={(c.code && aliasesMap[c.code]) ? aliasesMap[c.code] : c.name}>
|
||||
{(c.code && aliasesMap[c.code]) ? aliasesMap[c.code] : c.name}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
<FormControl display="flex" alignItems="center" mt={3}>
|
||||
<FormLabel mb="0">Veřejná</FormLabel>
|
||||
<Switch isChecked={!!editing?.is_public} onChange={(e) => setEditing(prev => ({ ...(prev || {}), is_public: e.target.checked }))} />
|
||||
</FormControl>
|
||||
|
||||
{/* ... (rest of the code remains the same) */}
|
||||
<HStack mt={4} align="flex-start">
|
||||
<FormControl>
|
||||
<FormLabel>Obrázek (náhled)</FormLabel>
|
||||
<HStack>
|
||||
<Input value={(editing as any)?.image_url || ''} onChange={(e) => setEditing(prev => ({ ...(prev || {}), image_url: e.target.value }))} placeholder="/uploads/...jpg" />
|
||||
<Button as="label" variant="outline">
|
||||
Nahrát
|
||||
<input type="file" accept="image/*" style={{ display: 'none' }} onChange={async (e) => {
|
||||
const f = e.target.files?.[0]; if (!f) return; const res = await uploadFile(f); setEditing(prev => ({ ...(prev || {}), image_url: (res as any).url }));
|
||||
}} />
|
||||
</Button>
|
||||
</HStack>
|
||||
</FormControl>
|
||||
<FormControl>
|
||||
<FormLabel>Přílohy (více souborů)</FormLabel>
|
||||
<Text fontSize="xs" color={textSecondary} mb={2}>
|
||||
Podporované formáty: PDF, Word (.doc, .docx), Excel (.xls, .xlsx), PowerPoint (.ppt, .pptx), Obrázky (.jpg, .png, .gif, .webp), Text (.txt), ZIP, RAR
|
||||
</Text>
|
||||
<HStack>
|
||||
<Button as="label" variant="outline">
|
||||
Nahrát
|
||||
<input
|
||||
type="file"
|
||||
multiple
|
||||
accept=".pdf,.doc,.docx,.xls,.xlsx,.ppt,.pptx,.jpg,.jpeg,.png,.gif,.webp,.txt,.zip,.rar"
|
||||
style={{ display: 'none' }}
|
||||
onChange={async (e) => {
|
||||
const files = Array.from(e.target.files || []);
|
||||
const allowedTypes = [
|
||||
'application/pdf',
|
||||
'application/msword',
|
||||
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||
'application/vnd.ms-excel',
|
||||
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||
'application/vnd.ms-powerpoint',
|
||||
'application/vnd.openxmlformats-officedocument.presentationml.presentation',
|
||||
'image/jpeg',
|
||||
'image/png',
|
||||
'image/gif',
|
||||
'image/webp',
|
||||
'text/plain',
|
||||
'application/zip',
|
||||
'application/x-zip-compressed',
|
||||
'application/x-rar-compressed',
|
||||
'application/vnd.rar',
|
||||
];
|
||||
for (const f of files) {
|
||||
if (!allowedTypes.includes(f.type) && !f.name.match(/\.(pdf|docx?|xlsx?|pptx?|jpe?g|png|gif|webp|txt|zip|rar)$/i)) {
|
||||
toast({ title: 'Nepodporovaný formát souboru', description: `Soubor "${f.name}" nelze nahrát.`, status: 'warning', duration: 4000 });
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
const res = await uploadFile(f as File);
|
||||
setEditing(prev => ({ ...(prev || {}), attachments: ([...((prev as any)?.attachments || []), { name: (f as File).name, url: (res as any).url, mime_type: (f as File).type, size: (f as File).size }]) as any }));
|
||||
} catch (err: any) {
|
||||
toast({ title: 'Chyba při nahrávání', description: `Soubor "${f.name}": ${err?.message || 'Neznámá chyba'}`, status: 'error', duration: 4000 });
|
||||
}
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Button>
|
||||
</HStack>
|
||||
<Box mt={2}>
|
||||
{Array.isArray((editing as any)?.attachments) && (editing as any).attachments.length > 0 ? (
|
||||
<Table size="sm" variant="simple">
|
||||
<Thead><Tr><Th>Název</Th><Th>Velikost</Th><Th>Akce</Th></Tr></Thead>
|
||||
<Tbody>
|
||||
{((editing as any).attachments as any[]).map((att: any, idx: number) => (
|
||||
<Tr key={idx}>
|
||||
<Td>{att.name || att.url}</Td>
|
||||
<Td>{typeof att.size === 'number' ? `${Math.round(att.size/1024)} kB` : '-'}</Td>
|
||||
<Td>
|
||||
<Button size="xs" variant="outline" onClick={() => setEditing(prev => ({ ...(prev as any), attachments: ((prev as any).attachments || []).filter((_: any, i: number) => i !== idx) }))}>Odebrat</Button>
|
||||
</Td>
|
||||
</Tr>
|
||||
))}
|
||||
</Tbody>
|
||||
</Table>
|
||||
) : (
|
||||
<Box color="gray.500">Žádné přílohy</Box>
|
||||
)}
|
||||
</Box>
|
||||
</FormControl>
|
||||
</HStack>
|
||||
|
||||
{/* Poll Linker */}
|
||||
{editing?.id && <PollLinker eventId={editing.id} />}
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<HStack w="100%" justify="space-between">
|
||||
<Text fontSize="xs" color="gray.500">
|
||||
Ukládáním souhlasíte s publikací podle nastavení „Veřejná“.
|
||||
</Text>
|
||||
<HStack>
|
||||
<Button variant="ghost" mr={1} onClick={closeModal}>Zrušit</Button>
|
||||
<Button bg="brand.primary" color="text.onPrimary" _hover={{ filter: 'brightness(0.95)' }} onClick={onSubmit} isLoading={createMut.isLoading || updateMut.isLoading}>Uložit</Button>
|
||||
</HStack>
|
||||
</HStack>
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
</Box>
|
||||
</AdminLayout>
|
||||
);
|
||||
};
|
||||
|
||||
export default AdminActivitiesPage;
|
||||
@@ -0,0 +1,484 @@
|
||||
import { Box, Text, SimpleGrid, Stat, StatLabel, StatNumber, StatHelpText, Icon, HStack, Skeleton, Alert, AlertIcon, AlertTitle, AlertDescription, Button, Link, VStack, Badge, Heading, useColorModeValue, Divider, Table, Thead, Tbody, Tr, Th, Td, Tooltip } from '@chakra-ui/react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import AdminLayout from '../../layouts/AdminLayout';
|
||||
import { getAnalytics, AnalyticsData, getAnalyticsOverview, getTopPages, AnalyticsOverview, PageStats } from '../../services/analyticsService';
|
||||
import { MatchesWidget } from '../../components/widgets/MatchesWidget';
|
||||
import { ArticlesWidget } from '../../components/widgets/ArticlesWidget';
|
||||
import { FaUsers, FaCalendarAlt, FaNewspaper, FaTrophy, FaChartLine, FaCog, FaBook, FaRocket, FaEye, FaMousePointer } from 'react-icons/fa';
|
||||
import AdminHelp from '../../components/admin/AdminHelp';
|
||||
import { getFacrTablesCache } from '../../services/facr/cache';
|
||||
import ScoreboardPreview from '../../components/scoreboard/ScoreboardPreview';
|
||||
import { getScoreboardState, ScoreboardState } from '../../services/scoreboard';
|
||||
import { Link as RouterLink } from 'react-router-dom';
|
||||
import api from '../../services/api';
|
||||
|
||||
const StatCard = ({ label, value, help, icon, color = 'blue' }: { label: string; value: number | string; help?: string; icon?: any; color?: string }) => {
|
||||
const bgCard = useColorModeValue('white', 'gray.800');
|
||||
const borderColor = useColorModeValue('gray.200', 'gray.700');
|
||||
const iconBg = useColorModeValue(`${color}.50`, `${color}.900`);
|
||||
const iconColor = useColorModeValue(`${color}.600`, `${color}.300`);
|
||||
|
||||
return (
|
||||
<Box
|
||||
bg={bgCard}
|
||||
p={6}
|
||||
borderRadius="xl"
|
||||
boxShadow="md"
|
||||
borderWidth="1px"
|
||||
borderColor={borderColor}
|
||||
_hover={{ shadow: 'lg', transform: 'translateY(-2px)' }}
|
||||
transition="all 0.2s"
|
||||
>
|
||||
<HStack justify="space-between" align="start" mb={3}>
|
||||
<Stat>
|
||||
<StatLabel fontSize="sm" fontWeight="medium" color="gray.500">{label}</StatLabel>
|
||||
<StatNumber fontSize="3xl" fontWeight="bold" mt={2}>{value}</StatNumber>
|
||||
{help && <StatHelpText fontSize="xs" mt={1}>{help}</StatHelpText>}
|
||||
</Stat>
|
||||
{icon && (
|
||||
<Box p={3} bg={iconBg} borderRadius="xl">
|
||||
<Icon as={icon} boxSize={6} color={iconColor} />
|
||||
</Box>
|
||||
)}
|
||||
</HStack>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
// Event translation and description mapping
|
||||
const getEventTranslation = (eventName: string): { name: string; source: string; description: string } => {
|
||||
const eventMap: Record<string, { name: string; source: string; description: string }> = {
|
||||
'Contact Form Submit': {
|
||||
name: 'Odeslání kontaktního formuláře',
|
||||
source: 'Kontaktní stránka',
|
||||
description: 'Uživatel odeslal kontaktní formulář na stránce Kontakt'
|
||||
},
|
||||
'Contact Form': {
|
||||
name: 'Zobrazení kontaktního formuláře',
|
||||
source: 'Kontaktní stránka',
|
||||
description: 'Uživatel zobrazil kontaktní formulář'
|
||||
},
|
||||
'Form Submit': {
|
||||
name: 'Odeslání formuláře',
|
||||
source: 'Různé stránky',
|
||||
description: 'Obecné odeslání formuláře na webu'
|
||||
},
|
||||
'Newsletter Subscribe': {
|
||||
name: 'Odběr newsletteru',
|
||||
source: 'Newsletter formulář',
|
||||
description: 'Uživatel se přihlásil k odběru newsletteru'
|
||||
},
|
||||
'Newsletter Submit': {
|
||||
name: 'Potvrzení newsletteru',
|
||||
source: 'Newsletter formulář',
|
||||
description: 'Uživatel potvrdil přihlášení k newsletteru'
|
||||
},
|
||||
'Newsletter Unsubscribe': {
|
||||
name: 'Odhlášení z newsletteru',
|
||||
source: 'Nastavení newsletteru',
|
||||
description: 'Uživatel se odhlásil z odběru newsletteru'
|
||||
},
|
||||
'Newsletter Preferences Saved': {
|
||||
name: 'Uložení předvoleb newsletteru',
|
||||
source: 'Nastavení newsletteru',
|
||||
description: 'Uživatel uložil své předvolby pro newsletter'
|
||||
},
|
||||
'Unsubscribe': {
|
||||
name: 'Odhlášení z odběru',
|
||||
source: 'Nastavení',
|
||||
description: 'Uživatel se odhlásil z odběru'
|
||||
},
|
||||
'Save Preferences': {
|
||||
name: 'Uložení předvoleb',
|
||||
source: 'Nastavení',
|
||||
description: 'Uživatel uložil své předvolby (souhlas s cookies apod.)'
|
||||
},
|
||||
'Refresh Preferences': {
|
||||
name: 'Obnovení předvoleb',
|
||||
source: 'Nastavení',
|
||||
description: 'Uživatel obnovil nebo změnil své předvolby'
|
||||
},
|
||||
'Article View': {
|
||||
name: 'Zobrazení článku',
|
||||
source: 'Blog',
|
||||
description: 'Uživatel si zobrazil článek na blogu'
|
||||
},
|
||||
'Match View': {
|
||||
name: 'Zobrazení zápasu',
|
||||
source: 'Stránka zápasů',
|
||||
description: 'Uživatel si zobrazil detail zápasu'
|
||||
},
|
||||
'Gallery View': {
|
||||
name: 'Zobrazení galerie',
|
||||
source: 'Galerie',
|
||||
description: 'Uživatel si otevřel galerii fotografií'
|
||||
},
|
||||
'Video Play': {
|
||||
name: 'Přehrání videa',
|
||||
source: 'Video sekce',
|
||||
description: 'Uživatel spustil přehrávání videa'
|
||||
},
|
||||
'Social Share': {
|
||||
name: 'Sdílení na sociálních sítích',
|
||||
source: 'Sdílecí tlačítka',
|
||||
description: 'Uživatel sdílel obsah na sociální síť'
|
||||
},
|
||||
'Download': {
|
||||
name: 'Stažení souboru',
|
||||
source: 'Různé stránky',
|
||||
description: 'Uživatel stáhl soubor'
|
||||
},
|
||||
'External Link Click': {
|
||||
name: 'Kliknutí na externí odkaz',
|
||||
source: 'Různé stránky',
|
||||
description: 'Uživatel klikl na odkaz vedoucí mimo web'
|
||||
}
|
||||
};
|
||||
|
||||
return eventMap[eventName] || {
|
||||
name: eventName,
|
||||
source: 'Neznámý zdroj',
|
||||
description: `Událost: ${eventName}`
|
||||
};
|
||||
};
|
||||
|
||||
const AdminDashboardPage = () => {
|
||||
const { data, isLoading, error, refetch, isFetching } = useQuery<AnalyticsData>({
|
||||
queryKey: ['admin', 'analytics'],
|
||||
queryFn: getAnalytics,
|
||||
staleTime: 5 * 60 * 1000,
|
||||
});
|
||||
|
||||
// Fetch analytics overview (page views, visitors, etc.)
|
||||
const { data: analyticsOverview, isLoading: isLoadingOverview } = useQuery<AnalyticsOverview>({
|
||||
queryKey: ['admin', 'analytics', 'overview'],
|
||||
queryFn: getAnalyticsOverview,
|
||||
staleTime: 5 * 60 * 1000,
|
||||
});
|
||||
|
||||
// Fetch top pages
|
||||
const { data: topPages } = useQuery<PageStats[]>({
|
||||
queryKey: ['admin', 'analytics', 'top-pages'],
|
||||
queryFn: () => getTopPages(5),
|
||||
staleTime: 10 * 60 * 1000,
|
||||
});
|
||||
|
||||
// Fetch top events from Umami
|
||||
const { data: topEvents } = useQuery<Array<{ x: string; y: number }>>({
|
||||
queryKey: ['admin', 'analytics', 'umami-events'],
|
||||
queryFn: async () => {
|
||||
const response = await api.get('/admin/umami/metrics/event?days=7');
|
||||
return response.data || [];
|
||||
},
|
||||
staleTime: 10 * 60 * 1000,
|
||||
});
|
||||
|
||||
// Club stats from FACR tables cache
|
||||
const { data: facrTables } = useQuery<any>({
|
||||
queryKey: ['facr-tables-cache'],
|
||||
queryFn: getFacrTablesCache,
|
||||
staleTime: 5 * 60 * 1000,
|
||||
});
|
||||
|
||||
const competitionsCount = Array.isArray(facrTables?.competitions) ? facrTables.competitions.length : 0;
|
||||
const uniqueTeamsCount = (() => {
|
||||
try {
|
||||
const comps = Array.isArray(facrTables?.competitions) ? facrTables.competitions : [];
|
||||
const set = new Set<string>();
|
||||
for (const c of comps) {
|
||||
const rows = Array.isArray(c?.table?.overall) ? c.table.overall : [];
|
||||
for (const r of rows) {
|
||||
const name = String(r?.team || '').normalize('NFD').replace(/[\u0300-\u036f]/g, '').replace(/\s+/g, ' ').trim().toLowerCase();
|
||||
if (name) set.add(name);
|
||||
}
|
||||
}
|
||||
return set.size;
|
||||
} catch {
|
||||
return 0;
|
||||
}
|
||||
})();
|
||||
|
||||
// Scoreboard state for compact preview
|
||||
const { data: scoreboardState } = useQuery<ScoreboardState>({
|
||||
queryKey: ['scoreboard-state'],
|
||||
queryFn: getScoreboardState,
|
||||
staleTime: 60 * 1000,
|
||||
});
|
||||
|
||||
return (
|
||||
<AdminLayout>
|
||||
<Box maxW="1600px" mx="auto">
|
||||
{/* Welcome Header */}
|
||||
<Box mb={8}>
|
||||
<HStack spacing={3} mb={2}>
|
||||
<Text fontSize="3xl">👋</Text>
|
||||
<Heading size="xl">Vítejte v administraci</Heading>
|
||||
</HStack>
|
||||
<Text color="gray.500" fontSize="lg">
|
||||
Přehled klíčových statistik a rychlý přístup k nejdůležitějším funkcím
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
{!!error && (
|
||||
<Alert status="error" mb={4} borderRadius="md">
|
||||
<AlertIcon />
|
||||
<Box>
|
||||
<AlertTitle>Nelze načíst statistiky</AlertTitle>
|
||||
<AlertDescription>
|
||||
Zkontrolujte připojení nebo přihlášení správce a zkuste to znovu.
|
||||
</AlertDescription>
|
||||
</Box>
|
||||
<Button onClick={() => refetch()} ml="auto" size="sm" isLoading={isFetching}>
|
||||
Zkusit znovu
|
||||
</Button>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* Top stats */}
|
||||
<SimpleGrid columns={{ base: 1, sm: 2, lg: 3 }} spacing={4} mb={6}>
|
||||
{isLoading ? (
|
||||
<>
|
||||
{[1,2,3].map((i) => (
|
||||
<Box key={i} bg="white" p={4} borderRadius="lg" boxShadow="sm" borderWidth="1px">
|
||||
<Skeleton height="20px" width="40%" mb={2} />
|
||||
<Skeleton height="28px" width="50%" mb={2} />
|
||||
<Skeleton height="16px" width="35%" />
|
||||
</Box>
|
||||
))}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<StatCard
|
||||
label="Uživatelé (admin)"
|
||||
value={data?.users?.total ?? '—'}
|
||||
help={data?.users ? `Nových tento týden: ${data.users.new_this_week ?? 0}` : undefined}
|
||||
icon={FaUsers}
|
||||
color="blue"
|
||||
/>
|
||||
<StatCard
|
||||
label="Události"
|
||||
value={data?.events?.total ?? '—'}
|
||||
help={data?.events ? `Nadcházející: ${data.events.upcoming ?? 0}` : undefined}
|
||||
icon={FaCalendarAlt}
|
||||
color="green"
|
||||
/>
|
||||
<StatCard
|
||||
label="Články"
|
||||
value={data?.articles?.total ?? '—'}
|
||||
help={data?.articles ? `Publikovaných: ${data.articles.published ?? 0}` : undefined}
|
||||
icon={FaNewspaper}
|
||||
color="purple"
|
||||
/>
|
||||
<StatCard
|
||||
label="Zobrazení stránek"
|
||||
value={analyticsOverview?.total_page_views?.toLocaleString('cs-CZ') ?? '—'}
|
||||
help={analyticsOverview ? `Dnes: ${analyticsOverview.page_views_today ?? 0}` : undefined}
|
||||
icon={FaEye}
|
||||
color="cyan"
|
||||
/>
|
||||
<StatCard
|
||||
label="Unikátní návštěvníci"
|
||||
value={analyticsOverview?.unique_visitors?.toLocaleString('cs-CZ') ?? '—'}
|
||||
help={analyticsOverview ? `Tento týden: ${analyticsOverview.unique_visitors_week ?? 0}` : undefined}
|
||||
icon={FaChartLine}
|
||||
color="teal"
|
||||
/>
|
||||
<StatCard
|
||||
label="Zobrazení (týden)"
|
||||
value={analyticsOverview?.page_views_week?.toLocaleString('cs-CZ') ?? '—'}
|
||||
help="Za posledních 7 dní"
|
||||
icon={FaMousePointer}
|
||||
color="orange"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</SimpleGrid>
|
||||
|
||||
{/* Quick Actions */}
|
||||
<Box mb={8}>
|
||||
<Heading size="md" mb={4}>Rychlé akce</Heading>
|
||||
<SimpleGrid columns={{ base: 2, md: 4 }} spacing={4}>
|
||||
{[
|
||||
{ label: 'Nastavení', icon: FaCog, to: '/admin/nastaveni', color: 'blue' },
|
||||
{ label: 'Dokumentace', icon: FaBook, to: '/admin/docs', color: 'purple' },
|
||||
{ label: 'Nový článek', icon: FaRocket, to: '/admin/clanky', color: 'green' },
|
||||
{ label: 'Prefetch', icon: FaChartLine, to: '/admin/prefetch', color: 'orange' },
|
||||
].map((action, idx) => (
|
||||
<Link
|
||||
key={idx}
|
||||
as={RouterLink}
|
||||
to={action.to}
|
||||
_hover={{ textDecoration: 'none' }}
|
||||
>
|
||||
<Box
|
||||
bg={useColorModeValue('white', 'gray.800')}
|
||||
p={4}
|
||||
borderRadius="lg"
|
||||
borderWidth="1px"
|
||||
borderColor={useColorModeValue('gray.200', 'gray.700')}
|
||||
textAlign="center"
|
||||
cursor="pointer"
|
||||
_hover={{
|
||||
shadow: 'lg',
|
||||
transform: 'translateY(-2px)',
|
||||
borderColor: `${action.color}.400`
|
||||
}}
|
||||
transition="all 0.2s"
|
||||
>
|
||||
<VStack spacing={2}>
|
||||
<Box
|
||||
p={3}
|
||||
bg={useColorModeValue(`${action.color}.50`, `${action.color}.900`)}
|
||||
borderRadius="lg"
|
||||
>
|
||||
<Icon
|
||||
as={action.icon}
|
||||
boxSize={6}
|
||||
color={useColorModeValue(`${action.color}.600`, `${action.color}.300`)}
|
||||
/>
|
||||
</Box>
|
||||
<Text fontWeight="semibold" fontSize="sm">{action.label}</Text>
|
||||
</VStack>
|
||||
</Box>
|
||||
</Link>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
</Box>
|
||||
|
||||
<Divider my={8} />
|
||||
|
||||
{/* Analytics Tables */}
|
||||
<Heading size="md" mb={4}>Analytika návštěvnosti</Heading>
|
||||
<SimpleGrid columns={{ base: 1, lg: 2 }} spacing={6} mb={8}>
|
||||
{/* Top Pages */}
|
||||
<Box
|
||||
bg={useColorModeValue('white', 'gray.800')}
|
||||
p={5}
|
||||
borderRadius="xl"
|
||||
boxShadow="md"
|
||||
borderWidth="1px"
|
||||
borderColor={useColorModeValue('gray.200', 'gray.700')}
|
||||
>
|
||||
<HStack justify="space-between" mb={4}>
|
||||
<Text fontWeight="bold" fontSize="lg">Nejnavštěvovanější stránky</Text>
|
||||
<Link as={RouterLink} to="/admin/analytika" color="blue.500" fontSize="sm" fontWeight="semibold">
|
||||
Více →
|
||||
</Link>
|
||||
</HStack>
|
||||
{topPages && topPages.length > 0 ? (
|
||||
<Table size="sm" variant="simple">
|
||||
<Thead>
|
||||
<Tr>
|
||||
<Th>Stránka</Th>
|
||||
<Th isNumeric>Zobrazení</Th>
|
||||
<Th isNumeric>Návštěvníci</Th>
|
||||
</Tr>
|
||||
</Thead>
|
||||
<Tbody>
|
||||
{topPages.map((page, idx) => (
|
||||
<Tr key={idx}>
|
||||
<Td fontSize="sm">
|
||||
<Text isTruncated maxW="200px" title={page.page_name || page.page_path}>
|
||||
{page.page_name || page.page_path}
|
||||
</Text>
|
||||
</Td>
|
||||
<Td isNumeric fontWeight="semibold">{page.view_count}</Td>
|
||||
<Td isNumeric>{page.unique_visitors}</Td>
|
||||
</Tr>
|
||||
))}
|
||||
</Tbody>
|
||||
</Table>
|
||||
) : (
|
||||
<Text color="gray.500" fontSize="sm">Zatím žádná data</Text>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* Top User Events */}
|
||||
<Box
|
||||
bg={useColorModeValue('white', 'gray.800')}
|
||||
p={5}
|
||||
borderRadius="xl"
|
||||
boxShadow="md"
|
||||
borderWidth="1px"
|
||||
borderColor={useColorModeValue('gray.200', 'gray.700')}
|
||||
>
|
||||
<HStack justify="space-between" mb={4}>
|
||||
<Text fontWeight="bold" fontSize="lg">Nejčastější interakce</Text>
|
||||
<Badge colorScheme="blue">7 dní</Badge>
|
||||
</HStack>
|
||||
{topEvents && topEvents.length > 0 ? (
|
||||
<Table size="sm" variant="simple">
|
||||
<Thead>
|
||||
<Tr>
|
||||
<Th>Akce</Th>
|
||||
<Th isNumeric>Počet</Th>
|
||||
</Tr>
|
||||
</Thead>
|
||||
<Tbody>
|
||||
{topEvents.slice(0, 10).map((event, idx) => {
|
||||
const eventName = event.x || '';
|
||||
const eventInfo = getEventTranslation(eventName);
|
||||
return (
|
||||
<Tr key={idx}>
|
||||
<Td fontSize="sm">
|
||||
<Tooltip label={eventInfo.description} placement="top" hasArrow>
|
||||
<Box>
|
||||
<Text fontWeight="medium">{eventInfo.name}</Text>
|
||||
<Text fontSize="xs" color="gray.500">{eventInfo.source}</Text>
|
||||
</Box>
|
||||
</Tooltip>
|
||||
</Td>
|
||||
<Td isNumeric>
|
||||
<Badge colorScheme="blue" fontWeight="semibold">{event.y}</Badge>
|
||||
</Td>
|
||||
</Tr>
|
||||
);
|
||||
})}
|
||||
</Tbody>
|
||||
</Table>
|
||||
) : (
|
||||
<Text color="gray.500" fontSize="sm">Zatím žádná data</Text>
|
||||
)}
|
||||
</Box>
|
||||
</SimpleGrid>
|
||||
|
||||
<Divider my={8} />
|
||||
|
||||
{/* Widgets grid */}
|
||||
<Heading size="md" mb={4}>Přehled aktivit</Heading>
|
||||
<SimpleGrid columns={{ base: 1, md: 2, xl: 3 }} spacing={6} mb={8}>
|
||||
<MatchesWidget />
|
||||
<ArticlesWidget />
|
||||
{/* Compact Scoreboard card */}
|
||||
<Box
|
||||
bg={useColorModeValue('white', 'gray.800')}
|
||||
p={5}
|
||||
borderRadius="xl"
|
||||
boxShadow="md"
|
||||
borderWidth="1px"
|
||||
borderColor={useColorModeValue('gray.200', 'gray.700')}
|
||||
>
|
||||
<HStack justify="space-between" mb={4}>
|
||||
<Text fontWeight="bold" fontSize="lg">Aktuální tabule</Text>
|
||||
<Link as={RouterLink} to="/admin/scoreboard" color="blue.500" fontSize="sm" fontWeight="semibold">
|
||||
Upravit →
|
||||
</Link>
|
||||
</HStack>
|
||||
{scoreboardState ? (
|
||||
<Box display="flex" justifyContent="center">
|
||||
<ScoreboardPreview state={scoreboardState} />
|
||||
</Box>
|
||||
) : (
|
||||
<Skeleton height="40px" />
|
||||
)}
|
||||
</Box>
|
||||
</SimpleGrid>
|
||||
|
||||
{/* Admin guidance */}
|
||||
<AdminHelp />
|
||||
</Box>
|
||||
</AdminLayout>
|
||||
);
|
||||
};
|
||||
|
||||
export default AdminDashboardPage;
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,404 @@
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import { Box, Heading, Text, List, ListItem, Link, Divider, Code, OrderedList, HStack, IconButton, useColorModeValue, useToast } from '@chakra-ui/react';
|
||||
import { FaLink, FaArrowUp } from 'react-icons/fa';
|
||||
import AdminLayout from '../../layouts/AdminLayout';
|
||||
|
||||
const AdminDocsPage: React.FC = () => {
|
||||
const sections = useMemo(() => [
|
||||
{ id: 'nastaveni', label: 'Nastavení (branding + SMTP)' },
|
||||
{ id: 'clanky', label: 'Články a kategorie' },
|
||||
{ id: 'zapasy', label: 'Zápasy (FAČR)' },
|
||||
{ id: 'hraci-tymy', label: 'Hráči a týmy' },
|
||||
{ id: 'media', label: 'Média' },
|
||||
{ id: 'sponzori-bannery', label: 'Sponzoři a bannery' },
|
||||
{ id: 'newsletter', label: 'Newsletter' },
|
||||
{ id: 'aliasy', label: 'Alias soutěží' },
|
||||
{ id: 'prefetch', label: 'Prefetch' },
|
||||
{ id: 'videa', label: 'Videa' },
|
||||
{ id: 'aktivity', label: 'Aktivity' },
|
||||
{ id: 'merch', label: 'Oblečení' },
|
||||
{ id: 'zpravy', label: 'Zprávy' },
|
||||
{ id: 'reset-admin', label: 'Reset hesla' },
|
||||
{ id: 'uzivatele', label: 'Uživatelé' },
|
||||
{ id: 'seo-analytics', label: 'SEO a Analytics' },
|
||||
{ id: 'troubleshooting', label: 'Troubleshooting' },
|
||||
], []);
|
||||
const [activeId, setActiveId] = useState<string>('');
|
||||
const toast = useToast();
|
||||
|
||||
useEffect(() => {
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
// Pick the entry closest to the top that is intersecting
|
||||
const visible = entries
|
||||
.filter((e) => e.isIntersecting)
|
||||
.sort((a, b) => (a.boundingClientRect.top > b.boundingClientRect.top ? 1 : -1));
|
||||
if (visible[0]) {
|
||||
setActiveId(visible[0].target.id);
|
||||
}
|
||||
},
|
||||
{ rootMargin: '-40% 0px -50% 0px', threshold: [0, 0.25, 0.5, 0.75, 1] }
|
||||
);
|
||||
const els = sections.map((s) => document.getElementById(s.id)).filter(Boolean) as Element[];
|
||||
els.forEach((el) => observer.observe(el));
|
||||
return () => observer.disconnect();
|
||||
}, [sections]);
|
||||
|
||||
// Persist active section and restore on reload
|
||||
useEffect(() => {
|
||||
if (activeId) {
|
||||
try { localStorage.setItem('adminDocs:lastAnchor', activeId); } catch {}
|
||||
}
|
||||
}, [activeId]);
|
||||
|
||||
useEffect(() => {
|
||||
// Run after paint so layout is ready
|
||||
const t = setTimeout(() => {
|
||||
const hash = (window.location.hash || '').replace('#', '').trim();
|
||||
let targetId = hash;
|
||||
if (!targetId) {
|
||||
try { targetId = localStorage.getItem('adminDocs:lastAnchor') || ''; } catch {}
|
||||
}
|
||||
if (targetId) {
|
||||
const el = document.getElementById(targetId);
|
||||
if (el) {
|
||||
el.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||
setActiveId(targetId);
|
||||
}
|
||||
}
|
||||
}, 50);
|
||||
return () => clearTimeout(t);
|
||||
}, []);
|
||||
|
||||
const copyDeepLink = async (id: string) => {
|
||||
try {
|
||||
const url = `${window.location.origin}${window.location.pathname}#${id}`;
|
||||
await navigator.clipboard.writeText(url);
|
||||
toast({ title: 'Odkaz zkopírován', status: 'success', duration: 1500, isClosable: true });
|
||||
} catch {
|
||||
toast({ title: 'Nelze zkopírovat odkaz', status: 'error', duration: 2000, isClosable: true });
|
||||
}
|
||||
};
|
||||
|
||||
const tocActiveColor = useColorModeValue('blue.700', 'blue.300');
|
||||
const tocActiveBg = useColorModeValue('blue.50', 'blue.900');
|
||||
|
||||
return (
|
||||
<AdminLayout>
|
||||
<Box
|
||||
display={{ base: 'block', md: 'grid' }}
|
||||
gridTemplateColumns={{ base: '1fr', md: '260px 1fr' }}
|
||||
gap={{ base: 0, md: 6 }}
|
||||
>
|
||||
{/* Sticky TOC */}
|
||||
<Box display={{ base: 'none', md: 'block' }}>
|
||||
<Box position="sticky" top="84px">
|
||||
<Box bg="white" p={4} borderRadius="lg" borderWidth="1px" boxShadow="sm">
|
||||
<Heading size="sm" mb={3}>Rychlá navigace</Heading>
|
||||
<OrderedList spacing={2} pl={5}>
|
||||
{sections.map((s) => (
|
||||
<ListItem key={s.id}>
|
||||
<Link
|
||||
href={`#${s.id}`}
|
||||
display="block"
|
||||
px={2}
|
||||
py={1}
|
||||
borderRadius="md"
|
||||
bg={activeId === s.id ? tocActiveBg : 'transparent'}
|
||||
color={activeId === s.id ? tocActiveColor : 'inherit'}
|
||||
onClick={() => {
|
||||
try { localStorage.setItem('adminDocs:lastAnchor', s.id); } catch {}
|
||||
}}
|
||||
>
|
||||
{s.label}
|
||||
</Link>
|
||||
</ListItem>
|
||||
))}
|
||||
</OrderedList>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Main content */}
|
||||
<Box bg="white" p={6} borderRadius="lg" borderWidth="1px" boxShadow="sm">
|
||||
<Box id="top" />
|
||||
<Heading size="lg" mb={1}>Dokumentace</Heading>
|
||||
<Text color="gray.600" mb={4}>Přehled funkcí, postupů a řešení problémů</Text>
|
||||
<Heading size="md" mb={3}>Začínáme</Heading>
|
||||
<Text mb={4}>
|
||||
Tato stránka shrnuje hlavní možnosti administrace webu. Pro rychlý start doporučujeme nejprve vyplnit
|
||||
<Link href="/admin/nastaveni" ml={1} color="blue.600">Nastavení</Link>, poté vytvořit první článek
|
||||
a ověřit funkčnost e‑mailů.
|
||||
</Text>
|
||||
|
||||
<Heading size="sm" mt={6} mb={2}>Obsah</Heading>
|
||||
<OrderedList spacing={1} mb={4} pl={5}>
|
||||
<ListItem><Link href="#nastaveni">Nastavení klubu, branding a SMTP</Link></ListItem>
|
||||
<ListItem><Link href="#clanky">Články a kategorie</Link></ListItem>
|
||||
<ListItem><Link href="#zapasy">Zápasy a výsledky (FAČR)</Link></ListItem>
|
||||
<ListItem><Link href="#hraci-tymy">Hráči a týmy</Link></ListItem>
|
||||
<ListItem><Link href="#media">Média (soubory a obrázky)</Link></ListItem>
|
||||
<ListItem><Link href="#sponzori-bannery">Sponzoři a bannery</Link></ListItem>
|
||||
<ListItem><Link href="#newsletter">Newsletter</Link></ListItem>
|
||||
<ListItem><Link href="#aliasy">Alias názvů soutěží</Link></ListItem>
|
||||
<ListItem><Link href="#prefetch">Prefetch (YouTube a další data)</Link></ListItem>
|
||||
<ListItem><Link href="#uzivatele">Uživatelé a přístupy</Link></ListItem>
|
||||
<ListItem><Link href="#seo-analytics">SEO a Analytics</Link></ListItem>
|
||||
<ListItem><Link href="#troubleshooting">Troubleshooting (řešení problémů)</Link></ListItem>
|
||||
</OrderedList>
|
||||
|
||||
<Divider my={6} />
|
||||
|
||||
<HStack align="center" justify="space-between" mb={2}>
|
||||
<Heading id="nastaveni" size="md">Nastavení klubu, branding a SMTP</Heading>
|
||||
<IconButton aria-label="Zkopírovat odkaz" variant="ghost" size="sm" icon={<FaLink />} onClick={() => copyDeepLink('nastaveni')} />
|
||||
</HStack>
|
||||
<List spacing={2} styleType="disc" pl={5}>
|
||||
<ListItem><strong>Branding</strong>: Nastavte název klubu, logo a barvy. Tyto hodnoty se propsají do e‑mailů i vzhledu webu.</ListItem>
|
||||
<ListItem><strong>SMTP</strong>: Vyplňte host, port, uživatele a heslo. U portu 465 se používá implicitní SSL, jinak STARTTLS.</ListItem>
|
||||
<ListItem><strong>Test</strong>: V sekci Newsletter → Test odešlete zkušební e‑mail a ověřte doručení.</ListItem>
|
||||
</List>
|
||||
<Box mt={2}><Link href="#top" color="blue.600"><HStack as="span" spacing={2}><FaArrowUp /><Text>Na začátek</Text></HStack></Link></Box>
|
||||
|
||||
<Divider my={6} />
|
||||
|
||||
<HStack align="center" justify="space-between" mb={2}>
|
||||
<Heading id="clanky" size="md">Články a kategorie</Heading>
|
||||
<IconButton aria-label="Zkopírovat odkaz" variant="ghost" size="sm" icon={<FaLink />} onClick={() => copyDeepLink('clanky')} />
|
||||
</HStack>
|
||||
<OrderedList spacing={2} pl={5}>
|
||||
<ListItem>Vytvořte kategorii (pokud ještě neexistuje) a poté nový článek.</ListItem>
|
||||
<ListItem>Vyplňte SEO název a popis — zlepší to výsledky ve vyhledávání.</ListItem>
|
||||
<ListItem>Obrázek nahrávejte do Média a vložte URL do článku.</ListItem>
|
||||
</OrderedList>
|
||||
<Text mt={3} fontSize="sm" color="gray.600">
|
||||
Další správa kategorií je v sekci <Link href="/admin/kategorie">Kategorie</Link>.
|
||||
</Text>
|
||||
<Box mt={2}><Link href="#top" color="blue.600"><HStack as="span" spacing={2}><FaArrowUp /><Text>Na začátek</Text></HStack></Link></Box>
|
||||
|
||||
<Divider my={6} />
|
||||
|
||||
<HStack align="center" justify="space-between" mb={2}>
|
||||
<Heading id="zapasy" size="md">Zápasy a výsledky (FAČR)</Heading>
|
||||
<IconButton aria-label="Zkopírovat odkaz" variant="ghost" size="sm" icon={<FaLink />} onClick={() => copyDeepLink('zapasy')} />
|
||||
</HStack>
|
||||
<List spacing={2} styleType="disc" pl={5}>
|
||||
<ListItem>Napojení na FAČR umožňuje zobrazit soutěže, tabulky a nadcházející utkání.</ListItem>
|
||||
<ListItem>Přes <Link href="/admin/aliasy-soutezi">Alias názvů soutěží</Link> si můžete upravit názvy pro front‑end.</ListItem>
|
||||
<ListItem>Pokud FAČR data nevidíte, spusťte <Link href="/admin/prefetch">Prefetch</Link> pro načtení cache.</ListItem>
|
||||
</List>
|
||||
<Box mt={2}><Link href="#top" color="blue.600"><HStack as="span" spacing={2}><FaArrowUp /><Text>Na začátek</Text></HStack></Link></Box>
|
||||
|
||||
<Divider my={6} />
|
||||
|
||||
<HStack align="center" justify="space-between" mb={2}>
|
||||
<Heading id="hraci-tymy" size="md">Hráči a týmy</Heading>
|
||||
<IconButton aria-label="Zkopírovat odkaz" variant="ghost" size="sm" icon={<FaLink />} onClick={() => copyDeepLink('hraci-tymy')} />
|
||||
</HStack>
|
||||
<List spacing={2} styleType="disc" pl={5}>
|
||||
<ListItem>Přidávejte týmy a hráče, udržujte je aktuální pro přehled na webu.</ListItem>
|
||||
<ListItem>Loga týmů lze přepsat v sekci <Link href="/admin/aliasy-soutezi">Alias/Overrides</Link>, pokud se nenačtou správně.</ListItem>
|
||||
</List>
|
||||
<Box mt={2}><Link href="#top" color="blue.600"><HStack as="span" spacing={2}><FaArrowUp /><Text>Na začátek</Text></HStack></Link></Box>
|
||||
|
||||
<Divider my={6} />
|
||||
|
||||
<HStack align="center" justify="space-between" mb={2}>
|
||||
<Heading id="media" size="md">Média (soubory a obrázky)</Heading>
|
||||
<IconButton aria-label="Zkopírovat odkaz" variant="ghost" size="sm" icon={<FaLink />} onClick={() => copyDeepLink('media')} />
|
||||
</HStack>
|
||||
<OrderedList spacing={2} pl={5}>
|
||||
<ListItem>Nahrajte soubor v sekci Média, zkopírujte URL a použijte ji v článcích nebo bannerech.</ListItem>
|
||||
<ListItem>Podporované formáty: JPEG/PNG/SVG/… dle konfigurace serveru.</ListItem>
|
||||
</OrderedList>
|
||||
<Box mt={2}><Link href="#top" color="blue.600"><HStack as="span" spacing={2}><FaArrowUp /><Text>Na začátek</Text></HStack></Link></Box>
|
||||
|
||||
<Divider my={6} />
|
||||
|
||||
<HStack align="center" justify="space-between" mb={2}>
|
||||
<Heading id="sponzori-bannery" size="md">Sponzoři a bannery</Heading>
|
||||
<IconButton aria-label="Zkopírovat odkaz" variant="ghost" size="sm" icon={<FaLink />} onClick={() => copyDeepLink('sponzori-bannery')} />
|
||||
</HStack>
|
||||
<List spacing={2} styleType="disc" pl={5}>
|
||||
<ListItem>Přidávejte sponzory s logem a odkazem. Můžete je zobrazit na vybraných sekcích webu.</ListItem>
|
||||
<ListItem>Pro reklamní plochy používejte sekci Bannery a nastavte cílové URL.</ListItem>
|
||||
</List>
|
||||
<Box mt={2}><Link href="#top" color="blue.600"><HStack as="span" spacing={2}><FaArrowUp /><Text>Na začátek</Text></HStack></Link></Box>
|
||||
|
||||
<Divider my={6} />
|
||||
|
||||
<HStack align="center" justify="space-between" mb={2}>
|
||||
<Heading id="newsletter" size="md">Newsletter</Heading>
|
||||
<IconButton aria-label="Zkopírovat odkaz" variant="ghost" size="sm" icon={<FaLink />} onClick={() => copyDeepLink('newsletter')} />
|
||||
</HStack>
|
||||
<OrderedList spacing={2} pl={5}>
|
||||
<ListItem>Nastavte SMTP a identitu odesílatele (jméno + adresa).</ListItem>
|
||||
<ListItem>Odešlete test na svou adresu, ověřte zobrazení a doručení.</ListItem>
|
||||
<ListItem>Hromadné rozesílky plánujte s ohledem na limity poskytovatele SMTP.</ListItem>
|
||||
</OrderedList>
|
||||
<Heading size="sm" mt={4} mb={2}>Postup: konfigurace SMTP (krok za krokem)</Heading>
|
||||
<OrderedList spacing={1} pl={5}>
|
||||
<ListItem>Otevřete <Link href="/admin/nastaveni">Nastavení</Link> a vyplňte Host, Port, Uživatelské jméno, Heslo a „From“ adresu.</ListItem>
|
||||
<ListItem>Pro port <Code>465</Code> použijte SSL. Pro port <Code>587</Code> použijte STARTTLS (Use TLS ano).</ListItem>
|
||||
<ListItem>Uložte změny.</ListItem>
|
||||
<ListItem>Přejděte do <Link href="/admin/newsletter">Newsletter</Link> a odešlete testovací e‑mail na vaši adresu.</ListItem>
|
||||
<ListItem>Zkontrolujte doručení v inboxu a případně složku spam. Pokud nedorazí, viz Troubleshooting níže.</ListItem>
|
||||
</OrderedList>
|
||||
|
||||
<Divider my={6} />
|
||||
|
||||
<HStack align="center" justify="space-between" mb={2}>
|
||||
<Heading id="aliasy" size="md">Alias názvů soutěží</Heading>
|
||||
<IconButton aria-label="Zkopírovat odkaz" variant="ghost" size="sm" icon={<FaLink />} onClick={() => copyDeepLink('aliasy')} />
|
||||
</HStack>
|
||||
<Text mb={2}>Upravit zobrazení soutěží (přejmenování, sjednocení názvů) můžete v sekci Alias. Změny se projeví na front‑endu.</Text>
|
||||
<Box mt={2}><Link href="#top" color="blue.600"><HStack as="span" spacing={2}><FaArrowUp /><Text>Na začátek</Text></HStack></Link></Box>
|
||||
|
||||
<Divider my={6} />
|
||||
|
||||
<HStack align="center" justify="space-between" mb={2}>
|
||||
<Heading id="prefetch" size="md">Prefetch (YouTube a další data)</Heading>
|
||||
<IconButton aria-label="Zkopírovat odkaz" variant="ghost" size="sm" icon={<FaLink />} onClick={() => copyDeepLink('prefetch')} />
|
||||
</HStack>
|
||||
<List spacing={2} styleType="disc" pl={5}>
|
||||
<ListItem>Prefetch vytváří rychlou cache pro veřejné části webu (YouTube, články…).</ListItem>
|
||||
<ListItem>Při problémech s rychlostí načtení spusťte ručně Prefetch a zkontrolujte stav.</ListItem>
|
||||
</List>
|
||||
<Box mt={2}><Link href="#top" color="blue.600"><HStack as="span" spacing={2}><FaArrowUp /><Text>Na začátek</Text></HStack></Link></Box>
|
||||
|
||||
<Divider my={6} />
|
||||
|
||||
<HStack align="center" justify="space-between" mb={2}>
|
||||
<Heading id="videa" size="md">Videa</Heading>
|
||||
<IconButton aria-label="Zkopírovat odkaz" variant="ghost" size="sm" icon={<FaLink />} onClick={() => copyDeepLink('videa')} />
|
||||
</HStack>
|
||||
<List spacing={2} styleType="disc" pl={5}>
|
||||
<ListItem>Správa videí a playlistů zobrazovaných na webu.</ListItem>
|
||||
<ListItem>Pro rychlé načítání videí použijte <Link href="/admin/prefetch">Prefetch</Link> (YouTube cache).</ListItem>
|
||||
</List>
|
||||
<Box mt={2}><Link href="#top" color="blue.600"><HStack as="span" spacing={2}><FaArrowUp /><Text>Na začátek</Text></HStack></Link></Box>
|
||||
|
||||
<Divider my={6} />
|
||||
|
||||
<HStack align="center" justify="space-between" mb={2}>
|
||||
<Heading id="aktivity" size="md">Aktivity</Heading>
|
||||
<IconButton aria-label="Zkopírovat odkaz" variant="ghost" size="sm" icon={<FaLink />} onClick={() => copyDeepLink('aktivity')} />
|
||||
</HStack>
|
||||
<List spacing={2} styleType="disc" pl={5}>
|
||||
<ListItem>Plánujte a publikujte klubové akce mimo soutěžní zápasy.</ListItem>
|
||||
<ListItem>Aktivity se mohou zobrazovat na nástěnce administrace i na veřejném webu.</ListItem>
|
||||
</List>
|
||||
<Box mt={2}><Link href="#top" color="blue.600"><HStack as="span" spacing={2}><FaArrowUp /><Text>Na začátek</Text></HStack></Link></Box>
|
||||
|
||||
<Divider my={6} />
|
||||
|
||||
<HStack align="center" justify="space-between" mb={2}>
|
||||
<Heading id="merch" size="md">Oblečení (Merch)</Heading>
|
||||
<IconButton aria-label="Zkopírovat odkaz" variant="ghost" size="sm" icon={<FaLink />} onClick={() => copyDeepLink('merch')} />
|
||||
</HStack>
|
||||
<List spacing={2} styleType="disc" pl={5}>
|
||||
<ListItem>Správa položek klubového merche a jejich zobrazení pro fanoušky.</ListItem>
|
||||
<ListItem>Pro každou položku nastavte název, popis, cenu a obrázek (z Média).</ListItem>
|
||||
</List>
|
||||
<Box mt={2}><Link href="#top" color="blue.600"><HStack as="span" spacing={2}><FaArrowUp /><Text>Na začátek</Text></HStack></Link></Box>
|
||||
|
||||
<Divider my={6} />
|
||||
|
||||
<HStack align="center" justify="space-between" mb={2}>
|
||||
<Heading id="zpravy" size="md">Zprávy</Heading>
|
||||
<IconButton aria-label="Zkopírovat odkaz" variant="ghost" size="sm" icon={<FaLink />} onClick={() => copyDeepLink('zpravy')} />
|
||||
</HStack>
|
||||
<List spacing={2} styleType="disc" pl={5}>
|
||||
<ListItem>Seznam zpráv odeslaných přes kontaktní formulář a systémové notifikace.</ListItem>
|
||||
<ListItem>Odpovídejte uživatelům přímo z vaší schránky, systém ukládá pouze záznamy.</ListItem>
|
||||
</List>
|
||||
<Box mt={2}><Link href="#top" color="blue.600"><HStack as="span" spacing={2}><FaArrowUp /><Text>Na začátek</Text></HStack></Link></Box>
|
||||
|
||||
<Divider my={6} />
|
||||
|
||||
<HStack align="center" justify="space-between" mb={2}>
|
||||
<Heading id="reset-admin" size="md">Reset hesla (admin nástroj)</Heading>
|
||||
<IconButton aria-label="Zkopírovat odkaz" variant="ghost" size="sm" icon={<FaLink />} onClick={() => copyDeepLink('reset-admin')} />
|
||||
</HStack>
|
||||
<OrderedList spacing={2} pl={5}>
|
||||
<ListItem>Přejděte na <Link href="/admin/users/send-reset">Nástroj pro reset hesla</Link>.</ListItem>
|
||||
<ListItem>Zadejte e‑mail uživatele a odešlete ověřovací kód/odkaz pro reset.</ListItem>
|
||||
<ListItem>Uživatel dokončí změnu hesla přes veřejnou stránku pro reset.</ListItem>
|
||||
</OrderedList>
|
||||
<Box mt={2}><Link href="#top" color="blue.600"><HStack as="span" spacing={2}><FaArrowUp /><Text>Na začátek</Text></HStack></Link></Box>
|
||||
|
||||
<Divider my={6} />
|
||||
|
||||
<HStack align="center" justify="space-between" mb={2}>
|
||||
<Heading id="uzivatele" size="md">Uživatelé a přístupy</Heading>
|
||||
<IconButton aria-label="Zkopírovat odkaz" variant="ghost" size="sm" icon={<FaLink />} onClick={() => copyDeepLink('uzivatele')} />
|
||||
</HStack>
|
||||
<List spacing={2} styleType="disc" pl={5}>
|
||||
<ListItem>Rozlišujeme role <Code>admin</Code> a <Code>user</Code>. Admin má přístup do všech sekcí.</ListItem>
|
||||
<ListItem>Hesla mají minimální délku 8 znaků a nesmí obsahovat mezery.</ListItem>
|
||||
<ListItem>Zapomenuté heslo lze obnovit přes stránku „Zapomenuté heslo“ (ověřovací kód e‑mailem).</ListItem>
|
||||
</List>
|
||||
<Box mt={2}><Link href="#top" color="blue.600"><HStack as="span" spacing={2}><FaArrowUp /><Text>Na začátek</Text></HStack></Link></Box>
|
||||
|
||||
<Divider my={6} />
|
||||
|
||||
<HStack align="center" justify="space-between" mb={2}>
|
||||
<Heading id="seo-analytics" size="md">SEO a Analytics</Heading>
|
||||
<IconButton aria-label="Zkopírovat odkaz" variant="ghost" size="sm" icon={<FaLink />} onClick={() => copyDeepLink('seo-analytics')} />
|
||||
</HStack>
|
||||
<List spacing={2} styleType="disc" pl={5}>
|
||||
<ListItem>U článků vyplňujte <strong>SEO titulek</strong> a <strong>SEO popis</strong>.</ListItem>
|
||||
<ListItem>V administraci je k dispozici přehled návštěvnosti a interakcí (sekce Analytics).</ListItem>
|
||||
</List>
|
||||
<Box mt={2}><Link href="#top" color="blue.600"><HStack as="span" spacing={2}><FaArrowUp /><Text>Na začátek</Text></HStack></Link></Box>
|
||||
|
||||
<Divider my={6} />
|
||||
|
||||
<HStack align="center" justify="space-between" mb={2}>
|
||||
<Heading id="troubleshooting" size="md">Troubleshooting (řešení problémů)</Heading>
|
||||
<IconButton aria-label="Zkopírovat odkaz" variant="ghost" size="sm" icon={<FaLink />} onClick={() => copyDeepLink('troubleshooting')} />
|
||||
</HStack>
|
||||
<Heading size="sm" mb={2}>E‑maily se neodesílají</Heading>
|
||||
<List spacing={2} styleType="disc" pl={5} mb={4}>
|
||||
<ListItem>Zkontrolujte <Link href="/admin/nastaveni">SMTP nastavení</Link> (host, port, uživatel, heslo, šifrování).</ListItem>
|
||||
<ListItem>Pro port 465 použijte SSL, pro 587 STARTTLS. Využijte tlačítko „Otestovat SMTP“ v průvodci či v newsletteru.</ListItem>
|
||||
<ListItem>Mrkněte do logů serveru na případné chyby SMTP.</ListItem>
|
||||
</List>
|
||||
|
||||
<Heading size="sm" mb={2}>Obrázky/Logo se nezobrazuje</Heading>
|
||||
<List spacing={2} styleType="disc" pl={5} mb={4}>
|
||||
<ListItem>Ověřte, že URL je správná a veřejně dostupná. V případě externích zdrojů lze využít proxy `/api/v1/proxy/image`.</ListItem>
|
||||
<ListItem>Nahrajte logo do sekce Média a použijte relativní cestu (např. <Code>/uploads/2025/01/logo.png</Code>).</ListItem>
|
||||
</List>
|
||||
|
||||
<Heading size="sm" mb={2}>FAČR data nejsou aktuální</Heading>
|
||||
<List spacing={2} styleType="disc" pl={5} mb={4}>
|
||||
<ListItem>Spusťte <Link href="/admin/prefetch">Prefetch</Link> a porovnejte čas posledního běhu.</ListItem>
|
||||
<ListItem>Zkontrolujte internetové připojení serveru a limity volání.</ListItem>
|
||||
</List>
|
||||
|
||||
<Heading size="sm" mb={2}>Přihlášení nefunguje</Heading>
|
||||
<List spacing={2} styleType="disc" pl={5} mb={4}>
|
||||
<ListItem>Ověřte e‑mail/heslo a zda má účet roli <Code>admin</Code>.</ListItem>
|
||||
<ListItem>Pokud jste zapomněli heslo, použijte „Zapomenuté heslo“ a kód z e‑mailu.</ListItem>
|
||||
</List>
|
||||
|
||||
<Heading size="sm" mb={2}>Newsletter padá do spamu</Heading>
|
||||
<List spacing={2} styleType="disc" pl={5}>
|
||||
<ListItem>Nastavte správně DNS záznamy <Code>SPF</Code>, <Code>DKIM</Code> a pokud možno <Code>DMARC</Code>.</ListItem>
|
||||
<ListItem>Ověřte, že „From“ doména odpovídá doméně vašeho SMTP odesílatele.</ListItem>
|
||||
</List>
|
||||
|
||||
<Divider my={6} />
|
||||
|
||||
<Heading size="md" mb={3}>API základ</Heading>
|
||||
<Text mb={2}>Veřejná API základna: <Code>/api/v1</Code></Text>
|
||||
<Text fontSize="sm" color="gray.600">(Rozšířená dokumentace API bude doplněna v budoucnu.)</Text>
|
||||
<Box mt={2}><Link href="#top" color="blue.600"><HStack as="span" spacing={2}><FaArrowUp /><Text>Na začátek</Text></HStack></Link></Box>
|
||||
</Box>
|
||||
</Box>
|
||||
</AdminLayout>
|
||||
);
|
||||
};
|
||||
|
||||
export default AdminDocsPage;
|
||||
@@ -0,0 +1,228 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import AdminLayout from '../../layouts/AdminLayout';
|
||||
import { Box, Heading, Button, SimpleGrid, FormControl, FormLabel, Input, HStack, VStack, Text, IconButton, useToast, Divider, Textarea, Switch, NumberInput, NumberInputField } from '@chakra-ui/react';
|
||||
import { getClothingAdmin, createClothing, updateClothing, deleteClothing, ClothingItem } from '../../services/clothing';
|
||||
import { FiPlus, FiTrash2, FiSave } from 'react-icons/fi';
|
||||
|
||||
const emptyItem: Partial<ClothingItem> = {
|
||||
title: '',
|
||||
description: '',
|
||||
price: 0,
|
||||
currency: 'Kč',
|
||||
image_url: '',
|
||||
url: '',
|
||||
is_active: true,
|
||||
display_order: 0
|
||||
};
|
||||
|
||||
const AdminMerchPage: React.FC = () => {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [items, setItems] = useState<ClothingItem[]>([]);
|
||||
const toast = useToast();
|
||||
|
||||
const fetchItems = async () => {
|
||||
try {
|
||||
const data = await getClothingAdmin();
|
||||
setItems(data);
|
||||
} catch (e) {
|
||||
toast({ status: 'error', title: 'Chyba', description: 'Nepodařilo se načíst oblečení.' });
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchItems();
|
||||
}, []);
|
||||
|
||||
const addItem = () => {
|
||||
const newItem: Partial<ClothingItem> = { ...emptyItem };
|
||||
setItems((prev) => [...prev, newItem as ClothingItem]);
|
||||
};
|
||||
|
||||
const removeItem = async (idx: number) => {
|
||||
const item = items[idx];
|
||||
if (item.id) {
|
||||
try {
|
||||
await deleteClothing(item.id);
|
||||
toast({ status: 'success', title: 'Smazáno', description: 'Položka byla smazána.' });
|
||||
fetchItems();
|
||||
} catch (e) {
|
||||
toast({ status: 'error', title: 'Chyba', description: 'Nepodařilo se smazat položku.' });
|
||||
}
|
||||
} else {
|
||||
setItems((prev) => prev.filter((_, i) => i !== idx));
|
||||
}
|
||||
};
|
||||
|
||||
const updateField = (idx: number, key: keyof ClothingItem, val: any) => {
|
||||
setItems((prev) => prev.map((it, i) => i === idx ? { ...it, [key]: val } : it));
|
||||
};
|
||||
|
||||
const saveItem = async (idx: number) => {
|
||||
const item = items[idx];
|
||||
if (!item.title || !item.image_url) {
|
||||
toast({ status: 'warning', title: 'Upozornění', description: 'Vyplňte alespoň název a obrázek.' });
|
||||
return;
|
||||
}
|
||||
|
||||
setSaving(true);
|
||||
try {
|
||||
if (item.id) {
|
||||
await updateClothing(item.id, item);
|
||||
toast({ status: 'success', title: 'Uloženo', description: 'Položka byla aktualizována.' });
|
||||
} else {
|
||||
await createClothing(item);
|
||||
toast({ status: 'success', title: 'Uloženo', description: 'Položka byla vytvořena.' });
|
||||
fetchItems();
|
||||
}
|
||||
} catch (e) {
|
||||
toast({ status: 'error', title: 'Chyba', description: 'Nepodařilo se uložit položku.' });
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<AdminLayout>
|
||||
<Box>
|
||||
<Heading size="md" mb={2}>Oblečení a Fan Shop</Heading>
|
||||
<Text fontSize="sm" color="gray.600" mb={2}>
|
||||
Spravujte položky oblečení a merchandisingu. Na titulní stránce se zobrazí 5 nejnovějších položek, všechny položky jsou k dispozici na stránce /obleceni.
|
||||
</Text>
|
||||
<Box mb={4} p={3} bg="blue.50" borderRadius="md" borderLeft="4px solid" borderColor="blue.500">
|
||||
<Text fontSize="sm" color="blue.800">
|
||||
💡 <strong>Tip:</strong> Přidejte cenu pro lepší přehlednost. Na veřejné stránce se zobrazí pouze aktivní položky.
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
<HStack justify="space-between" mb={3}>
|
||||
<Button leftIcon={<FiPlus />} onClick={addItem}>Přidat položku</Button>
|
||||
</HStack>
|
||||
|
||||
<Divider my={3} />
|
||||
|
||||
{loading ? (
|
||||
<Text>Načítání…</Text>
|
||||
) : (
|
||||
<VStack align="stretch" spacing={4}>
|
||||
{items.map((it, idx) => (
|
||||
<Box key={idx} borderWidth="1px" borderRadius="md" p={4} bg="white">
|
||||
<HStack justify="space-between" mb={3}>
|
||||
<Heading size="sm">
|
||||
{it.title || `Položka #${idx + 1}`}
|
||||
{!it.is_active && <Text as="span" ml={2} fontSize="xs" color="gray.500">(neaktivní)</Text>}
|
||||
</Heading>
|
||||
<HStack>
|
||||
<Button
|
||||
size="sm"
|
||||
colorScheme="blue"
|
||||
leftIcon={<FiSave />}
|
||||
onClick={() => saveItem(idx)}
|
||||
isLoading={saving}
|
||||
>
|
||||
Uložit
|
||||
</Button>
|
||||
<IconButton
|
||||
aria-label="Smazat"
|
||||
icon={<FiTrash2 />}
|
||||
onClick={() => removeItem(idx)}
|
||||
variant="outline"
|
||||
colorScheme="red"
|
||||
size="sm"
|
||||
/>
|
||||
</HStack>
|
||||
</HStack>
|
||||
|
||||
<SimpleGrid columns={{ base: 1, md: 2 }} spacing={3}>
|
||||
<FormControl isRequired>
|
||||
<FormLabel>Název</FormLabel>
|
||||
<Input
|
||||
value={it.title || ''}
|
||||
onChange={(e) => updateField(idx, 'title', e.target.value)}
|
||||
placeholder="např. Dres domácí 2024/25"
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
<FormControl isRequired>
|
||||
<FormLabel>Obrázek (URL)</FormLabel>
|
||||
<Input
|
||||
value={it.image_url || ''}
|
||||
onChange={(e) => updateField(idx, 'image_url', e.target.value)}
|
||||
placeholder="https://example.com/img.jpg"
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
<FormControl>
|
||||
<FormLabel>Cena</FormLabel>
|
||||
<HStack>
|
||||
<NumberInput
|
||||
value={it.price || 0}
|
||||
onChange={(_, val) => updateField(idx, 'price', val)}
|
||||
min={0}
|
||||
precision={2}
|
||||
flex={1}
|
||||
>
|
||||
<NumberInputField placeholder="0.00" />
|
||||
</NumberInput>
|
||||
<Input
|
||||
value={it.currency || 'Kč'}
|
||||
onChange={(e) => updateField(idx, 'currency', e.target.value)}
|
||||
placeholder="Kč"
|
||||
width="80px"
|
||||
/>
|
||||
</HStack>
|
||||
</FormControl>
|
||||
|
||||
<FormControl>
|
||||
<FormLabel>Odkaz (e‑shop)</FormLabel>
|
||||
<Input
|
||||
value={it.url || ''}
|
||||
onChange={(e) => updateField(idx, 'url', e.target.value)}
|
||||
placeholder="https://eshop.example.com/produkt"
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
<FormControl>
|
||||
<FormLabel>Pořadí zobrazení</FormLabel>
|
||||
<NumberInput
|
||||
value={it.display_order || 0}
|
||||
onChange={(_, val) => updateField(idx, 'display_order', val)}
|
||||
min={0}
|
||||
>
|
||||
<NumberInputField />
|
||||
</NumberInput>
|
||||
</FormControl>
|
||||
|
||||
<FormControl display="flex" alignItems="center">
|
||||
<FormLabel mb={0}>Aktivní</FormLabel>
|
||||
<Switch
|
||||
isChecked={it.is_active !== false}
|
||||
onChange={(e) => updateField(idx, 'is_active', e.target.checked)}
|
||||
/>
|
||||
</FormControl>
|
||||
</SimpleGrid>
|
||||
|
||||
<FormControl mt={3}>
|
||||
<FormLabel>Popis</FormLabel>
|
||||
<Textarea
|
||||
value={it.description || ''}
|
||||
onChange={(e) => updateField(idx, 'description', e.target.value)}
|
||||
placeholder="Volitelný popis položky"
|
||||
rows={2}
|
||||
/>
|
||||
</FormControl>
|
||||
</Box>
|
||||
))}
|
||||
{items.length === 0 && (
|
||||
<Text color="gray.600">Zatím žádné položky. Použijte tlačítko „Přidat položku".</Text>
|
||||
)}
|
||||
</VStack>
|
||||
)}
|
||||
</Box>
|
||||
</AdminLayout>
|
||||
);
|
||||
};
|
||||
|
||||
export default AdminMerchPage;
|
||||
@@ -0,0 +1,48 @@
|
||||
import React, { useState } from 'react';
|
||||
import AdminLayout from '../../layouts/AdminLayout';
|
||||
import { Box, Heading, FormControl, FormLabel, Input, Button, VStack, useToast, Text } from '@chakra-ui/react';
|
||||
import api from '../../services/api';
|
||||
|
||||
const AdminResetPasswordPage: React.FC = () => {
|
||||
const [email, setEmail] = useState('');
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [sent, setSent] = useState(false);
|
||||
const toast = useToast();
|
||||
|
||||
const sendReset = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setIsLoading(true);
|
||||
try {
|
||||
await api.post('/admin/users/send-reset', { email });
|
||||
setSent(true);
|
||||
toast({ status: 'success', title: 'Odesláno', description: 'Pokud účet existuje, byl odeslán e-mail pro reset hesla.' });
|
||||
} catch (err: any) {
|
||||
toast({ status: 'error', title: 'Chyba', description: err.response?.data?.error || 'Nepodařilo se odeslat e-mail.' });
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<AdminLayout>
|
||||
<Box maxW="lg">
|
||||
<Heading size="md" mb={4}>Odeslat reset hesla</Heading>
|
||||
<Text fontSize="sm" color="gray.600" mb={3}>
|
||||
Tato akce odešle uživateli e-mail s odkazem pro nastavení nového hesla. Použije se speciální SMTP konfigurace určená pouze pro reset.
|
||||
</Text>
|
||||
<VStack as="form" onSubmit={sendReset} spacing={4} align="stretch">
|
||||
<FormControl isRequired>
|
||||
<FormLabel>Email uživatele</FormLabel>
|
||||
<Input type="email" value={email} onChange={(e) => setEmail(e.target.value)} placeholder="uzivatel@example.com" />
|
||||
</FormControl>
|
||||
<Button colorScheme="blue" type="submit" isLoading={isLoading}>Odeslat e-mail</Button>
|
||||
{sent && (
|
||||
<Text color="green.600">Pokud adresa existuje, e-mail s odkazem byl odeslán.</Text>
|
||||
)}
|
||||
</VStack>
|
||||
</Box>
|
||||
</AdminLayout>
|
||||
);
|
||||
};
|
||||
|
||||
export default AdminResetPasswordPage;
|
||||
@@ -0,0 +1,523 @@
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import AdminLayout from '../../layouts/AdminLayout';
|
||||
import { Box, Heading, Button, SimpleGrid, FormControl, FormLabel, Input, HStack, VStack, Text, IconButton, useToast, Divider, Alert, AlertIcon, Badge, Tooltip, Checkbox, Image, Spinner, Link, Switch, ButtonGroup } from '@chakra-ui/react';
|
||||
import { getAdminSettings, updateAdminSettings, AdminSettings } from '../../services/settings';
|
||||
import { FiPlus, FiTrash2, FiSave } from 'react-icons/fi';
|
||||
import { triggerPrefetch } from '../../services/admin/prefetch';
|
||||
import { getCachedYouTube, YouTubeVideo } from '../../services/youtube';
|
||||
|
||||
export type AdminVideoItem = {
|
||||
url: string;
|
||||
title?: string;
|
||||
length?: string;
|
||||
uploaded_at?: string;
|
||||
thumbnail_url?: string;
|
||||
};
|
||||
|
||||
const emptyItem: AdminVideoItem = { url: '' };
|
||||
|
||||
const AdminVideosPage: React.FC = () => {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [items, setItems] = useState<AdminVideoItem[]>([]);
|
||||
const [videosSource, setVideosSource] = useState<'auto'|'manual'>('manual');
|
||||
const [videosEnabled, setVideosEnabled] = useState<boolean>(true);
|
||||
const toast = useToast();
|
||||
|
||||
// YouTube Scraper API integration state
|
||||
const [channelInput, setChannelInput] = useState<string>('');
|
||||
const [ytLoading, setYtLoading] = useState<boolean>(false);
|
||||
const [ytError, setYtError] = useState<string>('');
|
||||
const [ytVideos, setYtVideos] = useState<Array<{
|
||||
video_id: string;
|
||||
title: string;
|
||||
length?: string;
|
||||
thumbnail_url?: string;
|
||||
views?: number;
|
||||
views_text?: string;
|
||||
published_text?: string;
|
||||
published_date?: string;
|
||||
}>>([]);
|
||||
const [selectedIds, setSelectedIds] = useState<Record<string, boolean>>({});
|
||||
|
||||
// Auto source preview state (cached YouTube)
|
||||
const [autoVideos, setAutoVideos] = useState<YouTubeVideo[]>([]);
|
||||
const [autoLoading, setAutoLoading] = useState<boolean>(false);
|
||||
const [autoError, setAutoError] = useState<string>('');
|
||||
const [filter, setFilter] = useState<string>('');
|
||||
|
||||
// Derived flags
|
||||
const hasChannel = useMemo(() => (channelInput || '').trim().length > 0, [channelInput]);
|
||||
|
||||
useEffect(() => {
|
||||
let mounted = true;
|
||||
(async () => {
|
||||
try {
|
||||
const s: AdminSettings = await getAdminSettings();
|
||||
if (!mounted) return;
|
||||
const vids = Array.isArray((s as any).videos_items) ? (s as any).videos_items as AdminVideoItem[] : [];
|
||||
const legacy = Array.isArray((s as any).videos) ? ((s as any).videos as string[]).map((url) => ({ url })) : [];
|
||||
setItems(vids.length ? vids : legacy);
|
||||
const src = (s as any).videos_source;
|
||||
if (src === 'auto' || src === 'manual') setVideosSource(src);
|
||||
// Default enable if not explicitly set and there are any videos configured
|
||||
const explicit = (s as any).videos_module_enabled;
|
||||
const hasAny = (vids.length + legacy.length) > 0;
|
||||
setVideosEnabled(typeof explicit === 'boolean' ? Boolean(explicit) : hasAny);
|
||||
// Prefill channel handle from settings if available (social/youtube_url)
|
||||
const ytUrl = (s as any).youtube_url || (s as any).social_youtube || '';
|
||||
if (ytUrl) setChannelInput(ytUrl);
|
||||
} catch (e) {
|
||||
// ignore
|
||||
} finally {
|
||||
if (mounted) setLoading(false);
|
||||
}
|
||||
})();
|
||||
return () => { mounted = false; };
|
||||
}, []);
|
||||
|
||||
// Load cached YouTube videos for preview when auto source is active
|
||||
useEffect(() => {
|
||||
let mounted = true;
|
||||
const run = async () => {
|
||||
if (loading) return;
|
||||
if (videosSource !== 'auto') return;
|
||||
setAutoError('');
|
||||
setAutoLoading(true);
|
||||
try {
|
||||
const payload = await getCachedYouTube();
|
||||
if (!mounted) return;
|
||||
setAutoVideos(payload?.videos || []);
|
||||
} catch (err) {
|
||||
if (!mounted) return;
|
||||
setAutoError('Nepodařilo se načíst cache videí. Zkuste Aktualizovat.');
|
||||
} finally {
|
||||
if (mounted) setAutoLoading(false);
|
||||
}
|
||||
};
|
||||
run();
|
||||
return () => { mounted = false; };
|
||||
}, [loading, videosSource]);
|
||||
|
||||
// Auto-disable videos module if there is neither channel nor manual items configured
|
||||
useEffect(() => {
|
||||
if (loading) return;
|
||||
if (!hasChannel && items.length === 0 && videosEnabled) {
|
||||
setVideosEnabled(false);
|
||||
}
|
||||
}, [loading, hasChannel, items.length, videosEnabled]);
|
||||
|
||||
// Auto-trigger backend prefetch of YouTube cache at most once per ~24h
|
||||
useEffect(() => {
|
||||
if (loading) return;
|
||||
if (videosSource !== 'auto') return;
|
||||
const channel = (channelInput || '').trim();
|
||||
if (!channel) return;
|
||||
const KEY = 'youtube_autoload_last';
|
||||
let last = 0;
|
||||
try { last = Number(localStorage.getItem(KEY) || '0'); } catch {}
|
||||
const DAY_MS = 24 * 60 * 60 * 1000;
|
||||
const due = !last || (Date.now() - last) > (23 * 60 * 60 * 1000); // ~23h to allow slight drift
|
||||
if (!due) return;
|
||||
(async () => {
|
||||
try {
|
||||
// Ask backend to refresh cached files; it will update youtube_channel.json opportunistically
|
||||
await triggerPrefetch();
|
||||
try { localStorage.setItem(KEY, String(Date.now())); } catch {}
|
||||
toast({ status: 'info', title: 'Aktualizace videí', description: 'Na pozadí se aktualizuje cache videí z YouTube.', duration: 3000 });
|
||||
} catch {
|
||||
// silent
|
||||
}
|
||||
})();
|
||||
}, [loading, videosSource, channelInput, toast]);
|
||||
|
||||
const refreshAuto = async () => {
|
||||
setAutoError('');
|
||||
setAutoLoading(true);
|
||||
try {
|
||||
await triggerPrefetch();
|
||||
const payload = await getCachedYouTube();
|
||||
setAutoVideos(payload?.videos || []);
|
||||
toast({ status: 'success', title: 'Aktualizováno', description: 'Cache videí byla obnovena.', duration: 3000 });
|
||||
} catch (e) {
|
||||
setAutoError('Aktualizace cache selhala.');
|
||||
} finally {
|
||||
setAutoLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchChannelVideos = async () => {
|
||||
const channel = channelInput?.trim();
|
||||
if (!channel) {
|
||||
toast({ status: 'warning', title: 'Zadejte kanál', description: 'Zadejte YouTube handle nebo URL kanálu.' });
|
||||
return;
|
||||
}
|
||||
setYtError('');
|
||||
setYtLoading(true);
|
||||
setYtVideos([]);
|
||||
setSelectedIds({});
|
||||
try {
|
||||
const url = `https://youtube.tdvorak.dev/channel_videos?channel=${encodeURIComponent(channel)}`;
|
||||
const res = await fetch(url, { method: 'GET' });
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const data = await res.json();
|
||||
const vids = Array.isArray(data?.videos) ? data.videos : [];
|
||||
setYtVideos(vids);
|
||||
if (vids.length === 0) {
|
||||
setYtError('Na kanálu nebyla nalezena žádná videa.');
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.error(err);
|
||||
setYtError('Nepodařilo se načíst videa z API. Zkontrolujte dostupnost služby na https://youtube.tdvorak.dev/ a CORS.');
|
||||
} finally {
|
||||
setYtLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const toggleSelect = (id: string) => {
|
||||
setSelectedIds((prev) => ({ ...prev, [id]: !prev[id] }));
|
||||
};
|
||||
|
||||
const importSelected = () => {
|
||||
const selected = ytVideos.filter((v) => selectedIds[v.video_id]);
|
||||
if (selected.length === 0) {
|
||||
toast({ status: 'info', title: 'Nic k importu', description: 'Vyberte alespoň jedno video.' });
|
||||
return;
|
||||
}
|
||||
const newItems: AdminVideoItem[] = selected.map((v) => ({
|
||||
url: `https://www.youtube.com/watch?v=${v.video_id}`,
|
||||
title: v.title,
|
||||
length: v.length,
|
||||
uploaded_at: (v.published_date || '').slice(0,10),
|
||||
thumbnail_url: v.thumbnail_url,
|
||||
}));
|
||||
// Avoid duplicates by URL
|
||||
setItems((prev) => {
|
||||
const urls = new Set(prev.map((p) => p.url));
|
||||
const merged = [...prev];
|
||||
for (const it of newItems) {
|
||||
if (!urls.has(it.url)) {
|
||||
merged.push(it);
|
||||
urls.add(it.url);
|
||||
}
|
||||
}
|
||||
return merged;
|
||||
});
|
||||
toast({ status: 'success', title: 'Videa přidána', description: `${selected.length} videí bylo přidáno do seznamu.` });
|
||||
};
|
||||
|
||||
const addItem = () => setItems((prev) => [...prev, { ...emptyItem }]);
|
||||
const removeItem = (idx: number) => setItems((prev) => prev.filter((_, i) => i !== idx));
|
||||
const updateField = (idx: number, key: keyof AdminVideoItem, val: string) => {
|
||||
setItems((prev) => prev.map((it, i) => i === idx ? { ...it, [key]: val } : it));
|
||||
};
|
||||
|
||||
const save = async () => {
|
||||
setSaving(true);
|
||||
try {
|
||||
const clean = items.filter((it) => it.url && it.url.trim().length > 0);
|
||||
await updateAdminSettings({ videos_items: clean, videos_module_enabled: videosEnabled });
|
||||
toast({ status: 'success', title: 'Uloženo', description: 'Seznam videí byl uložen.' });
|
||||
} catch (e) {
|
||||
toast({ status: 'error', title: 'Chyba', description: 'Nepodařilo se uložit nastavení.' });
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const setDateQuick = (idx: number, daysAgo: number) => {
|
||||
const d = new Date();
|
||||
d.setDate(d.getDate() - daysAgo);
|
||||
const iso = d.toISOString().slice(0,10);
|
||||
setItems((prev) => prev.map((it, i) => i === idx ? { ...it, uploaded_at: iso } : it));
|
||||
};
|
||||
|
||||
// Helper: derive YouTube thumbnail safely from a URL (supports youtube.com and youtu.be)
|
||||
const getThumbFromUrl = (raw: string): string | undefined => {
|
||||
try {
|
||||
const u = raw.trim();
|
||||
if (!u) return undefined;
|
||||
if (u.includes('youtu.be/')) {
|
||||
const id = u.split('youtu.be/')[1]?.split(/[?&#]/)[0];
|
||||
return id ? `https://i.ytimg.com/vi/${id}/hqdefault.jpg` : undefined;
|
||||
}
|
||||
if (u.includes('youtube.com')) {
|
||||
// Try URL API first
|
||||
try {
|
||||
const url = new URL(u);
|
||||
const id = url.searchParams.get('v') || '';
|
||||
if (id) return `https://i.ytimg.com/vi/${id}/hqdefault.jpg`;
|
||||
} catch {}
|
||||
// Fallback regex
|
||||
const m = u.match(/[?&]v=([^&#]+)/);
|
||||
const id = m?.[1];
|
||||
return id ? `https://i.ytimg.com/vi/${id}/hqdefault.jpg` : undefined;
|
||||
}
|
||||
} catch {}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
return (
|
||||
<AdminLayout>
|
||||
<Box>
|
||||
<Heading size="md" mb={2}>Videa (pro titulní stránku)</Heading>
|
||||
<Text fontSize="sm" color="gray.600" mb={2}>Přidejte až 5 videí (doporučeno). První se zobrazí jako hlavní, další 4 v mřížce. Podporováno YouTube/Vimeo URL.</Text>
|
||||
|
||||
{/* Source toggle */}
|
||||
<HStack justify="space-between" mb={3} flexWrap="wrap">
|
||||
<HStack>
|
||||
<Text fontWeight="semibold">Zdroj videí:</Text>
|
||||
<ButtonGroup size="sm" isAttached>
|
||||
<Button
|
||||
variant={videosSource === 'auto' ? 'solid' : 'outline'}
|
||||
onClick={async () => {
|
||||
if (videosSource === 'auto') return;
|
||||
setVideosSource('auto');
|
||||
try {
|
||||
await updateAdminSettings({ videos_source: 'auto' });
|
||||
toast({ status: 'success', title: 'Zdroj nastaven', description: 'Videa se načítají automaticky z YouTube.', duration: 2500 });
|
||||
} catch {
|
||||
toast({ status: 'error', title: 'Chyba', description: 'Nelze uložit zdroj videí.', duration: 3000 });
|
||||
}
|
||||
}}
|
||||
>Automaticky</Button>
|
||||
<Button
|
||||
variant={videosSource === 'manual' ? 'solid' : 'outline'}
|
||||
onClick={async () => {
|
||||
if (videosSource === 'manual') return;
|
||||
setVideosSource('manual');
|
||||
try {
|
||||
await updateAdminSettings({ videos_source: 'manual' });
|
||||
toast({ status: 'success', title: 'Zdroj nastaven', description: 'Videa spravujete ručně.', duration: 2500 });
|
||||
} catch {
|
||||
toast({ status: 'error', title: 'Chyba', description: 'Nelze uložit zdroj videí.', duration: 3000 });
|
||||
}
|
||||
}}
|
||||
>Ručně</Button>
|
||||
</ButtonGroup>
|
||||
</HStack>
|
||||
<FormControl display="flex" alignItems="center" w="auto">
|
||||
<FormLabel mb={0}>Zobrazit sekci Videa na titulní stránce</FormLabel>
|
||||
<Switch
|
||||
isChecked={videosEnabled}
|
||||
isDisabled={loading || saving}
|
||||
onChange={async (e) => {
|
||||
const next = e.target.checked;
|
||||
// Require either channel or at least one manual video to enable
|
||||
if (next && !hasChannel && items.length === 0) {
|
||||
setVideosEnabled(false);
|
||||
toast({ status: 'warning', title: 'Doplňte kanál nebo videa', description: 'Pro zobrazení sekce vyplňte YouTube kanál nebo přidejte alespoň jedno video.', duration: 4000 });
|
||||
// Try to focus channel input
|
||||
setTimeout(() => {
|
||||
const el = document.getElementById('admin-videos-channel-input') as HTMLInputElement | null;
|
||||
if (el) el.focus();
|
||||
}, 0);
|
||||
return;
|
||||
}
|
||||
setVideosEnabled(next);
|
||||
try {
|
||||
await updateAdminSettings({ videos_module_enabled: next });
|
||||
} catch {
|
||||
toast({ status: 'error', title: 'Uložení selhalo', description: 'Nepodařilo se uložit změnu zobrazení sekce.', duration: 3000 });
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
</HStack>
|
||||
{!hasChannel && items.length === 0 && (
|
||||
<Text fontSize="sm" color="orange.600" mb={2}>Pro aktivaci sekce vyplňte YouTube kanál nebo přidejte video.</Text>
|
||||
)}
|
||||
{videosSource === 'auto' && (
|
||||
<Alert status="info" mb={3} borderRadius="md">
|
||||
<AlertIcon />
|
||||
Automatický režim je zapnutý. Videa se načítají z YouTube kanálu z Nastavení → Sociální sítě (YouTube URL) a správy „Videa (YouTube modul)“. Manuální seznam je v tomto režimu skryt.
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{videosSource !== 'auto' && (
|
||||
<Box borderWidth="1px" borderRadius="md" p={3} mb={4}>
|
||||
<Heading size="sm" mb={2}>Import z YouTube kanálu</Heading>
|
||||
<Text fontSize="sm" color="gray.600" mb={3}>
|
||||
Použijte Scraper API. Zadejte handle (např. <code>@FotbalKunovice</code>) nebo URL kanálu a načtěte videa z karty „Videa“.
|
||||
Služba: <Link href="https://youtube.tdvorak.dev/" isExternal color="blue.500">https://youtube.tdvorak.dev/</Link>
|
||||
</Text>
|
||||
<HStack align="start" spacing={3}>
|
||||
<FormControl maxW={{ base: '100%', md: '400px' }}>
|
||||
<FormLabel>Kanál (handle nebo URL)</FormLabel>
|
||||
<Input id="admin-videos-channel-input" placeholder="@FCBizoniUH nebo https://www.youtube.com/@FCBizoniUH/videos" value={channelInput} onChange={(e) => setChannelInput(e.target.value)} />
|
||||
</FormControl>
|
||||
<Button onClick={fetchChannelVideos} isLoading={ytLoading} variant="outline">Načíst videa</Button>
|
||||
<Button colorScheme="green" onClick={importSelected} isDisabled={ytVideos.length === 0}>Přidat vybraná</Button>
|
||||
</HStack>
|
||||
{ytError && (
|
||||
<Alert status="error" mt={3} borderRadius="md">
|
||||
<AlertIcon />
|
||||
{ytError}
|
||||
</Alert>
|
||||
)}
|
||||
{ytLoading && (
|
||||
<HStack mt={3} color="gray.600"><Spinner size="sm" /><Text>Načítám videa…</Text></HStack>
|
||||
)}
|
||||
{!ytLoading && ytVideos.length > 0 && (
|
||||
<SimpleGrid columns={{ base: 1, sm: 2, md: 3, lg: 4 }} spacing={3} mt={3}>
|
||||
{ytVideos.map((v) => (
|
||||
<Box key={v.video_id} borderWidth="1px" borderRadius="md" p={2}>
|
||||
<VStack align="stretch" spacing={2}>
|
||||
<Image src={v.thumbnail_url} alt={v.title} borderRadius="md" />
|
||||
<Checkbox isChecked={!!selectedIds[v.video_id]} onChange={() => toggleSelect(v.video_id)}>
|
||||
Vybrat
|
||||
</Checkbox>
|
||||
<Box>
|
||||
<Text fontWeight="semibold" noOfLines={2}>{v.title}</Text>
|
||||
<HStack spacing={2} color="gray.600" fontSize="sm">
|
||||
{v.length && <Badge>{v.length}</Badge>}
|
||||
{v.published_text && <Text>{v.published_text}</Text>}
|
||||
</HStack>
|
||||
</Box>
|
||||
</VStack>
|
||||
</Box>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Always-visible preview of effective videos */}
|
||||
<Box borderWidth="1px" borderRadius="md" p={3} mb={4}>
|
||||
<HStack justify="space-between" align="center" mb={2} flexWrap="wrap">
|
||||
<Heading size="sm">Náhled: všechna videa (aktivní zdroj)</Heading>
|
||||
{videosSource === 'auto' && (
|
||||
<HStack spacing={2}>
|
||||
<Input size="sm" placeholder="Filtrovat podle názvu" value={filter} onChange={(e) => setFilter(e.target.value)} />
|
||||
<Button size="sm" onClick={refreshAuto} isLoading={autoLoading} variant="outline">Aktualizovat cache</Button>
|
||||
</HStack>
|
||||
)}
|
||||
</HStack>
|
||||
{videosSource === 'auto' ? (
|
||||
<>
|
||||
{autoError && (
|
||||
<Alert status="error" mb={2} borderRadius="md"><AlertIcon />{autoError}</Alert>
|
||||
)}
|
||||
{autoLoading ? (
|
||||
<HStack color="gray.600"><Spinner size="sm" /><Text>Načítám videa…</Text></HStack>
|
||||
) : (
|
||||
<>
|
||||
<Text fontSize="sm" color="gray.600" mb={2}>Počet videí: {autoVideos.filter(v => v.title.toLowerCase().includes(filter.toLowerCase())).length}</Text>
|
||||
<SimpleGrid columns={{ base: 1, sm: 2, md: 3, lg: 4 }} spacing={3}>
|
||||
{autoVideos
|
||||
.filter(v => v.title.toLowerCase().includes(filter.toLowerCase()))
|
||||
.map((v) => (
|
||||
<Box key={v.video_id} borderWidth="1px" borderRadius="md" p={2}>
|
||||
<VStack align="stretch" spacing={2}>
|
||||
<Image src={v.thumbnail_url} alt={v.title} borderRadius="md" />
|
||||
<Box>
|
||||
<Text fontWeight="semibold" noOfLines={2}>{v.title}</Text>
|
||||
<HStack spacing={2} color="gray.600" fontSize="sm">
|
||||
{v.published_date && <Badge>{new Date(v.published_date).toLocaleDateString('cs-CZ')}</Badge>}
|
||||
</HStack>
|
||||
</Box>
|
||||
</VStack>
|
||||
</Box>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
{autoVideos.length === 0 && (
|
||||
<Text color="gray.600">Žádná videa v cache. Zkontrolujte YouTube URL v nastavení a použijte „Aktualizovat cache“.</Text>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Text fontSize="sm" color="gray.600" mb={2}>Počet videí: {items.length}</Text>
|
||||
<SimpleGrid columns={{ base: 1, sm: 2, md: 3, lg: 4 }} spacing={3}>
|
||||
{items.map((it, idx) => (
|
||||
<Box key={`${idx}-${it.url}`} borderWidth="1px" borderRadius="md" p={2}>
|
||||
<VStack align="stretch" spacing={2}>
|
||||
<Image src={it.thumbnail_url || getThumbFromUrl(it.url)} alt={it.title || `Video ${idx+1}`} borderRadius="md" />
|
||||
<Box>
|
||||
<Text fontWeight="semibold" noOfLines={2}>{it.title || `Video ${idx+1}`}</Text>
|
||||
<HStack spacing={2} color="gray.600" fontSize="sm">
|
||||
{it.uploaded_at && <Badge>{(new Date(it.uploaded_at)).toLocaleDateString('cs-CZ')}</Badge>}
|
||||
</HStack>
|
||||
</Box>
|
||||
</VStack>
|
||||
</Box>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
{items.length === 0 && (
|
||||
<Text color="gray.600">Zatím žádná videa.</Text>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<HStack justify="space-between" mb={3}>
|
||||
<Button leftIcon={<FiPlus />} onClick={addItem}>Přidat video</Button>
|
||||
<Button colorScheme="blue" leftIcon={<FiSave />} onClick={save} isLoading={saving}>Uložit</Button>
|
||||
</HStack>
|
||||
|
||||
<Divider my={3} />
|
||||
|
||||
{loading ? (
|
||||
<Text>Načítání…</Text>
|
||||
) : videosSource === 'auto' ? (
|
||||
<Text color="gray.600">Automatický zdroj videí je aktivní. Pro ruční správu přepněte zdroj na „Ručně“.</Text>
|
||||
) : (
|
||||
<VStack align="stretch" spacing={4}>
|
||||
{items.map((it, idx) => (
|
||||
<Box key={idx} borderWidth="1px" borderRadius="md" p={3}>
|
||||
<HStack justify="space-between">
|
||||
<Heading size="sm">Video #{idx + 1}</Heading>
|
||||
</HStack>
|
||||
<SimpleGrid columns={{ base: 1, md: 2 }} spacing={3} mt={3}>
|
||||
<FormControl>
|
||||
<FormLabel>URL videa</FormLabel>
|
||||
<Input value={it.url} onChange={(e) => updateField(idx, 'url', e.target.value)} placeholder="https://www.youtube.com/watch?v=..." />
|
||||
</FormControl>
|
||||
<FormControl>
|
||||
<FormLabel>Thumbnail (volitelné)</FormLabel>
|
||||
<Input value={it.thumbnail_url || ''} onChange={(e) => updateField(idx, 'thumbnail_url', e.target.value)} placeholder="https://example.com/thumb.jpg" />
|
||||
</FormControl>
|
||||
<FormControl>
|
||||
<FormLabel>Název (volitelné)</FormLabel>
|
||||
<Input value={it.title || ''} onChange={(e) => updateField(idx, 'title', e.target.value)} placeholder="Titulek videa" />
|
||||
</FormControl>
|
||||
<FormControl>
|
||||
<FormLabel>Délka (volitelné)</FormLabel>
|
||||
<Input value={it.length || ''} onChange={(e) => updateField(idx, 'length', e.target.value)} placeholder="3:45" />
|
||||
</FormControl>
|
||||
<FormControl>
|
||||
<FormLabel>Datum nahrání (volitelné)</FormLabel>
|
||||
<HStack>
|
||||
<Input type="date" value={(it.uploaded_at || '').slice(0,10)} onChange={(e) => updateField(idx, 'uploaded_at', e.target.value)} />
|
||||
<Tooltip label="Dnes">
|
||||
<Button size="sm" variant="outline" onClick={() => setDateQuick(idx, 0)}>Dnes</Button>
|
||||
</Tooltip>
|
||||
<Tooltip label="Včera">
|
||||
<Button size="sm" variant="outline" onClick={() => setDateQuick(idx, 1)}>Včera</Button>
|
||||
</Tooltip>
|
||||
<Tooltip label="Před týdnem">
|
||||
<Button size="sm" variant="outline" onClick={() => setDateQuick(idx, 7)}>−7 dní</Button>
|
||||
</Tooltip>
|
||||
<Tooltip label="Vymazat datum">
|
||||
<Button size="sm" variant="ghost" onClick={() => updateField(idx, 'uploaded_at', '')}>Vymazat</Button>
|
||||
</Tooltip>
|
||||
</HStack>
|
||||
</FormControl>
|
||||
</SimpleGrid>
|
||||
<HStack justify="flex-end" mt={2}>
|
||||
<IconButton aria-label="Smazat" icon={<FiTrash2 />} onClick={() => removeItem(idx)} variant="outline" colorScheme="red" />
|
||||
</HStack>
|
||||
</Box>
|
||||
))}
|
||||
{items.length === 0 && (
|
||||
<Text color="gray.600">Zatím žádná videa. Použijte tlačítko „Přidat video“.</Text>
|
||||
)}
|
||||
</VStack>
|
||||
)}
|
||||
</Box>
|
||||
</AdminLayout>
|
||||
);
|
||||
};
|
||||
|
||||
export default AdminVideosPage;
|
||||
@@ -0,0 +1,843 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Container,
|
||||
Heading,
|
||||
SimpleGrid,
|
||||
Stat,
|
||||
StatLabel,
|
||||
StatNumber,
|
||||
StatHelpText,
|
||||
Card,
|
||||
CardHeader,
|
||||
CardBody,
|
||||
Select,
|
||||
Tabs,
|
||||
TabList,
|
||||
TabPanels,
|
||||
Tab,
|
||||
TabPanel,
|
||||
Table,
|
||||
Thead,
|
||||
Tbody,
|
||||
Tr,
|
||||
Th,
|
||||
Td,
|
||||
Spinner,
|
||||
Text,
|
||||
useColorModeValue,
|
||||
VStack,
|
||||
HStack,
|
||||
Badge,
|
||||
Divider,
|
||||
Modal,
|
||||
ModalOverlay,
|
||||
ModalContent,
|
||||
ModalHeader,
|
||||
ModalBody,
|
||||
ModalCloseButton,
|
||||
useDisclosure,
|
||||
Button,
|
||||
Flex,
|
||||
Progress,
|
||||
Tooltip,
|
||||
Icon,
|
||||
} from '@chakra-ui/react';
|
||||
import AdminLayout from '../../layouts/AdminLayout';
|
||||
import api from '../../services/api';
|
||||
import { Bar } from 'react-chartjs-2';
|
||||
import {
|
||||
Chart as ChartJS,
|
||||
CategoryScale,
|
||||
LinearScale,
|
||||
BarElement,
|
||||
Title,
|
||||
Tooltip as ChartTooltip,
|
||||
Legend,
|
||||
} from 'chart.js';
|
||||
import {
|
||||
FiEye,
|
||||
FiUsers,
|
||||
FiActivity,
|
||||
FiPercent,
|
||||
FiClock,
|
||||
FiFile,
|
||||
FiLink,
|
||||
FiMonitor,
|
||||
FiCpu,
|
||||
FiGlobe,
|
||||
FiSmartphone,
|
||||
FiZap,
|
||||
FiTrendingUp,
|
||||
FiCalendar,
|
||||
FiSearch
|
||||
} from 'react-icons/fi';
|
||||
|
||||
// Register ChartJS components
|
||||
ChartJS.register(
|
||||
CategoryScale,
|
||||
LinearScale,
|
||||
BarElement,
|
||||
Title,
|
||||
ChartTooltip,
|
||||
Legend
|
||||
);
|
||||
|
||||
interface Stats {
|
||||
pageviews?: { value: number };
|
||||
visitors?: { value: number };
|
||||
visits?: { value: number };
|
||||
bounces?: { value: number };
|
||||
totaltime?: { value: number };
|
||||
}
|
||||
|
||||
interface MetricItem {
|
||||
x: string;
|
||||
y: number;
|
||||
}
|
||||
|
||||
interface PageviewsData {
|
||||
date: string;
|
||||
value: number;
|
||||
}
|
||||
|
||||
// Event translation and description mapping
|
||||
const getEventTranslation = (eventName: string): { name: string; source: string; description: string } => {
|
||||
const eventMap: Record<string, { name: string; source: string; description: string }> = {
|
||||
'Contact Form Submit': {
|
||||
name: 'Odeslání kontaktního formuláře',
|
||||
source: 'Kontaktní stránka',
|
||||
description: 'Uživatel odeslal kontaktní formulář na stránce Kontakt'
|
||||
},
|
||||
'Contact Form': {
|
||||
name: 'Zobrazení kontaktního formuláře',
|
||||
source: 'Kontaktní stránka',
|
||||
description: 'Uživatel zobrazil kontaktní formulář'
|
||||
},
|
||||
'Form Submit': {
|
||||
name: 'Odeslání formuláře',
|
||||
source: 'Různé stránky',
|
||||
description: 'Obecné odeslání formuláře na webu'
|
||||
},
|
||||
'Newsletter Subscribe': {
|
||||
name: 'Odběr newsletteru',
|
||||
source: 'Newsletter formulář',
|
||||
description: 'Uživatel se přihlásil k odběru newsletteru'
|
||||
},
|
||||
'Newsletter Submit': {
|
||||
name: 'Potvrzení newsletteru',
|
||||
source: 'Newsletter formulář',
|
||||
description: 'Uživatel potvrdil přihlášení k newsletteru'
|
||||
},
|
||||
'Newsletter Unsubscribe': {
|
||||
name: 'Odhlášení z newsletteru',
|
||||
source: 'Nastavení newsletteru',
|
||||
description: 'Uživatel se odhlásil z odběru newsletteru'
|
||||
},
|
||||
'Newsletter Preferences Saved': {
|
||||
name: 'Uložení předvoleb newsletteru',
|
||||
source: 'Nastavení newsletteru',
|
||||
description: 'Uživatel uložil své předvolby pro newsletter'
|
||||
},
|
||||
'Unsubscribe': {
|
||||
name: 'Odhlášení z odběru',
|
||||
source: 'Nastavení',
|
||||
description: 'Uživatel se odhlásil z odběru'
|
||||
},
|
||||
'Save Preferences': {
|
||||
name: 'Uložení předvoleb',
|
||||
source: 'Nastavení',
|
||||
description: 'Uživatel uložil své předvolby (souhlas s cookies apod.)'
|
||||
},
|
||||
'Refresh Preferences': {
|
||||
name: 'Obnovení předvoleb',
|
||||
source: 'Nastavení',
|
||||
description: 'Uživatel obnovil nebo změnil své předvolby'
|
||||
},
|
||||
'Article View': {
|
||||
name: 'Zobrazení článku',
|
||||
source: 'Blog',
|
||||
description: 'Uživatel si zobrazil článek na blogu'
|
||||
},
|
||||
'Match View': {
|
||||
name: 'Zobrazení zápasu',
|
||||
source: 'Stránka zápasů',
|
||||
description: 'Uživatel si zobrazil detail zápasu'
|
||||
},
|
||||
'Gallery View': {
|
||||
name: 'Zobrazení galerie',
|
||||
source: 'Galerie',
|
||||
description: 'Uživatel si otevřel galerii fotografií'
|
||||
},
|
||||
'Video Play': {
|
||||
name: 'Přehrání videa',
|
||||
source: 'Video sekce',
|
||||
description: 'Uživatel spustil přehrávání videa'
|
||||
},
|
||||
'Social Share': {
|
||||
name: 'Sdílení na sociálních sítích',
|
||||
source: 'Sdílecí tlačítka',
|
||||
description: 'Uživatel sdílel obsah na sociální síť'
|
||||
},
|
||||
'Download': {
|
||||
name: 'Stažení souboru',
|
||||
source: 'Různé stránky',
|
||||
description: 'Uživatel stáhl soubor'
|
||||
},
|
||||
'External Link Click': {
|
||||
name: 'Kliknutí na externí odkaz',
|
||||
source: 'Různé stránky',
|
||||
description: 'Uživatel klikl na odkaz vedoucí mimo web'
|
||||
}
|
||||
};
|
||||
|
||||
return eventMap[eventName] || {
|
||||
name: eventName,
|
||||
source: 'Neznámý zdroj',
|
||||
description: `Událost: ${eventName}`
|
||||
};
|
||||
};
|
||||
|
||||
const AnalyticsAdminPage: React.FC = () => {
|
||||
const [stats, setStats] = useState<Stats | null>(null);
|
||||
const [pageMetrics, setPageMetrics] = useState<MetricItem[]>([]);
|
||||
const [referrerMetrics, setReferrerMetrics] = useState<MetricItem[]>([]);
|
||||
const [browserMetrics, setBrowserMetrics] = useState<MetricItem[]>([]);
|
||||
const [osMetrics, setOsMetrics] = useState<MetricItem[]>([]);
|
||||
const [countryMetrics, setCountryMetrics] = useState<MetricItem[]>([]);
|
||||
const [deviceMetrics, setDeviceMetrics] = useState<MetricItem[]>([]);
|
||||
const [eventMetrics, setEventMetrics] = useState<MetricItem[]>([]);
|
||||
const [queryMetrics, setQueryMetrics] = useState<MetricItem[]>([]);
|
||||
const [pageviewsData, setPageviewsData] = useState<PageviewsData[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [timeRange, setTimeRange] = useState('0'); // Default to "today"
|
||||
const [hasData, setHasData] = useState(false);
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||
const [selectedCountry, setSelectedCountry] = useState<{
|
||||
code: string;
|
||||
name: string;
|
||||
value: number;
|
||||
} | null>(null);
|
||||
const [countryDetails, setCountryDetails] = useState<any>(null);
|
||||
const [loadingCountryDetails, setLoadingCountryDetails] = useState(false);
|
||||
const { isOpen, onOpen, onClose } = useDisclosure();
|
||||
|
||||
const bgColor = useColorModeValue('white', 'gray.800');
|
||||
const borderColor = useColorModeValue('gray.200', 'gray.700');
|
||||
|
||||
const fetchAnalytics = async (days: string) => {
|
||||
setLoading(true);
|
||||
setErrorMessage(null);
|
||||
try {
|
||||
const daysNum = parseInt(days);
|
||||
|
||||
// Fetch stats with calculated time range
|
||||
const statsResponse = await api.get(`/admin/umami/stats?days=${days}`);
|
||||
setStats(statsResponse.data);
|
||||
|
||||
// Check if we have any data
|
||||
const hasAnyStats = statsResponse.data && (
|
||||
(statsResponse.data.pageviews?.value && statsResponse.data.pageviews.value > 0) ||
|
||||
(statsResponse.data.visitors?.value && statsResponse.data.visitors.value > 0)
|
||||
);
|
||||
|
||||
// Fetch metrics
|
||||
const [pages, browsers, os, countries, devices, events, queries, pageviews] = await Promise.all([
|
||||
api.get(`/admin/umami/metrics/url?days=${days}`),
|
||||
api.get(`/admin/umami/metrics/browser?days=${days}`),
|
||||
api.get(`/admin/umami/metrics/os?days=${days}`),
|
||||
api.get(`/admin/umami/metrics/country?days=${days}`),
|
||||
api.get(`/admin/umami/metrics/device?days=${days}`),
|
||||
api.get(`/admin/umami/metrics/event?days=${days}`),
|
||||
api.get(`/admin/umami/metrics/query?days=${days}`).catch(() => ({ data: [] })),
|
||||
api.get(`/admin/umami/pageviews?days=${days}`),
|
||||
]);
|
||||
|
||||
setPageMetrics(pages.data || []);
|
||||
setReferrerMetrics([]); // Removed - no longer fetching referrers
|
||||
setBrowserMetrics(browsers.data || []);
|
||||
setOsMetrics(os.data || []);
|
||||
setCountryMetrics(countries.data || []);
|
||||
setDeviceMetrics(devices.data || []);
|
||||
setEventMetrics(events.data || []);
|
||||
setQueryMetrics(queries.data || []);
|
||||
|
||||
// Process real pageviews data from Umami
|
||||
const pageviewsDataArray: PageviewsData[] = [];
|
||||
const pageviewsResponse = pageviews.data || [];
|
||||
|
||||
if (pageviewsResponse.length > 0) {
|
||||
for (const item of pageviewsResponse) {
|
||||
let label = '';
|
||||
const timestamp = item.t || item.time || item.date;
|
||||
|
||||
if (daysNum === 0 || daysNum === 1) {
|
||||
// Hourly data - format as "HH:00"
|
||||
const date = new Date(timestamp);
|
||||
label = `${date.getHours().toString().padStart(2, '0')}:00`;
|
||||
} else {
|
||||
// Daily data - format as "d. M."
|
||||
const date = new Date(timestamp);
|
||||
label = `${date.getDate()}. ${date.getMonth() + 1}.`;
|
||||
}
|
||||
|
||||
pageviewsDataArray.push({
|
||||
date: label,
|
||||
value: item.y || item.value || 0
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
setPageviewsData(pageviewsDataArray);
|
||||
|
||||
// Determine if we have data
|
||||
const hasPageviews = pageviewsDataArray.length > 0 && pageviewsDataArray.some(d => d.value > 0);
|
||||
const hasMetrics = pages.data?.length > 0 || countries.data?.length > 0;
|
||||
setHasData(hasAnyStats || hasPageviews || hasMetrics);
|
||||
|
||||
// Set error message if no data
|
||||
if (!hasAnyStats && !hasPageviews && !hasMetrics) {
|
||||
setErrorMessage('Umami není správně nakonfigurováno nebo ještě nebyly zaznamenány žádné návštěvy. Zkontrolujte UMAMI_WEBSITE_ID v .env souboru.');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch analytics:', error);
|
||||
setErrorMessage('Chyba při načítání analytiky. Zkontrolujte připojení k Umami.');
|
||||
setHasData(false);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchAnalytics(timeRange);
|
||||
}, [timeRange]);
|
||||
|
||||
const handleCountryClick = async (countryCode: string, countryName: string, value: number) => {
|
||||
setSelectedCountry({ code: countryCode, name: countryName, value });
|
||||
setLoadingCountryDetails(true);
|
||||
onOpen();
|
||||
|
||||
try {
|
||||
// Fetch detailed analytics for the selected country
|
||||
const [pages, browsers, os, devices, events] = await Promise.all([
|
||||
api.get(`/admin/umami/metrics/url?days=${timeRange}&country=${countryCode}`),
|
||||
api.get(`/admin/umami/metrics/browser?days=${timeRange}&country=${countryCode}`),
|
||||
api.get(`/admin/umami/metrics/os?days=${timeRange}&country=${countryCode}`),
|
||||
api.get(`/admin/umami/metrics/device?days=${timeRange}&country=${countryCode}`),
|
||||
api.get(`/admin/umami/metrics/event?days=${timeRange}&country=${countryCode}`),
|
||||
]);
|
||||
|
||||
setCountryDetails({
|
||||
pages: pages.data || [],
|
||||
browsers: browsers.data || [],
|
||||
os: os.data || [],
|
||||
devices: devices.data || [],
|
||||
events: events.data || [],
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch country details:', error);
|
||||
setCountryDetails(null);
|
||||
} finally {
|
||||
setLoadingCountryDetails(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleModalClose = () => {
|
||||
onClose();
|
||||
setSelectedCountry(null);
|
||||
setCountryDetails(null);
|
||||
};
|
||||
|
||||
const formatNumber = (num: number | undefined) => {
|
||||
if (!num) return '0';
|
||||
return new Intl.NumberFormat('cs-CZ').format(num);
|
||||
};
|
||||
|
||||
const formatDuration = (ms: number | undefined) => {
|
||||
if (!ms || ms === 0) return '0s';
|
||||
|
||||
// Handle both milliseconds and seconds (Umami might return either)
|
||||
let totalSeconds = ms > 10000 ? Math.floor(ms / 1000) : Math.floor(ms);
|
||||
|
||||
const hours = Math.floor(totalSeconds / 3600);
|
||||
const minutes = Math.floor((totalSeconds % 3600) / 60);
|
||||
const seconds = totalSeconds % 60;
|
||||
|
||||
if (hours > 0) {
|
||||
return `${hours}h ${minutes}m ${seconds}s`;
|
||||
} else if (minutes > 0) {
|
||||
return `${minutes}m ${seconds}s`;
|
||||
}
|
||||
return `${seconds}s`;
|
||||
};
|
||||
|
||||
const renderMetricTable = (metrics: MetricItem[], title: string, isEventMetrics: boolean = false) => (
|
||||
<Card bg={bgColor} borderColor={borderColor}>
|
||||
<CardHeader>
|
||||
<Heading size="sm">{title}</Heading>
|
||||
</CardHeader>
|
||||
<CardBody>
|
||||
<Table variant="simple" size="sm">
|
||||
<Thead>
|
||||
<Tr>
|
||||
<Th>Název</Th>
|
||||
<Th isNumeric>Návštěvy</Th>
|
||||
</Tr>
|
||||
</Thead>
|
||||
<Tbody>
|
||||
{metrics.slice(0, 10).map((item, index) => {
|
||||
const eventInfo = isEventMetrics ? getEventTranslation(item.x) : null;
|
||||
return (
|
||||
<Tr key={index}>
|
||||
<Td>
|
||||
{isEventMetrics && eventInfo ? (
|
||||
<Tooltip label={eventInfo.description} placement="top" hasArrow>
|
||||
<Box>
|
||||
<Text noOfLines={1} maxW="300px" fontWeight="medium">
|
||||
{eventInfo.name}
|
||||
</Text>
|
||||
<Text fontSize="xs" color="gray.500">
|
||||
{eventInfo.source}
|
||||
</Text>
|
||||
</Box>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<Text noOfLines={1} maxW="300px">
|
||||
{item.x || '(prázdné)'}
|
||||
</Text>
|
||||
)}
|
||||
</Td>
|
||||
<Td isNumeric>
|
||||
<Badge colorScheme="blue">{formatNumber(item.y)}</Badge>
|
||||
</Td>
|
||||
</Tr>
|
||||
);
|
||||
})}
|
||||
{metrics.length === 0 && (
|
||||
<Tr>
|
||||
<Td colSpan={2} textAlign="center">
|
||||
<Text color="gray.500">Žádná data</Text>
|
||||
</Td>
|
||||
</Tr>
|
||||
)}
|
||||
</Tbody>
|
||||
</Table>
|
||||
</CardBody>
|
||||
</Card>
|
||||
);
|
||||
|
||||
if (loading && !stats) {
|
||||
return (
|
||||
<AdminLayout>
|
||||
<Container maxW="container.xl" py={8}>
|
||||
<VStack spacing={4}>
|
||||
<Spinner size="xl" />
|
||||
<Text>Načítání analytiky...</Text>
|
||||
</VStack>
|
||||
</Container>
|
||||
</AdminLayout>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<AdminLayout>
|
||||
<Container maxW="container.xl" py={8}>
|
||||
<VStack spacing={6} align="stretch">
|
||||
{/* Header */}
|
||||
<HStack justify="space-between" align="center">
|
||||
<HStack spacing={3}>
|
||||
<Icon as={FiCalendar} color="blue.500" boxSize={6} />
|
||||
<Heading size="lg">Analytika webu</Heading>
|
||||
</HStack>
|
||||
<HStack>
|
||||
<Icon as={FiCalendar} color="gray.500" boxSize={4} />
|
||||
<Select
|
||||
value={timeRange}
|
||||
onChange={(e) => setTimeRange(e.target.value)}
|
||||
maxW="200px"
|
||||
>
|
||||
<option value="0">Dnes (dnešní den)</option>
|
||||
<option value="1">Včera</option>
|
||||
<option value="7">Posledních 7 dní</option>
|
||||
<option value="30">Posledních 30 dní</option>
|
||||
<option value="90">Posledních 90 dní</option>
|
||||
<option value="365">Poslední rok</option>
|
||||
</Select>
|
||||
</HStack>
|
||||
</HStack>
|
||||
|
||||
{/* Stats Overview */}
|
||||
<SimpleGrid columns={{ base: 1, md: 2, lg: 5 }} spacing={4}>
|
||||
<Card bg={bgColor} borderColor={borderColor}>
|
||||
<CardBody>
|
||||
<Stat>
|
||||
<HStack spacing={2} mb={2}>
|
||||
<Icon as={FiEye} color="blue.500" boxSize={5} />
|
||||
<StatLabel>Zobrazení stránek</StatLabel>
|
||||
</HStack>
|
||||
<StatNumber>{formatNumber(stats?.pageviews?.value)}</StatNumber>
|
||||
<StatHelpText>Celkový počet zobrazení</StatHelpText>
|
||||
</Stat>
|
||||
</CardBody>
|
||||
</Card>
|
||||
|
||||
<Card bg={bgColor} borderColor={borderColor}>
|
||||
<CardBody>
|
||||
<Stat>
|
||||
<HStack spacing={2} mb={2}>
|
||||
<Icon as={FiUsers} color="green.500" boxSize={5} />
|
||||
<StatLabel>Návštěvníci</StatLabel>
|
||||
</HStack>
|
||||
<StatNumber>{formatNumber(stats?.visitors?.value)}</StatNumber>
|
||||
<StatHelpText>Unikátní návštěvníci</StatHelpText>
|
||||
</Stat>
|
||||
</CardBody>
|
||||
</Card>
|
||||
|
||||
<Card bg={bgColor} borderColor={borderColor}>
|
||||
<CardBody>
|
||||
<Stat>
|
||||
<HStack spacing={2} mb={2}>
|
||||
<Icon as={FiActivity} color="purple.500" boxSize={5} />
|
||||
<StatLabel>Návštěvy</StatLabel>
|
||||
</HStack>
|
||||
<StatNumber>{formatNumber(stats?.visits?.value)}</StatNumber>
|
||||
<StatHelpText>Celkový počet relací</StatHelpText>
|
||||
</Stat>
|
||||
</CardBody>
|
||||
</Card>
|
||||
|
||||
<Card bg={bgColor} borderColor={borderColor}>
|
||||
<CardBody>
|
||||
<Stat>
|
||||
<HStack spacing={2} mb={2}>
|
||||
<Icon as={FiPercent} color="orange.500" boxSize={5} />
|
||||
<StatLabel>Míra opuštění</StatLabel>
|
||||
</HStack>
|
||||
<StatNumber>
|
||||
{stats?.bounces?.value && stats?.visits?.value
|
||||
? `${Math.round((stats.bounces.value / stats.visits.value) * 100)}%`
|
||||
: '0%'}
|
||||
</StatNumber>
|
||||
<StatHelpText>Odchody po 1 stránce</StatHelpText>
|
||||
</Stat>
|
||||
</CardBody>
|
||||
</Card>
|
||||
|
||||
<Card bg={bgColor} borderColor={borderColor}>
|
||||
<CardBody>
|
||||
<Stat>
|
||||
<HStack spacing={2} mb={2}>
|
||||
<Icon as={FiClock} color="teal.500" boxSize={5} />
|
||||
<StatLabel>Průměrný čas</StatLabel>
|
||||
</HStack>
|
||||
<StatNumber>
|
||||
{formatDuration(
|
||||
stats?.totaltime?.value && stats?.visits?.value
|
||||
? stats.totaltime.value / stats.visits.value
|
||||
: 0
|
||||
)}
|
||||
</StatNumber>
|
||||
<StatHelpText>Průměrná délka návštěvy</StatHelpText>
|
||||
</Stat>
|
||||
</CardBody>
|
||||
</Card>
|
||||
</SimpleGrid>
|
||||
|
||||
{/* Error Message */}
|
||||
{errorMessage && (
|
||||
<Card bg="orange.50" borderColor="orange.300" borderWidth={2}>
|
||||
<CardBody>
|
||||
<HStack spacing={3} align="start">
|
||||
<Icon as={FiZap} color="orange.500" boxSize={6} mt={1} />
|
||||
<VStack align="start" spacing={2} flex={1}>
|
||||
<Text fontWeight="bold" color="orange.800" fontSize="lg">Analytika není k dispozici</Text>
|
||||
<Text fontSize="sm" color="orange.700">{errorMessage}</Text>
|
||||
<Divider borderColor="orange.200" />
|
||||
<Text fontSize="sm" color="orange.800" fontWeight="semibold">Možné příčiny:</Text>
|
||||
<VStack align="start" spacing={1} pl={4}>
|
||||
<Text fontSize="xs" color="orange.700">• Umami není spuštěno nebo není dostupné</Text>
|
||||
<Text fontSize="xs" color="orange.700">• V Umami instanci neexistuje žádný web</Text>
|
||||
<Text fontSize="xs" color="orange.700">• Nebyly ještě zaznamenány žádné návštěvy</Text>
|
||||
<Text fontSize="xs" color="orange.700">• Chybné přihlašovací údaje v .env souboru</Text>
|
||||
</VStack>
|
||||
<Divider borderColor="orange.200" />
|
||||
<Text fontSize="sm" color="orange.800" fontWeight="semibold">Řešení:</Text>
|
||||
<VStack align="start" spacing={1} pl={4}>
|
||||
<Text fontSize="xs" color="orange.700">
|
||||
1. Zkontrolujte, že Umami běží na <strong>{process.env.REACT_APP_UMAMI_URL || 'nakonfigurované URL'}</strong>
|
||||
</Text>
|
||||
<Text fontSize="xs" color="orange.700">
|
||||
2. Přihlaste se do Umami a vytvořte nový web, pokud žádný neexistuje
|
||||
</Text>
|
||||
<Text fontSize="xs" color="orange.700">
|
||||
3. Restartujte backend server pro opětovné připojení
|
||||
</Text>
|
||||
<Text fontSize="xs" color="orange.700">
|
||||
4. Zkontrolujte backend logy pro detailní chybové zprávy
|
||||
</Text>
|
||||
</VStack>
|
||||
<Button
|
||||
size="sm"
|
||||
colorScheme="orange"
|
||||
variant="outline"
|
||||
leftIcon={<Icon as={FiZap} />}
|
||||
onClick={() => window.location.reload()}
|
||||
mt={2}
|
||||
>
|
||||
Znovu načíst stránku
|
||||
</Button>
|
||||
</VStack>
|
||||
</HStack>
|
||||
</CardBody>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Pageviews Chart */}
|
||||
<Card bg={bgColor} borderColor={borderColor}>
|
||||
<CardHeader>
|
||||
<HStack spacing={2}>
|
||||
<Icon as={FiTrendingUp} color="blue.500" boxSize={5} />
|
||||
<Heading size="md">Zobrazení stránek v čase</Heading>
|
||||
</HStack>
|
||||
</CardHeader>
|
||||
<CardBody>
|
||||
{loading && pageviewsData.length === 0 ? (
|
||||
<Flex justify="center" py={8}>
|
||||
<Spinner size="lg" />
|
||||
</Flex>
|
||||
) : pageviewsData.length === 0 || pageviewsData.every(d => d.value === 0) ? (
|
||||
<Flex justify="center" align="center" direction="column" py={8}>
|
||||
<Icon as={FiTrendingUp} color="gray.300" boxSize={12} mb={3} />
|
||||
<Text color="gray.500" fontWeight="medium">Žádná data pro zobrazení</Text>
|
||||
<Text color="gray.400" fontSize="sm" mt={1}>Pro vybrané časové období nejsou k dispozici žádná data o návštěvnosti</Text>
|
||||
</Flex>
|
||||
) : (
|
||||
<Box height="300px">
|
||||
<Bar
|
||||
data={{
|
||||
labels: pageviewsData.map(d => d.date),
|
||||
datasets: [
|
||||
{
|
||||
label: 'Zobrazení',
|
||||
data: pageviewsData.map(d => d.value),
|
||||
backgroundColor: 'rgba(66, 153, 225, 0.6)',
|
||||
borderColor: 'rgb(66, 153, 225)',
|
||||
borderWidth: 1,
|
||||
borderRadius: 4,
|
||||
},
|
||||
],
|
||||
}}
|
||||
options={{
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
legend: {
|
||||
display: false,
|
||||
},
|
||||
tooltip: {
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.8)',
|
||||
titleColor: '#ffffff',
|
||||
bodyColor: '#ffffff',
|
||||
borderColor: 'rgba(255, 255, 255, 0.1)',
|
||||
borderWidth: 1,
|
||||
padding: 12,
|
||||
displayColors: false,
|
||||
callbacks: {
|
||||
label: function(context) {
|
||||
return `Zobrazení: ${context.parsed.y}`;
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
scales: {
|
||||
y: {
|
||||
beginAtZero: true,
|
||||
ticks: {
|
||||
color: '#718096',
|
||||
},
|
||||
grid: {
|
||||
color: 'rgba(0, 0, 0, 0.1)',
|
||||
},
|
||||
},
|
||||
x: {
|
||||
ticks: {
|
||||
color: '#718096',
|
||||
},
|
||||
grid: {
|
||||
display: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
</CardBody>
|
||||
</Card>
|
||||
|
||||
<Divider />
|
||||
|
||||
{/* Country Flags Section */}
|
||||
<Card bg={bgColor} borderColor={borderColor}>
|
||||
<CardHeader>
|
||||
<HStack spacing={2}>
|
||||
<Icon as={FiGlobe} color="green.500" boxSize={5} />
|
||||
<Heading size="md">Návštěvníci podle zemí</Heading>
|
||||
</HStack>
|
||||
</CardHeader>
|
||||
<CardBody>
|
||||
{loading && countryMetrics.length === 0 ? (
|
||||
<Flex justify="center" py={8}>
|
||||
<Spinner size="lg" />
|
||||
</Flex>
|
||||
) : countryMetrics.length === 0 ? (
|
||||
<Text textAlign="center" color="gray.500" py={8}>
|
||||
Žádná data o zemích
|
||||
</Text>
|
||||
) : (
|
||||
<SimpleGrid columns={{ base: 1, sm: 2, md: 3, lg: 4 }} spacing={4}>
|
||||
{countryMetrics.slice(0, 12).map((country, index) => {
|
||||
const countryCode = country.x?.toUpperCase() || '';
|
||||
const countryName = new Intl.DisplayNames(['cs', 'en'], { type: 'region' }).of(countryCode) || countryCode;
|
||||
const flagEmoji = countryCode.length === 2
|
||||
? String.fromCodePoint(...[...countryCode].map(c => 0x1F1E6 - 65 + c.charCodeAt(0)))
|
||||
: '🏳️';
|
||||
|
||||
return (
|
||||
<Card
|
||||
key={index}
|
||||
variant="outline"
|
||||
cursor="pointer"
|
||||
transition="all 0.2s"
|
||||
_hover={{
|
||||
transform: 'translateY(-2px)',
|
||||
boxShadow: 'md',
|
||||
borderColor: 'blue.400'
|
||||
}}
|
||||
onClick={() => handleCountryClick(countryCode, countryName, country.y)}
|
||||
>
|
||||
<CardBody p={4}>
|
||||
<VStack spacing={2}>
|
||||
<Text fontSize="4xl">{flagEmoji}</Text>
|
||||
<Text fontWeight="semibold" fontSize="sm" noOfLines={1}>
|
||||
{countryName}
|
||||
</Text>
|
||||
<Badge colorScheme="blue" fontSize="sm">
|
||||
{formatNumber(country.y)} návštěv
|
||||
</Badge>
|
||||
</VStack>
|
||||
</CardBody>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</SimpleGrid>
|
||||
)}
|
||||
</CardBody>
|
||||
</Card>
|
||||
|
||||
{/* Detailed Metrics */}
|
||||
<Tabs colorScheme="blue">
|
||||
<TabList flexWrap="wrap">
|
||||
<Tab><HStack spacing={2}><Icon as={FiFile} /><Text>Stránky</Text></HStack></Tab>
|
||||
<Tab><HStack spacing={2}><Icon as={FiMonitor} /><Text>Prohlížeče</Text></HStack></Tab>
|
||||
<Tab><HStack spacing={2}><Icon as={FiCpu} /><Text>Operační systémy</Text></HStack></Tab>
|
||||
<Tab><HStack spacing={2}><Icon as={FiGlobe} /><Text>Země</Text></HStack></Tab>
|
||||
<Tab><HStack spacing={2}><Icon as={FiSmartphone} /><Text>Zařízení</Text></HStack></Tab>
|
||||
<Tab><HStack spacing={2}><Icon as={FiZap} /><Text>Události</Text></HStack></Tab>
|
||||
<Tab><HStack spacing={2}><Icon as={FiSearch} /><Text>Query parametry</Text></HStack></Tab>
|
||||
</TabList>
|
||||
|
||||
<TabPanels>
|
||||
<TabPanel>
|
||||
{renderMetricTable(pageMetrics, 'Nejnavštěvovanější stránky')}
|
||||
</TabPanel>
|
||||
|
||||
<TabPanel>
|
||||
{renderMetricTable(browserMetrics, 'Používané prohlížeče')}
|
||||
</TabPanel>
|
||||
|
||||
<TabPanel>{renderMetricTable(osMetrics, 'Operační systémy')}</TabPanel>
|
||||
|
||||
<TabPanel>{renderMetricTable(countryMetrics, 'Země návštěvníků')}</TabPanel>
|
||||
|
||||
<TabPanel>{renderMetricTable(deviceMetrics, 'Typy zařízení')}</TabPanel>
|
||||
|
||||
<TabPanel>
|
||||
{renderMetricTable(eventMetrics, 'Události uživatelů', true)}
|
||||
</TabPanel>
|
||||
|
||||
<TabPanel>
|
||||
{renderMetricTable(queryMetrics, 'Query parametry URL')}
|
||||
</TabPanel>
|
||||
</TabPanels>
|
||||
</Tabs>
|
||||
</VStack>
|
||||
</Container>
|
||||
|
||||
{/* Country Details Modal */}
|
||||
<Modal isOpen={isOpen} onClose={handleModalClose} size="6xl">
|
||||
<ModalOverlay />
|
||||
<ModalContent>
|
||||
<ModalHeader>
|
||||
<Flex align="center" gap={3}>
|
||||
<Text>Analytika pro zemi: {selectedCountry?.name}</Text>
|
||||
<Badge colorScheme="blue" fontSize="sm">
|
||||
{formatNumber(selectedCountry?.value)} návštěv
|
||||
</Badge>
|
||||
</Flex>
|
||||
</ModalHeader>
|
||||
<ModalCloseButton />
|
||||
<ModalBody pb={6}>
|
||||
{loadingCountryDetails ? (
|
||||
<Flex justify="center" align="center" py={8}>
|
||||
<Spinner size="lg" />
|
||||
<Text ml={3}>Načítání detailů...</Text>
|
||||
</Flex>
|
||||
) : countryDetails ? (
|
||||
<Tabs colorScheme="blue">
|
||||
<TabList>
|
||||
<Tab>Stránky</Tab>
|
||||
<Tab>Prohlížeče</Tab>
|
||||
<Tab>Operační systémy</Tab>
|
||||
<Tab>Zařízení</Tab>
|
||||
<Tab>Události</Tab>
|
||||
</TabList>
|
||||
|
||||
<TabPanels>
|
||||
<TabPanel>
|
||||
{renderMetricTable(countryDetails.pages, `Nejnavštěvovanější stránky v ${selectedCountry?.name}`)}
|
||||
</TabPanel>
|
||||
|
||||
<TabPanel>
|
||||
{renderMetricTable(countryDetails.browsers, `Používané prohlížeče v ${selectedCountry?.name}`)}
|
||||
</TabPanel>
|
||||
|
||||
<TabPanel>
|
||||
{renderMetricTable(countryDetails.os, `Operační systémy v ${selectedCountry?.name}`)}
|
||||
</TabPanel>
|
||||
|
||||
<TabPanel>
|
||||
{renderMetricTable(countryDetails.devices, `Typy zařízení v ${selectedCountry?.name}`)}
|
||||
</TabPanel>
|
||||
|
||||
<TabPanel>
|
||||
{renderMetricTable(countryDetails.events, `Události uživatelů z ${selectedCountry?.name}`, true)}
|
||||
</TabPanel>
|
||||
</TabPanels>
|
||||
</Tabs>
|
||||
) : (
|
||||
<Box textAlign="center" py={8}>
|
||||
<Text color="gray.500">
|
||||
Pro vybranou zemi nejsou k dispozici detailní data.
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
</ModalBody>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
</AdminLayout>
|
||||
);
|
||||
};
|
||||
|
||||
export default AnalyticsAdminPage;
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,517 @@
|
||||
import React, { useState, useRef } from 'react';
|
||||
import { Box, Button, FormControl, FormLabel, Heading, HStack, IconButton, Image, Input, Modal, ModalBody, ModalCloseButton, ModalContent, ModalFooter, ModalHeader, ModalOverlay, Spinner, Table, Tbody, Td, Th, Thead, Tr, useColorModeValue, useDisclosure, useToast, VStack, Select, Text, Switch, Badge, Alert, AlertIcon, AlertTitle, AlertDescription, Divider, Grid, GridItem } from '@chakra-ui/react';
|
||||
import { FiPlus, FiEdit2, FiTrash2, FiUpload, FiAlertCircle, FiCheckCircle } from 'react-icons/fi';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import AdminLayout from '../../layouts/AdminLayout';
|
||||
import { Sponsor, getSponsors, createSponsor, updateSponsor, deleteSponsor } from '../../services/sponsors';
|
||||
import { uploadFile } from '../../services/articles';
|
||||
import { assetUrl } from '../../utils/url';
|
||||
|
||||
// Banner placement presets with dimensions and descriptions
|
||||
type BannerPreset = {
|
||||
value: string;
|
||||
label: string;
|
||||
description: string;
|
||||
width: number;
|
||||
height: number;
|
||||
aspectRatio: number;
|
||||
position: 'top' | 'middle' | 'sidebar' | 'footer' | 'article';
|
||||
};
|
||||
|
||||
const BANNER_PRESETS: BannerPreset[] = [
|
||||
{
|
||||
value: 'homepage_top',
|
||||
label: 'Hlavní banner (Homepage - vrchol)',
|
||||
description: 'Hlavní reklamní plocha nahoře, zobrazena všem návštěvníkům',
|
||||
width: 1200,
|
||||
height: 200,
|
||||
aspectRatio: 6,
|
||||
position: 'top'
|
||||
},
|
||||
{
|
||||
value: 'homepage_middle',
|
||||
label: 'Střední banner (Homepage - střed)',
|
||||
description: 'Banner ve středu stránky mezi obsahem',
|
||||
width: 970,
|
||||
height: 250,
|
||||
aspectRatio: 3.88,
|
||||
position: 'middle'
|
||||
},
|
||||
{
|
||||
value: 'homepage_sidebar',
|
||||
label: 'Postranní banner (Homepage - sidebar)',
|
||||
description: 'Menší banner v pravém postranním panelu',
|
||||
width: 300,
|
||||
height: 250,
|
||||
aspectRatio: 1.2,
|
||||
position: 'sidebar'
|
||||
},
|
||||
{
|
||||
value: 'homepage_footer',
|
||||
label: 'Spodní banner (Homepage - zápatí)',
|
||||
description: 'Banner v dolní části stránky před zápatím',
|
||||
width: 1200,
|
||||
height: 200,
|
||||
aspectRatio: 6,
|
||||
position: 'footer'
|
||||
},
|
||||
{
|
||||
value: 'article_inline',
|
||||
label: 'Banner v článcích',
|
||||
description: 'Banner zobrazený v textu článků',
|
||||
width: 728,
|
||||
height: 90,
|
||||
aspectRatio: 8.09,
|
||||
position: 'article'
|
||||
}
|
||||
];
|
||||
|
||||
const BannersAdminPage: React.FC = () => {
|
||||
const cardBg = useColorModeValue('white', 'gray.800');
|
||||
const borderColor = useColorModeValue('gray.200', 'gray.700');
|
||||
const inputBg = useColorModeValue('white', 'gray.700');
|
||||
const toast = useToast();
|
||||
const qc = useQueryClient();
|
||||
const { data, isLoading } = useQuery({ queryKey: ['admin-banners'], queryFn: getSponsors });
|
||||
const [editing, setEditing] = useState<Partial<Sponsor> | null>(null);
|
||||
const [imageResolution, setImageResolution] = useState<{ width: number; height: number } | null>(null);
|
||||
const [recommendedPlacements, setRecommendedPlacements] = useState<BannerPreset[]>([]);
|
||||
const [uploadingImage, setUploadingImage] = useState(false);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const { isOpen, onOpen, onClose } = useDisclosure();
|
||||
|
||||
// Get preset by value
|
||||
const getPreset = (placement?: string): BannerPreset | undefined => {
|
||||
return BANNER_PRESETS.find(p => p.value === placement);
|
||||
};
|
||||
|
||||
// Recommend placements based on image resolution
|
||||
const recommendPlacement = (imgWidth: number, imgHeight: number): BannerPreset[] => {
|
||||
const imgAspectRatio = imgWidth / imgHeight;
|
||||
|
||||
// Sort presets by how close their aspect ratio is to the image
|
||||
const scored = BANNER_PRESETS.map(preset => {
|
||||
const ratioDiff = Math.abs(preset.aspectRatio - imgAspectRatio);
|
||||
const widthDiff = Math.abs(preset.width - imgWidth) / preset.width;
|
||||
const heightDiff = Math.abs(preset.height - imgHeight) / preset.height;
|
||||
|
||||
// Lower score is better
|
||||
const score = ratioDiff * 2 + widthDiff + heightDiff;
|
||||
|
||||
return { preset, score };
|
||||
}).sort((a, b) => a.score - b.score);
|
||||
|
||||
// Return top 3 recommendations
|
||||
return scored.slice(0, 3).map(s => s.preset);
|
||||
};
|
||||
const openCreate = () => {
|
||||
const defaultPreset = BANNER_PRESETS[0];
|
||||
setEditing({
|
||||
name: '',
|
||||
is_active: true,
|
||||
placement: defaultPreset.value,
|
||||
width: defaultPreset.width,
|
||||
height: defaultPreset.height
|
||||
} as any);
|
||||
setImageResolution(null);
|
||||
setRecommendedPlacements([]);
|
||||
onOpen();
|
||||
};
|
||||
const openEdit = (s: Sponsor) => {
|
||||
setEditing({ ...s });
|
||||
setImageResolution(null);
|
||||
setRecommendedPlacements([]);
|
||||
onOpen();
|
||||
};
|
||||
const closeModal = () => {
|
||||
setEditing(null);
|
||||
setImageResolution(null);
|
||||
setRecommendedPlacements([]);
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.value = '';
|
||||
}
|
||||
onClose();
|
||||
};
|
||||
|
||||
const createMut = useMutation({
|
||||
mutationFn: (payload: any) => createSponsor(payload),
|
||||
onSuccess: () => { toast({ title: 'Banner vytvořen', status: 'success' }); qc.invalidateQueries({ queryKey: ['admin-banners'] }); closeModal(); },
|
||||
onError: (e: any) => toast({ title: 'Vytvoření selhalo', description: e?.response?.data?.message || 'Chyba', status: 'error' }),
|
||||
});
|
||||
const updateMut = useMutation({
|
||||
mutationFn: ({ id, payload }: { id: number | string; payload: any }) => updateSponsor(id, payload),
|
||||
onSuccess: () => { toast({ title: 'Banner upraven', status: 'success' }); qc.invalidateQueries({ queryKey: ['admin-banners'] }); closeModal(); },
|
||||
onError: (e: any) => toast({ title: 'Aktualizace selhala', description: e?.response?.data?.message || 'Chyba', status: 'error' }),
|
||||
});
|
||||
const deleteMut = useMutation({
|
||||
mutationFn: (id: number | string) => deleteSponsor(id),
|
||||
onSuccess: () => { toast({ title: 'Banner smazán', status: 'success' }); qc.invalidateQueries({ queryKey: ['admin-banners'] }); },
|
||||
onError: (e: any) => toast({ title: 'Smazání selhalo', description: e?.response?.data?.message || 'Chyba', status: 'error' }),
|
||||
});
|
||||
|
||||
const onSubmit = async () => {
|
||||
if (!editing) return;
|
||||
const payload = {
|
||||
name: editing.name || '',
|
||||
logo_url: editing.logo_url,
|
||||
website_url: editing.website_url,
|
||||
is_active: editing.is_active ?? true,
|
||||
placement: (editing as any).placement || '',
|
||||
width: (editing as any).width || undefined,
|
||||
height: (editing as any).height || undefined,
|
||||
};
|
||||
if ((editing as any).id != null) {
|
||||
await updateMut.mutateAsync({ id: (editing as any).id, payload });
|
||||
} else {
|
||||
await createMut.mutateAsync(payload);
|
||||
}
|
||||
};
|
||||
|
||||
const onUpload = async (file?: File | null) => {
|
||||
if (!file) return;
|
||||
|
||||
setUploadingImage(true);
|
||||
|
||||
try {
|
||||
// First, detect image resolution
|
||||
const img = new window.Image();
|
||||
const imageLoadPromise = new Promise<{ width: number; height: number }>((resolve, reject) => {
|
||||
img.onload = () => resolve({ width: img.width, height: img.height });
|
||||
img.onerror = reject;
|
||||
img.src = URL.createObjectURL(file);
|
||||
});
|
||||
|
||||
const resolution = await imageLoadPromise;
|
||||
setImageResolution(resolution);
|
||||
|
||||
// Recommend placements based on resolution
|
||||
const recommended = recommendPlacement(resolution.width, resolution.height);
|
||||
setRecommendedPlacements(recommended);
|
||||
|
||||
// Upload the file
|
||||
const res = await uploadFile(file);
|
||||
|
||||
// Update editing state with uploaded URL
|
||||
setEditing((prev) => ({ ...(prev || {}), logo_url: res.url }));
|
||||
|
||||
// If no placement selected yet, auto-select the best recommendation
|
||||
if (!editing?.placement && recommended.length > 0) {
|
||||
const bestMatch = recommended[0];
|
||||
setEditing((prev) => ({
|
||||
...(prev || {}),
|
||||
placement: bestMatch.value,
|
||||
width: bestMatch.width,
|
||||
height: bestMatch.height
|
||||
}));
|
||||
|
||||
toast({
|
||||
title: 'Obrázek nahrán',
|
||||
description: `Rozlišení: ${resolution.width}×${resolution.height}. Doporučeno umístění: ${bestMatch.label}`,
|
||||
status: 'success',
|
||||
duration: 6000
|
||||
});
|
||||
} else {
|
||||
toast({
|
||||
title: 'Obrázek nahrán',
|
||||
description: `Rozlišení: ${resolution.width}×${resolution.height}`,
|
||||
status: 'success'
|
||||
});
|
||||
}
|
||||
|
||||
// Clean up object URL
|
||||
URL.revokeObjectURL(img.src);
|
||||
} catch (e: any) {
|
||||
toast({ title: 'Nahrání selhalo', description: e?.message || 'Zkuste to znovu', status: 'error' });
|
||||
} finally {
|
||||
setUploadingImage(false);
|
||||
}
|
||||
};
|
||||
|
||||
const banners = data || [];
|
||||
|
||||
return (
|
||||
<AdminLayout>
|
||||
<Box>
|
||||
<HStack justify="space-between" mb={4}>
|
||||
<Heading size="lg">Bannery a reklamní plochy</Heading>
|
||||
<Button leftIcon={<FiPlus />} colorScheme="blue" onClick={openCreate}>
|
||||
Nový banner
|
||||
</Button>
|
||||
</HStack>
|
||||
<Text color="gray.500" mb={6}>
|
||||
Správa bannerů a reklamních ploch zobrazovaných na webu. Můžete přidávat, upravovat a odebírat bannery.
|
||||
</Text>
|
||||
|
||||
<Box
|
||||
bg={useColorModeValue('white', 'gray.800')}
|
||||
borderWidth="1px"
|
||||
borderRadius="lg"
|
||||
overflowX="auto"
|
||||
boxShadow="sm"
|
||||
mb={6}
|
||||
>
|
||||
<Table size="sm">
|
||||
<Thead>
|
||||
<Tr>
|
||||
<Th w="100px">Náhled</Th>
|
||||
<Th>Název</Th>
|
||||
<Th>Umístění</Th>
|
||||
<Th>Rozměry</Th>
|
||||
<Th w="100px">Aktivní</Th>
|
||||
<Th w="160px">Akce</Th>
|
||||
</Tr>
|
||||
</Thead>
|
||||
<Tbody>
|
||||
{isLoading && (
|
||||
<Tr><Td colSpan={6} textAlign="center"><Spinner size="sm" mr={2} />Načítání…</Td></Tr>
|
||||
)}
|
||||
{!isLoading && banners.map((b) => {
|
||||
const preset = getPreset((b as any).placement);
|
||||
return (
|
||||
<Tr key={b.id}>
|
||||
<Td>
|
||||
<Image src={assetUrl(b.logo_url) || '/logo192.png'} alt={b.name} boxSize="56px" objectFit="contain" bg={inputBg} borderRadius="md" />
|
||||
</Td>
|
||||
<Td>
|
||||
<Text fontWeight="500">{b.name}</Text>
|
||||
{b.website_url && (
|
||||
<Text fontSize="xs" color="gray.500" noOfLines={1}>{b.website_url}</Text>
|
||||
)}
|
||||
</Td>
|
||||
<Td>
|
||||
{preset ? (
|
||||
<VStack align="start" spacing={0}>
|
||||
<Text fontSize="sm" fontWeight="500">{preset.label}</Text>
|
||||
<Badge colorScheme="blue" fontSize="xs">{preset.position}</Badge>
|
||||
</VStack>
|
||||
) : (
|
||||
<Text fontSize="xs" color="gray.500">-</Text>
|
||||
)}
|
||||
</Td>
|
||||
<Td>
|
||||
{(b as any).width && (b as any).height ? (
|
||||
<Text fontSize="xs">{(b as any).width} × {(b as any).height}</Text>
|
||||
) : '-'}
|
||||
</Td>
|
||||
<Td>
|
||||
<Badge colorScheme={b.is_active ? 'green' : 'gray'}>
|
||||
{b.is_active ? 'Ano' : 'Ne'}
|
||||
</Badge>
|
||||
</Td>
|
||||
<Td>
|
||||
<HStack>
|
||||
<IconButton aria-label="Upravit" size="sm" icon={<FiEdit2 />} onClick={() => openEdit(b)} />
|
||||
<IconButton aria-label="Smazat" size="sm" colorScheme="red" icon={<FiTrash2 />} onClick={() => { if (b.id != null) deleteMut.mutate(b.id); }} />
|
||||
</HStack>
|
||||
</Td>
|
||||
</Tr>
|
||||
);
|
||||
})}
|
||||
</Tbody>
|
||||
</Table>
|
||||
</Box>
|
||||
<Modal isOpen={isOpen} onClose={closeModal} size="lg">
|
||||
<ModalOverlay />
|
||||
<ModalContent maxW="90vw" maxH="90vh" overflowY="auto">
|
||||
<ModalHeader>{(editing as any)?.id ? 'Upravit banner' : 'Nový banner'}</ModalHeader>
|
||||
<ModalCloseButton />
|
||||
<ModalBody>
|
||||
<VStack align="stretch" spacing={4}>
|
||||
<FormControl isRequired>
|
||||
<FormLabel>Název</FormLabel>
|
||||
<Input value={editing?.name || ''} onChange={(e) => setEditing((prev) => ({ ...(prev as any), name: e.target.value }))} />
|
||||
</FormControl>
|
||||
<FormControl>
|
||||
<FormLabel>Odkaz (po kliku)</FormLabel>
|
||||
<Input type="url" value={editing?.website_url || ''} onChange={(e) => setEditing((prev) => ({ ...(prev as any), website_url: e.target.value }))} placeholder="https://partner.cz" />
|
||||
</FormControl>
|
||||
{/* Image resolution info */}
|
||||
{imageResolution && (
|
||||
<Alert status="info" borderRadius="md">
|
||||
<AlertIcon />
|
||||
<Box flex="1">
|
||||
<AlertTitle fontSize="sm">Rozlišení obrázku: {imageResolution.width} × {imageResolution.height} px</AlertTitle>
|
||||
<AlertDescription fontSize="xs">
|
||||
Poměr stran: {(imageResolution.width / imageResolution.height).toFixed(2)}:1
|
||||
</AlertDescription>
|
||||
</Box>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* Recommended placements */}
|
||||
{recommendedPlacements.length > 0 && (
|
||||
<Box p={3} bg={useColorModeValue('blue.50', 'blue.900')} borderRadius="md">
|
||||
<Text fontSize="sm" fontWeight="600" mb={2} color={useColorModeValue('blue.700', 'blue.200')}>
|
||||
<FiCheckCircle style={{ display: 'inline', marginRight: '6px' }} />
|
||||
Doporučená umístění na základě rozlišení:
|
||||
</Text>
|
||||
<VStack align="stretch" spacing={1}>
|
||||
{recommendedPlacements.map((preset, idx) => (
|
||||
<HStack key={preset.value} justify="space-between" fontSize="xs">
|
||||
<Text>
|
||||
<Badge colorScheme={idx === 0 ? 'green' : 'blue'} mr={2}>
|
||||
{idx === 0 ? 'Nejlepší' : `#${idx + 1}`}
|
||||
</Badge>
|
||||
{preset.label} ({preset.width}×{preset.height})
|
||||
</Text>
|
||||
{editing?.placement !== preset.value && (
|
||||
<Button
|
||||
size="xs"
|
||||
variant="link"
|
||||
colorScheme="blue"
|
||||
onClick={() => {
|
||||
setEditing(prev => ({
|
||||
...prev,
|
||||
placement: preset.value,
|
||||
width: preset.width,
|
||||
height: preset.height
|
||||
}));
|
||||
}}
|
||||
>
|
||||
Použít
|
||||
</Button>
|
||||
)}
|
||||
</HStack>
|
||||
))}
|
||||
</VStack>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<FormControl isRequired>
|
||||
<FormLabel>Umístění na webu</FormLabel>
|
||||
<Select
|
||||
value={(editing as any)?.placement || ''}
|
||||
onChange={(e) => {
|
||||
const placement = e.target.value;
|
||||
const preset = getPreset(placement);
|
||||
setEditing((prev) => ({
|
||||
...(prev as any),
|
||||
placement,
|
||||
width: preset?.width,
|
||||
height: preset?.height
|
||||
}));
|
||||
}}
|
||||
>
|
||||
<option value="">— vyberte umístění —</option>
|
||||
{BANNER_PRESETS.map(preset => (
|
||||
<option key={preset.value} value={preset.value}>
|
||||
{preset.label} ({preset.width}×{preset.height})
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
{editing?.placement && (() => {
|
||||
const preset = getPreset((editing as any).placement);
|
||||
return preset ? (
|
||||
<Text fontSize="xs" color="gray.500" mt={1}>
|
||||
{preset.description}
|
||||
</Text>
|
||||
) : null;
|
||||
})()}
|
||||
</FormControl>
|
||||
|
||||
{/* Placement dimensions display (read-only) */}
|
||||
{editing?.placement && (() => {
|
||||
const preset = getPreset((editing as any).placement);
|
||||
return preset ? (
|
||||
<Box p={3} bg={inputBg} borderRadius="md" borderWidth="1px" borderColor={borderColor}>
|
||||
<HStack justify="space-between" mb={1}>
|
||||
<Text fontSize="sm" fontWeight="600">Rozměry banneru:</Text>
|
||||
<Badge colorScheme="blue">{preset.width} × {preset.height} px</Badge>
|
||||
</HStack>
|
||||
<Text fontSize="xs" color="gray.500">
|
||||
Poměr stran: {preset.aspectRatio.toFixed(2)}:1 • Pozice: {preset.position}
|
||||
</Text>
|
||||
</Box>
|
||||
) : null;
|
||||
})()}
|
||||
|
||||
<Divider />
|
||||
<FormControl>
|
||||
<FormLabel>Obrázek banneru</FormLabel>
|
||||
<VStack align="stretch" spacing={3}>
|
||||
{/* Preview */}
|
||||
{editing?.logo_url && (() => {
|
||||
const preset = getPreset((editing as any)?.placement);
|
||||
const previewWidth = preset ? Math.min(preset.width, 600) : 300;
|
||||
const previewHeight = preset ? (previewWidth / preset.aspectRatio) : 150;
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Text fontSize="xs" color="gray.500" mb={2}>Náhled banneru:</Text>
|
||||
<Box
|
||||
borderWidth="2px"
|
||||
borderColor={borderColor}
|
||||
borderRadius="md"
|
||||
p={2}
|
||||
bg={inputBg}
|
||||
>
|
||||
<Image
|
||||
src={assetUrl(editing?.logo_url) || '/logo192.png'}
|
||||
alt="banner preview"
|
||||
width={`${previewWidth}px`}
|
||||
height={`${previewHeight}px`}
|
||||
objectFit="contain"
|
||||
mx="auto"
|
||||
display="block"
|
||||
/>
|
||||
</Box>
|
||||
{preset && (
|
||||
<Text fontSize="xs" color="gray.500" mt={1} textAlign="center">
|
||||
Zobrazení v pozici: {preset.label}
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
})()}
|
||||
|
||||
{/* Upload button */}
|
||||
<HStack>
|
||||
<Button
|
||||
as="label"
|
||||
type="button"
|
||||
leftIcon={<FiUpload />}
|
||||
colorScheme="blue"
|
||||
variant="outline"
|
||||
isLoading={uploadingImage}
|
||||
loadingText="Nahrávání..."
|
||||
>
|
||||
{editing?.logo_url ? 'Změnit obrázek' : 'Nahrát obrázek'}
|
||||
<Input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
display="none"
|
||||
accept="image/*"
|
||||
onChange={async (e) => {
|
||||
await onUpload(e.target.files?.[0]);
|
||||
}}
|
||||
/>
|
||||
</Button>
|
||||
{uploadingImage && <Spinner size="sm" />}
|
||||
</HStack>
|
||||
|
||||
{!editing?.logo_url && (
|
||||
<Alert status="warning" fontSize="xs">
|
||||
<AlertIcon boxSize="12px" />
|
||||
<Text fontSize="xs">Nahrajte obrázek pro automatické doporučení umístění</Text>
|
||||
</Alert>
|
||||
)}
|
||||
</VStack>
|
||||
</FormControl>
|
||||
<FormControl display="flex" alignItems="center">
|
||||
<FormLabel mb="0">Aktivní</FormLabel>
|
||||
<Switch isChecked={!!editing?.is_active} onChange={(e) => setEditing((prev) => ({ ...(prev as any), is_active: e.target.checked }))} />
|
||||
</FormControl>
|
||||
</VStack>
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<Button variant="ghost" mr={3} onClick={closeModal}>Zrušit</Button>
|
||||
<Button colorScheme="blue" onClick={onSubmit} isLoading={createMut.isLoading || updateMut.isLoading}>Uložit</Button>
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
</Box>
|
||||
</AdminLayout>
|
||||
);
|
||||
};
|
||||
|
||||
export default BannersAdminPage;
|
||||
@@ -0,0 +1,286 @@
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
Heading,
|
||||
HStack,
|
||||
IconButton,
|
||||
Input,
|
||||
Table,
|
||||
Tbody,
|
||||
Td,
|
||||
Th,
|
||||
Thead,
|
||||
Tr,
|
||||
useDisclosure,
|
||||
useToast,
|
||||
Modal,
|
||||
ModalOverlay,
|
||||
ModalContent,
|
||||
ModalHeader,
|
||||
ModalCloseButton,
|
||||
ModalBody,
|
||||
ModalFooter,
|
||||
FormControl,
|
||||
FormLabel,
|
||||
VStack,
|
||||
Text,
|
||||
useColorModeValue,
|
||||
Skeleton,
|
||||
Alert,
|
||||
AlertIcon,
|
||||
Badge,
|
||||
Textarea,
|
||||
} from '@chakra-ui/react';
|
||||
import { useState } from 'react';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { FiEdit2, FiPlus, FiTrash2, FiTag } from 'react-icons/fi';
|
||||
import AdminLayout from '../../layouts/AdminLayout';
|
||||
import { PageHeader } from '../../components/admin/PageHeader';
|
||||
import {
|
||||
CategoryItem,
|
||||
getCategories,
|
||||
createCategory,
|
||||
updateCategory,
|
||||
deleteCategory,
|
||||
} from '../../services/categories';
|
||||
|
||||
const CategoriesAdminPage = () => {
|
||||
const toast = useToast();
|
||||
const qc = useQueryClient();
|
||||
const { data: categories, isLoading } = useQuery({ queryKey: ['admin-categories'], queryFn: getCategories });
|
||||
const [editing, setEditing] = useState<Partial<CategoryItem> | null>(null);
|
||||
const { isOpen, onOpen, onClose } = useDisclosure();
|
||||
const bgCard = useColorModeValue('white', 'gray.800');
|
||||
const borderColor = useColorModeValue('gray.200', 'gray.700');
|
||||
|
||||
const openCreate = () => {
|
||||
setEditing({ name: '', description: '' });
|
||||
onOpen();
|
||||
};
|
||||
|
||||
const openEdit = (cat: CategoryItem) => {
|
||||
setEditing({ ...cat });
|
||||
onOpen();
|
||||
};
|
||||
|
||||
const closeModal = () => {
|
||||
setEditing(null);
|
||||
onClose();
|
||||
};
|
||||
|
||||
const createMut = useMutation({
|
||||
mutationFn: (payload: any) => createCategory(payload),
|
||||
onSuccess: () => {
|
||||
toast({ title: 'Kategorie vytvořena', status: 'success', duration: 3000 });
|
||||
qc.invalidateQueries({ queryKey: ['admin-categories'] });
|
||||
closeModal();
|
||||
},
|
||||
onError: (e: any) =>
|
||||
toast({
|
||||
title: 'Vytvoření selhalo',
|
||||
description: e?.response?.data?.chyba || e?.message || 'Chyba',
|
||||
status: 'error',
|
||||
duration: 5000,
|
||||
}),
|
||||
});
|
||||
|
||||
const updateMut = useMutation({
|
||||
mutationFn: ({ id, payload }: { id: number | string; payload: any }) => updateCategory(id, payload),
|
||||
onSuccess: () => {
|
||||
toast({ title: 'Kategorie aktualizována', status: 'success', duration: 3000 });
|
||||
qc.invalidateQueries({ queryKey: ['admin-categories'] });
|
||||
closeModal();
|
||||
},
|
||||
onError: (e: any) =>
|
||||
toast({
|
||||
title: 'Aktualizace selhala',
|
||||
description: e?.response?.data?.chyba || e?.message || 'Chyba',
|
||||
status: 'error',
|
||||
duration: 5000,
|
||||
}),
|
||||
});
|
||||
|
||||
const deleteMut = useMutation({
|
||||
mutationFn: (id: number | string) => deleteCategory(id),
|
||||
onSuccess: () => {
|
||||
toast({ title: 'Kategorie smazána', status: 'success', duration: 3000 });
|
||||
qc.invalidateQueries({ queryKey: ['admin-categories'] });
|
||||
},
|
||||
onError: (e: any) =>
|
||||
toast({
|
||||
title: 'Smazání selhalo',
|
||||
description: e?.response?.data?.chyba || e?.response?.data?.detail || e?.message || 'Chyba',
|
||||
status: 'error',
|
||||
duration: 5000,
|
||||
}),
|
||||
});
|
||||
|
||||
const onSubmit = async () => {
|
||||
if (!editing) return;
|
||||
if (!editing.name?.trim()) {
|
||||
toast({ title: 'Název je povinný', status: 'warning' });
|
||||
return;
|
||||
}
|
||||
|
||||
const payload = {
|
||||
name: editing.name.trim(),
|
||||
description: editing.description?.trim() || '',
|
||||
};
|
||||
|
||||
if (editing.id != null) {
|
||||
await updateMut.mutateAsync({ id: editing.id, payload });
|
||||
} else {
|
||||
await createMut.mutateAsync(payload);
|
||||
}
|
||||
};
|
||||
|
||||
const onDelete = async (cat: CategoryItem) => {
|
||||
if (!window.confirm(`Opravdu chcete smazat kategorii "${cat.name}"?`)) return;
|
||||
await deleteMut.mutateAsync(cat.id);
|
||||
};
|
||||
|
||||
return (
|
||||
<AdminLayout requireAdmin={false}>
|
||||
<Box maxW="1200px" mx="auto">
|
||||
<PageHeader
|
||||
title="Kategorie článků"
|
||||
description="Spravujte kategorie pro články. Kategorie lze přiřadit při vytváření nebo úpravě článku."
|
||||
icon={FiTag}
|
||||
action={{
|
||||
label: 'Přidat kategorii',
|
||||
icon: <FiPlus />,
|
||||
onClick: openCreate,
|
||||
colorScheme: 'blue',
|
||||
}}
|
||||
/>
|
||||
|
||||
{isLoading ? (
|
||||
<VStack spacing={3}>
|
||||
{[1, 2, 3].map((i) => (
|
||||
<Skeleton key={i} height="60px" w="100%" borderRadius="md" />
|
||||
))}
|
||||
</VStack>
|
||||
) : !categories || categories.length === 0 ? (
|
||||
<Alert status="info" borderRadius="lg">
|
||||
<AlertIcon />
|
||||
<Box>
|
||||
<Text fontWeight="semibold">Zatím nejsou žádné kategorie</Text>
|
||||
<Text fontSize="sm">
|
||||
Kategorie se vytvoří automaticky při psaní článků, nebo je můžete vytvořit ručně.
|
||||
</Text>
|
||||
</Box>
|
||||
</Alert>
|
||||
) : (
|
||||
<Box
|
||||
bg={bgCard}
|
||||
borderWidth="1px"
|
||||
borderColor={borderColor}
|
||||
borderRadius="xl"
|
||||
overflow="hidden"
|
||||
shadow="md"
|
||||
>
|
||||
<Table variant="simple" size="md">
|
||||
<Thead bg={useColorModeValue('gray.50', 'gray.700')}>
|
||||
<Tr>
|
||||
<Th width="40%">Název</Th>
|
||||
<Th width="50%">Popis</Th>
|
||||
<Th isNumeric>Akce</Th>
|
||||
</Tr>
|
||||
</Thead>
|
||||
<Tbody>
|
||||
{categories.map((cat) => (
|
||||
<Tr
|
||||
key={cat.id}
|
||||
_hover={{ bg: useColorModeValue('gray.50', 'gray.700') }}
|
||||
transition="background 0.2s"
|
||||
>
|
||||
<Td>
|
||||
<HStack>
|
||||
<Badge colorScheme="blue" fontSize="sm" px={3} py={1} borderRadius="full">
|
||||
{cat.name}
|
||||
</Badge>
|
||||
</HStack>
|
||||
</Td>
|
||||
<Td>
|
||||
<Text fontSize="sm" color="gray.600" noOfLines={2}>
|
||||
{cat.description || <Text as="span" fontStyle="italic" color="gray.400">Bez popisu</Text>}
|
||||
</Text>
|
||||
</Td>
|
||||
<Td isNumeric>
|
||||
<HStack justify="flex-end" spacing={2}>
|
||||
<IconButton
|
||||
aria-label="Upravit"
|
||||
icon={<FiEdit2 />}
|
||||
size="sm"
|
||||
colorScheme="blue"
|
||||
variant="ghost"
|
||||
onClick={() => openEdit(cat)}
|
||||
/>
|
||||
<IconButton
|
||||
aria-label="Smazat"
|
||||
icon={<FiTrash2 />}
|
||||
size="sm"
|
||||
colorScheme="red"
|
||||
variant="ghost"
|
||||
onClick={() => onDelete(cat)}
|
||||
isLoading={deleteMut.isPending}
|
||||
/>
|
||||
</HStack>
|
||||
</Td>
|
||||
</Tr>
|
||||
))}
|
||||
</Tbody>
|
||||
</Table>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Create/Edit Modal */}
|
||||
<Modal isOpen={isOpen} onClose={closeModal} size="lg">
|
||||
<ModalOverlay />
|
||||
<ModalContent>
|
||||
<ModalHeader>
|
||||
{editing?.id ? 'Upravit kategorii' : 'Nová kategorie'}
|
||||
</ModalHeader>
|
||||
<ModalCloseButton />
|
||||
<ModalBody>
|
||||
<VStack spacing={4}>
|
||||
<FormControl isRequired>
|
||||
<FormLabel>Název kategorie</FormLabel>
|
||||
<Input
|
||||
placeholder="např. Novinky, A-tým, Mládež..."
|
||||
value={editing?.name || ''}
|
||||
onChange={(e) => setEditing((prev) => ({ ...prev, name: e.target.value }))}
|
||||
autoFocus
|
||||
/>
|
||||
</FormControl>
|
||||
<FormControl>
|
||||
<FormLabel>Popis (volitelné)</FormLabel>
|
||||
<Textarea
|
||||
placeholder="Krátký popis kategorie..."
|
||||
value={editing?.description || ''}
|
||||
onChange={(e) => setEditing((prev) => ({ ...prev, description: e.target.value }))}
|
||||
rows={3}
|
||||
/>
|
||||
</FormControl>
|
||||
</VStack>
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<Button variant="ghost" mr={3} onClick={closeModal}>
|
||||
Zrušit
|
||||
</Button>
|
||||
<Button
|
||||
colorScheme="blue"
|
||||
onClick={onSubmit}
|
||||
isLoading={createMut.isPending || updateMut.isPending}
|
||||
>
|
||||
{editing?.id ? 'Uložit' : 'Vytvořit'}
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
</Box>
|
||||
</AdminLayout>
|
||||
);
|
||||
};
|
||||
|
||||
export default CategoriesAdminPage;
|
||||
@@ -0,0 +1,722 @@
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
Flex,
|
||||
FormControl,
|
||||
FormLabel,
|
||||
Heading,
|
||||
HStack,
|
||||
IconButton,
|
||||
Input,
|
||||
Select,
|
||||
Spinner,
|
||||
Table,
|
||||
Tbody,
|
||||
Td,
|
||||
Text,
|
||||
Th,
|
||||
Thead,
|
||||
Tr,
|
||||
useDisclosure,
|
||||
useToast,
|
||||
VStack,
|
||||
Badge,
|
||||
Tooltip,
|
||||
Alert,
|
||||
AlertIcon,
|
||||
useColorModeValue,
|
||||
Divider,
|
||||
} from '@chakra-ui/react';
|
||||
import { FiPlus, FiTrash2, FiSave, FiRefreshCcw, FiDownload, FiEdit3, FiMove } from 'react-icons/fi';
|
||||
import {
|
||||
CompetitionAlias,
|
||||
getCompetitionAliasesAdmin,
|
||||
upsertCompetitionAlias,
|
||||
deleteCompetitionAlias,
|
||||
reorderCompetitionAliases,
|
||||
} from '../../services/competitionAliases';
|
||||
import AdminLayout from '../../layouts/AdminLayout';
|
||||
import { PageHeader } from '../../components/admin/PageHeader';
|
||||
|
||||
const CompetitionAliasesAdminPage: React.FC = () => {
|
||||
const cardBg = useColorModeValue('white', 'gray.800');
|
||||
const borderColor = useColorModeValue('gray.200', 'gray.700');
|
||||
const inputBg = useColorModeValue('white', 'gray.700');
|
||||
const toast = useToast();
|
||||
const [items, setItems] = useState<CompetitionAlias[]>([]);
|
||||
const [loading, setLoading] = useState<boolean>(false);
|
||||
const [newCode, setNewCode] = useState('');
|
||||
const [newAlias, setNewAlias] = useState('');
|
||||
const [editing, setEditing] = useState<Record<string, { alias: string }>>({});
|
||||
const [draggedIndex, setDraggedIndex] = useState<number | null>(null);
|
||||
const [reorderMode, setReorderMode] = useState<boolean>(false);
|
||||
|
||||
const load = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const data = await getCompetitionAliasesAdmin();
|
||||
setItems(data || []);
|
||||
} catch (e: any) {
|
||||
toast({ title: 'Chyba při načítání', description: e?.message || 'Nelze načíst aliasy', status: 'error' });
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
// Load current aliases, then silently try to import missing ones from FACR cache
|
||||
load().then(async () => {
|
||||
await importFromFacrAuto();
|
||||
});
|
||||
}, []);
|
||||
|
||||
const importFromFacr = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
// Attempt to fetch cache JSON served by backend/static
|
||||
const resolveBackendUrl = (path: string) => {
|
||||
try {
|
||||
if (/^https?:\/\//i.test(path)) return path;
|
||||
if (path.startsWith('/cache') || path.startsWith('/uploads')) {
|
||||
const base = (process.env.REACT_APP_API_BASE_URL || process.env.REACT_APP_API_URL || 'http://localhost:8080/api/v1');
|
||||
const u = new URL(base);
|
||||
u.pathname = path;
|
||||
return u.toString();
|
||||
}
|
||||
return path;
|
||||
} catch {
|
||||
return path;
|
||||
}
|
||||
};
|
||||
|
||||
const url = resolveBackendUrl('/cache/prefetch/facr_club_info.json');
|
||||
const res = await fetch(url, { cache: 'no-cache' });
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const data = await res.json();
|
||||
const comps: Array<{ id?: string; code?: string; name?: string }> = Array.isArray(data?.competitions) ? data.competitions : [];
|
||||
// Fetch fresh aliases to avoid using potentially stale state
|
||||
const latest = await getCompetitionAliasesAdmin();
|
||||
const existing = new Set((latest || []).map(i => i.code));
|
||||
const missing = comps
|
||||
.map(c => ({ code: String(c.code || c.id || '').trim(), name: String(c.name || '').trim() }))
|
||||
.filter(c => c.code);
|
||||
const newOnes = missing.filter(c => !existing.has(c.code));
|
||||
if (!newOnes.length) {
|
||||
toast({ title: 'Žádné nové soutěže', description: 'Všechny soutěže již mají alias.', status: 'info' });
|
||||
return;
|
||||
}
|
||||
// Bulk upsert with alias defaulting to competition name
|
||||
await Promise.all(newOnes.map(c => upsertCompetitionAlias(c.code, { alias: c.name || c.code, original_name: c.name || undefined })));
|
||||
await load();
|
||||
toast({ title: 'Import dokončen', description: `Přidáno: ${newOnes.length}`, status: 'success' });
|
||||
} catch (e: any) {
|
||||
toast({ title: 'Import selhal', description: e?.message || 'Nelze načíst FACR soutěže', status: 'error' });
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Silent auto-import on mount: upserts only when missing; minimal toast
|
||||
const importFromFacrAuto = async () => {
|
||||
try {
|
||||
const resolveBackendUrl = (path: string) => {
|
||||
try {
|
||||
if (/^https?:\/\//i.test(path)) return path;
|
||||
if (path.startsWith('/cache') || path.startsWith('/uploads')) {
|
||||
const base = (process.env.REACT_APP_API_BASE_URL || process.env.REACT_APP_API_URL || 'http://localhost:8080/api/v1');
|
||||
const u = new URL(base);
|
||||
u.pathname = path;
|
||||
return u.toString();
|
||||
}
|
||||
return path;
|
||||
} catch {
|
||||
return path;
|
||||
}
|
||||
};
|
||||
|
||||
const url = resolveBackendUrl('/cache/prefetch/facr_club_info.json');
|
||||
const res = await fetch(url, { cache: 'no-cache' });
|
||||
if (!res.ok) return; // silent
|
||||
const data = await res.json();
|
||||
const comps: Array<{ id?: string; code?: string; name?: string }> = Array.isArray(data?.competitions) ? data.competitions : [];
|
||||
const latest = await getCompetitionAliasesAdmin();
|
||||
const existing = new Set((latest || []).map(i => i.code));
|
||||
const missing = comps
|
||||
.map(c => ({ code: String(c.code || c.id || '').trim(), name: String(c.name || '').trim() }))
|
||||
.filter(c => c.code);
|
||||
const newOnes = missing.filter(c => !existing.has(c.code));
|
||||
if (!newOnes.length) return;
|
||||
await Promise.all(newOnes.map(c => upsertCompetitionAlias(c.code, { alias: c.name || c.code, original_name: c.name || undefined })));
|
||||
await load();
|
||||
toast({ title: 'Alias soutěží doplněny', description: `Přidáno: ${newOnes.length}`, status: 'success', duration: 3000 });
|
||||
} catch {
|
||||
// silent on auto
|
||||
}
|
||||
};
|
||||
|
||||
const onAdd = async () => {
|
||||
const code = newCode.trim();
|
||||
const alias = newAlias.trim();
|
||||
if (!code || !alias) {
|
||||
toast({ title: 'Vyplňte code a alias', status: 'warning' });
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const saved = await upsertCompetitionAlias(code, { alias });
|
||||
setItems((prev) => {
|
||||
const filtered = prev.filter((i) => i.code !== saved.code);
|
||||
return [...filtered, saved].sort((a, b) => a.code.localeCompare(b.code));
|
||||
});
|
||||
setNewCode(''); setNewAlias('');
|
||||
toast({ title: 'Alias uložen', status: 'success' });
|
||||
} catch (e: any) {
|
||||
toast({ title: 'Uložení selhalo', description: e?.message || 'Zkuste znovu', status: 'error' });
|
||||
}
|
||||
};
|
||||
|
||||
const onSave = async (code: string) => {
|
||||
const data = editing[code];
|
||||
if (!data) return;
|
||||
if (!data.alias?.trim()) {
|
||||
toast({ title: 'Alias je povinný', status: 'warning' });
|
||||
return;
|
||||
}
|
||||
try {
|
||||
// Preserve original_name if present on the existing item to avoid losing it on edit
|
||||
const existing = items.find((i) => i.code === code);
|
||||
const payload: { alias: string; original_name?: string } = { alias: data.alias.trim() };
|
||||
if (existing?.original_name) payload.original_name = existing.original_name;
|
||||
const saved = await upsertCompetitionAlias(code, payload);
|
||||
setItems((prev) => prev.map((i) => (i.code === code ? saved : i)));
|
||||
setEditing((prev) => { const n = { ...prev }; delete n[code]; return n; });
|
||||
toast({ title: 'Uloženo', status: 'success' });
|
||||
} catch (e: any) {
|
||||
toast({ title: 'Uložení selhalo', description: e?.message || 'Zkuste znovu', status: 'error' });
|
||||
}
|
||||
};
|
||||
|
||||
const onDelete = async (code: string) => {
|
||||
if (!window.confirm(`Smazat alias pro soutěž ${code}?`)) return;
|
||||
try {
|
||||
await deleteCompetitionAlias(code);
|
||||
setItems((prev) => prev.filter((i) => i.code !== code));
|
||||
setEditing((prev) => { const n = { ...prev }; delete n[code]; return n; });
|
||||
toast({ title: 'Smazáno', status: 'success' });
|
||||
} catch (e: any) {
|
||||
toast({ title: 'Smazání selhalo', description: e?.message || 'Zkuste znovu', status: 'error' });
|
||||
}
|
||||
};
|
||||
|
||||
// In reorder mode, show items as-is (ordered by display_order from backend)
|
||||
// In normal mode, sort by code for editing
|
||||
const sorted = useMemo(() => {
|
||||
if (!items) return [];
|
||||
if (reorderMode) return items;
|
||||
|
||||
const normalizeOrder = (value?: number) => {
|
||||
if (!value || value <= 0) return Number.MAX_SAFE_INTEGER;
|
||||
return value;
|
||||
};
|
||||
|
||||
return [...items].sort((a, b) => {
|
||||
const orderA = normalizeOrder(a.display_order);
|
||||
const orderB = normalizeOrder(b.display_order);
|
||||
if (orderA !== orderB) return orderA - orderB;
|
||||
return a.code.localeCompare(b.code);
|
||||
});
|
||||
}, [items, reorderMode]);
|
||||
|
||||
const onSaveAll = async () => {
|
||||
const entries = Object.entries(editing);
|
||||
if (!entries.length) {
|
||||
toast({ title: 'Nic k uložení', status: 'info' });
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
try {
|
||||
// Validate
|
||||
for (const [code, data] of entries) {
|
||||
if (!data?.alias?.trim()) {
|
||||
throw new Error(`Alias je povinný pro ${code}`);
|
||||
}
|
||||
}
|
||||
// Save all in parallel
|
||||
// When saving multiple edits, preserve original_name from existing items where available
|
||||
const savedList = await Promise.all(
|
||||
entries.map(([code, data]) => {
|
||||
const existing = items.find((i) => i.code === code);
|
||||
const payload: { alias: string; original_name?: string } = { alias: data.alias.trim() };
|
||||
if (existing?.original_name) payload.original_name = existing.original_name;
|
||||
return upsertCompetitionAlias(code, payload);
|
||||
})
|
||||
);
|
||||
// Merge into items
|
||||
setItems((prev) => {
|
||||
const map = new Map(prev.map((i) => [i.code, i] as const));
|
||||
for (const s of savedList) map.set(s.code, s);
|
||||
return Array.from(map.values()).sort((a, b) => a.code.localeCompare(b.code));
|
||||
});
|
||||
setEditing({});
|
||||
toast({ title: 'Vše uloženo', status: 'success' });
|
||||
} catch (e: any) {
|
||||
toast({ title: 'Hromadné uložení selhalo', description: e?.message || 'Zkuste znovu', status: 'error' });
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const hasChanges = Object.keys(editing).length > 0;
|
||||
|
||||
// Drag-and-drop handlers
|
||||
const handleDragStart = (e: React.DragEvent<HTMLTableRowElement>, index: number) => {
|
||||
setDraggedIndex(index);
|
||||
e.dataTransfer.effectAllowed = 'move';
|
||||
};
|
||||
|
||||
const handleDragOver = (e: React.DragEvent<HTMLTableRowElement>, index: number) => {
|
||||
e.preventDefault();
|
||||
if (draggedIndex === null || draggedIndex === index) return;
|
||||
|
||||
// Reorder items array
|
||||
const newItems = [...items];
|
||||
const draggedItem = newItems[draggedIndex];
|
||||
newItems.splice(draggedIndex, 1);
|
||||
newItems.splice(index, 0, draggedItem);
|
||||
|
||||
setItems(newItems);
|
||||
setDraggedIndex(index);
|
||||
};
|
||||
|
||||
const handleDragEnd = () => {
|
||||
setDraggedIndex(null);
|
||||
};
|
||||
|
||||
const saveReorder = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
// Assign display_order based on current position (1-indexed)
|
||||
const reordered = items.map((item, idx) => ({
|
||||
code: item.code,
|
||||
display_order: idx + 1,
|
||||
}));
|
||||
await reorderCompetitionAliases(reordered);
|
||||
toast({ title: 'Pořadí uloženo', status: 'success' });
|
||||
setReorderMode(false);
|
||||
await load();
|
||||
} catch (e: any) {
|
||||
toast({ title: 'Chyba při ukládání pořadí', description: e?.message || 'Zkuste znovu', status: 'error' });
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const cancelReorder = () => {
|
||||
setReorderMode(false);
|
||||
load(); // Reload to reset order
|
||||
};
|
||||
|
||||
return (
|
||||
<AdminLayout>
|
||||
<Box maxW="1400px" mx="auto">
|
||||
<PageHeader
|
||||
title="Alias soutěží"
|
||||
description="Spravujte zobrazované názvy soutěží. Můžete je importovat z cache (FACR) a upravit aliasy."
|
||||
/>
|
||||
|
||||
{/* Action Bar */}
|
||||
<Flex
|
||||
align="center"
|
||||
justify="space-between"
|
||||
mb={6}
|
||||
wrap="wrap"
|
||||
gap={3}
|
||||
bg={cardBg}
|
||||
p={4}
|
||||
borderRadius="lg"
|
||||
shadow="sm"
|
||||
borderWidth="1px"
|
||||
borderColor="gray.200"
|
||||
>
|
||||
<HStack spacing={3}>
|
||||
{!reorderMode ? (
|
||||
<>
|
||||
<Button
|
||||
leftIcon={<FiRefreshCcw />}
|
||||
onClick={load}
|
||||
isLoading={loading}
|
||||
variant="outline"
|
||||
size="md"
|
||||
_hover={{ bg: 'gray.50', borderColor: 'gray.400' }}
|
||||
>
|
||||
Obnovit
|
||||
</Button>
|
||||
<Button
|
||||
leftIcon={<FiSave />}
|
||||
onClick={onSaveAll}
|
||||
isDisabled={!hasChanges}
|
||||
isLoading={loading}
|
||||
colorScheme="green"
|
||||
size="md"
|
||||
variant={hasChanges ? 'solid' : 'outline'}
|
||||
_hover={hasChanges ? { bg: 'green.600' } : {}}
|
||||
>
|
||||
Uložit vše {hasChanges && <Badge ml={2} colorScheme="green" variant="solid">{Object.keys(editing).length}</Badge>}
|
||||
</Button>
|
||||
<Button
|
||||
leftIcon={<FiMove />}
|
||||
onClick={() => setReorderMode(true)}
|
||||
isLoading={loading}
|
||||
colorScheme="purple"
|
||||
size="md"
|
||||
variant="outline"
|
||||
_hover={{ bg: 'purple.50', borderColor: 'purple.400' }}
|
||||
>
|
||||
Změnit pořadí
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Button
|
||||
leftIcon={<FiSave />}
|
||||
onClick={saveReorder}
|
||||
isLoading={loading}
|
||||
colorScheme="green"
|
||||
size="md"
|
||||
>
|
||||
Uložit pořadí
|
||||
</Button>
|
||||
<Button
|
||||
onClick={cancelReorder}
|
||||
isDisabled={loading}
|
||||
variant="outline"
|
||||
size="md"
|
||||
>
|
||||
Zrušit
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</HStack>
|
||||
{!reorderMode && (
|
||||
<Button
|
||||
leftIcon={<FiDownload />}
|
||||
onClick={importFromFacr}
|
||||
isLoading={loading}
|
||||
colorScheme="blue"
|
||||
size="md"
|
||||
shadow="sm"
|
||||
>
|
||||
Importovat ze soutěží
|
||||
</Button>
|
||||
)}
|
||||
</Flex>
|
||||
|
||||
{/* Add New Section - Hidden in reorder mode */}
|
||||
{!reorderMode && (
|
||||
<Box
|
||||
bg={cardBg}
|
||||
borderWidth="1px"
|
||||
borderColor="gray.200"
|
||||
borderRadius="lg"
|
||||
p={6}
|
||||
mb={6}
|
||||
shadow="sm"
|
||||
_hover={{ shadow: 'md' }}
|
||||
transition="all 0.2s"
|
||||
>
|
||||
<HStack mb={4} spacing={2}>
|
||||
<Box bg="blue.500" p={2} borderRadius="md">
|
||||
<FiPlus color="white" size={18} />
|
||||
</Box>
|
||||
<Heading size="md" color="gray.700">Přidat nový alias</Heading>
|
||||
</HStack>
|
||||
<Divider mb={4} />
|
||||
<Flex gap={3} wrap="wrap" align="flex-end">
|
||||
<VStack align="flex-start" spacing={2} flex="0 0 240px">
|
||||
<Text fontSize="sm" fontWeight="medium" color="gray.600">Kód soutěže</Text>
|
||||
<Input
|
||||
placeholder="např. A1A"
|
||||
value={newCode}
|
||||
onChange={(e) => setNewCode(e.target.value.toUpperCase())}
|
||||
size="md"
|
||||
bg={useColorModeValue('gray.50', 'gray.900')}
|
||||
borderColor="gray.300"
|
||||
_hover={{ borderColor: 'blue.400', bg: 'white' }}
|
||||
_focus={{ borderColor: 'blue.500', bg: 'white', shadow: 'sm' }}
|
||||
fontFamily="mono"
|
||||
fontWeight="semibold"
|
||||
/>
|
||||
</VStack>
|
||||
<VStack align="flex-start" spacing={2} flex="1" minW="300px">
|
||||
<Text fontSize="sm" fontWeight="medium" color="gray.600">Zobrazovaný název (alias)</Text>
|
||||
<Input
|
||||
placeholder="např. Krajský přebor"
|
||||
value={newAlias}
|
||||
onChange={(e) => setNewAlias(e.target.value)}
|
||||
size="md"
|
||||
bg={useColorModeValue('gray.50', 'gray.900')}
|
||||
borderColor="gray.300"
|
||||
_hover={{ borderColor: 'blue.400', bg: 'white' }}
|
||||
_focus={{ borderColor: 'blue.500', bg: 'white', shadow: 'sm' }}
|
||||
/>
|
||||
</VStack>
|
||||
<Button
|
||||
leftIcon={<FiPlus />}
|
||||
onClick={onAdd}
|
||||
colorScheme="blue"
|
||||
size="md"
|
||||
px={8}
|
||||
shadow="sm"
|
||||
_hover={{ shadow: 'md', transform: 'translateY(-1px)' }}
|
||||
transition="all 0.2s"
|
||||
>
|
||||
Přidat
|
||||
</Button>
|
||||
</Flex>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Table Section */}
|
||||
<Box
|
||||
bg={cardBg}
|
||||
borderWidth="1px"
|
||||
borderColor="gray.200"
|
||||
borderRadius="lg"
|
||||
overflow="hidden"
|
||||
shadow="sm"
|
||||
>
|
||||
<Box overflowX="auto">
|
||||
<Table variant="simple" size="md">
|
||||
<Thead bg={useColorModeValue('gray.50', 'gray.900')}>
|
||||
<Tr>
|
||||
{reorderMode && (
|
||||
<Th width="60px" textTransform="uppercase" fontSize="xs" fontWeight="bold" color="gray.600" letterSpacing="wide">
|
||||
Pořadí
|
||||
</Th>
|
||||
)}
|
||||
<Th
|
||||
width="200px"
|
||||
textTransform="uppercase"
|
||||
fontSize="xs"
|
||||
fontWeight="bold"
|
||||
color="gray.600"
|
||||
letterSpacing="wide"
|
||||
>
|
||||
Kód
|
||||
</Th>
|
||||
{!reorderMode && (
|
||||
<>
|
||||
<Th
|
||||
textTransform="uppercase"
|
||||
fontSize="xs"
|
||||
fontWeight="bold"
|
||||
color="gray.600"
|
||||
letterSpacing="wide"
|
||||
>
|
||||
Původní název
|
||||
</Th>
|
||||
<Th
|
||||
textTransform="uppercase"
|
||||
fontSize="xs"
|
||||
fontWeight="bold"
|
||||
color="gray.600"
|
||||
letterSpacing="wide"
|
||||
>
|
||||
Alias
|
||||
</Th>
|
||||
<Th
|
||||
isNumeric
|
||||
textTransform="uppercase"
|
||||
fontSize="xs"
|
||||
fontWeight="bold"
|
||||
color="gray.600"
|
||||
letterSpacing="wide"
|
||||
>
|
||||
Akce
|
||||
</Th>
|
||||
</>
|
||||
)}
|
||||
{reorderMode && (
|
||||
<Th
|
||||
textTransform="uppercase"
|
||||
fontSize="xs"
|
||||
fontWeight="bold"
|
||||
color="gray.600"
|
||||
letterSpacing="wide"
|
||||
>
|
||||
Alias
|
||||
</Th>
|
||||
)}
|
||||
</Tr>
|
||||
</Thead>
|
||||
<Tbody>
|
||||
{sorted.map((it, idx) => {
|
||||
const ed = editing[it.code] ?? { alias: it.alias };
|
||||
const isEdited = editing[it.code] !== undefined;
|
||||
|
||||
if (reorderMode) {
|
||||
// Reorder mode: simplified draggable row
|
||||
return (
|
||||
<Tr
|
||||
key={it.code}
|
||||
draggable
|
||||
onDragStart={(e) => handleDragStart(e, idx)}
|
||||
onDragOver={(e) => handleDragOver(e, idx)}
|
||||
onDragEnd={handleDragEnd}
|
||||
cursor="move"
|
||||
bg={draggedIndex === idx ? 'blue.50' : undefined}
|
||||
_hover={{ bg: 'gray.100' }}
|
||||
transition="background 0.15s"
|
||||
opacity={draggedIndex === idx ? 0.5 : 1}
|
||||
>
|
||||
<Td>
|
||||
<HStack spacing={2}>
|
||||
<Box as={FiMove} color="gray.500" />
|
||||
<Text fontWeight="bold" color="gray.700">{idx + 1}</Text>
|
||||
</HStack>
|
||||
</Td>
|
||||
<Td>
|
||||
<Badge
|
||||
colorScheme="blue"
|
||||
variant="subtle"
|
||||
fontFamily="mono"
|
||||
fontSize="sm"
|
||||
px={2}
|
||||
py={1}
|
||||
>
|
||||
{it.code}
|
||||
</Badge>
|
||||
</Td>
|
||||
<Td>
|
||||
<Text fontSize="sm" fontWeight="medium">{it.alias}</Text>
|
||||
</Td>
|
||||
</Tr>
|
||||
);
|
||||
}
|
||||
|
||||
// Edit mode: full editing capabilities
|
||||
return (
|
||||
<Tr
|
||||
key={it.code}
|
||||
_hover={{ bg: 'gray.50' }}
|
||||
transition="background 0.15s"
|
||||
>
|
||||
<Td>
|
||||
<HStack>
|
||||
<Badge
|
||||
colorScheme="blue"
|
||||
variant="subtle"
|
||||
fontFamily="mono"
|
||||
fontSize="sm"
|
||||
px={2}
|
||||
py={1}
|
||||
>
|
||||
{it.code}
|
||||
</Badge>
|
||||
{isEdited && (
|
||||
<Box as={FiEdit3} color="orange.500" size={14} />
|
||||
)}
|
||||
</HStack>
|
||||
</Td>
|
||||
<Td>
|
||||
<Text fontSize="sm" color="gray.600" fontWeight="medium">
|
||||
{it.original_name || <Text as="span" color="gray.400">—</Text>}
|
||||
</Text>
|
||||
</Td>
|
||||
<Td>
|
||||
<Input
|
||||
size="md"
|
||||
value={ed.alias}
|
||||
onChange={(e) => setEditing((prev) => ({ ...prev, [it.code]: { alias: e.target.value } }))}
|
||||
bg={isEdited ? 'orange.50' : 'white'}
|
||||
borderColor={isEdited ? 'orange.300' : 'gray.200'}
|
||||
_hover={{ borderColor: isEdited ? 'orange.400' : 'blue.300', bg: 'white' }}
|
||||
_focus={{ borderColor: 'blue.500', bg: 'white', shadow: 'sm' }}
|
||||
fontWeight="medium"
|
||||
/>
|
||||
</Td>
|
||||
<Td isNumeric>
|
||||
<HStack justify="flex-end" spacing={2}>
|
||||
<IconButton
|
||||
aria-label="Uložit"
|
||||
icon={<FiSave />}
|
||||
size="sm"
|
||||
onClick={() => onSave(it.code)}
|
||||
colorScheme="green"
|
||||
variant="ghost"
|
||||
_hover={{ bg: 'green.50' }}
|
||||
/>
|
||||
<IconButton
|
||||
aria-label="Smazat"
|
||||
icon={<FiTrash2 />}
|
||||
size="sm"
|
||||
colorScheme="red"
|
||||
variant="ghost"
|
||||
_hover={{ bg: 'red.50' }}
|
||||
onClick={() => onDelete(it.code)}
|
||||
/>
|
||||
</HStack>
|
||||
</Td>
|
||||
</Tr>
|
||||
);
|
||||
})}
|
||||
{sorted.length === 0 && (
|
||||
<Tr>
|
||||
<Td colSpan={4}>
|
||||
<VStack py={12} spacing={3}>
|
||||
<Box color="gray.300" fontSize="4xl">
|
||||
<FiEdit3 />
|
||||
</Box>
|
||||
<Text color="gray.500" fontSize="md" fontWeight="medium">
|
||||
Žádné aliasy zatím nejsou
|
||||
</Text>
|
||||
<Text color="gray.400" fontSize="sm">
|
||||
Přidejte nový alias nebo importujte ze soutěží
|
||||
</Text>
|
||||
</VStack>
|
||||
</Td>
|
||||
</Tr>
|
||||
)}
|
||||
</Tbody>
|
||||
</Table>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Summary Footer */}
|
||||
{sorted.length > 0 && (
|
||||
<Box
|
||||
mt={4}
|
||||
p={4}
|
||||
bg={useColorModeValue('gray.50', 'gray.900')}
|
||||
borderRadius="md"
|
||||
borderWidth="1px"
|
||||
borderColor="gray.200"
|
||||
>
|
||||
<HStack justify="space-between" wrap="wrap">
|
||||
<Text fontSize="sm" color="gray.600">
|
||||
Celkem aliasů: <Badge colorScheme="blue" ml={1}>{sorted.length}</Badge>
|
||||
</Text>
|
||||
{hasChanges && (
|
||||
<HStack spacing={2}>
|
||||
<Badge colorScheme="orange" variant="subtle">
|
||||
{Object.keys(editing).length} neuložených změn
|
||||
</Badge>
|
||||
<Button
|
||||
size="sm"
|
||||
colorScheme="green"
|
||||
onClick={onSaveAll}
|
||||
leftIcon={<FiSave />}
|
||||
>
|
||||
Uložit vše
|
||||
</Button>
|
||||
</HStack>
|
||||
)}
|
||||
</HStack>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
</AdminLayout>
|
||||
);
|
||||
};
|
||||
|
||||
export default CompetitionAliasesAdminPage;
|
||||
@@ -0,0 +1,832 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
Table,
|
||||
Thead,
|
||||
Tbody,
|
||||
Tr,
|
||||
Th,
|
||||
Td,
|
||||
IconButton,
|
||||
useToast,
|
||||
Modal,
|
||||
ModalOverlay,
|
||||
ModalContent,
|
||||
ModalHeader,
|
||||
ModalCloseButton,
|
||||
ModalBody,
|
||||
ModalFooter,
|
||||
FormControl,
|
||||
FormLabel,
|
||||
Input,
|
||||
Textarea,
|
||||
Switch,
|
||||
Select,
|
||||
Tabs,
|
||||
TabList,
|
||||
TabPanels,
|
||||
Tab,
|
||||
TabPanel,
|
||||
Heading,
|
||||
Text,
|
||||
Badge,
|
||||
HStack,
|
||||
VStack,
|
||||
useDisclosure,
|
||||
AlertDialog,
|
||||
AlertDialogBody,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogContent,
|
||||
AlertDialogOverlay,
|
||||
SimpleGrid,
|
||||
Divider,
|
||||
FormHelperText,
|
||||
useColorModeValue,
|
||||
} from '@chakra-ui/react';
|
||||
import { FiEdit, FiTrash2, FiPlus, FiUser } from 'react-icons/fi';
|
||||
import AdminLayout from '../../layouts/AdminLayout';
|
||||
import {
|
||||
getContacts,
|
||||
createContact,
|
||||
updateContact,
|
||||
deleteContact,
|
||||
Contact,
|
||||
getContactCategories,
|
||||
ContactCategory,
|
||||
} from '../../services/contactInfo';
|
||||
import api, { uploadImage } from '../../services/api';
|
||||
import { getImageUrl } from '../../utils/imageUtils';
|
||||
import { getAdminSettings, updateAdminSettings, AdminSettings, PublicSettings } from '../../services/settings';
|
||||
import MapLinkImporter from '../../components/admin/MapLinkImporter';
|
||||
import { MapCoordinates } from '../../utils/mapUrlParser';
|
||||
import ContactMap from '../../components/home/ContactMap';
|
||||
import MapStyleSelector from '../../components/admin/MapStyleSelector';
|
||||
|
||||
const ContactsAdminPage: 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 bgMain = useColorModeValue('gray.50', 'gray.900');
|
||||
const tableBg = useColorModeValue('white', 'gray.800');
|
||||
const hoverBg = useColorModeValue('gray.50', 'gray.700');
|
||||
const infoBg = useColorModeValue('blue.50', 'blue.900');
|
||||
const infoBorder = useColorModeValue('blue.200', 'blue.700');
|
||||
const [contacts, setContacts] = useState<Contact[]>([]);
|
||||
const [categories, setCategories] = useState<ContactCategory[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [selectedContact, setSelectedContact] = useState<Contact | null>(null);
|
||||
const [isContactModalOpen, setIsContactModalOpen] = useState(false);
|
||||
const [deleteItem, setDeleteItem] = useState<{ type: 'contact'; id: number } | null>(null);
|
||||
const cancelRef = React.useRef<HTMLButtonElement>(null);
|
||||
const toast = useToast();
|
||||
|
||||
// Form states
|
||||
const [contactForm, setContactForm] = useState({
|
||||
name: '',
|
||||
position: '',
|
||||
email: '',
|
||||
phone: '',
|
||||
category_id: undefined as number | undefined,
|
||||
image_url: '',
|
||||
description: '',
|
||||
display_order: 0,
|
||||
is_active: true,
|
||||
});
|
||||
|
||||
|
||||
const [uploadingImage, setUploadingImage] = useState(false);
|
||||
const [settings, setSettings] = useState<AdminSettings>({});
|
||||
const [savingSettings, setSavingSettings] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
loadData();
|
||||
loadSettings();
|
||||
}, []);
|
||||
|
||||
const loadData = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const [contactsData, categoriesData] = await Promise.all([
|
||||
getContacts(),
|
||||
getContactCategories(),
|
||||
]);
|
||||
setContacts(contactsData);
|
||||
setCategories(categoriesData);
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: 'Chyba při načítání',
|
||||
description: 'Nepodařilo se načíst kontakty a kategorie',
|
||||
status: 'error',
|
||||
duration: 3000,
|
||||
});
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Contact handlers
|
||||
const openContactModal = (contact?: Contact) => {
|
||||
if (contact) {
|
||||
setSelectedContact(contact);
|
||||
setContactForm({
|
||||
name: contact.name,
|
||||
position: contact.position,
|
||||
email: contact.email,
|
||||
phone: contact.phone,
|
||||
category_id: contact.category_id,
|
||||
image_url: contact.image_url || '',
|
||||
description: contact.description || '',
|
||||
display_order: contact.display_order,
|
||||
is_active: contact.is_active,
|
||||
});
|
||||
} else {
|
||||
setSelectedContact(null);
|
||||
setContactForm({
|
||||
name: '',
|
||||
position: '',
|
||||
email: '',
|
||||
phone: '',
|
||||
category_id: undefined,
|
||||
image_url: '',
|
||||
description: '',
|
||||
display_order: contacts.length * 10,
|
||||
is_active: true,
|
||||
});
|
||||
}
|
||||
setIsContactModalOpen(true);
|
||||
};
|
||||
|
||||
const loadSettings = async () => {
|
||||
try {
|
||||
const data = await getAdminSettings();
|
||||
setSettings(data || {});
|
||||
} catch (error) {
|
||||
console.error('Failed to load settings:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSettingsChange = (key: keyof AdminSettings) => (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setSettings((prev) => ({ ...prev, [key]: e.target.value }));
|
||||
};
|
||||
|
||||
const handleNumChange = (key: keyof AdminSettings) => (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const val = e.target.value;
|
||||
const n = val === '' ? undefined : Number(val);
|
||||
setSettings((prev) => ({ ...prev, [key]: (Number.isFinite(n as number) ? (n as any) : undefined) }));
|
||||
};
|
||||
|
||||
const handleBoolChange = (key: keyof AdminSettings) => (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const checked = (e.target as any).checked as boolean;
|
||||
setSettings((prev) => ({ ...prev, [key]: checked as any }));
|
||||
};
|
||||
|
||||
const handleSelectChange = (key: keyof AdminSettings) => (e: React.ChangeEvent<HTMLSelectElement>) => {
|
||||
setSettings((prev) => ({ ...prev, [key]: e.target.value }));
|
||||
};
|
||||
|
||||
const handleSaveSettings = async () => {
|
||||
setSavingSettings(true);
|
||||
try {
|
||||
// Extract and validate settings
|
||||
const lat = typeof settings.location_latitude === 'number' ? settings.location_latitude : undefined;
|
||||
const lng = typeof settings.location_longitude === 'number' ? settings.location_longitude : undefined;
|
||||
const zoom = typeof settings.map_zoom_level === 'number' ? settings.map_zoom_level : undefined;
|
||||
|
||||
// Auto-enable map display if coordinates are set
|
||||
const hasCoordinates = lat !== undefined && lng !== undefined;
|
||||
|
||||
// Build payload with proper types, filtering out undefined values
|
||||
const payload: Partial<AdminSettings> = {};
|
||||
|
||||
if (settings.contact_address !== undefined) payload.contact_address = settings.contact_address;
|
||||
if (settings.contact_city !== undefined) payload.contact_city = settings.contact_city;
|
||||
if (settings.contact_zip !== undefined) payload.contact_zip = settings.contact_zip;
|
||||
if (settings.contact_country !== undefined) payload.contact_country = settings.contact_country;
|
||||
if (settings.contact_phone !== undefined) payload.contact_phone = settings.contact_phone;
|
||||
if (settings.contact_email !== undefined) payload.contact_email = settings.contact_email;
|
||||
if (lat !== undefined) payload.location_latitude = lat;
|
||||
if (lng !== undefined) payload.location_longitude = lng;
|
||||
if (zoom !== undefined) payload.map_zoom_level = zoom;
|
||||
if (settings.map_style !== undefined) payload.map_style = settings.map_style;
|
||||
payload.show_map_on_homepage = hasCoordinates;
|
||||
|
||||
await updateAdminSettings(payload);
|
||||
toast({
|
||||
title: 'Nastavení uloženo',
|
||||
description: 'Kontaktní informace a mapa byly aktualizovány',
|
||||
status: 'success',
|
||||
duration: 3000,
|
||||
});
|
||||
} catch (error: any) {
|
||||
const errorMsg = error?.response?.data?.chyba || error?.response?.data?.detail || error?.response?.data?.error || error?.message || 'Uložení nastavení se nezdařilo';
|
||||
console.error('Settings save error:', error);
|
||||
toast({
|
||||
title: 'Chyba při ukládání',
|
||||
description: errorMsg,
|
||||
status: 'error',
|
||||
duration: 5000,
|
||||
isClosable: true,
|
||||
});
|
||||
} finally {
|
||||
setSavingSettings(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleContactSubmit = async () => {
|
||||
if (!contactForm.name.trim()) {
|
||||
toast({
|
||||
title: 'Chyba validace',
|
||||
description: 'Jméno je povinné',
|
||||
status: 'error',
|
||||
duration: 3000,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
if (selectedContact) {
|
||||
await updateContact(selectedContact.id, contactForm);
|
||||
toast({
|
||||
title: 'Kontakt aktualizován',
|
||||
status: 'success',
|
||||
duration: 2000,
|
||||
});
|
||||
} else {
|
||||
await createContact(contactForm);
|
||||
toast({
|
||||
title: 'Kontakt vytvořen',
|
||||
status: 'success',
|
||||
duration: 2000,
|
||||
});
|
||||
}
|
||||
setIsContactModalOpen(false);
|
||||
loadData();
|
||||
} catch (error: any) {
|
||||
toast({
|
||||
title: 'Chyba',
|
||||
description: error.response?.data?.error || 'Uložení kontaktu se nezdařilo',
|
||||
status: 'error',
|
||||
duration: 3000,
|
||||
});
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteContact = async (id: number) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
await deleteContact(id);
|
||||
toast({
|
||||
title: 'Kontakt smazán',
|
||||
status: 'success',
|
||||
duration: 2000,
|
||||
});
|
||||
loadData();
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: 'Chyba',
|
||||
description: 'Smazání kontaktu se nezdařilo',
|
||||
status: 'error',
|
||||
duration: 3000,
|
||||
});
|
||||
} finally {
|
||||
setLoading(false);
|
||||
setDeleteItem(null);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
// Image upload handler
|
||||
const handleImageUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
// Validate file type
|
||||
if (!file.type.startsWith('image/')) {
|
||||
toast({
|
||||
title: 'Neplatný typ souboru',
|
||||
description: 'Nahrajte prosím obrázkový soubor',
|
||||
status: 'error',
|
||||
duration: 3000,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setUploadingImage(true);
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
|
||||
const result = await uploadImage(formData);
|
||||
setContactForm({ ...contactForm, image_url: result.url });
|
||||
toast({
|
||||
title: 'Obrázek nahrán',
|
||||
status: 'success',
|
||||
duration: 2000,
|
||||
});
|
||||
} catch (error: any) {
|
||||
toast({
|
||||
title: 'Nahrávání selhalo',
|
||||
description: error.response?.data?.error || 'Nahrání obrázku se nezdařilo',
|
||||
status: 'error',
|
||||
duration: 3000,
|
||||
});
|
||||
} finally {
|
||||
setUploadingImage(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<AdminLayout>
|
||||
<Box p={6} bg={bgMain} minH="100vh">
|
||||
<Heading size="lg" mb={6}>Správa kontaktů</Heading>
|
||||
|
||||
<Tabs colorScheme="blue">
|
||||
<TabList>
|
||||
<Tab>Kontakty</Tab>
|
||||
<Tab>Mapa a adresa</Tab>
|
||||
</TabList>
|
||||
|
||||
<TabPanels>
|
||||
{/* Contacts Tab */}
|
||||
<TabPanel>
|
||||
<HStack justify="space-between" mb={4}>
|
||||
<Text>Spravujte kontaktní osoby vašeho klubu</Text>
|
||||
<Button
|
||||
leftIcon={<FiPlus />}
|
||||
colorScheme="blue"
|
||||
onClick={() => openContactModal()}
|
||||
>
|
||||
Přidat kontakt
|
||||
</Button>
|
||||
</HStack>
|
||||
|
||||
<Box overflowX="auto" bg={tableBg} borderRadius="md" borderWidth="1px" borderColor={borderColor}>
|
||||
<Table variant="simple">
|
||||
<Thead>
|
||||
<Tr>
|
||||
<Th>Foto</Th>
|
||||
<Th>Jméno</Th>
|
||||
<Th>Pozice</Th>
|
||||
<Th>Soutěž</Th>
|
||||
<Th>Email</Th>
|
||||
<Th>Telefon</Th>
|
||||
<Th>Pořadí</Th>
|
||||
<Th>Stav</Th>
|
||||
<Th>Akce</Th>
|
||||
</Tr>
|
||||
</Thead>
|
||||
<Tbody>
|
||||
{contacts.map((contact) => (
|
||||
<Tr key={contact.id}>
|
||||
<Td>
|
||||
{contact.image_url ? (
|
||||
<Box
|
||||
as="img"
|
||||
src={getImageUrl(contact.image_url)}
|
||||
alt={contact.name}
|
||||
boxSize="40px"
|
||||
objectFit="cover"
|
||||
borderRadius="md"
|
||||
/>
|
||||
) : (
|
||||
<Box
|
||||
boxSize="40px"
|
||||
bg="gray.200"
|
||||
borderRadius="md"
|
||||
display="flex"
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
>
|
||||
<FiUser />
|
||||
</Box>
|
||||
)}
|
||||
</Td>
|
||||
<Td fontWeight="bold">{contact.name}</Td>
|
||||
<Td>{contact.position}</Td>
|
||||
<Td>
|
||||
{contact.category_id ? (
|
||||
<Badge colorScheme="purple">{categories.find(c => c.id === contact.category_id)?.name || contact.category_id}</Badge>
|
||||
) : (
|
||||
<Text fontSize="sm" color="gray.500">Bez kategorie</Text>
|
||||
)}
|
||||
</Td>
|
||||
<Td>{contact.email}</Td>
|
||||
<Td>{contact.phone}</Td>
|
||||
<Td>{contact.display_order}</Td>
|
||||
<Td>
|
||||
<Badge colorScheme={contact.is_active ? 'green' : 'red'}>
|
||||
{contact.is_active ? 'Aktivní' : 'Neaktivní'}
|
||||
</Badge>
|
||||
</Td>
|
||||
<Td>
|
||||
<HStack spacing={2}>
|
||||
<IconButton
|
||||
aria-label="Upravit"
|
||||
icon={<FiEdit />}
|
||||
size="sm"
|
||||
onClick={() => openContactModal(contact)}
|
||||
/>
|
||||
<IconButton
|
||||
aria-label="Smazat"
|
||||
icon={<FiTrash2 />}
|
||||
size="sm"
|
||||
colorScheme="red"
|
||||
onClick={() => setDeleteItem({ type: 'contact', id: contact.id })}
|
||||
/>
|
||||
</HStack>
|
||||
</Td>
|
||||
</Tr>
|
||||
))}
|
||||
{contacts.length === 0 && (
|
||||
<Tr>
|
||||
<Td colSpan={9} textAlign="center" py={8}>
|
||||
<Text color="gray.500">Zatím žádné kontakty. Přidejte svůj první kontakt!</Text>
|
||||
</Td>
|
||||
</Tr>
|
||||
)}
|
||||
</Tbody>
|
||||
</Table>
|
||||
</Box>
|
||||
</TabPanel>
|
||||
|
||||
{/* Map & Address Tab */}
|
||||
<TabPanel>
|
||||
<VStack align="stretch" spacing={6}>
|
||||
<Box bg={cardBg} p={6} borderRadius="lg" borderWidth="1px" borderColor={borderColor}>
|
||||
<Heading size="md" mb={4}>Kontaktní údaje</Heading>
|
||||
<SimpleGrid columns={{ base: 1, md: 2 }} spacing={4}>
|
||||
<FormControl>
|
||||
<FormLabel>Adresa</FormLabel>
|
||||
<Input
|
||||
bg={inputBg}
|
||||
value={settings.contact_address || ''}
|
||||
onChange={handleSettingsChange('contact_address' as keyof AdminSettings)}
|
||||
placeholder="Ulice a č.p."
|
||||
/>
|
||||
</FormControl>
|
||||
<FormControl>
|
||||
<FormLabel>Město</FormLabel>
|
||||
<Input
|
||||
bg={inputBg}
|
||||
value={settings.contact_city || ''}
|
||||
onChange={handleSettingsChange('contact_city' as keyof AdminSettings)}
|
||||
placeholder="Město"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormControl>
|
||||
<FormLabel>PSČ</FormLabel>
|
||||
<Input
|
||||
bg={inputBg}
|
||||
value={settings.contact_zip || ''}
|
||||
onChange={handleSettingsChange('contact_zip' as keyof AdminSettings)}
|
||||
placeholder="12345"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormControl>
|
||||
<FormLabel>Země</FormLabel>
|
||||
<Input
|
||||
bg={inputBg}
|
||||
value={settings.contact_country || ''}
|
||||
onChange={handleSettingsChange('contact_country' as keyof AdminSettings)}
|
||||
placeholder="Česká republika"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormControl>
|
||||
<FormLabel>Telefon</FormLabel>
|
||||
<Input
|
||||
bg={inputBg}
|
||||
value={settings.contact_phone || ''}
|
||||
onChange={handleSettingsChange('contact_phone' as keyof AdminSettings)}
|
||||
placeholder="+420 123 456 789"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormControl>
|
||||
<FormLabel>E-mail</FormLabel>
|
||||
<Input
|
||||
bg={inputBg}
|
||||
type="email"
|
||||
value={settings.contact_email || ''}
|
||||
onChange={handleSettingsChange('contact_email' as keyof AdminSettings)}
|
||||
placeholder="kontakt@klub.cz"
|
||||
/>
|
||||
</FormControl>
|
||||
</SimpleGrid>
|
||||
</Box>
|
||||
|
||||
<Box bg={cardBg} p={6} borderRadius="lg" borderWidth="1px" borderColor={borderColor}>
|
||||
<Heading size="md" mb={4}>Poloha na mapě</Heading>
|
||||
<MapLinkImporter
|
||||
currentLatitude={settings.location_latitude}
|
||||
currentLongitude={settings.location_longitude}
|
||||
currentZoom={settings.map_zoom_level}
|
||||
mapStyle={settings.map_style || 'positron'}
|
||||
clubPrimaryColor={settings.primary_color}
|
||||
clubSecondaryColor={settings.accent_color}
|
||||
clubName={settings.club_name}
|
||||
onImport={(coords: MapCoordinates) => {
|
||||
setSettings((prev) => ({
|
||||
...prev,
|
||||
location_latitude: coords.latitude,
|
||||
location_longitude: coords.longitude,
|
||||
map_zoom_level: coords.zoom,
|
||||
// Auto-fill address fields if available
|
||||
contact_address: coords.street || prev.contact_address,
|
||||
contact_city: coords.city || prev.contact_city,
|
||||
contact_zip: coords.zip || prev.contact_zip,
|
||||
contact_country: coords.country || prev.contact_country,
|
||||
}));
|
||||
toast({
|
||||
title: 'Souřadnice a adresa importovány',
|
||||
description: `GPS poloha${coords.city ? ' a kontaktní údaje' : ''} byly načteny`,
|
||||
status: 'success',
|
||||
duration: 3000,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
|
||||
<Text fontSize="sm" color={textSecondary} mt={2} fontStyle="italic">
|
||||
Mapa se automaticky zobrazí na titulní stránce při nastavení GPS souřadnic.
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
<Box bg={cardBg} p={6} borderRadius="lg" borderWidth="1px" borderColor={borderColor}>
|
||||
<Heading size="md" mb={4}>Ruční nastavení souřadnic</Heading>
|
||||
<SimpleGrid columns={{ base: 1, md: 3 }} spacing={4}>
|
||||
<FormControl>
|
||||
<FormLabel>Zeměpisná šířka (Latitude)</FormLabel>
|
||||
<Input
|
||||
bg={inputBg}
|
||||
type="number"
|
||||
step="0.000001"
|
||||
value={settings.location_latitude ?? ''}
|
||||
onChange={handleNumChange('location_latitude' as keyof AdminSettings)}
|
||||
placeholder="50.0947"
|
||||
/>
|
||||
<FormHelperText fontSize="xs">Rozsah: -90 až 90</FormHelperText>
|
||||
</FormControl>
|
||||
<FormControl>
|
||||
<FormLabel>Zeměpisná délka (Longitude)</FormLabel>
|
||||
<Input
|
||||
bg={inputBg}
|
||||
type="number"
|
||||
step="0.000001"
|
||||
value={settings.location_longitude ?? ''}
|
||||
onChange={handleNumChange('location_longitude' as keyof AdminSettings)}
|
||||
placeholder="17.6997"
|
||||
/>
|
||||
<FormHelperText fontSize="xs">Rozsah: -180 až 180</FormHelperText>
|
||||
</FormControl>
|
||||
<FormControl>
|
||||
<FormLabel>Úroveň přiblížení (Zoom)</FormLabel>
|
||||
<Input
|
||||
bg={inputBg}
|
||||
type="number"
|
||||
min="1"
|
||||
max="20"
|
||||
value={settings.map_zoom_level ?? 15}
|
||||
onChange={handleNumChange('map_zoom_level' as keyof AdminSettings)}
|
||||
placeholder="15"
|
||||
/>
|
||||
<FormHelperText fontSize="xs">Rozsah: 1-20 (vyšší = větší přiblížení)</FormHelperText>
|
||||
</FormControl>
|
||||
</SimpleGrid>
|
||||
</Box>
|
||||
|
||||
<Box bg={cardBg} p={6} borderRadius="lg" borderWidth="1px" borderColor={borderColor}>
|
||||
<MapStyleSelector
|
||||
value={settings.map_style || 'positron'}
|
||||
onChange={(value) => {
|
||||
setSettings((prev) => ({ ...prev, map_style: value as PublicSettings['map_style'] }));
|
||||
}}
|
||||
clubPrimaryColor={settings.primary_color}
|
||||
clubSecondaryColor={settings.accent_color}
|
||||
showPreview={true}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{/* Live Map Preview with Current Coordinates */}
|
||||
{settings.location_latitude && settings.location_longitude && (
|
||||
<Box bg={cardBg} p={6} borderRadius="lg" borderWidth="1px" borderColor={borderColor}>
|
||||
<VStack align="stretch" spacing={3}>
|
||||
<HStack justify="space-between">
|
||||
<Heading size="md">Náhled vaší mapy</Heading>
|
||||
<Badge colorScheme="green">Aktuální poloha</Badge>
|
||||
</HStack>
|
||||
<Text fontSize="sm" color={textSecondary}>
|
||||
Toto je náhled mapy s vaší aktuální polohou a vybraným stylem. Takto se zobrazí návštěvníkům na webu.
|
||||
</Text>
|
||||
<ContactMap
|
||||
latitude={settings.location_latitude}
|
||||
longitude={settings.location_longitude}
|
||||
zoom={settings.map_zoom_level || 15}
|
||||
address={`${settings.contact_address || ''}${settings.contact_city ? ', ' + settings.contact_city : ''}`}
|
||||
clubName={settings.club_name}
|
||||
mapStyle={settings.map_style || 'positron'}
|
||||
clubPrimaryColor={settings.primary_color}
|
||||
clubSecondaryColor={settings.accent_color}
|
||||
height={400}
|
||||
/>
|
||||
</VStack>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<Box bg={infoBg} p={4} borderRadius="md" borderWidth="1px" borderColor={infoBorder}>
|
||||
<HStack justify="space-between" align="center">
|
||||
<VStack align="start" spacing={1}>
|
||||
<Text fontSize="sm" fontWeight="semibold">
|
||||
📍 Nezapomeňte uložit změny
|
||||
</Text>
|
||||
<Text fontSize="xs" color={textSecondary}>
|
||||
Uložte nastavení, aby se změny projevily na webu.
|
||||
</Text>
|
||||
</VStack>
|
||||
<Button
|
||||
colorScheme="blue"
|
||||
size="lg"
|
||||
onClick={handleSaveSettings}
|
||||
isLoading={savingSettings}
|
||||
loadingText="Ukládám..."
|
||||
>
|
||||
Uložit nastavení
|
||||
</Button>
|
||||
</HStack>
|
||||
</Box>
|
||||
</VStack>
|
||||
</TabPanel>
|
||||
</TabPanels>
|
||||
</Tabs>
|
||||
|
||||
{/* Contact Modal */}
|
||||
<Modal isOpen={isContactModalOpen} onClose={() => setIsContactModalOpen(false)} size="xl">
|
||||
<ModalOverlay />
|
||||
<ModalContent>
|
||||
<ModalHeader>{selectedContact ? 'Upravit kontakt' : 'Přidat kontakt'}</ModalHeader>
|
||||
<ModalCloseButton />
|
||||
<ModalBody>
|
||||
<VStack spacing={4}>
|
||||
<FormControl isRequired>
|
||||
<FormLabel>Jméno</FormLabel>
|
||||
<Input
|
||||
value={contactForm.name}
|
||||
onChange={(e) => setContactForm({ ...contactForm, name: e.target.value })}
|
||||
placeholder="Jan Novák"
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
<FormControl>
|
||||
<FormLabel>Pozice</FormLabel>
|
||||
<Input
|
||||
value={contactForm.position}
|
||||
onChange={(e) => setContactForm({ ...contactForm, position: e.target.value })}
|
||||
placeholder="Předseda, Hlavní trenér, atd."
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
<FormControl>
|
||||
<FormLabel>Kategorie</FormLabel>
|
||||
<Select
|
||||
value={contactForm.category_id || ''}
|
||||
onChange={(e) =>
|
||||
setContactForm({
|
||||
...contactForm,
|
||||
category_id: e.target.value ? parseInt(e.target.value) : undefined,
|
||||
})
|
||||
}
|
||||
>
|
||||
<option value="">Bez přiřazení</option>
|
||||
{categories.map((cat) => (
|
||||
<option key={cat.id} value={cat.id}>
|
||||
{cat.name}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
<FormHelperText fontSize="xs">Přiřaďte kontakt ke konkrétní kategorii</FormHelperText>
|
||||
</FormControl>
|
||||
|
||||
<FormControl>
|
||||
<FormLabel>Email</FormLabel>
|
||||
<Input
|
||||
type="email"
|
||||
value={contactForm.email}
|
||||
onChange={(e) => setContactForm({ ...contactForm, email: e.target.value })}
|
||||
placeholder="john@example.com"
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
<FormControl>
|
||||
<FormLabel>Telefon</FormLabel>
|
||||
<Input
|
||||
value={contactForm.phone}
|
||||
onChange={(e) => setContactForm({ ...contactForm, phone: e.target.value })}
|
||||
placeholder="+420 123 456 789"
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
<FormControl>
|
||||
<FormLabel>Fotografie</FormLabel>
|
||||
<Input
|
||||
type="file"
|
||||
accept="image/*"
|
||||
onChange={handleImageUpload}
|
||||
disabled={uploadingImage}
|
||||
/>
|
||||
{contactForm.image_url && (
|
||||
<Box mt={2}>
|
||||
<img
|
||||
src={getImageUrl(contactForm.image_url)}
|
||||
alt="Náhled"
|
||||
style={{ maxWidth: '200px', borderRadius: '8px' }}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
</FormControl>
|
||||
|
||||
<FormControl>
|
||||
<FormLabel>Popis</FormLabel>
|
||||
<Textarea
|
||||
value={contactForm.description}
|
||||
onChange={(e) => setContactForm({ ...contactForm, description: e.target.value })}
|
||||
placeholder="Stručný popis nebo bio"
|
||||
rows={3}
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
<FormControl>
|
||||
<FormLabel>Pořadí zobrazení</FormLabel>
|
||||
<Input
|
||||
type="number"
|
||||
value={contactForm.display_order}
|
||||
onChange={(e) =>
|
||||
setContactForm({ ...contactForm, display_order: parseInt(e.target.value) || 0 })
|
||||
}
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
<FormControl display="flex" alignItems="center">
|
||||
<FormLabel mb="0">Aktivní</FormLabel>
|
||||
<Switch
|
||||
isChecked={contactForm.is_active}
|
||||
onChange={(e) => setContactForm({ ...contactForm, is_active: e.target.checked })}
|
||||
/>
|
||||
</FormControl>
|
||||
</VStack>
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<Button variant="ghost" mr={3} onClick={() => setIsContactModalOpen(false)}>
|
||||
Zrušit
|
||||
</Button>
|
||||
<Button colorScheme="blue" onClick={handleContactSubmit} isLoading={loading}>
|
||||
{selectedContact ? 'Aktualizovat' : 'Vytvořit'}
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
|
||||
{/* Delete Confirmation Dialog */}
|
||||
<AlertDialog
|
||||
isOpen={deleteItem !== null}
|
||||
leastDestructiveRef={cancelRef}
|
||||
onClose={() => setDeleteItem(null)}
|
||||
>
|
||||
<AlertDialogOverlay>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader fontSize="lg" fontWeight="bold">
|
||||
Smazat kontakt
|
||||
</AlertDialogHeader>
|
||||
|
||||
<AlertDialogBody>
|
||||
Opravdu chcete smazat tento kontakt? Tuto akci nelze vrátit zpět.
|
||||
</AlertDialogBody>
|
||||
|
||||
<AlertDialogFooter>
|
||||
<Button ref={cancelRef} onClick={() => setDeleteItem(null)}>
|
||||
Zrušit
|
||||
</Button>
|
||||
<Button
|
||||
colorScheme="red"
|
||||
onClick={() => {
|
||||
if (deleteItem) {
|
||||
handleDeleteContact(deleteItem.id);
|
||||
}
|
||||
}}
|
||||
ml={3}
|
||||
isLoading={loading}
|
||||
>
|
||||
Smazat
|
||||
</Button>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialogOverlay>
|
||||
</AlertDialog>
|
||||
</Box>
|
||||
</AdminLayout>
|
||||
);
|
||||
};
|
||||
|
||||
export default ContactsAdminPage;
|
||||
@@ -0,0 +1,95 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Users, Calendar, FileText } from 'lucide-react';
|
||||
import { getAnalytics, AnalyticsData } from '@/services/analyticsService';
|
||||
|
||||
export default function DashboardPage() {
|
||||
const [analytics, setAnalytics] = useState<AnalyticsData | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchAnalytics = async () => {
|
||||
try {
|
||||
const data = await getAnalytics();
|
||||
setAnalytics(data);
|
||||
} catch (err) {
|
||||
setError('Nepodařilo se načíst analytická data');
|
||||
console.error('Error fetching analytics:', err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchAnalytics();
|
||||
}, []);
|
||||
|
||||
if (loading) return <div>Načítám data...</div>;
|
||||
if (error) return <div className="text-red-500">{error}</div>;
|
||||
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
{/* Header removed; Admin layout already provides the main title */}
|
||||
|
||||
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-3 mb-8">
|
||||
<StatsCard
|
||||
title="Uživatelé"
|
||||
value={analytics?.users.total || 0}
|
||||
description={`+${analytics?.users.new_this_week || 0} tento týden`}
|
||||
icon={Users}
|
||||
color="text-blue-500"
|
||||
bgColor="bg-blue-50"
|
||||
/>
|
||||
|
||||
<StatsCard
|
||||
title="Události"
|
||||
value={analytics?.events.total || 0}
|
||||
description={`${analytics?.events.upcoming || 0} nadcházejících`}
|
||||
icon={Calendar}
|
||||
color="text-green-500"
|
||||
bgColor="bg-green-50"
|
||||
/>
|
||||
|
||||
<StatsCard
|
||||
title="Články"
|
||||
value={analytics?.articles.total || 0}
|
||||
description={`${analytics?.articles.published || 0} publikováno`}
|
||||
icon={FileText}
|
||||
color="text-purple-500"
|
||||
bgColor="bg-purple-50"
|
||||
/>
|
||||
|
||||
</div>
|
||||
|
||||
{/* Add more dashboard sections here */}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface StatsCardProps {
|
||||
title: string;
|
||||
value: string | number;
|
||||
description: string;
|
||||
icon: React.ComponentType<{ className?: string }>;
|
||||
color: string;
|
||||
bgColor: string;
|
||||
}
|
||||
|
||||
function StatsCard({ title, value, description, icon: Icon, color, bgColor }: StatsCardProps) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground">
|
||||
{title}
|
||||
</CardTitle>
|
||||
<div className={`p-2 rounded-lg ${bgColor}`}>
|
||||
<Icon className={`h-4 w-4 ${color}`} />
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">{value}</div>
|
||||
<p className="text-xs text-muted-foreground">{description}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,664 @@
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
Heading,
|
||||
HStack,
|
||||
IconButton,
|
||||
Image,
|
||||
Input,
|
||||
InputGroup,
|
||||
InputLeftElement,
|
||||
Modal,
|
||||
ModalBody,
|
||||
ModalCloseButton,
|
||||
ModalContent,
|
||||
ModalFooter,
|
||||
ModalHeader,
|
||||
ModalOverlay,
|
||||
Table,
|
||||
Tbody,
|
||||
Td,
|
||||
Th,
|
||||
Thead,
|
||||
Tr,
|
||||
useDisclosure,
|
||||
useToast,
|
||||
VStack,
|
||||
Text,
|
||||
Badge,
|
||||
Tabs,
|
||||
TabList,
|
||||
Tab,
|
||||
TabPanels,
|
||||
TabPanel,
|
||||
useColorModeValue,
|
||||
Tooltip,
|
||||
Select,
|
||||
Flex,
|
||||
Spacer,
|
||||
Alert,
|
||||
AlertIcon,
|
||||
AlertTitle,
|
||||
AlertDescription,
|
||||
Stack,
|
||||
Link as ChakraLink,
|
||||
Divider,
|
||||
Code,
|
||||
Icon,
|
||||
} from '@chakra-ui/react';
|
||||
import { useState, useMemo } from 'react';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { FiTrash2, FiSearch, FiRefreshCw, FiCopy, FiAlertTriangle, FiExternalLink } from 'react-icons/fi';
|
||||
import AdminLayout from '../../layouts/AdminLayout';
|
||||
import {
|
||||
FileInfo,
|
||||
FileUsage,
|
||||
DuplicateFiles,
|
||||
getAllFiles,
|
||||
getUnusedFiles,
|
||||
getDuplicateFiles,
|
||||
deleteFile,
|
||||
scanAndSyncFiles,
|
||||
formatFileSize,
|
||||
getFileIcon,
|
||||
} from '../../services/files';
|
||||
|
||||
const FilesAdminPage: React.FC = () => {
|
||||
const toast = useToast();
|
||||
const qc = useQueryClient();
|
||||
const [search, setSearch] = useState('');
|
||||
const [mimeFilter, setMimeFilter] = useState('');
|
||||
const [selectedFile, setSelectedFile] = useState<FileInfo | null>(null);
|
||||
const [deleteTarget, setDeleteTarget] = useState<FileInfo | null>(null);
|
||||
const [forceDelete, setForceDelete] = useState(false);
|
||||
const [scanResult, setScanResult] = useState<any>(null);
|
||||
|
||||
const { isOpen: isUsagesOpen, onOpen: onUsagesOpen, onClose: onUsagesClose } = useDisclosure();
|
||||
const { isOpen: isDeleteOpen, onOpen: onDeleteOpen, onClose: onDeleteClose } = useDisclosure();
|
||||
const { isOpen: isScanResultOpen, onOpen: onScanResultOpen, onClose: onScanResultClose } = useDisclosure();
|
||||
|
||||
const borderColor = useColorModeValue('gray.200', 'gray.600');
|
||||
const bgHover = useColorModeValue('gray.50', 'gray.700');
|
||||
|
||||
// Fetch all files
|
||||
const { data: allFiles = [], isLoading, refetch } = useQuery({
|
||||
queryKey: ['admin-files', search, mimeFilter],
|
||||
queryFn: () => getAllFiles({ search, mime_type: mimeFilter }),
|
||||
});
|
||||
|
||||
// Fetch unused files
|
||||
const { data: unusedFiles = [] } = useQuery({
|
||||
queryKey: ['admin-files-unused'],
|
||||
queryFn: getUnusedFiles,
|
||||
});
|
||||
|
||||
// Fetch duplicate files
|
||||
const { data: duplicateFiles = {} } = useQuery({
|
||||
queryKey: ['admin-files-duplicates'],
|
||||
queryFn: getDuplicateFiles,
|
||||
});
|
||||
|
||||
// Delete mutation
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: ({ id, force }: { id: number; force: boolean }) => deleteFile(id, force),
|
||||
onSuccess: () => {
|
||||
toast({ title: 'Soubor smazán', status: 'success' });
|
||||
qc.invalidateQueries({ queryKey: ['admin-files'] });
|
||||
qc.invalidateQueries({ queryKey: ['admin-files-unused'] });
|
||||
qc.invalidateQueries({ queryKey: ['admin-files-duplicates'] });
|
||||
onDeleteClose();
|
||||
setDeleteTarget(null);
|
||||
setForceDelete(false);
|
||||
},
|
||||
onError: (error: any) => {
|
||||
if (error.response?.status === 409) {
|
||||
// File is in use - show warning
|
||||
setForceDelete(false);
|
||||
toast({
|
||||
title: 'Soubor je používán',
|
||||
description: 'Soubor je používán. Zkontrolujte použití a potvrďte smazání.',
|
||||
status: 'warning',
|
||||
duration: 5000,
|
||||
});
|
||||
} else {
|
||||
toast({
|
||||
title: 'Chyba při mazání',
|
||||
description: error?.response?.data?.error || 'Nepodařilo se smazat soubor',
|
||||
status: 'error',
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
// Scan mutation
|
||||
const scanMutation = useMutation({
|
||||
mutationFn: scanAndSyncFiles,
|
||||
onSuccess: (data) => {
|
||||
setScanResult(data);
|
||||
onScanResultOpen();
|
||||
qc.invalidateQueries({ queryKey: ['admin-files'] });
|
||||
qc.invalidateQueries({ queryKey: ['admin-files-unused'] });
|
||||
qc.invalidateQueries({ queryKey: ['admin-files-duplicates'] });
|
||||
},
|
||||
onError: () => {
|
||||
toast({ title: 'Chyba při skenování', status: 'error' });
|
||||
},
|
||||
});
|
||||
|
||||
const handleDelete = (file: FileInfo) => {
|
||||
setDeleteTarget(file);
|
||||
setForceDelete(false);
|
||||
onDeleteOpen();
|
||||
};
|
||||
|
||||
const confirmDelete = () => {
|
||||
if (deleteTarget) {
|
||||
deleteMutation.mutate({ id: deleteTarget.id, force: forceDelete });
|
||||
}
|
||||
};
|
||||
|
||||
const handleViewUsages = (file: FileInfo) => {
|
||||
setSelectedFile(file);
|
||||
onUsagesOpen();
|
||||
};
|
||||
|
||||
const copyToClipboard = (text: string) => {
|
||||
navigator.clipboard.writeText(text);
|
||||
toast({ title: 'Zkopírováno', status: 'success', duration: 2000 });
|
||||
};
|
||||
|
||||
const getImageUrl = (url: string) => {
|
||||
if (url.startsWith('http')) return url;
|
||||
const apiUrl = process.env.REACT_APP_API_URL || 'http://localhost:8080/api/v1';
|
||||
const origin = new URL(apiUrl).origin;
|
||||
return `${origin}${url}`;
|
||||
};
|
||||
|
||||
// Mime type options
|
||||
const mimeTypes = useMemo(() => {
|
||||
const types = new Set<string>();
|
||||
allFiles.forEach(file => {
|
||||
if (file.mime_type) {
|
||||
const baseType = file.mime_type.split('/')[0];
|
||||
types.add(baseType);
|
||||
}
|
||||
});
|
||||
return Array.from(types);
|
||||
}, [allFiles]);
|
||||
|
||||
const FileRow: React.FC<{ file: FileInfo; showUsageCount?: boolean }> = ({ file, showUsageCount = true }) => (
|
||||
<Tr _hover={{ bg: bgHover }}>
|
||||
<Td>
|
||||
<HStack>
|
||||
{file.mime_type?.startsWith('image/') ? (
|
||||
<Image
|
||||
src={getImageUrl(file.file_url)}
|
||||
alt={file.filename}
|
||||
boxSize="40px"
|
||||
objectFit="cover"
|
||||
borderRadius="md"
|
||||
fallbackSrc="/logo192.png"
|
||||
/>
|
||||
) : (
|
||||
<Icon as={getFileIcon(file.mime_type || '')} boxSize={6} color="blue.500" />
|
||||
)}
|
||||
<VStack align="start" spacing={0}>
|
||||
<Text fontWeight="medium" fontSize="sm">{file.filename}</Text>
|
||||
<Text fontSize="xs" color="gray.500">{file.file_path}</Text>
|
||||
</VStack>
|
||||
</HStack>
|
||||
</Td>
|
||||
<Td fontSize="sm">{formatFileSize(file.file_size)}</Td>
|
||||
<Td fontSize="sm">
|
||||
<Badge colorScheme={file.mime_type?.startsWith('image/') ? 'blue' : 'gray'}>
|
||||
{file.mime_type}
|
||||
</Badge>
|
||||
</Td>
|
||||
{showUsageCount && (
|
||||
<Td>
|
||||
<HStack>
|
||||
<Badge colorScheme={file.usage_count > 0 ? 'green' : 'red'}>
|
||||
{file.usage_count}
|
||||
</Badge>
|
||||
{file.usage_count > 0 && (
|
||||
<IconButton
|
||||
aria-label="Zobrazit použití"
|
||||
icon={<FiExternalLink />}
|
||||
size="xs"
|
||||
variant="ghost"
|
||||
onClick={() => handleViewUsages(file)}
|
||||
/>
|
||||
)}
|
||||
</HStack>
|
||||
</Td>
|
||||
)}
|
||||
<Td fontSize="sm">{new Date(file.created_at).toLocaleDateString('cs-CZ')}</Td>
|
||||
<Td>
|
||||
<HStack spacing={1}>
|
||||
<Tooltip label="Kopírovat URL">
|
||||
<IconButton
|
||||
aria-label="Kopírovat URL"
|
||||
icon={<FiCopy />}
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => copyToClipboard(file.file_url)}
|
||||
/>
|
||||
</Tooltip>
|
||||
<Tooltip label="Smazat">
|
||||
<IconButton
|
||||
aria-label="Smazat"
|
||||
icon={<FiTrash2 />}
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
colorScheme="red"
|
||||
onClick={() => handleDelete(file)}
|
||||
/>
|
||||
</Tooltip>
|
||||
</HStack>
|
||||
</Td>
|
||||
</Tr>
|
||||
);
|
||||
|
||||
const duplicateGroups = Object.entries(duplicateFiles);
|
||||
|
||||
return (
|
||||
<AdminLayout requireAdmin={true}>
|
||||
<VStack align="stretch" spacing={6}>
|
||||
<HStack justify="space-between">
|
||||
<Heading size="lg">Správa souborů</Heading>
|
||||
<Button
|
||||
leftIcon={<FiRefreshCw />}
|
||||
onClick={() => scanMutation.mutate()}
|
||||
isLoading={scanMutation.isPending}
|
||||
colorScheme="blue"
|
||||
size="sm"
|
||||
>
|
||||
Skenovat soubory
|
||||
</Button>
|
||||
</HStack>
|
||||
|
||||
<Tabs colorScheme="blue" variant="enclosed">
|
||||
<TabList>
|
||||
<Tab>Všechny soubory ({allFiles.length})</Tab>
|
||||
<Tab>Nepoužívané ({unusedFiles.length})</Tab>
|
||||
<Tab>Duplicity ({duplicateGroups.length})</Tab>
|
||||
</TabList>
|
||||
|
||||
<TabPanels>
|
||||
{/* All Files Tab */}
|
||||
<TabPanel>
|
||||
<VStack align="stretch" spacing={4}>
|
||||
{allFiles.length === 0 && !isLoading && (
|
||||
<Alert status="info" borderRadius="md">
|
||||
<AlertIcon />
|
||||
<Box flex="1">
|
||||
<AlertTitle>Žádné soubory v databázi</AlertTitle>
|
||||
<AlertDescription>
|
||||
Klikněte na tlačítko "Skenovat soubory" pro načtení souborů z uploads složky do databáze.
|
||||
</AlertDescription>
|
||||
</Box>
|
||||
</Alert>
|
||||
)}
|
||||
<HStack spacing={4}>
|
||||
<InputGroup maxW="400px">
|
||||
<InputLeftElement pointerEvents="none">
|
||||
<FiSearch />
|
||||
</InputLeftElement>
|
||||
<Input
|
||||
placeholder="Hledat soubory..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
/>
|
||||
</InputGroup>
|
||||
<Select
|
||||
placeholder="Všechny typy"
|
||||
maxW="200px"
|
||||
value={mimeFilter}
|
||||
onChange={(e) => setMimeFilter(e.target.value)}
|
||||
>
|
||||
{mimeTypes.map(type => (
|
||||
<option key={type} value={type}>{type}</option>
|
||||
))}
|
||||
</Select>
|
||||
<Spacer />
|
||||
<Text fontSize="sm" color="gray.500">
|
||||
Celkem: {allFiles.length} souborů
|
||||
</Text>
|
||||
</HStack>
|
||||
|
||||
<Box overflowX="auto" borderWidth="1px" borderRadius="md" borderColor={borderColor}>
|
||||
<Table size="sm">
|
||||
<Thead>
|
||||
<Tr>
|
||||
<Th>Soubor</Th>
|
||||
<Th>Velikost</Th>
|
||||
<Th>Typ</Th>
|
||||
<Th>Použití</Th>
|
||||
<Th>Vytvořeno</Th>
|
||||
<Th>Akce</Th>
|
||||
</Tr>
|
||||
</Thead>
|
||||
<Tbody>
|
||||
{isLoading ? (
|
||||
<Tr>
|
||||
<Td colSpan={6} textAlign="center" py={8}>
|
||||
Načítání...
|
||||
</Td>
|
||||
</Tr>
|
||||
) : allFiles.length === 0 ? (
|
||||
<Tr>
|
||||
<Td colSpan={6} textAlign="center" py={8}>
|
||||
Žádné soubory nenalezeny
|
||||
</Td>
|
||||
</Tr>
|
||||
) : (
|
||||
allFiles.map(file => <FileRow key={file.id} file={file} />)
|
||||
)}
|
||||
</Tbody>
|
||||
</Table>
|
||||
</Box>
|
||||
</VStack>
|
||||
</TabPanel>
|
||||
|
||||
{/* Unused Files Tab */}
|
||||
<TabPanel>
|
||||
<VStack align="stretch" spacing={4}>
|
||||
<Alert status="info" borderRadius="md">
|
||||
<AlertIcon />
|
||||
<Box>
|
||||
<AlertTitle>Nepoužívané soubory</AlertTitle>
|
||||
<AlertDescription>
|
||||
Tyto soubory nejsou použity v žádném článku, hráči, sponzorovi nebo jiné entitě.
|
||||
</AlertDescription>
|
||||
</Box>
|
||||
</Alert>
|
||||
|
||||
<Box overflowX="auto" borderWidth="1px" borderRadius="md" borderColor={borderColor}>
|
||||
<Table size="sm">
|
||||
<Thead>
|
||||
<Tr>
|
||||
<Th>Soubor</Th>
|
||||
<Th>Velikost</Th>
|
||||
<Th>Typ</Th>
|
||||
<Th>Vytvořeno</Th>
|
||||
<Th>Akce</Th>
|
||||
</Tr>
|
||||
</Thead>
|
||||
<Tbody>
|
||||
{unusedFiles.length === 0 ? (
|
||||
<Tr>
|
||||
<Td colSpan={5} textAlign="center" py={8}>
|
||||
Všechny soubory jsou používány
|
||||
</Td>
|
||||
</Tr>
|
||||
) : (
|
||||
unusedFiles.map(file => <FileRow key={file.id} file={file} showUsageCount={false} />)
|
||||
)}
|
||||
</Tbody>
|
||||
</Table>
|
||||
</Box>
|
||||
</VStack>
|
||||
</TabPanel>
|
||||
|
||||
{/* Duplicates Tab */}
|
||||
<TabPanel>
|
||||
<VStack align="stretch" spacing={4}>
|
||||
<Alert status="warning" borderRadius="md">
|
||||
<AlertIcon />
|
||||
<Box>
|
||||
<AlertTitle>Duplicitní soubory</AlertTitle>
|
||||
<AlertDescription>
|
||||
Tyto soubory mají identický obsah (MD5 hash). Můžete je smazat a aktualizovat odkazy.
|
||||
</AlertDescription>
|
||||
</Box>
|
||||
</Alert>
|
||||
|
||||
{duplicateGroups.length === 0 ? (
|
||||
<Box textAlign="center" py={8}>
|
||||
<Text color="gray.500">Žádné duplicity nenalezeny</Text>
|
||||
</Box>
|
||||
) : (
|
||||
duplicateGroups.map(([hash, files]) => (
|
||||
<Box key={hash} borderWidth="1px" borderRadius="md" p={4} borderColor={borderColor}>
|
||||
<VStack align="stretch" spacing={2}>
|
||||
<HStack>
|
||||
<FiAlertTriangle color="orange" />
|
||||
<Text fontWeight="bold" fontSize="sm">
|
||||
Duplicitní skupina ({files.length} souborů)
|
||||
</Text>
|
||||
<Code fontSize="xs">{hash.substring(0, 12)}...</Code>
|
||||
</HStack>
|
||||
<Divider />
|
||||
<Table size="sm" variant="simple">
|
||||
<Thead>
|
||||
<Tr>
|
||||
<Th>Soubor</Th>
|
||||
<Th>Velikost</Th>
|
||||
<Th>Použití</Th>
|
||||
<Th>Vytvořeno</Th>
|
||||
<Th>Akce</Th>
|
||||
</Tr>
|
||||
</Thead>
|
||||
<Tbody>
|
||||
{files.map(file => (
|
||||
<FileRow key={file.id} file={file} />
|
||||
))}
|
||||
</Tbody>
|
||||
</Table>
|
||||
</VStack>
|
||||
</Box>
|
||||
))
|
||||
)}
|
||||
</VStack>
|
||||
</TabPanel>
|
||||
</TabPanels>
|
||||
</Tabs>
|
||||
</VStack>
|
||||
|
||||
{/* File Usages Modal */}
|
||||
<Modal isOpen={isUsagesOpen} onClose={onUsagesClose} size="xl">
|
||||
<ModalOverlay />
|
||||
<ModalContent>
|
||||
<ModalHeader>Použití souboru</ModalHeader>
|
||||
<ModalCloseButton />
|
||||
<ModalBody>
|
||||
{selectedFile && (
|
||||
<VStack align="stretch" spacing={4}>
|
||||
<HStack>
|
||||
{selectedFile.mime_type?.startsWith('image/') && (
|
||||
<Image
|
||||
src={getImageUrl(selectedFile.file_url)}
|
||||
alt={selectedFile.filename}
|
||||
maxH="100px"
|
||||
borderRadius="md"
|
||||
/>
|
||||
)}
|
||||
<VStack align="start" flex={1}>
|
||||
<Text fontWeight="bold">{selectedFile.filename}</Text>
|
||||
<Text fontSize="sm" color="gray.500">{selectedFile.file_path}</Text>
|
||||
</VStack>
|
||||
</HStack>
|
||||
|
||||
<Divider />
|
||||
|
||||
<VStack align="stretch" spacing={2}>
|
||||
<Text fontWeight="bold">Používáno v ({selectedFile.usage_count}):</Text>
|
||||
{selectedFile.usages && selectedFile.usages.length > 0 ? (
|
||||
selectedFile.usages.map((usage) => (
|
||||
<Box key={usage.id} p={3} borderWidth="1px" borderRadius="md">
|
||||
<HStack justify="space-between">
|
||||
<VStack align="start" spacing={0}>
|
||||
<Badge colorScheme="purple">{usage.entity_type}</Badge>
|
||||
<Text fontSize="sm" mt={1}>
|
||||
{usage.entity_info?.title || usage.entity_info?.name || `ID: ${usage.entity_id}`}
|
||||
</Text>
|
||||
{usage.field_name && (
|
||||
<Text fontSize="xs" color="gray.500">Pole: {usage.field_name}</Text>
|
||||
)}
|
||||
</VStack>
|
||||
{usage.entity_info?.url && (
|
||||
<ChakraLink href={usage.entity_info.url} isExternal>
|
||||
<IconButton
|
||||
aria-label="Otevřít"
|
||||
icon={<FiExternalLink />}
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
/>
|
||||
</ChakraLink>
|
||||
)}
|
||||
</HStack>
|
||||
</Box>
|
||||
))
|
||||
) : (
|
||||
<Text color="gray.500" fontSize="sm">Soubor není nikde použit</Text>
|
||||
)}
|
||||
</VStack>
|
||||
</VStack>
|
||||
)}
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<Button onClick={onUsagesClose}>Zavřít</Button>
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
|
||||
{/* Delete Confirmation Modal */}
|
||||
<Modal isOpen={isDeleteOpen} onClose={onDeleteClose}>
|
||||
<ModalOverlay />
|
||||
<ModalContent>
|
||||
<ModalHeader>Smazat soubor</ModalHeader>
|
||||
<ModalCloseButton />
|
||||
<ModalBody>
|
||||
{deleteTarget && (
|
||||
<VStack align="stretch" spacing={4}>
|
||||
<Text>
|
||||
Opravdu chcete smazat soubor <strong>{deleteTarget.filename}</strong>?
|
||||
</Text>
|
||||
|
||||
{deleteTarget.usage_count > 0 && (
|
||||
<Alert status="warning">
|
||||
<AlertIcon />
|
||||
<Box>
|
||||
<AlertTitle>Pozor!</AlertTitle>
|
||||
<AlertDescription>
|
||||
Tento soubor je použit na {deleteTarget.usage_count} místech.
|
||||
Smazáním může dojít k nefunkčnosti odkazů.
|
||||
</AlertDescription>
|
||||
</Box>
|
||||
</Alert>
|
||||
)}
|
||||
</VStack>
|
||||
)}
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<HStack spacing={3}>
|
||||
<Button variant="ghost" onClick={onDeleteClose}>
|
||||
Zrušit
|
||||
</Button>
|
||||
<Button
|
||||
colorScheme="red"
|
||||
onClick={confirmDelete}
|
||||
isLoading={deleteMutation.isPending}
|
||||
>
|
||||
{(deleteTarget?.usage_count ?? 0) > 0 ? 'Přesto smazat' : 'Smazat'}
|
||||
</Button>
|
||||
</HStack>
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
|
||||
{/* Scan Results Modal */}
|
||||
<Modal isOpen={isScanResultOpen} onClose={onScanResultClose} size="lg">
|
||||
<ModalOverlay />
|
||||
<ModalContent>
|
||||
<ModalHeader>Výsledky skenování souborů</ModalHeader>
|
||||
<ModalCloseButton />
|
||||
<ModalBody>
|
||||
{scanResult && (
|
||||
<VStack align="stretch" spacing={4}>
|
||||
<Alert status="success" borderRadius="md">
|
||||
<AlertIcon />
|
||||
<Box flex="1">
|
||||
<AlertTitle>Skenování dokončeno</AlertTitle>
|
||||
<AlertDescription>{scanResult.message}</AlertDescription>
|
||||
</Box>
|
||||
</Alert>
|
||||
|
||||
<Stack spacing={3}>
|
||||
<HStack justify="space-between" p={3} borderWidth="1px" borderRadius="md">
|
||||
<Text fontWeight="medium">Nalezených souborů:</Text>
|
||||
<Badge colorScheme="blue" fontSize="md" px={3} py={1}>
|
||||
{scanResult.found_files}
|
||||
</Badge>
|
||||
</HStack>
|
||||
|
||||
<HStack justify="space-between" p={3} borderWidth="1px" borderRadius="md">
|
||||
<Text fontWeight="medium">Nově přidaných:</Text>
|
||||
<Badge colorScheme="green" fontSize="md" px={3} py={1}>
|
||||
{scanResult.new_files}
|
||||
</Badge>
|
||||
</HStack>
|
||||
|
||||
{scanResult.skipped_files !== undefined && scanResult.skipped_files > 0 && (
|
||||
<HStack justify="space-between" p={3} borderWidth="1px" borderRadius="md">
|
||||
<Text fontWeight="medium">Přeskočených (.gitkeep, atd.):</Text>
|
||||
<Badge colorScheme="gray" fontSize="md" px={3} py={1}>
|
||||
{scanResult.skipped_files}
|
||||
</Badge>
|
||||
</HStack>
|
||||
)}
|
||||
|
||||
{scanResult.orphaned_files > 0 && (
|
||||
<HStack justify="space-between" p={3} borderWidth="1px" borderRadius="md" borderColor="orange.300">
|
||||
<Text fontWeight="medium">Osiřelých záznamů:</Text>
|
||||
<Badge colorScheme="orange" fontSize="md" px={3} py={1}>
|
||||
{scanResult.orphaned_files}
|
||||
</Badge>
|
||||
</HStack>
|
||||
)}
|
||||
</Stack>
|
||||
|
||||
{scanResult.new_files_list && scanResult.new_files_list.length > 0 && (
|
||||
<Box>
|
||||
<Text fontWeight="bold" mb={2}>Nově přidané soubory:</Text>
|
||||
<Box maxH="200px" overflowY="auto" borderWidth="1px" borderRadius="md" p={3}>
|
||||
<VStack align="stretch" spacing={1}>
|
||||
{scanResult.new_files_list.map((filename: string, idx: number) => (
|
||||
<Text key={idx} fontSize="sm" fontFamily="monospace">
|
||||
✓ {filename}
|
||||
</Text>
|
||||
))}
|
||||
</VStack>
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{scanResult.orphaned_list && scanResult.orphaned_list.length > 0 && (
|
||||
<Box>
|
||||
<Text fontWeight="bold" mb={2} color="orange.500">Osiřelé záznamy (soubory v DB, ale ne na disku):</Text>
|
||||
<Box maxH="200px" overflowY="auto" borderWidth="1px" borderRadius="md" p={3} borderColor="orange.300">
|
||||
<VStack align="stretch" spacing={1}>
|
||||
{scanResult.orphaned_list.map((filename: string, idx: number) => (
|
||||
<Text key={idx} fontSize="sm" fontFamily="monospace" color="orange.600">
|
||||
⚠ {filename}
|
||||
</Text>
|
||||
))}
|
||||
</VStack>
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
</VStack>
|
||||
)}
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<Button colorScheme="blue" onClick={onScanResultClose}>
|
||||
Zavřít
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
</AdminLayout>
|
||||
);
|
||||
};
|
||||
|
||||
export default FilesAdminPage;
|
||||
@@ -0,0 +1,368 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Container,
|
||||
Heading,
|
||||
Text,
|
||||
Table,
|
||||
Thead,
|
||||
Tbody,
|
||||
Tr,
|
||||
Th,
|
||||
Td,
|
||||
Button,
|
||||
HStack,
|
||||
VStack,
|
||||
Badge,
|
||||
Spinner,
|
||||
useToast,
|
||||
Image,
|
||||
Link,
|
||||
Alert,
|
||||
AlertIcon,
|
||||
AlertTitle,
|
||||
AlertDescription,
|
||||
useColorModeValue,
|
||||
} from '@chakra-ui/react';
|
||||
import { RefreshCw, ExternalLink, Calendar, Image as ImageIcon, Eye } from 'lucide-react';
|
||||
import AdminLayout from '../../components/layout/AdminLayout';
|
||||
|
||||
interface Album {
|
||||
id: string;
|
||||
title: string;
|
||||
url: string;
|
||||
date: string;
|
||||
photos_count: number;
|
||||
views_count?: number;
|
||||
photos: Array<{
|
||||
id: string;
|
||||
page_url: string;
|
||||
image_1500: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
const resolveBackendUrl = (path: string) => {
|
||||
try {
|
||||
if (/^https?:\/\//i.test(path)) return path;
|
||||
if (path.startsWith('/cache') || path.startsWith('/uploads') || path.startsWith('/api/')) {
|
||||
const base = process.env.REACT_APP_API_BASE_URL || process.env.REACT_APP_API_URL || 'http://localhost:8080/api/v1';
|
||||
const b = new URL(base);
|
||||
const abs = new URL(path, `${b.protocol}//${b.host}`);
|
||||
return abs.toString();
|
||||
}
|
||||
return path;
|
||||
} catch {
|
||||
return path;
|
||||
}
|
||||
};
|
||||
|
||||
const GalleryAdminPage: React.FC = () => {
|
||||
const cardBg = useColorModeValue('white', 'gray.800');
|
||||
const borderColor = useColorModeValue('gray.200', 'gray.700');
|
||||
const textSecondary = useColorModeValue('gray.600', 'gray.400');
|
||||
const [albums, setAlbums] = useState<Album[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
const [error, setError] = useState<string>('');
|
||||
const toast = useToast();
|
||||
|
||||
const fetchAlbums = async () => {
|
||||
setLoading(true);
|
||||
setError('');
|
||||
|
||||
try {
|
||||
// Try to load from both sources and combine them (similar to frontpage)
|
||||
const [profileRes, albumsRes] = await Promise.allSettled([
|
||||
fetch(resolveBackendUrl('/cache/prefetch/zonerama_profile.json'), { cache: 'no-cache' }),
|
||||
fetch(resolveBackendUrl('/cache/prefetch/zonerama_albums.json'), { cache: 'no-cache' })
|
||||
]);
|
||||
|
||||
let combinedAlbums: Album[] = [];
|
||||
|
||||
// Get profile albums (main source)
|
||||
if (profileRes.status === 'fulfilled' && profileRes.value.ok) {
|
||||
const profileData = await profileRes.value.json();
|
||||
if (profileData.albums && Array.isArray(profileData.albums)) {
|
||||
combinedAlbums = [...profileData.albums];
|
||||
}
|
||||
}
|
||||
|
||||
// Get additional albums (fallback source)
|
||||
if (albumsRes.status === 'fulfilled' && albumsRes.value.ok) {
|
||||
const albumsData = await albumsRes.value.json();
|
||||
const blogAlbums = Array.isArray(albumsData) ? albumsData : [];
|
||||
|
||||
// Filter out empty albums and avoid duplicates
|
||||
const validBlogAlbums = blogAlbums.filter((album: any) =>
|
||||
album.id &&
|
||||
album.title &&
|
||||
!combinedAlbums.some(existing => existing.id === album.id)
|
||||
);
|
||||
|
||||
combinedAlbums = [...combinedAlbums, ...validBlogAlbums];
|
||||
}
|
||||
|
||||
setAlbums(combinedAlbums);
|
||||
} catch (err: any) {
|
||||
setError(err.message || 'Nepodařilo se načíst alba');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRefresh = async () => {
|
||||
setRefreshing(true);
|
||||
|
||||
try {
|
||||
const apiUrl = process.env.REACT_APP_API_URL || 'http://localhost:8080/api/v1';
|
||||
const token = localStorage.getItem('token');
|
||||
|
||||
const response = await fetch(`${apiUrl}/admin/gallery/refresh`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Chyba při obnově galerie');
|
||||
}
|
||||
|
||||
toast({
|
||||
title: 'Galerie obnovena',
|
||||
description: 'Data z Zonerama byla úspěšně načtena',
|
||||
status: 'success',
|
||||
duration: 3000,
|
||||
isClosable: true,
|
||||
});
|
||||
|
||||
// Reload albums after refresh
|
||||
await fetchAlbums();
|
||||
} catch (err: any) {
|
||||
toast({
|
||||
title: 'Chyba',
|
||||
description: err.message || 'Nepodařilo se obnovit galerii',
|
||||
status: 'error',
|
||||
duration: 5000,
|
||||
isClosable: true,
|
||||
});
|
||||
} finally {
|
||||
setRefreshing(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchAlbums();
|
||||
}, []);
|
||||
|
||||
const totalPhotos = albums.reduce((sum, album) => sum + album.photos_count, 0);
|
||||
const totalViews = albums.reduce((sum, album) => sum + (album.views_count || 0), 0);
|
||||
|
||||
return (
|
||||
<AdminLayout>
|
||||
<Container maxW="7xl" py={8}>
|
||||
<VStack align="stretch" spacing={6}>
|
||||
{/* Header */}
|
||||
<HStack justify="space-between" align="center" flexWrap="wrap">
|
||||
<VStack align="start" spacing={1}>
|
||||
<Heading size="xl">Správa galerie</Heading>
|
||||
<Text color="gray.600">
|
||||
Správa alb a fotografií ze Zonerama
|
||||
</Text>
|
||||
</VStack>
|
||||
|
||||
<Button
|
||||
leftIcon={<RefreshCw size={18} />}
|
||||
colorScheme="blue"
|
||||
onClick={handleRefresh}
|
||||
isLoading={refreshing}
|
||||
loadingText="Obnova..."
|
||||
>
|
||||
Obnovit z Zonerama
|
||||
</Button>
|
||||
</HStack>
|
||||
|
||||
{/* Zonerama Info */}
|
||||
<Alert status="info" borderRadius="md">
|
||||
<AlertIcon />
|
||||
<Box>
|
||||
<AlertTitle>Zonerama integrace</AlertTitle>
|
||||
<AlertDescription>
|
||||
Alba jsou automaticky načítána ze Zonerama profilu. Klikněte na "Obnovit z Zonerama" pro synchronizaci s nejnovějšími daty.
|
||||
</AlertDescription>
|
||||
</Box>
|
||||
</Alert>
|
||||
|
||||
{/* Statistics */}
|
||||
{!loading && !error && albums.length > 0 && (
|
||||
<HStack spacing={4} flexWrap="wrap">
|
||||
<Badge colorScheme="purple" fontSize="md" p={3} borderRadius="md">
|
||||
{albums.length} alb
|
||||
</Badge>
|
||||
<Badge colorScheme="blue" fontSize="md" p={3} borderRadius="md">
|
||||
{totalPhotos} fotografií
|
||||
</Badge>
|
||||
<Badge colorScheme="green" fontSize="md" p={3} borderRadius="md">
|
||||
{totalViews} zhlédnutí
|
||||
</Badge>
|
||||
</HStack>
|
||||
)}
|
||||
|
||||
{/* Loading State */}
|
||||
{loading && (
|
||||
<VStack spacing={4} py={12}>
|
||||
<Spinner size="xl" color="brand.primary" />
|
||||
<Text color="gray.600">Načítám alba...</Text>
|
||||
</VStack>
|
||||
)}
|
||||
|
||||
{/* Error State */}
|
||||
{error && !loading && (
|
||||
<Alert status="error" borderRadius="md">
|
||||
<AlertIcon />
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* Empty State */}
|
||||
{!loading && !error && albums.length === 0 && (
|
||||
<Box
|
||||
bg={cardBg}
|
||||
borderWidth="1px"
|
||||
borderRadius="lg"
|
||||
p={12}
|
||||
textAlign="center"
|
||||
>
|
||||
<VStack spacing={4}>
|
||||
<ImageIcon size={48} color="gray" />
|
||||
<Heading size="md" color="gray.600">
|
||||
Zatím nejsou k dispozici žádná alba
|
||||
</Heading>
|
||||
<Text color="gray.500">
|
||||
Klikněte na tlačítko "Obnovit z Zonerama" pro načtení alb.
|
||||
</Text>
|
||||
<Button
|
||||
leftIcon={<RefreshCw size={18} />}
|
||||
colorScheme="blue"
|
||||
onClick={handleRefresh}
|
||||
isLoading={refreshing}
|
||||
>
|
||||
Obnovit z Zonerama
|
||||
</Button>
|
||||
</VStack>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Albums Table */}
|
||||
{!loading && !error && albums.length > 0 && (
|
||||
<Box
|
||||
bg={cardBg}
|
||||
borderWidth="1px"
|
||||
borderRadius="lg"
|
||||
overflow="hidden"
|
||||
boxShadow="sm"
|
||||
>
|
||||
<Table variant="simple">
|
||||
<Thead bg={useColorModeValue('gray.50', 'gray.900')}>
|
||||
<Tr>
|
||||
<Th width="100px">Náhled</Th>
|
||||
<Th>Název</Th>
|
||||
<Th width="120px">Datum</Th>
|
||||
<Th width="100px" isNumeric>Fotky</Th>
|
||||
<Th width="120px" isNumeric>Zhlédnutí</Th>
|
||||
<Th width="180px">Akce</Th>
|
||||
</Tr>
|
||||
</Thead>
|
||||
<Tbody>
|
||||
{albums.map((album) => {
|
||||
const coverPhoto = album.photos && album.photos.length > 0
|
||||
? album.photos[0]
|
||||
: null;
|
||||
|
||||
return (
|
||||
<Tr key={album.id} _hover={{ bg: 'gray.50' }}>
|
||||
<Td>
|
||||
{coverPhoto ? (
|
||||
<Image
|
||||
src={coverPhoto.image_1500}
|
||||
alt={album.title}
|
||||
boxSize="60px"
|
||||
objectFit="cover"
|
||||
borderRadius="md"
|
||||
/>
|
||||
) : (
|
||||
<Box
|
||||
boxSize="60px"
|
||||
bg="gray.200"
|
||||
borderRadius="md"
|
||||
display="flex"
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
>
|
||||
<ImageIcon size={24} color="gray" />
|
||||
</Box>
|
||||
)}
|
||||
</Td>
|
||||
<Td>
|
||||
<Text fontWeight="600" color="gray.800" noOfLines={2}>
|
||||
{album.title}
|
||||
</Text>
|
||||
</Td>
|
||||
<Td>
|
||||
<HStack spacing={1} fontSize="sm" color="gray.600">
|
||||
<Calendar size={14} />
|
||||
<Text>{album.date}</Text>
|
||||
</HStack>
|
||||
</Td>
|
||||
<Td isNumeric>
|
||||
<Badge colorScheme="blue">
|
||||
{album.photos_count}
|
||||
</Badge>
|
||||
</Td>
|
||||
<Td isNumeric>
|
||||
<HStack spacing={1} justify="flex-end">
|
||||
<Eye size={14} />
|
||||
<Text fontSize="sm">{album.views_count || 0}</Text>
|
||||
</HStack>
|
||||
</Td>
|
||||
<Td>
|
||||
<HStack spacing={2}>
|
||||
<Button
|
||||
as={Link}
|
||||
href={`/galerie/album/${album.id}`}
|
||||
target="_blank"
|
||||
size="sm"
|
||||
colorScheme="purple"
|
||||
variant="outline"
|
||||
>
|
||||
Náhled
|
||||
</Button>
|
||||
<Button
|
||||
as={Link}
|
||||
href={album.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
size="sm"
|
||||
colorScheme="blue"
|
||||
variant="ghost"
|
||||
rightIcon={<ExternalLink size={14} />}
|
||||
>
|
||||
Zonerama
|
||||
</Button>
|
||||
</HStack>
|
||||
</Td>
|
||||
</Tr>
|
||||
);
|
||||
})}
|
||||
</Tbody>
|
||||
</Table>
|
||||
</Box>
|
||||
)}
|
||||
</VStack>
|
||||
</Container>
|
||||
</AdminLayout>
|
||||
);
|
||||
};
|
||||
|
||||
export default GalleryAdminPage;
|
||||
@@ -0,0 +1,990 @@
|
||||
import {
|
||||
Box,
|
||||
Heading,
|
||||
Text,
|
||||
Spinner,
|
||||
Alert,
|
||||
AlertIcon,
|
||||
Table,
|
||||
Thead,
|
||||
Tbody,
|
||||
Tr,
|
||||
Th,
|
||||
Td,
|
||||
HStack,
|
||||
Badge,
|
||||
Button,
|
||||
useToast,
|
||||
Drawer,
|
||||
DrawerOverlay,
|
||||
DrawerContent,
|
||||
DrawerHeader,
|
||||
DrawerBody,
|
||||
DrawerFooter,
|
||||
FormControl,
|
||||
FormLabel,
|
||||
Input,
|
||||
Stack,
|
||||
InputGroup,
|
||||
InputRightElement,
|
||||
List,
|
||||
ListItem,
|
||||
FormErrorMessage,
|
||||
Image,
|
||||
useBreakpointValue,
|
||||
Wrap,
|
||||
WrapItem,
|
||||
useColorModeValue,
|
||||
Select
|
||||
} from '@chakra-ui/react';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { TeamLogo } from '../../components/common/TeamLogo';
|
||||
import AdminLayout from '../../layouts/AdminLayout';
|
||||
import { putMatchOverride, patchMatchOverride, searchClubs, uploadImage, fetchLogoAsBlob, uploadToLogaSportcreative } from '../../services/adminMatches';
|
||||
import { getPublicSettings } from '../../services/settings';
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import { parse } from 'date-fns';
|
||||
import { assetUrl } from '../../utils/url';
|
||||
|
||||
const MatchesAdminPage = () => {
|
||||
const queryClient = useQueryClient();
|
||||
const toast = useToast();
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [focusSide, setFocusSide] = useState<'home' | 'away' | null>(null);
|
||||
const [selected, setSelected] = useState<any | null>(null);
|
||||
const [form, setForm] = useState({
|
||||
home_name_override: '',
|
||||
away_name_override: '',
|
||||
venue_override: '',
|
||||
date_time_override: '',
|
||||
home_logo_url: '',
|
||||
away_logo_url: '',
|
||||
notes: '',
|
||||
});
|
||||
|
||||
// External logo upload helpers/state
|
||||
const [homeExternalTeamId, setHomeExternalTeamId] = useState<string>('');
|
||||
const [awayExternalTeamId, setAwayExternalTeamId] = useState<string>('');
|
||||
const [homeUploadedFile, setHomeUploadedFile] = useState<File | null>(null);
|
||||
const [awayUploadedFile, setAwayUploadedFile] = useState<File | null>(null);
|
||||
|
||||
// Team search state
|
||||
const [homeQuery, setHomeQuery] = useState('');
|
||||
const [awayQuery, setAwayQuery] = useState('');
|
||||
const [debouncedHome, setDebouncedHome] = useState('');
|
||||
const [debouncedAway, setDebouncedAway] = useState('');
|
||||
useEffect(() => {
|
||||
const t = setTimeout(() => setDebouncedHome(homeQuery), 300);
|
||||
return () => clearTimeout(t);
|
||||
}, [homeQuery]);
|
||||
useEffect(() => {
|
||||
const t = setTimeout(() => setDebouncedAway(awayQuery), 300);
|
||||
return () => clearTimeout(t);
|
||||
}, [awayQuery]);
|
||||
const { data: homeResults = [] } = useQuery({
|
||||
queryKey: ['club-search-home', debouncedHome],
|
||||
queryFn: () => searchClubs(debouncedHome),
|
||||
enabled: debouncedHome.trim().length >= 2,
|
||||
});
|
||||
const { data: awayResults = [] } = useQuery({
|
||||
queryKey: ['club-search-away', debouncedAway],
|
||||
queryFn: () => searchClubs(debouncedAway),
|
||||
enabled: debouncedAway.trim().length >= 2,
|
||||
});
|
||||
|
||||
// Upload refs
|
||||
const homeFileRef = useRef<HTMLInputElement | null>(null);
|
||||
const awayFileRef = useRef<HTMLInputElement | null>(null);
|
||||
|
||||
const { data: matches = [], isLoading, error } = useQuery<any[], Error>({
|
||||
queryKey: ['admin-matches-list-cache'],
|
||||
queryFn: async () => {
|
||||
// Read cached FACR club info
|
||||
const apiUrl = process.env.REACT_APP_API_URL || 'http://localhost:8080/api/v1';
|
||||
const origin = new URL(apiUrl).origin;
|
||||
const url = `${origin}/cache/prefetch/facr_club_info.json`;
|
||||
const res = await fetch(url, { headers: { 'Cache-Control': 'no-cache' } });
|
||||
if (!res.ok) throw new Error(`Failed to load cache: ${res.status}`);
|
||||
const json = await res.json();
|
||||
|
||||
const comps = Array.isArray(json?.competitions) ? json.competitions : [];
|
||||
const items: any[] = comps.flatMap((c: any) =>
|
||||
(Array.isArray(c.matches) ? c.matches : []).map((m: any) => ({ ...m, competitionName: c.name, competition_id: c.id }))
|
||||
);
|
||||
|
||||
// Optional: stable sort by date ascending
|
||||
const FACR_DATE_FMT = 'dd.MM.yyyy HH:mm';
|
||||
items.sort((a, b) => {
|
||||
const da = parse(String(a.date_time || a.date), FACR_DATE_FMT, new Date()).getTime();
|
||||
const db = parse(String(b.date_time || b.date), FACR_DATE_FMT, new Date()).getTime();
|
||||
return da - db;
|
||||
});
|
||||
|
||||
return items.map((m: any) => ({
|
||||
id: m.match_id,
|
||||
date_time: m.date_time || m.date,
|
||||
competitionName: m.competitionName,
|
||||
competition_id: m.competition_id,
|
||||
home: m.home || m.home_team,
|
||||
home_id: m.home_id || m.home_team_id || m.home_team_facr_id,
|
||||
away: m.away || m.away_team,
|
||||
away_id: m.away_id || m.away_team_id || m.away_team_facr_id,
|
||||
score: m.score,
|
||||
venue: m.venue,
|
||||
home_logo_url: m.home_logo_url,
|
||||
away_logo_url: m.away_logo_url,
|
||||
}));
|
||||
},
|
||||
});
|
||||
|
||||
// Filters
|
||||
const [teamFilter, setTeamFilter] = useState('');
|
||||
const [dateFrom, setDateFrom] = useState<string>(''); // YYYY-MM-DD
|
||||
const [dateTo, setDateTo] = useState<string>(''); // YYYY-MM-DD
|
||||
const [competitionFilter, setCompetitionFilter] = useState<string>('');
|
||||
const [sideFilter, setSideFilter] = useState<'home' | 'away' | ''>('');
|
||||
const normalizedTeam = teamFilter.trim().toLowerCase();
|
||||
const FACR_DATE_FMT = 'dd.MM.yyyy HH:mm';
|
||||
// Club name (for side filter)
|
||||
const { data: publicSettings } = useQuery({
|
||||
queryKey: ['public-settings'],
|
||||
queryFn: getPublicSettings,
|
||||
});
|
||||
const { data: facrClubInfo } = useQuery({
|
||||
queryKey: ['facr-club-info-name'],
|
||||
queryFn: async () => {
|
||||
const apiUrl = process.env.REACT_APP_API_URL || 'http://localhost:8080/api/v1';
|
||||
const origin = new URL(apiUrl).origin;
|
||||
const url = `${origin}/cache/prefetch/facr_club_info.json`;
|
||||
const res = await fetch(url, { headers: { 'Cache-Control': 'no-cache' } });
|
||||
if (!res.ok) return null;
|
||||
return await res.json();
|
||||
},
|
||||
});
|
||||
const clubName: string = (publicSettings as any)?.club_name || (facrClubInfo as any)?.name || '';
|
||||
const normalize = (s: string) => String(s || '')
|
||||
.normalize('NFD')
|
||||
.replace(/[\u0300-\u036f]/g, '')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
const stripPrefixes = (s: string) => {
|
||||
let x = normalize(s);
|
||||
// Common Czech club prefixes/words
|
||||
x = x.replace(/\b(mestsky|m\.?f\.?k\.?|mfk|tj|sk|sokol|fotbalovy|fotbalový|fotbalovy\s+klub|fotbalovy\s+klub)\b/g, '').replace(/\s+/g, ' ').trim();
|
||||
return x;
|
||||
};
|
||||
const clubNorm = normalize(clubName);
|
||||
const clubStrip = stripPrefixes(clubName);
|
||||
const teamMatchesClub = (team: string): boolean => {
|
||||
const t = normalize(team);
|
||||
const ts = stripPrefixes(team);
|
||||
return !!clubNorm && (t.includes(clubNorm) || ts.includes(clubStrip) || t.endsWith(clubStrip) || clubStrip.endsWith(ts));
|
||||
};
|
||||
const competitionOptions = useMemo(() => {
|
||||
const set = new Set<string>();
|
||||
for (const m of matches) {
|
||||
if (m.competitionName) set.add(String(m.competitionName));
|
||||
}
|
||||
return Array.from(set).sort((a, b) => a.localeCompare(b));
|
||||
}, [matches]);
|
||||
const filteredMatches = matches.filter((m: any) => {
|
||||
// team filter
|
||||
const teamOk = normalizedTeam
|
||||
? (
|
||||
sideFilter === 'home'
|
||||
? [m.home, m.home_team].filter(Boolean).some((v: string) => String(v).toLowerCase().includes(normalizedTeam))
|
||||
: sideFilter === 'away'
|
||||
? [m.away, m.away_team].filter(Boolean).some((v: string) => String(v).toLowerCase().includes(normalizedTeam))
|
||||
: [m.home, m.home_team, m.away, m.away_team]
|
||||
.filter(Boolean)
|
||||
.some((v: string) => String(v).toLowerCase().includes(normalizedTeam))
|
||||
)
|
||||
: true;
|
||||
if (!teamOk) return false;
|
||||
|
||||
// competition filter
|
||||
if (competitionFilter && String(m.competitionName || '') !== competitionFilter) return false;
|
||||
|
||||
// side filter based on club name
|
||||
if (sideFilter && clubNorm) {
|
||||
const homeName = String(m.home || m.home_team || '');
|
||||
const awayName = String(m.away || m.away_team || '');
|
||||
if (sideFilter === 'home' && !teamMatchesClub(homeName)) return false;
|
||||
if (sideFilter === 'away' && !teamMatchesClub(awayName)) return false;
|
||||
}
|
||||
|
||||
// date parse
|
||||
const dtStr = String(m.date_time || m.date || '');
|
||||
let ts = NaN;
|
||||
try {
|
||||
ts = parse(dtStr, FACR_DATE_FMT, new Date()).getTime();
|
||||
} catch (_) {
|
||||
const d2 = new Date(dtStr);
|
||||
ts = d2.getTime();
|
||||
}
|
||||
if (isNaN(ts)) return true; // if can't parse, let it pass other filters
|
||||
|
||||
// date range filter
|
||||
if (dateFrom) {
|
||||
const fromTs = new Date(dateFrom + 'T00:00:00').getTime();
|
||||
if (!isNaN(fromTs) && ts < fromTs) return false;
|
||||
}
|
||||
if (dateTo) {
|
||||
const toTs = new Date(dateTo + 'T23:59:59').getTime();
|
||||
if (!isNaN(toTs) && ts > toTs) return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
// Pagination (Load more) + page size selector
|
||||
const [pageSize, setPageSize] = useState(50);
|
||||
const [limit, setLimit] = useState(50);
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
|
||||
// Initialize filters from URL on first load and when data changes (so comps are known)
|
||||
useEffect(() => {
|
||||
const spTeam = searchParams.get('team') || '';
|
||||
const spFrom = searchParams.get('from') || '';
|
||||
const spTo = searchParams.get('to') || '';
|
||||
const spComp = searchParams.get('comp') || '';
|
||||
const spVenue = searchParams.get('venue') || '';
|
||||
const spSide = searchParams.get('side') || '';
|
||||
const spSize = parseInt(searchParams.get('size') || '') || undefined;
|
||||
const spLimit = parseInt(searchParams.get('limit') || '') || undefined;
|
||||
if (spTeam) setTeamFilter(spTeam);
|
||||
if (spFrom) setDateFrom(spFrom);
|
||||
if (spTo) setDateTo(spTo);
|
||||
if (spComp) setCompetitionFilter(spComp);
|
||||
// venue filter removed
|
||||
if (spSide === 'home' || spSide === 'away') setSideFilter(spSide);
|
||||
if (spSize) {
|
||||
setPageSize(spSize);
|
||||
setLimit(spSize);
|
||||
}
|
||||
if (spLimit) setLimit(spLimit);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
// Keep URL in sync when filters/pagination change
|
||||
useEffect(() => {
|
||||
const params: Record<string, string> = {};
|
||||
if (teamFilter) params.team = teamFilter;
|
||||
if (dateFrom) params.from = dateFrom;
|
||||
if (dateTo) params.to = dateTo;
|
||||
if (competitionFilter) params.comp = competitionFilter;
|
||||
// venue filter removed
|
||||
if (sideFilter) params.side = sideFilter;
|
||||
if (pageSize !== 50) params.size = String(pageSize);
|
||||
if (limit !== pageSize) params.limit = String(limit);
|
||||
setSearchParams(params, { replace: true });
|
||||
}, [teamFilter, dateFrom, dateTo, competitionFilter, sideFilter, pageSize, limit, setSearchParams]);
|
||||
useEffect(() => {
|
||||
// reset pagination on filter change
|
||||
setLimit(pageSize);
|
||||
}, [normalizedTeam, dateFrom, dateTo, competitionFilter, sideFilter, clubNorm, pageSize]);
|
||||
const visibleMatches = filteredMatches.slice(0, limit);
|
||||
|
||||
// Date presets
|
||||
const setThisWeek = () => {
|
||||
const now = new Date();
|
||||
const day = now.getDay(); // 0 Sun .. 6 Sat
|
||||
const diffToMonday = (day === 0 ? -6 : 1 - day); // Monday start
|
||||
const monday = new Date(now);
|
||||
monday.setDate(now.getDate() + diffToMonday);
|
||||
const sunday = new Date(monday);
|
||||
sunday.setDate(monday.getDate() + 6);
|
||||
const f = monday.toISOString().slice(0, 10);
|
||||
const t = sunday.toISOString().slice(0, 10);
|
||||
setDateFrom(f);
|
||||
setDateTo(t);
|
||||
};
|
||||
const setNext30Days = () => {
|
||||
const now = new Date();
|
||||
const to = new Date(now);
|
||||
to.setDate(now.getDate() + 30);
|
||||
const f = now.toISOString().slice(0, 10);
|
||||
const t = to.toISOString().slice(0, 10);
|
||||
setDateFrom(f);
|
||||
setDateTo(t);
|
||||
};
|
||||
|
||||
// Export CSV of filtered results
|
||||
const exportCsv = () => {
|
||||
const rows = filteredMatches.map((m: any) => {
|
||||
const date = m.date_time || m.date || '';
|
||||
const comp = m.competitionName || '';
|
||||
const home = m.home || m.home_team || '';
|
||||
const away = m.away || m.away_team || '';
|
||||
const score = m.score || (m.result_home != null && m.result_away != null ? `${m.result_home}:${m.result_away}` : '');
|
||||
const venue = m.venue || '';
|
||||
return { date, competition: comp, home, away, score, venue };
|
||||
});
|
||||
const headers = ['date', 'competition', 'home', 'away', 'score', 'venue'];
|
||||
const escape = (v: any) => {
|
||||
const s = String(v ?? '');
|
||||
if (/[",\n]/.test(s)) return '"' + s.replace(/"/g, '""') + '"';
|
||||
return s;
|
||||
};
|
||||
const csv = [headers.join(','), ...rows.map(r => headers.map(h => escape((r as any)[h])).join(','))].join('\n');
|
||||
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = 'matches.csv';
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
// Datetime validation (RFC3339-ish)
|
||||
const isDateInvalid = form.date_time_override.trim() !== '' && isNaN(Date.parse(form.date_time_override));
|
||||
|
||||
const saveMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
const externalMatchId: string = selected?.match_id || selected?.id;
|
||||
if (!externalMatchId) throw new Error('Chybí match_id');
|
||||
const payload: any = { ...form };
|
||||
// normalize empty strings to null so backend can clear values
|
||||
Object.keys(payload).forEach((k) => {
|
||||
if (payload[k as keyof typeof payload] === '') payload[k as keyof typeof payload] = null;
|
||||
});
|
||||
// First store current overrides
|
||||
await putMatchOverride(externalMatchId, payload);
|
||||
|
||||
// Best-effort upload to logoapi.sportcreative.eu for home/away
|
||||
const results: { home?: { success: boolean; error?: string }; away?: { success: boolean; error?: string } } = {};
|
||||
|
||||
const processSide = async (
|
||||
side: 'home' | 'away',
|
||||
externalTeamId: string,
|
||||
uploadedFile: File | null,
|
||||
nameOverride: string,
|
||||
logoUrl: string | null
|
||||
) => {
|
||||
try {
|
||||
if (!externalTeamId) return { success: false, error: 'Chybí ID týmu' };
|
||||
let file: File | Blob | null = uploadedFile;
|
||||
if (!file && logoUrl) {
|
||||
file = await fetchLogoAsBlob(logoUrl);
|
||||
}
|
||||
if (!file) return { success: false, error: 'Nelze získat soubor loga' };
|
||||
const up = await uploadToLogaSportcreative(externalTeamId, file, {
|
||||
filename: file instanceof File ? file.name : `${externalTeamId}.png`,
|
||||
clubName: nameOverride || 'Neznámý klub',
|
||||
clubType: 'football',
|
||||
});
|
||||
if (!up.success) return { success: false, error: up.error || 'Upload selhal' };
|
||||
if (up.url) {
|
||||
// Patch override to immediately use external URL
|
||||
await patchMatchOverride(
|
||||
externalMatchId,
|
||||
side === 'home' ? { home_logo_url: up.url } : { away_logo_url: up.url }
|
||||
);
|
||||
}
|
||||
return { success: true };
|
||||
} catch (e: any) {
|
||||
return { success: false, error: e?.message || 'Chyba při uploadu' };
|
||||
}
|
||||
};
|
||||
|
||||
if (homeExternalTeamId && (form.home_logo_url || homeUploadedFile)) {
|
||||
results.home = await processSide('home', homeExternalTeamId, homeUploadedFile, form.home_name_override, form.home_logo_url);
|
||||
}
|
||||
if (awayExternalTeamId && (form.away_logo_url || awayUploadedFile)) {
|
||||
results.away = await processSide('away', awayExternalTeamId, awayUploadedFile, form.away_name_override, form.away_logo_url);
|
||||
}
|
||||
|
||||
return { ok: true, results };
|
||||
},
|
||||
onSuccess: (res: any) => {
|
||||
const r = res?.results || {};
|
||||
const parts: string[] = [];
|
||||
if (r.home) parts.push(r.home.success ? 'Logo domácích nahráno' : `Domácí: ${r.home.error || 'chyba'}`);
|
||||
if (r.away) parts.push(r.away.success ? 'Logo hostů nahráno' : `Hosté: ${r.away.error || 'chyba'}`);
|
||||
const description = parts.length ? parts.join(' • ') : undefined;
|
||||
toast({ title: 'Uloženo', description, status: 'success' });
|
||||
setIsOpen(false);
|
||||
setSelected(null);
|
||||
setHomeUploadedFile(null);
|
||||
setAwayUploadedFile(null);
|
||||
// Invalidate the cache-backed list to refresh any merged overrides
|
||||
queryClient.invalidateQueries({ queryKey: ['admin-matches-list-cache'] });
|
||||
},
|
||||
onError: (e: any) => {
|
||||
toast({ title: 'Uložení selhalo', description: e?.message || 'Zkuste to znovu', status: 'error' });
|
||||
},
|
||||
});
|
||||
|
||||
const openEdit = (m: any, side?: 'home' | 'away') => {
|
||||
setSelected(m);
|
||||
// Convert FACR-style date (e.g., 25.08.2025 18:30) to RFC3339 for backend
|
||||
const facrStr: string = m.date_time || m.date || '';
|
||||
let iso = '';
|
||||
if (facrStr) {
|
||||
try {
|
||||
const dt = parse(String(facrStr), 'dd.MM.yyyy HH:mm', new Date());
|
||||
if (!isNaN(dt.getTime())) iso = dt.toISOString();
|
||||
} catch (_) {
|
||||
// If it's already ISO or another parseable format, keep as-is if valid
|
||||
const d2 = new Date(facrStr);
|
||||
if (!isNaN(d2.getTime())) iso = d2.toISOString();
|
||||
}
|
||||
}
|
||||
setForm({
|
||||
home_name_override: m.home || m.home_team || '',
|
||||
away_name_override: m.away || m.away_team || '',
|
||||
venue_override: m.venue || '',
|
||||
date_time_override: iso,
|
||||
home_logo_url: m.home_logo_url || '',
|
||||
away_logo_url: m.away_logo_url || '',
|
||||
notes: '',
|
||||
});
|
||||
setIsOpen(true);
|
||||
setFocusSide(side ?? null);
|
||||
// Reset external selections and uploaded files to avoid stale state
|
||||
setHomeExternalTeamId('');
|
||||
setAwayExternalTeamId('');
|
||||
setHomeUploadedFile(null);
|
||||
setAwayUploadedFile(null);
|
||||
};
|
||||
|
||||
// Autofocus on the selected team input when drawer opens
|
||||
const homeInputRef = useRef<HTMLInputElement | null>(null);
|
||||
const awayInputRef = useRef<HTMLInputElement | null>(null);
|
||||
const handleHomeInput = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setHomeQuery(e.target.value);
|
||||
};
|
||||
const handleAwayInput = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setAwayQuery(e.target.value);
|
||||
};
|
||||
useEffect(() => {
|
||||
if (isOpen && focusSide) {
|
||||
const t = setTimeout(() => {
|
||||
if (focusSide === 'home') homeInputRef.current?.focus();
|
||||
if (focusSide === 'away') awayInputRef.current?.focus();
|
||||
}, 50);
|
||||
return () => clearTimeout(t);
|
||||
}
|
||||
}, [isOpen, focusSide]);
|
||||
|
||||
const drawerSize = useBreakpointValue({ base: 'full', md: 'md' });
|
||||
// Horizontal scroll affordance
|
||||
const scrollRef = useRef<HTMLDivElement | null>(null);
|
||||
const [canScrollLeft, setCanScrollLeft] = useState(false);
|
||||
const [canScrollRight, setCanScrollRight] = useState(false);
|
||||
const [showScrollHint, setShowScrollHint] = useState(true);
|
||||
const thBg = useColorModeValue('gray.50', 'gray.700');
|
||||
|
||||
// Drag-to-scroll state
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const [startX, setStartX] = useState(0);
|
||||
const [scrollLeft, setScrollLeft] = useState(0);
|
||||
|
||||
// Color modes for past/future matches
|
||||
const pastMatchBg = useColorModeValue('gray.100', 'gray.700');
|
||||
const futureMatchBg = useColorModeValue('white', 'gray.800');
|
||||
const pastMatchHoverBg = useColorModeValue('gray.200', 'gray.600');
|
||||
const futureMatchHoverBg = useColorModeValue('gray.50', 'gray.700');
|
||||
|
||||
const updateScrollShadow = () => {
|
||||
const el = scrollRef.current;
|
||||
if (!el) return;
|
||||
setCanScrollLeft(el.scrollLeft > 0);
|
||||
setCanScrollRight(el.scrollLeft + el.clientWidth < el.scrollWidth - 1);
|
||||
};
|
||||
|
||||
// Drag-to-scroll handlers
|
||||
const handleMouseDown = (e: React.MouseEvent<HTMLDivElement>) => {
|
||||
if (!scrollRef.current) return;
|
||||
setIsDragging(true);
|
||||
setStartX(e.pageX - scrollRef.current.offsetLeft);
|
||||
setScrollLeft(scrollRef.current.scrollLeft);
|
||||
scrollRef.current.style.cursor = 'grabbing';
|
||||
scrollRef.current.style.userSelect = 'none';
|
||||
};
|
||||
|
||||
const handleMouseLeave = () => {
|
||||
setIsDragging(false);
|
||||
if (scrollRef.current) {
|
||||
scrollRef.current.style.cursor = 'grab';
|
||||
scrollRef.current.style.userSelect = 'auto';
|
||||
}
|
||||
};
|
||||
|
||||
const handleMouseUp = () => {
|
||||
setIsDragging(false);
|
||||
if (scrollRef.current) {
|
||||
scrollRef.current.style.cursor = 'grab';
|
||||
scrollRef.current.style.userSelect = 'auto';
|
||||
}
|
||||
};
|
||||
|
||||
const handleMouseMove = (e: React.MouseEvent<HTMLDivElement>) => {
|
||||
if (!isDragging || !scrollRef.current) return;
|
||||
e.preventDefault();
|
||||
const x = e.pageX - scrollRef.current.offsetLeft;
|
||||
const walk = (x - startX) * 2; // Scroll speed multiplier
|
||||
scrollRef.current.scrollLeft = scrollLeft - walk;
|
||||
};
|
||||
|
||||
// Utility to check if match is in the past
|
||||
const isMatchPast = (dateTimeStr: string): boolean => {
|
||||
if (!dateTimeStr) return false;
|
||||
try {
|
||||
const dt = parse(dateTimeStr, FACR_DATE_FMT, new Date());
|
||||
if (!isNaN(dt.getTime())) {
|
||||
return dt.getTime() < Date.now();
|
||||
}
|
||||
} catch (_) {
|
||||
const d = new Date(dateTimeStr);
|
||||
if (!isNaN(d.getTime())) {
|
||||
return d.getTime() < Date.now();
|
||||
}
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
updateScrollShadow();
|
||||
const onResize = () => updateScrollShadow();
|
||||
window.addEventListener('resize', onResize);
|
||||
return () => window.removeEventListener('resize', onResize);
|
||||
}, []);
|
||||
|
||||
const headerBg = useColorModeValue('brand.primary', 'gray.700');
|
||||
const headerText = useColorModeValue('text.onPrimary', 'white');
|
||||
const cardBg = useColorModeValue('white', 'gray.800');
|
||||
const borderColor = useColorModeValue('gray.200', 'gray.700');
|
||||
|
||||
return (
|
||||
<AdminLayout requireAdmin={false}>
|
||||
<Box>
|
||||
<Box bg={headerBg} color={headerText} borderRadius="xl" p={6} mb={6} boxShadow="lg">
|
||||
<Heading size="lg" mb={2}>Správa zápasů</Heading>
|
||||
<Text opacity={0.9}>
|
||||
Správa a úprava zápasů. Můžete upravovat informace o zápasech, včetně názvů týmů, termínů, log a dalších detailů.
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
{isLoading ? (
|
||||
<HStack spacing={3} mb={4}>
|
||||
<Spinner />
|
||||
<Text>Načítám zápasy…</Text>
|
||||
</HStack>
|
||||
) : error ? (
|
||||
<Alert status="error" variant="left-accent" mb={4}>
|
||||
<AlertIcon />
|
||||
Nepodařilo se načíst zápasy.
|
||||
</Alert>
|
||||
) : (
|
||||
<Box>
|
||||
<Wrap mb={4} spacing={3} align="center">
|
||||
<WrapItem minW="160px">
|
||||
<Select size="sm" value={sideFilter} onChange={(e) => setSideFilter((e.target.value as any) || '')}>
|
||||
<option value="">Všechny strany</option>
|
||||
<option value="home">Domácí</option>
|
||||
<option value="away">Hosté</option>
|
||||
</Select>
|
||||
</WrapItem>
|
||||
<WrapItem flex={1} minW="220px">
|
||||
<Input
|
||||
placeholder="Filtrovat podle týmu…"
|
||||
value={teamFilter}
|
||||
onChange={(e) => setTeamFilter(e.target.value)}
|
||||
size="sm"
|
||||
/>
|
||||
</WrapItem>
|
||||
<WrapItem>
|
||||
<HStack>
|
||||
<Input
|
||||
type="date"
|
||||
size="sm"
|
||||
value={dateFrom}
|
||||
onChange={(e) => setDateFrom(e.target.value)}
|
||||
/>
|
||||
<Text color="gray.500" fontSize="sm">–</Text>
|
||||
<Input
|
||||
type="date"
|
||||
size="sm"
|
||||
value={dateTo}
|
||||
onChange={(e) => setDateTo(e.target.value)}
|
||||
/>
|
||||
</HStack>
|
||||
</WrapItem>
|
||||
<WrapItem>
|
||||
<HStack>
|
||||
<Button size="sm" variant="outline" onClick={setThisWeek} borderRadius="md" _hover={{ borderColor: 'brand.primary', color: 'brand.primary' }}>Tento týden</Button>
|
||||
<Button size="sm" variant="outline" onClick={setNext30Days} borderRadius="md" _hover={{ borderColor: 'brand.primary', color: 'brand.primary' }}>Dalších 30 dní</Button>
|
||||
</HStack>
|
||||
</WrapItem>
|
||||
<WrapItem minW="220px">
|
||||
<Select size="sm" value={competitionFilter} onChange={(e) => setCompetitionFilter(e.target.value)}>
|
||||
<option value="">Všechny soutěže</option>
|
||||
{competitionOptions.map((c) => (
|
||||
<option key={c} value={c}>{c}</option>
|
||||
))}
|
||||
</Select>
|
||||
</WrapItem>
|
||||
{(teamFilter || dateFrom || dateTo || competitionFilter || sideFilter) && (
|
||||
<WrapItem>
|
||||
<Button size="sm" variant="outline" colorScheme="red" onClick={() => { setTeamFilter(''); setDateFrom(''); setDateTo(''); setCompetitionFilter(''); setSideFilter(''); }} borderRadius="md">
|
||||
Vymazat filtry
|
||||
</Button>
|
||||
</WrapItem>
|
||||
)}
|
||||
<WrapItem>
|
||||
<HStack>
|
||||
<Text fontSize="sm">Na stránku:</Text>
|
||||
<Select size="sm" value={pageSize} onChange={(e) => setPageSize(parseInt(e.target.value) || 25)} width="auto">
|
||||
<option value={25}>25</option>
|
||||
<option value={50}>50</option>
|
||||
<option value={100}>100</option>
|
||||
<option value={200}>200</option>
|
||||
</Select>
|
||||
</HStack>
|
||||
</WrapItem>
|
||||
<WrapItem>
|
||||
<Button size="sm" onClick={exportCsv} bg="brand.primary" color="text.onPrimary" _hover={{ filter: 'brightness(0.95)' }} borderRadius="md">Export CSV</Button>
|
||||
</WrapItem>
|
||||
<WrapItem>
|
||||
<Text color="gray.500" fontSize="sm">
|
||||
Zobrazeno {visibleMatches.length} / {filteredMatches.length}
|
||||
</Text>
|
||||
</WrapItem>
|
||||
</Wrap>
|
||||
{showScrollHint && (
|
||||
<Text fontSize="xs" color="blue.600" fontWeight="600" mb={2}>
|
||||
💡 Tip: Tabulku můžete posouvat tažením myší nebo touchem →
|
||||
</Text>
|
||||
)}
|
||||
<Box
|
||||
ref={scrollRef}
|
||||
overflowX="auto"
|
||||
borderWidth="2px"
|
||||
borderRadius="xl"
|
||||
borderColor={borderColor}
|
||||
w="full"
|
||||
bg={cardBg}
|
||||
boxShadow="md"
|
||||
maxW="100%"
|
||||
position="relative"
|
||||
cursor="grab"
|
||||
onMouseDown={handleMouseDown}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
onMouseUp={handleMouseUp}
|
||||
onMouseMove={handleMouseMove}
|
||||
onScroll={(e) => {
|
||||
updateScrollShadow();
|
||||
if ((e.currentTarget as HTMLDivElement).scrollLeft > 0 && showScrollHint) setShowScrollHint(false);
|
||||
}}
|
||||
sx={{
|
||||
WebkitOverflowScrolling: 'touch',
|
||||
'th, td': { whiteSpace: 'nowrap' },
|
||||
'::-webkit-scrollbar': { height: '12px' },
|
||||
'::-webkit-scrollbar-thumb': {
|
||||
background: '#3182ce',
|
||||
borderRadius: '8px',
|
||||
'&:hover': { background: '#2c5aa0' }
|
||||
},
|
||||
'::-webkit-scrollbar-track': {
|
||||
background: '#e2e8f0',
|
||||
borderRadius: '8px',
|
||||
margin: '0 4px'
|
||||
},
|
||||
}}
|
||||
>
|
||||
{/* Gradient edges to indicate horizontal scroll */}
|
||||
{canScrollLeft && (
|
||||
<Box position="sticky" left={0} top={0} bottom={0} w="24px" pointerEvents="none"
|
||||
bgGradient={useColorModeValue('linear(to-r, white, transparent)', 'linear(to-r, gray.800, transparent)')}
|
||||
zIndex={1}
|
||||
/>
|
||||
)}
|
||||
{canScrollRight && (
|
||||
<Box position="sticky" right={0} top={0} bottom={0} w="24px" pointerEvents="none"
|
||||
bgGradient={useColorModeValue('linear(to-l, white, transparent)', 'linear(to-l, gray.800, transparent)')}
|
||||
zIndex={1}
|
||||
/>
|
||||
)}
|
||||
<Table size="sm" sx={{ width: 'max-content' }}>
|
||||
<Thead sx={{ position: 'sticky', top: 0, zIndex: 2, backgroundColor: headerBg, 'th': { bg: headerBg, color: headerText, fontWeight: 'bold', textTransform: 'uppercase', fontSize: 'xs', letterSpacing: '0.05em' } }}>
|
||||
<Tr>
|
||||
<Th minW="140px">Datum</Th>
|
||||
<Th minW="200px">Soutěž</Th>
|
||||
<Th minW="260px">Domácí</Th>
|
||||
<Th minW="80px" textAlign="center">Skóre</Th>
|
||||
<Th minW="260px">Hosté</Th>
|
||||
<Th minW="220px">Místo</Th>
|
||||
<Th minW="180px">Akce</Th>
|
||||
</Tr>
|
||||
</Thead>
|
||||
<Tbody>
|
||||
{filteredMatches.length === 0 ? (
|
||||
<Tr>
|
||||
<Td colSpan={6}>
|
||||
<Text color="gray.500">Žádné zápasy k zobrazení.</Text>
|
||||
</Td>
|
||||
</Tr>
|
||||
) : (
|
||||
visibleMatches.map((m: any, idx: number) => {
|
||||
const isPast = isMatchPast(m.date_time || m.date || '');
|
||||
const hasScore = m.score || (m.result_home != null && m.result_away != null);
|
||||
return (
|
||||
<Tr
|
||||
key={m.id ?? idx}
|
||||
bg={isPast ? pastMatchBg : futureMatchBg}
|
||||
_hover={{ bg: isPast ? pastMatchHoverBg : futureMatchHoverBg }}
|
||||
opacity={isPast ? 0.85 : 1}
|
||||
transition="all 0.2s"
|
||||
>
|
||||
<Td>
|
||||
<HStack spacing={2}>
|
||||
<Text>{m.date_time || m.date || ''}</Text>
|
||||
{isPast && <Badge colorScheme="gray" fontSize="xs">Odehráno</Badge>}
|
||||
{!isPast && <Badge colorScheme="green" fontSize="xs">Nadcházející</Badge>}
|
||||
</HStack>
|
||||
</Td>
|
||||
<Td>
|
||||
<HStack spacing={2}>
|
||||
<Badge bg="brand.primary" color="text.onPrimary" borderRadius="md">{m.competitionName}</Badge>
|
||||
</HStack>
|
||||
</Td>
|
||||
<Td>
|
||||
<HStack spacing={2}>
|
||||
<TeamLogo
|
||||
teamId={m.home_id}
|
||||
teamName={m.home || m.home_team || ''}
|
||||
facrLogo={m.home_logo_url}
|
||||
size="custom"
|
||||
boxSize="24px"
|
||||
/>
|
||||
<Text fontWeight={isPast ? 'normal' : 'medium'}>{m.home || m.home_team || ''}</Text>
|
||||
<Button size="xs" variant="outline" onClick={() => openEdit(m, 'home')} borderRadius="md" _hover={{ borderColor: 'brand.primary', color: 'brand.primary' }}>Tým</Button>
|
||||
</HStack>
|
||||
</Td>
|
||||
<Td textAlign="center">
|
||||
<Text fontWeight={hasScore ? 'bold' : 'normal'} color={hasScore ? 'blue.600' : 'gray.500'}>
|
||||
{m.score || (m.result_home != null && m.result_away != null ? `${m.result_home}:${m.result_away}` : '–:–')}
|
||||
</Text>
|
||||
</Td>
|
||||
<Td>
|
||||
<HStack spacing={2}>
|
||||
<TeamLogo
|
||||
teamId={m.away_id}
|
||||
teamName={m.away || m.away_team || ''}
|
||||
facrLogo={m.away_logo_url}
|
||||
size="custom"
|
||||
boxSize="24px"
|
||||
/>
|
||||
<Text fontWeight={isPast ? 'normal' : 'medium'}>{m.away || m.away_team || ''}</Text>
|
||||
<Button size="xs" variant="outline" onClick={() => openEdit(m, 'away')} borderRadius="md" _hover={{ borderColor: 'brand.primary', color: 'brand.primary' }}>Tým</Button>
|
||||
</HStack>
|
||||
</Td>
|
||||
<Td>{m.venue || ''}</Td>
|
||||
<Td>
|
||||
<HStack spacing={2}>
|
||||
<Button size="xs" onClick={() => openEdit(m)} bg="brand.primary" color="text.onPrimary" _hover={{ filter: 'brightness(0.95)' }} borderRadius="md">Upravit</Button>
|
||||
</HStack>
|
||||
</Td>
|
||||
</Tr>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</Tbody>
|
||||
</Table>
|
||||
</Box>
|
||||
{filteredMatches.length > visibleMatches.length && (
|
||||
<HStack justify="center" mt={6}>
|
||||
<Button onClick={() => setLimit((n) => n + pageSize)} size="lg" bg="brand.primary" color="text.onPrimary" _hover={{ filter: 'brightness(0.95)' }} borderRadius="lg" px={8}>
|
||||
Načíst další ({filteredMatches.length - visibleMatches.length} zápasů)
|
||||
</Button>
|
||||
</HStack>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* Edit Drawer */}
|
||||
<Drawer isOpen={isOpen} placement="right" onClose={() => setIsOpen(false)} size={drawerSize}>
|
||||
<DrawerOverlay />
|
||||
<DrawerContent>
|
||||
<DrawerHeader>Upravit zápas</DrawerHeader>
|
||||
<DrawerBody>
|
||||
{!selected ? (
|
||||
<Text color="gray.500">Není vybrán žádný zápas.</Text>
|
||||
) : (
|
||||
<Stack spacing={4}>
|
||||
<FormControl>
|
||||
<FormLabel>Datum a čas (ISO)</FormLabel>
|
||||
<Input
|
||||
placeholder="YYYY-MM-DDTHH:mm:ss.sssZ"
|
||||
value={form.date_time_override}
|
||||
onChange={(e) => setForm((f) => ({ ...f, date_time_override: e.target.value }))}
|
||||
/>
|
||||
{isDateInvalid && (
|
||||
<FormErrorMessage>Neplatný formát data/času</FormErrorMessage>
|
||||
)}
|
||||
</FormControl>
|
||||
|
||||
<FormControl>
|
||||
<FormLabel>Místo</FormLabel>
|
||||
<Input
|
||||
placeholder="Místo konání"
|
||||
value={form.venue_override}
|
||||
onChange={(e) => setForm((f) => ({ ...f, venue_override: e.target.value }))}
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
{/* Home team */}
|
||||
<FormControl>
|
||||
<FormLabel>Domácí tým (název)</FormLabel>
|
||||
<InputGroup>
|
||||
<Input
|
||||
ref={homeInputRef}
|
||||
placeholder="Zadejte název týmu"
|
||||
value={form.home_name_override}
|
||||
onChange={(e) => {
|
||||
setForm((f) => ({ ...f, home_name_override: e.target.value }));
|
||||
handleHomeInput(e);
|
||||
}}
|
||||
/>
|
||||
<InputRightElement width="4.5rem">
|
||||
<Button h="1.75rem" size="sm" onClick={() => homeFileRef.current?.click()}>Logo</Button>
|
||||
</InputRightElement>
|
||||
</InputGroup>
|
||||
<input
|
||||
type="file"
|
||||
accept="image/*"
|
||||
hidden
|
||||
ref={homeFileRef}
|
||||
onChange={async (e) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
try {
|
||||
const up = await uploadImage(file);
|
||||
setForm((f) => ({ ...f, home_logo_url: up.url }));
|
||||
setHomeUploadedFile(file);
|
||||
toast({ title: 'Logo nahráno (domácí)', status: 'success' });
|
||||
} catch (err: any) {
|
||||
toast({ title: 'Nahrání se nezdařilo', description: err?.message || '', status: 'error' });
|
||||
} finally {
|
||||
if (homeFileRef.current) homeFileRef.current.value = '' as any;
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{homeResults.length > 0 && (
|
||||
<Box mt={2} borderWidth="1px" borderRadius="md" p={2} maxH="180px" overflowY="auto">
|
||||
<List spacing={1}>
|
||||
{homeResults.map((r: any) => (
|
||||
<ListItem key={r.id}>
|
||||
<Button size="xs" variant="ghost" onClick={() => {
|
||||
setForm((f) => ({ ...f, home_name_override: r.name, home_logo_url: r.logo_url || f.home_logo_url }));
|
||||
setHomeQuery(r.name);
|
||||
setHomeExternalTeamId(String(r.id || ''));
|
||||
}}>
|
||||
{r.name}
|
||||
</Button>
|
||||
</ListItem>
|
||||
))}
|
||||
</List>
|
||||
</Box>
|
||||
)}
|
||||
{form.home_logo_url && (
|
||||
<HStack mt={2} spacing={3}>
|
||||
<Image src={form.home_logo_url} alt="home logo" boxSize="28px" objectFit="contain" />
|
||||
<Button size="xs" variant="outline" onClick={() => setForm((f) => ({ ...f, home_logo_url: '' }))}>Odebrat logo</Button>
|
||||
</HStack>
|
||||
)}
|
||||
</FormControl>
|
||||
|
||||
{/* Away team */}
|
||||
<FormControl>
|
||||
<FormLabel>Hostující tým (název)</FormLabel>
|
||||
<InputGroup>
|
||||
<Input
|
||||
ref={awayInputRef}
|
||||
placeholder="Zadejte název týmu"
|
||||
value={form.away_name_override}
|
||||
onChange={(e) => {
|
||||
setForm((f) => ({ ...f, away_name_override: e.target.value }));
|
||||
handleAwayInput(e);
|
||||
}}
|
||||
/>
|
||||
<InputRightElement width="4.5rem">
|
||||
<Button h="1.75rem" size="sm" onClick={() => awayFileRef.current?.click()}>Logo</Button>
|
||||
</InputRightElement>
|
||||
</InputGroup>
|
||||
<input
|
||||
type="file"
|
||||
accept="image/*"
|
||||
hidden
|
||||
ref={awayFileRef}
|
||||
onChange={async (e) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
try {
|
||||
const up = await uploadImage(file);
|
||||
setForm((f) => ({ ...f, away_logo_url: up.url }));
|
||||
setAwayUploadedFile(file);
|
||||
toast({ title: 'Logo nahráno (hosté)', status: 'success' });
|
||||
} catch (err: any) {
|
||||
toast({ title: 'Nahrání se nezdařilo', description: err?.message || '', status: 'error' });
|
||||
} finally {
|
||||
if (awayFileRef.current) awayFileRef.current.value = '' as any;
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{awayResults.length > 0 && (
|
||||
<Box mt={2} borderWidth="1px" borderRadius="md" p={2} maxH="180px" overflowY="auto">
|
||||
<List spacing={1}>
|
||||
{awayResults.map((r: any) => (
|
||||
<ListItem key={r.id}>
|
||||
<Button size="xs" variant="ghost" onClick={() => {
|
||||
setForm((f) => ({ ...f, away_name_override: r.name, away_logo_url: r.logo_url || f.away_logo_url }));
|
||||
setAwayQuery(r.name);
|
||||
setAwayExternalTeamId(String(r.id || ''));
|
||||
}}>
|
||||
{r.name}
|
||||
</Button>
|
||||
</ListItem>
|
||||
))}
|
||||
</List>
|
||||
</Box>
|
||||
)}
|
||||
{form.away_logo_url && (
|
||||
<HStack mt={2} spacing={3}>
|
||||
<Image src={form.away_logo_url} alt="away logo" boxSize="28px" objectFit="contain" />
|
||||
<Button size="xs" variant="outline" onClick={() => setForm((f) => ({ ...f, away_logo_url: '' }))}>Odebrat logo</Button>
|
||||
</HStack>
|
||||
)}
|
||||
</FormControl>
|
||||
|
||||
<FormControl>
|
||||
<FormLabel>Poznámka</FormLabel>
|
||||
<Input
|
||||
placeholder="Libovolná poznámka (interní)"
|
||||
value={form.notes}
|
||||
onChange={(e) => setForm((f) => ({ ...f, notes: e.target.value }))}
|
||||
/>
|
||||
</FormControl>
|
||||
</Stack>
|
||||
)}
|
||||
</DrawerBody>
|
||||
<DrawerFooter>
|
||||
<HStack spacing={3}>
|
||||
<Button variant="outline" onClick={() => setIsOpen(false)}>Zavřít</Button>
|
||||
<Button colorScheme="blue" isLoading={saveMutation.isPending} onClick={() => saveMutation.mutate()} isDisabled={isDateInvalid}>
|
||||
Uložit změny
|
||||
</Button>
|
||||
</HStack>
|
||||
</DrawerFooter>
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
</AdminLayout>
|
||||
);
|
||||
};
|
||||
|
||||
export default MatchesAdminPage;
|
||||
@@ -0,0 +1,599 @@
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
Heading,
|
||||
HStack,
|
||||
IconButton,
|
||||
Image,
|
||||
Input,
|
||||
InputGroup,
|
||||
InputLeftElement,
|
||||
Modal,
|
||||
ModalBody,
|
||||
ModalCloseButton,
|
||||
ModalContent,
|
||||
ModalFooter,
|
||||
ModalHeader,
|
||||
ModalOverlay,
|
||||
SimpleGrid,
|
||||
useDisclosure,
|
||||
useToast,
|
||||
VStack,
|
||||
Text,
|
||||
Badge,
|
||||
Tabs,
|
||||
TabList,
|
||||
Tab,
|
||||
TabPanels,
|
||||
TabPanel,
|
||||
useColorModeValue,
|
||||
Tooltip,
|
||||
Select,
|
||||
Flex,
|
||||
Spacer,
|
||||
Stack,
|
||||
AspectRatio,
|
||||
Divider,
|
||||
Code,
|
||||
Skeleton,
|
||||
} from '@chakra-ui/react';
|
||||
import { useState, useMemo } from 'react';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { FiTrash2, FiSearch, FiRefreshCw, FiCopy, FiUpload, FiImage, FiVideo, FiFile, FiDownload, FiExternalLink } from 'react-icons/fi';
|
||||
import AdminLayout from '../../layouts/AdminLayout';
|
||||
import {
|
||||
FileInfo,
|
||||
getAllFiles,
|
||||
deleteFile,
|
||||
scanAndSyncFiles,
|
||||
formatFileSize,
|
||||
} from '../../services/files';
|
||||
import { uploadFile } from '../../services/articles';
|
||||
|
||||
const MediaAdminPage: React.FC = () => {
|
||||
const toast = useToast();
|
||||
const qc = useQueryClient();
|
||||
const [search, setSearch] = useState('');
|
||||
const [typeFilter, setTypeFilter] = useState<'all' | 'images' | 'videos' | 'documents'>('all');
|
||||
const [selectedFile, setSelectedFile] = useState<FileInfo | null>(null);
|
||||
const [deleteTarget, setDeleteTarget] = useState<FileInfo | null>(null);
|
||||
const [uploadFiles, setUploadFiles] = useState<FileList | null>(null);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
|
||||
const { isOpen: isDetailOpen, onOpen: onDetailOpen, onClose: onDetailClose } = useDisclosure();
|
||||
const { isOpen: isDeleteOpen, onOpen: onDeleteOpen, onClose: onDeleteClose } = useDisclosure();
|
||||
const { isOpen: isUploadOpen, onOpen: onUploadOpen, onClose: onUploadClose } = useDisclosure();
|
||||
|
||||
const borderColor = useColorModeValue('gray.200', 'gray.600');
|
||||
const bgHover = useColorModeValue('gray.50', 'gray.700');
|
||||
const cardBg = useColorModeValue('white', 'gray.800');
|
||||
|
||||
// Build MIME filter based on type
|
||||
const mimeFilter = useMemo(() => {
|
||||
if (typeFilter === 'images') return 'image/';
|
||||
if (typeFilter === 'videos') return 'video/';
|
||||
if (typeFilter === 'documents') return 'application/';
|
||||
return '';
|
||||
}, [typeFilter]);
|
||||
|
||||
// Fetch all files
|
||||
const { data: allFiles = [], isLoading, refetch } = useQuery({
|
||||
queryKey: ['admin-media-files', search, mimeFilter],
|
||||
queryFn: () => getAllFiles({ search, mime_type: mimeFilter }),
|
||||
});
|
||||
|
||||
// Filter files by type
|
||||
const filteredFiles = useMemo(() => {
|
||||
return allFiles.filter(file => {
|
||||
if (search && !file.filename?.toLowerCase().includes(search.toLowerCase())) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}, [allFiles, search]);
|
||||
|
||||
// Separate by media type
|
||||
const imageFiles = filteredFiles.filter(f => f.mime_type?.startsWith('image/'));
|
||||
const videoFiles = filteredFiles.filter(f => f.mime_type?.startsWith('video/'));
|
||||
const documentFiles = filteredFiles.filter(f =>
|
||||
f.mime_type?.startsWith('application/') || f.mime_type?.startsWith('text/')
|
||||
);
|
||||
|
||||
// Delete mutation
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: ({ id, force }: { id: number; force: boolean }) => deleteFile(id, force),
|
||||
onSuccess: () => {
|
||||
toast({ title: 'Soubor smazán', status: 'success' });
|
||||
qc.invalidateQueries({ queryKey: ['admin-media-files'] });
|
||||
onDeleteClose();
|
||||
setDeleteTarget(null);
|
||||
},
|
||||
onError: (error: any) => {
|
||||
toast({
|
||||
title: 'Chyba při mazání',
|
||||
description: error?.response?.data?.error || 'Nepodařilo se smazat soubor',
|
||||
status: 'error',
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
// Scan mutation
|
||||
const scanMutation = useMutation({
|
||||
mutationFn: scanAndSyncFiles,
|
||||
onSuccess: (data) => {
|
||||
toast({
|
||||
title: 'Skenování dokončeno',
|
||||
description: `Přidáno: ${data.new_files || 0}, Smazáno: ${data.orphaned_files || 0}`,
|
||||
status: 'success'
|
||||
});
|
||||
qc.invalidateQueries({ queryKey: ['admin-media-files'] });
|
||||
},
|
||||
onError: () => {
|
||||
toast({ title: 'Chyba při skenování', status: 'error' });
|
||||
},
|
||||
});
|
||||
|
||||
const handleDelete = (file: FileInfo) => {
|
||||
setDeleteTarget(file);
|
||||
onDeleteOpen();
|
||||
};
|
||||
|
||||
const confirmDelete = () => {
|
||||
if (deleteTarget) {
|
||||
deleteMutation.mutate({ id: deleteTarget.id, force: false });
|
||||
}
|
||||
};
|
||||
|
||||
const handleViewDetails = (file: FileInfo) => {
|
||||
setSelectedFile(file);
|
||||
onDetailOpen();
|
||||
};
|
||||
|
||||
const copyToClipboard = (text: string) => {
|
||||
navigator.clipboard.writeText(text);
|
||||
toast({ title: 'Zkopírováno do schránky', status: 'success', duration: 2000 });
|
||||
};
|
||||
|
||||
const handleUpload = async () => {
|
||||
if (!uploadFiles || uploadFiles.length === 0) return;
|
||||
|
||||
setUploading(true);
|
||||
try {
|
||||
const promises = Array.from(uploadFiles).map(file => uploadFile(file));
|
||||
await Promise.all(promises);
|
||||
toast({
|
||||
title: 'Soubory nahrány',
|
||||
description: `Úspěšně nahráno ${uploadFiles.length} souborů`,
|
||||
status: 'success'
|
||||
});
|
||||
qc.invalidateQueries({ queryKey: ['admin-media-files'] });
|
||||
onUploadClose();
|
||||
setUploadFiles(null);
|
||||
} catch (error) {
|
||||
toast({ title: 'Chyba při nahrávání', status: 'error' });
|
||||
} finally {
|
||||
setUploading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const getFileUrl = (file: FileInfo) => {
|
||||
if (file.url.startsWith('http')) return file.url;
|
||||
const base = window.location.origin;
|
||||
return `${base}${file.url}`;
|
||||
};
|
||||
|
||||
const MediaCard = ({ file }: { file: FileInfo }) => {
|
||||
const isImage = file.mime_type?.startsWith('image/');
|
||||
const isVideo = file.mime_type?.startsWith('video/');
|
||||
|
||||
return (
|
||||
<Box
|
||||
bg={cardBg}
|
||||
borderRadius="lg"
|
||||
borderWidth="1px"
|
||||
borderColor={borderColor}
|
||||
overflow="hidden"
|
||||
cursor="pointer"
|
||||
onClick={() => handleViewDetails(file)}
|
||||
_hover={{ shadow: 'md', transform: 'translateY(-2px)' }}
|
||||
transition="all 0.2s"
|
||||
>
|
||||
<AspectRatio ratio={16 / 9}>
|
||||
<Box bg="gray.100" display="flex" alignItems="center" justifyContent="center">
|
||||
{isImage ? (
|
||||
<Image
|
||||
src={getFileUrl(file)}
|
||||
alt={file.filename}
|
||||
objectFit="cover"
|
||||
w="100%"
|
||||
h="100%"
|
||||
/>
|
||||
) : isVideo ? (
|
||||
<Box fontSize="48px" color="gray.400">
|
||||
<FiVideo />
|
||||
</Box>
|
||||
) : (
|
||||
<Box fontSize="48px" color="gray.400">
|
||||
<FiFile />
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
</AspectRatio>
|
||||
<VStack align="stretch" p={3} spacing={2}>
|
||||
<Text fontSize="sm" fontWeight="semibold" noOfLines={1}>
|
||||
{file.filename}
|
||||
</Text>
|
||||
<HStack justify="space-between" fontSize="xs" color="gray.500">
|
||||
<Text>{formatFileSize(file.size || 0)}</Text>
|
||||
<Badge colorScheme="blue" fontSize="9px">
|
||||
{file.mime_type?.split('/')[0]}
|
||||
</Badge>
|
||||
</HStack>
|
||||
<HStack spacing={1}>
|
||||
<Tooltip label="Kopírovat URL">
|
||||
<IconButton
|
||||
aria-label="Copy URL"
|
||||
icon={<FiCopy />}
|
||||
size="xs"
|
||||
variant="ghost"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
copyToClipboard(getFileUrl(file));
|
||||
}}
|
||||
/>
|
||||
</Tooltip>
|
||||
<Tooltip label="Smazat">
|
||||
<IconButton
|
||||
aria-label="Delete"
|
||||
icon={<FiTrash2 />}
|
||||
size="xs"
|
||||
variant="ghost"
|
||||
colorScheme="red"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleDelete(file);
|
||||
}}
|
||||
/>
|
||||
</Tooltip>
|
||||
</HStack>
|
||||
</VStack>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<AdminLayout requireAdmin={false}>
|
||||
<Box>
|
||||
<Flex justify="space-between" align="center" mb={6}>
|
||||
<Box>
|
||||
<Heading size="lg" mb={1}>Média</Heading>
|
||||
<Text color="gray.500">Správa obrázků, videí a dalších souborů</Text>
|
||||
</Box>
|
||||
<HStack>
|
||||
<Button
|
||||
leftIcon={<FiRefreshCw />}
|
||||
onClick={() => scanMutation.mutate()}
|
||||
isLoading={scanMutation.isPending}
|
||||
size="sm"
|
||||
>
|
||||
Skenovat
|
||||
</Button>
|
||||
<Button
|
||||
leftIcon={<FiUpload />}
|
||||
colorScheme="blue"
|
||||
onClick={onUploadOpen}
|
||||
size="sm"
|
||||
>
|
||||
Nahrát
|
||||
</Button>
|
||||
</HStack>
|
||||
</Flex>
|
||||
|
||||
{/* Stats */}
|
||||
<HStack spacing={4} mb={6}>
|
||||
<Box p={4} bg={cardBg} borderRadius="lg" borderWidth="1px" borderColor={borderColor}>
|
||||
<HStack>
|
||||
<FiImage />
|
||||
<VStack align="start" spacing={0}>
|
||||
<Text fontSize="2xl" fontWeight="bold">{imageFiles.length}</Text>
|
||||
<Text fontSize="xs" color="gray.500">Obrázků</Text>
|
||||
</VStack>
|
||||
</HStack>
|
||||
</Box>
|
||||
<Box p={4} bg={cardBg} borderRadius="lg" borderWidth="1px" borderColor={borderColor}>
|
||||
<HStack>
|
||||
<FiVideo />
|
||||
<VStack align="start" spacing={0}>
|
||||
<Text fontSize="2xl" fontWeight="bold">{videoFiles.length}</Text>
|
||||
<Text fontSize="xs" color="gray.500">Videí</Text>
|
||||
</VStack>
|
||||
</HStack>
|
||||
</Box>
|
||||
<Box p={4} bg={cardBg} borderRadius="lg" borderWidth="1px" borderColor={borderColor}>
|
||||
<HStack>
|
||||
<FiFile />
|
||||
<VStack align="start" spacing={0}>
|
||||
<Text fontSize="2xl" fontWeight="bold">{documentFiles.length}</Text>
|
||||
<Text fontSize="xs" color="gray.500">Dokumentů</Text>
|
||||
</VStack>
|
||||
</HStack>
|
||||
</Box>
|
||||
</HStack>
|
||||
|
||||
{/* Filters */}
|
||||
<HStack mb={6} spacing={4}>
|
||||
<InputGroup maxW="400px">
|
||||
<InputLeftElement pointerEvents="none">
|
||||
<FiSearch color="gray" />
|
||||
</InputLeftElement>
|
||||
<Input
|
||||
placeholder="Hledat soubory..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
/>
|
||||
</InputGroup>
|
||||
<Select
|
||||
maxW="200px"
|
||||
value={typeFilter}
|
||||
onChange={(e) => setTypeFilter(e.target.value as any)}
|
||||
>
|
||||
<option value="all">Všechny typy</option>
|
||||
<option value="images">Pouze obrázky</option>
|
||||
<option value="videos">Pouze videa</option>
|
||||
<option value="documents">Pouze dokumenty</option>
|
||||
</Select>
|
||||
</HStack>
|
||||
|
||||
{/* Tabs */}
|
||||
<Tabs>
|
||||
<TabList>
|
||||
<Tab>Všechny ({filteredFiles.length})</Tab>
|
||||
<Tab>Obrázky ({imageFiles.length})</Tab>
|
||||
<Tab>Videa ({videoFiles.length})</Tab>
|
||||
<Tab>Dokumenty ({documentFiles.length})</Tab>
|
||||
</TabList>
|
||||
|
||||
<TabPanels>
|
||||
{/* All Files */}
|
||||
<TabPanel px={0}>
|
||||
{isLoading ? (
|
||||
<SimpleGrid columns={{ base: 1, md: 3, lg: 4 }} spacing={4}>
|
||||
{[...Array(8)].map((_, i) => (
|
||||
<Skeleton key={i} height="250px" borderRadius="lg" />
|
||||
))}
|
||||
</SimpleGrid>
|
||||
) : filteredFiles.length === 0 ? (
|
||||
<Box textAlign="center" py={12}>
|
||||
<Text color="gray.500">Žádné soubory</Text>
|
||||
</Box>
|
||||
) : (
|
||||
<SimpleGrid columns={{ base: 1, md: 3, lg: 4 }} spacing={4}>
|
||||
{filteredFiles.map(file => (
|
||||
<MediaCard key={file.id} file={file} />
|
||||
))}
|
||||
</SimpleGrid>
|
||||
)}
|
||||
</TabPanel>
|
||||
|
||||
{/* Images */}
|
||||
<TabPanel px={0}>
|
||||
{imageFiles.length === 0 ? (
|
||||
<Box textAlign="center" py={12}>
|
||||
<Text color="gray.500">Žádné obrázky</Text>
|
||||
</Box>
|
||||
) : (
|
||||
<SimpleGrid columns={{ base: 1, md: 3, lg: 4 }} spacing={4}>
|
||||
{imageFiles.map(file => (
|
||||
<MediaCard key={file.id} file={file} />
|
||||
))}
|
||||
</SimpleGrid>
|
||||
)}
|
||||
</TabPanel>
|
||||
|
||||
{/* Videos */}
|
||||
<TabPanel px={0}>
|
||||
{videoFiles.length === 0 ? (
|
||||
<Box textAlign="center" py={12}>
|
||||
<Text color="gray.500">Žádná videa</Text>
|
||||
</Box>
|
||||
) : (
|
||||
<SimpleGrid columns={{ base: 1, md: 3, lg: 4 }} spacing={4}>
|
||||
{videoFiles.map(file => (
|
||||
<MediaCard key={file.id} file={file} />
|
||||
))}
|
||||
</SimpleGrid>
|
||||
)}
|
||||
</TabPanel>
|
||||
|
||||
{/* Documents */}
|
||||
<TabPanel px={0}>
|
||||
{documentFiles.length === 0 ? (
|
||||
<Box textAlign="center" py={12}>
|
||||
<Text color="gray.500">Žádné dokumenty</Text>
|
||||
</Box>
|
||||
) : (
|
||||
<SimpleGrid columns={{ base: 1, md: 3, lg: 4 }} spacing={4}>
|
||||
{documentFiles.map(file => (
|
||||
<MediaCard key={file.id} file={file} />
|
||||
))}
|
||||
</SimpleGrid>
|
||||
)}
|
||||
</TabPanel>
|
||||
</TabPanels>
|
||||
</Tabs>
|
||||
|
||||
{/* File Details Modal */}
|
||||
<Modal isOpen={isDetailOpen} onClose={onDetailClose} size="2xl">
|
||||
<ModalOverlay />
|
||||
<ModalContent>
|
||||
<ModalHeader>Detail souboru</ModalHeader>
|
||||
<ModalCloseButton />
|
||||
<ModalBody>
|
||||
{selectedFile && (
|
||||
<VStack align="stretch" spacing={4}>
|
||||
{selectedFile.mime_type?.startsWith('image/') && (
|
||||
<Image
|
||||
src={getFileUrl(selectedFile)}
|
||||
alt={selectedFile.filename}
|
||||
borderRadius="lg"
|
||||
maxH="400px"
|
||||
objectFit="contain"
|
||||
/>
|
||||
)}
|
||||
<Divider />
|
||||
<Stack spacing={2}>
|
||||
<HStack justify="space-between">
|
||||
<Text fontWeight="semibold">Název:</Text>
|
||||
<Text>{selectedFile.filename}</Text>
|
||||
</HStack>
|
||||
<HStack justify="space-between">
|
||||
<Text fontWeight="semibold">Velikost:</Text>
|
||||
<Text>{formatFileSize(selectedFile.size || 0)}</Text>
|
||||
</HStack>
|
||||
<HStack justify="space-between">
|
||||
<Text fontWeight="semibold">Typ:</Text>
|
||||
<Badge>{selectedFile.mime_type}</Badge>
|
||||
</HStack>
|
||||
<HStack justify="space-between">
|
||||
<Text fontWeight="semibold">Vytvořeno:</Text>
|
||||
<Text fontSize="sm">
|
||||
{selectedFile.created_at ? new Date(selectedFile.created_at).toLocaleString('cs-CZ') : 'N/A'}
|
||||
</Text>
|
||||
</HStack>
|
||||
</Stack>
|
||||
<Divider />
|
||||
<Box>
|
||||
<Text fontWeight="semibold" mb={2}>URL:</Text>
|
||||
<HStack>
|
||||
<Code flex={1} p={2} fontSize="xs" borderRadius="md">
|
||||
{getFileUrl(selectedFile)}
|
||||
</Code>
|
||||
<IconButton
|
||||
aria-label="Copy URL"
|
||||
icon={<FiCopy />}
|
||||
size="sm"
|
||||
onClick={() => copyToClipboard(getFileUrl(selectedFile))}
|
||||
/>
|
||||
</HStack>
|
||||
</Box>
|
||||
</VStack>
|
||||
)}
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<HStack spacing={2}>
|
||||
{selectedFile && (
|
||||
<Button
|
||||
as="a"
|
||||
href={getFileUrl(selectedFile)}
|
||||
target="_blank"
|
||||
leftIcon={<FiExternalLink />}
|
||||
size="sm"
|
||||
>
|
||||
Otevřít
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
colorScheme="red"
|
||||
leftIcon={<FiTrash2 />}
|
||||
onClick={() => {
|
||||
if (selectedFile) {
|
||||
onDetailClose();
|
||||
handleDelete(selectedFile);
|
||||
}
|
||||
}}
|
||||
size="sm"
|
||||
>
|
||||
Smazat
|
||||
</Button>
|
||||
<Button onClick={onDetailClose} size="sm">Zavřít</Button>
|
||||
</HStack>
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
|
||||
{/* Delete Confirmation Modal */}
|
||||
<Modal isOpen={isDeleteOpen} onClose={onDeleteClose}>
|
||||
<ModalOverlay />
|
||||
<ModalContent>
|
||||
<ModalHeader>Smazat soubor</ModalHeader>
|
||||
<ModalCloseButton />
|
||||
<ModalBody>
|
||||
<Text>
|
||||
Opravdu chcete smazat soubor <strong>{deleteTarget?.filename}</strong>?
|
||||
</Text>
|
||||
<Text mt={2} fontSize="sm" color="gray.500">
|
||||
Tato akce je nevratná.
|
||||
</Text>
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<HStack spacing={2}>
|
||||
<Button onClick={onDeleteClose} size="sm">Zrušit</Button>
|
||||
<Button
|
||||
colorScheme="red"
|
||||
onClick={confirmDelete}
|
||||
isLoading={deleteMutation.isPending}
|
||||
size="sm"
|
||||
>
|
||||
Smazat
|
||||
</Button>
|
||||
</HStack>
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
|
||||
{/* Upload Modal */}
|
||||
<Modal isOpen={isUploadOpen} onClose={onUploadClose}>
|
||||
<ModalOverlay />
|
||||
<ModalContent>
|
||||
<ModalHeader>Nahrát soubory</ModalHeader>
|
||||
<ModalCloseButton />
|
||||
<ModalBody>
|
||||
<VStack align="stretch" spacing={4}>
|
||||
<Text fontSize="sm" color="gray.500">
|
||||
Vyberte jeden nebo více souborů k nahrání.
|
||||
</Text>
|
||||
<Input
|
||||
type="file"
|
||||
multiple
|
||||
accept="image/*,video/*,application/pdf"
|
||||
onChange={(e) => setUploadFiles(e.target.files)}
|
||||
p={1}
|
||||
/>
|
||||
{uploadFiles && uploadFiles.length > 0 && (
|
||||
<Box>
|
||||
<Text fontSize="sm" fontWeight="semibold" mb={2}>
|
||||
Vybrané soubory ({uploadFiles.length}):
|
||||
</Text>
|
||||
<VStack align="stretch" spacing={1}>
|
||||
{Array.from(uploadFiles).map((file, i) => (
|
||||
<Text key={i} fontSize="sm">
|
||||
• {file.name} ({formatFileSize(file.size)})
|
||||
</Text>
|
||||
))}
|
||||
</VStack>
|
||||
</Box>
|
||||
)}
|
||||
</VStack>
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<HStack spacing={2}>
|
||||
<Button onClick={onUploadClose} size="sm">Zrušit</Button>
|
||||
<Button
|
||||
colorScheme="blue"
|
||||
onClick={handleUpload}
|
||||
isLoading={uploading}
|
||||
isDisabled={!uploadFiles || uploadFiles.length === 0}
|
||||
size="sm"
|
||||
>
|
||||
Nahrát
|
||||
</Button>
|
||||
</HStack>
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
</Box>
|
||||
</AdminLayout>
|
||||
);
|
||||
};
|
||||
|
||||
export default MediaAdminPage;
|
||||
@@ -0,0 +1,508 @@
|
||||
import { useState, useMemo } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
Checkbox,
|
||||
Flex,
|
||||
Heading,
|
||||
IconButton,
|
||||
Menu,
|
||||
MenuButton,
|
||||
MenuItem,
|
||||
MenuList,
|
||||
Select,
|
||||
Stack,
|
||||
Table,
|
||||
Tbody,
|
||||
Td,
|
||||
Text,
|
||||
Th,
|
||||
Thead,
|
||||
Tr,
|
||||
useDisclosure,
|
||||
useToast,
|
||||
Badge,
|
||||
InputGroup,
|
||||
InputLeftElement,
|
||||
Input,
|
||||
HStack,
|
||||
useColorModeValue,
|
||||
Modal,
|
||||
ModalOverlay,
|
||||
ModalContent,
|
||||
ModalHeader,
|
||||
ModalBody,
|
||||
ModalFooter,
|
||||
ModalCloseButton,
|
||||
FormControl,
|
||||
FormLabel,
|
||||
VStack
|
||||
} from '@chakra-ui/react';
|
||||
import { AddIcon, DeleteIcon, EmailIcon, Search2Icon, StarIcon, ArrowForwardIcon } from '@chakra-ui/icons';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { format } from 'date-fns';
|
||||
import { cs } from 'date-fns/locale';
|
||||
import AdminLayout from '../../layouts/AdminLayout';
|
||||
import {
|
||||
getContactMessages,
|
||||
markAsRead,
|
||||
deleteMessage,
|
||||
deleteMultipleMessages,
|
||||
forwardAllMessages,
|
||||
ContactMessage
|
||||
} from '../../services/admin/contactMessages';
|
||||
import Pagination from '../../components/common/Pagination';
|
||||
import MessageDetailModal from '../../components/admin/MessageDetailModal';
|
||||
import ConfirmationDialog from '../../components/common/ConfirmationDialog';
|
||||
|
||||
export default function MessagesAdminPage() {
|
||||
const cardBg = useColorModeValue('white', 'gray.800');
|
||||
const borderColor = useColorModeValue('gray.200', 'gray.700');
|
||||
const inputBg = useColorModeValue('white', 'gray.700');
|
||||
const [selectedMessages, setSelectedMessages] = useState<string[]>([]);
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [statusFilter, setStatusFilter] = useState<'all' | 'read' | 'unread'>('all');
|
||||
const [sortBy, setSortBy] = useState<{ field: string; order: 'asc' | 'desc' }>({
|
||||
field: 'createdAt',
|
||||
order: 'desc',
|
||||
});
|
||||
const [pagination, setPagination] = useState({
|
||||
page: 1,
|
||||
limit: 10,
|
||||
});
|
||||
|
||||
const {
|
||||
isOpen: isDetailOpen,
|
||||
onOpen: onDetailOpen,
|
||||
onClose: onDetailClose
|
||||
} = useDisclosure();
|
||||
const {
|
||||
isOpen: isDeleteOpen,
|
||||
onOpen: onDeleteOpen,
|
||||
onClose: onDeleteClose
|
||||
} = useDisclosure();
|
||||
const {
|
||||
isOpen: isForwardAllOpen,
|
||||
onOpen: onForwardAllOpen,
|
||||
onClose: onForwardAllClose
|
||||
} = useDisclosure();
|
||||
|
||||
const [forwardAllEmail, setForwardAllEmail] = useState('');
|
||||
|
||||
const [selectedMessage, setSelectedMessage] = useState<ContactMessage | null>(null);
|
||||
const toast = useToast();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const { data, isLoading, isError } = useQuery({
|
||||
queryKey: ['admin', 'contact-messages', { ...pagination, searchTerm, statusFilter, sortBy }],
|
||||
queryFn: () =>
|
||||
getContactMessages({
|
||||
page: pagination.page,
|
||||
limit: pagination.limit,
|
||||
search: searchTerm,
|
||||
isRead: statusFilter === 'all' ? undefined : statusFilter === 'read',
|
||||
sortBy: sortBy.field,
|
||||
sortOrder: sortBy.order,
|
||||
}),
|
||||
keepPreviousData: true,
|
||||
});
|
||||
|
||||
const markAsReadMutation = useMutation({
|
||||
mutationFn: markAsRead,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['admin', 'contact-messages'] });
|
||||
toast({
|
||||
title: 'Zpráva označena jako přečtená',
|
||||
status: 'success',
|
||||
duration: 3000,
|
||||
isClosable: true,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const deleteMessageMutation = useMutation({
|
||||
mutationFn: deleteMessage,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['admin', 'contact-messages'] });
|
||||
toast({
|
||||
title: 'Zpráva smazána',
|
||||
status: 'success',
|
||||
duration: 3000,
|
||||
isClosable: true,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const deleteMultipleMutation = useMutation({
|
||||
mutationFn: deleteMultipleMessages,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['admin', 'contact-messages'] });
|
||||
setSelectedMessages([]);
|
||||
toast({
|
||||
title: 'Vybrané zprávy byly smazány',
|
||||
status: 'success',
|
||||
duration: 3000,
|
||||
isClosable: true,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const forwardAllMutation = useMutation({
|
||||
mutationFn: forwardAllMessages,
|
||||
onSuccess: (data) => {
|
||||
toast({
|
||||
title: 'Zprávy se přeposílají',
|
||||
description: data.message || 'Všechny zprávy budou přeposlány na zadaný e-mail',
|
||||
status: 'success',
|
||||
duration: 5000,
|
||||
isClosable: true,
|
||||
});
|
||||
setForwardAllEmail('');
|
||||
onForwardAllClose();
|
||||
},
|
||||
onError: () => {
|
||||
toast({
|
||||
title: 'Chyba',
|
||||
description: 'Nepodařilo se přeposlat zprávy',
|
||||
status: 'error',
|
||||
duration: 3000,
|
||||
isClosable: true,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const handleViewMessage = (message: ContactMessage) => {
|
||||
setSelectedMessage(message);
|
||||
if (!message.isRead) {
|
||||
markAsReadMutation.mutate(message.id);
|
||||
}
|
||||
onDetailOpen();
|
||||
};
|
||||
|
||||
const handleDeleteClick = (message: ContactMessage) => {
|
||||
setSelectedMessage(message);
|
||||
onDeleteOpen();
|
||||
};
|
||||
|
||||
const handleDeleteConfirm = () => {
|
||||
if (selectedMessage) {
|
||||
deleteMessageMutation.mutate(selectedMessage.id);
|
||||
}
|
||||
onDeleteClose();
|
||||
};
|
||||
|
||||
const handleBulkDelete = () => {
|
||||
if (selectedMessages.length > 0) {
|
||||
deleteMultipleMutation.mutate(selectedMessages);
|
||||
}
|
||||
};
|
||||
|
||||
const handleForwardAll = () => {
|
||||
if (!forwardAllEmail || !forwardAllEmail.includes('@')) {
|
||||
toast({
|
||||
title: 'Chyba',
|
||||
description: 'Zadejte platnou e-mailovou adresu',
|
||||
status: 'error',
|
||||
duration: 3000,
|
||||
});
|
||||
return;
|
||||
}
|
||||
forwardAllMutation.mutate(forwardAllEmail);
|
||||
};
|
||||
|
||||
const handleSelectAll = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
if (e.target.checked) {
|
||||
setSelectedMessages(data?.data.map((msg) => msg.id) || []);
|
||||
} else {
|
||||
setSelectedMessages([]);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSelectMessage = (id: string, isSelected: boolean) => {
|
||||
if (isSelected) {
|
||||
setSelectedMessages((prev) => [...prev, id]);
|
||||
} else {
|
||||
setSelectedMessages((prev) => prev.filter((msgId) => msgId !== id));
|
||||
}
|
||||
};
|
||||
|
||||
const handleSort = (field: string) => {
|
||||
setSortBy((prev) => ({
|
||||
field,
|
||||
order: prev.field === field && prev.order === 'asc' ? 'desc' : 'asc',
|
||||
}));
|
||||
};
|
||||
|
||||
const formatDate = (dateString: string) => {
|
||||
return format(new Date(dateString), 'd. M. yyyy HH:mm', { locale: cs });
|
||||
};
|
||||
|
||||
const getSortIcon = (field: string) => {
|
||||
if (sortBy.field !== field) return null;
|
||||
return sortBy.order === 'asc' ? '↑' : '↓';
|
||||
};
|
||||
|
||||
return (
|
||||
<AdminLayout>
|
||||
<Box p={6}>
|
||||
<Flex justify="space-between" align="center" mb={6}>
|
||||
<Box>
|
||||
<Heading size="lg" mb={2}>
|
||||
Příchozí zprávy
|
||||
</Heading>
|
||||
<Text color="gray.600">
|
||||
Spravujte příchozí zprávy z kontaktního formuláře
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
<HStack spacing={3} flexWrap="wrap">
|
||||
<Button
|
||||
colorScheme="teal"
|
||||
variant="outline"
|
||||
leftIcon={<ArrowForwardIcon />}
|
||||
onClick={onForwardAllOpen}
|
||||
size={{ base: "sm", md: "md" }}
|
||||
>
|
||||
Přeposlat vše
|
||||
</Button>
|
||||
{selectedMessages.length > 0 && (
|
||||
<Button
|
||||
colorScheme="red"
|
||||
variant="outline"
|
||||
leftIcon={<DeleteIcon />}
|
||||
onClick={handleBulkDelete}
|
||||
isLoading={deleteMultipleMutation.isLoading}
|
||||
size={{ base: "sm", md: "md" }}
|
||||
>
|
||||
Smazat vybrané ({selectedMessages.length})
|
||||
</Button>
|
||||
)}
|
||||
</HStack>
|
||||
</Flex>
|
||||
|
||||
<Box bg={cardBg} borderRadius="lg" boxShadow="sm" p={4} mb={6}>
|
||||
<Flex mb={4} gap={4} flexWrap="wrap">
|
||||
<InputGroup maxW="md">
|
||||
<InputLeftElement pointerEvents="none">
|
||||
<Search2Icon color="gray.400" />
|
||||
</InputLeftElement>
|
||||
<Input
|
||||
placeholder="Hledat v zprávách..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
/>
|
||||
</InputGroup>
|
||||
|
||||
<Select
|
||||
value={statusFilter}
|
||||
onChange={(e) => setStatusFilter(e.target.value as any)}
|
||||
maxW="200px"
|
||||
>
|
||||
<option value="all">Všechny zprávy</option>
|
||||
<option value="unread">Nepřečtené</option>
|
||||
<option value="read">Přečtené</option>
|
||||
</Select>
|
||||
</Flex>
|
||||
|
||||
<Box overflowX="auto">
|
||||
<Table variant="simple">
|
||||
<Thead>
|
||||
<Tr>
|
||||
<Th w="40px">
|
||||
<Checkbox
|
||||
isChecked={selectedMessages.length > 0 && selectedMessages.length === data?.data.length}
|
||||
onChange={handleSelectAll}
|
||||
/>
|
||||
</Th>
|
||||
<Th
|
||||
cursor="pointer"
|
||||
onClick={() => handleSort('name')}
|
||||
_hover={{ textDecoration: 'underline' }}
|
||||
>
|
||||
Jméno {getSortIcon('name')}
|
||||
</Th>
|
||||
<Th
|
||||
cursor="pointer"
|
||||
onClick={() => handleSort('email')}
|
||||
_hover={{ textDecoration: 'underline' }}
|
||||
>
|
||||
E-mail {getSortIcon('email')}
|
||||
</Th>
|
||||
<Th>Předmět</Th>
|
||||
<Th>Zdroj</Th>
|
||||
<Th
|
||||
cursor="pointer"
|
||||
onClick={() => handleSort('createdAt')}
|
||||
_hover={{ textDecoration: 'underline' }}
|
||||
>
|
||||
Datum {getSortIcon('createdAt')}
|
||||
</Th>
|
||||
<Th>Stav</Th>
|
||||
<Th w="120px">Akce</Th>
|
||||
</Tr>
|
||||
</Thead>
|
||||
<Tbody>
|
||||
{isLoading ? (
|
||||
<Tr>
|
||||
<Td colSpan={8} textAlign="center" py={8}>
|
||||
Načítání...
|
||||
</Td>
|
||||
</Tr>
|
||||
) : isError ? (
|
||||
<Tr>
|
||||
<Td colSpan={8} textAlign="center" py={8} color="red.500">
|
||||
Chyba při načítání zpráv
|
||||
</Td>
|
||||
</Tr>
|
||||
) : data?.data.length === 0 ? (
|
||||
<Tr>
|
||||
<Td colSpan={8} textAlign="center" py={8} color="gray.500">
|
||||
Žádné zprávy nenalezeny
|
||||
</Td>
|
||||
</Tr>
|
||||
) : (
|
||||
data?.data.map((message) => (
|
||||
<Tr
|
||||
key={message.id}
|
||||
bg={!message.isRead ? 'blue.50' : 'transparent'}
|
||||
_hover={{ bg: !message.isRead ? 'blue.50' : 'gray.50' }}
|
||||
>
|
||||
<Td>
|
||||
<Checkbox
|
||||
isChecked={selectedMessages.includes(message.id)}
|
||||
onChange={(e) => handleSelectMessage(message.id, e.target.checked)}
|
||||
/>
|
||||
</Td>
|
||||
<Td fontWeight={!message.isRead ? 'semibold' : 'normal'}>
|
||||
{message.name}
|
||||
</Td>
|
||||
<Td>{message.email}</Td>
|
||||
<Td maxW="200px" isTruncated title={message.subject || 'Bez předmětu'}>
|
||||
{message.subject || '—'}
|
||||
</Td>
|
||||
<Td>
|
||||
{message.source === 'sponsor' ? (
|
||||
<Badge colorScheme="purple">Sponzor</Badge>
|
||||
) : (
|
||||
<Badge colorScheme="gray">Kontakt</Badge>
|
||||
)}
|
||||
</Td>
|
||||
<Td whiteSpace="nowrap">
|
||||
{formatDate(message.createdAt)}
|
||||
</Td>
|
||||
<Td>
|
||||
{message.isRead ? (
|
||||
<Badge colorScheme="green">Přečteno</Badge>
|
||||
) : (
|
||||
<Badge colorScheme="blue">Nová zpráva</Badge>
|
||||
)}
|
||||
</Td>
|
||||
<Td>
|
||||
<HStack spacing={2}>
|
||||
<IconButton
|
||||
aria-label="Zobrazit zprávu"
|
||||
icon={<EmailIcon />}
|
||||
size="sm"
|
||||
colorScheme="blue"
|
||||
variant="ghost"
|
||||
onClick={() => handleViewMessage(message)}
|
||||
/>
|
||||
<IconButton
|
||||
aria-label="Smazat zprávu"
|
||||
icon={<DeleteIcon />}
|
||||
size="sm"
|
||||
colorScheme="red"
|
||||
variant="ghost"
|
||||
onClick={() => handleDeleteClick(message)}
|
||||
isLoading={
|
||||
deleteMessageMutation.isLoading &&
|
||||
deleteMessageMutation.variables === message.id
|
||||
}
|
||||
/>
|
||||
</HStack>
|
||||
</Td>
|
||||
</Tr>
|
||||
))
|
||||
)}
|
||||
</Tbody>
|
||||
</Table>
|
||||
</Box>
|
||||
|
||||
{data && data.total > 0 && (
|
||||
<Flex justify="space-between" mt={4} alignItems="center">
|
||||
<Text color="gray.600" fontSize="sm">
|
||||
Zobrazeno {data.data.length} z {data.total} zpráv
|
||||
</Text>
|
||||
<Pagination
|
||||
currentPage={pagination.page}
|
||||
totalPages={data.totalPages || 1}
|
||||
onPageChange={(page) => setPagination((p) => ({ ...p, page }))}
|
||||
/>
|
||||
</Flex>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{selectedMessage && (
|
||||
<MessageDetailModal
|
||||
isOpen={isDetailOpen}
|
||||
onClose={onDetailClose}
|
||||
message={selectedMessage}
|
||||
onDelete={() => {
|
||||
onDetailClose();
|
||||
handleDeleteClick(selectedMessage);
|
||||
}}
|
||||
onMarkAsRead={() => markAsReadMutation.mutate(selectedMessage.id)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<ConfirmationDialog
|
||||
isOpen={isDeleteOpen}
|
||||
onClose={onDeleteClose}
|
||||
onConfirm={handleDeleteConfirm}
|
||||
title="Smazat zprávu"
|
||||
message="Opravdu chcete smazat tuto zprávu? Tato akce je nevratná."
|
||||
confirmText="Smazat"
|
||||
cancelText="Zrušit"
|
||||
isDanger
|
||||
isLoading={deleteMessageMutation.isLoading}
|
||||
/>
|
||||
|
||||
<Modal isOpen={isForwardAllOpen} onClose={onForwardAllClose}>
|
||||
<ModalOverlay />
|
||||
<ModalContent>
|
||||
<ModalHeader>Přeposlat všechny zprávy</ModalHeader>
|
||||
<ModalCloseButton />
|
||||
<ModalBody>
|
||||
<VStack spacing={4}>
|
||||
<Text>
|
||||
Všechny příchozí zprávy budou přeposlány na zadanou e-mailovou adresu.
|
||||
</Text>
|
||||
<FormControl isRequired>
|
||||
<FormLabel>E-mailová adresa</FormLabel>
|
||||
<Input
|
||||
type="email"
|
||||
placeholder="prijemce@email.cz"
|
||||
value={forwardAllEmail}
|
||||
onChange={(e) => setForwardAllEmail(e.target.value)}
|
||||
/>
|
||||
</FormControl>
|
||||
</VStack>
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<Button variant="ghost" mr={3} onClick={onForwardAllClose}>
|
||||
Zrušit
|
||||
</Button>
|
||||
<Button
|
||||
colorScheme="teal"
|
||||
onClick={handleForwardAll}
|
||||
isLoading={forwardAllMutation.isLoading}
|
||||
>
|
||||
Přeposlat
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
</AdminLayout>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { Box, Button, Center, HStack, Heading, Image, SimpleGrid, Text, useColorModeValue, useToast, VStack } from '@chakra-ui/react';
|
||||
import AdminLayout from '@/layouts/AdminLayout';
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { getAdminScoreboard, updateAdminScoreboard, ScoreboardState } from '@/services/scoreboard';
|
||||
|
||||
const MobileScoreboardControlPage: React.FC = () => {
|
||||
const toast = useToast();
|
||||
const qc = useQueryClient();
|
||||
const cardBg = useColorModeValue('white', 'gray.800');
|
||||
const borderCol = useColorModeValue('gray.200', 'gray.700');
|
||||
|
||||
const { data: state, isLoading } = useQuery<ScoreboardState>({
|
||||
queryKey: ['admin-scoreboard-mobile'],
|
||||
queryFn: getAdminScoreboard,
|
||||
refetchInterval: 5000,
|
||||
staleTime: 3000,
|
||||
});
|
||||
|
||||
const setPartial = async (patch: Partial<ScoreboardState>) => {
|
||||
try {
|
||||
await updateAdminScoreboard(patch);
|
||||
await qc.invalidateQueries({ queryKey: ['admin-scoreboard-mobile'] });
|
||||
} catch (e) {
|
||||
toast({ title: 'Uložení selhalo', status: 'error' });
|
||||
}
|
||||
};
|
||||
|
||||
// Simple local match timer (upwards). Does not persist to backend; overlay remains score-only.
|
||||
const [running, setRunning] = useState(false);
|
||||
const [elapsed, setElapsed] = useState(0); // seconds
|
||||
const startRef = useRef<number | null>(null);
|
||||
useEffect(() => {
|
||||
let raf: number;
|
||||
const tick = () => {
|
||||
if (running) {
|
||||
const now = Date.now();
|
||||
const base = startRef.current ?? now;
|
||||
startRef.current = base;
|
||||
setElapsed(Math.floor((now - base) / 1000));
|
||||
}
|
||||
raf = requestAnimationFrame(tick);
|
||||
};
|
||||
raf = requestAnimationFrame(tick);
|
||||
return () => cancelAnimationFrame(raf);
|
||||
}, [running]);
|
||||
const resetTimer = () => { setRunning(false); setElapsed(0); startRef.current = null; };
|
||||
const mmss = useMemo(() => {
|
||||
const mm = Math.floor(elapsed / 60);
|
||||
const ss = elapsed % 60;
|
||||
return `${String(mm).padStart(2, '0')}:${String(ss).padStart(2, '0')}`;
|
||||
}, [elapsed]);
|
||||
|
||||
if (isLoading || !state) {
|
||||
return (
|
||||
<AdminLayout>
|
||||
<Center minH="50vh">Načítání…</Center>
|
||||
</AdminLayout>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<AdminLayout>
|
||||
<Box p={3}>
|
||||
<Heading size="md" mb={3}>Mobilní ovládání tabule</Heading>
|
||||
<VStack align="stretch" spacing={3}>
|
||||
<Box borderWidth="1px" borderColor={borderCol} bg={cardBg} borderRadius="lg" p={3}>
|
||||
<SimpleGrid columns={3} spacing={2} alignItems="center">
|
||||
<VStack spacing={2}>
|
||||
{state.homeLogo ? <Image src={state.homeLogo} alt="DOM" boxSize="64px" objectFit="contain" /> : null}
|
||||
<Text fontWeight="bold" textAlign="center">{state.homeShort || 'DOM'}</Text>
|
||||
<HStack>
|
||||
<Button size="lg" onClick={() => setPartial({ homeScore: Math.max(0, (state.homeScore || 0) - 1) })}>−</Button>
|
||||
<Button size="lg" colorScheme="green" onClick={() => setPartial({ homeScore: (state.homeScore || 0) + 1 })}>+</Button>
|
||||
</HStack>
|
||||
</VStack>
|
||||
<VStack spacing={2}>
|
||||
<Text fontSize="5xl" fontWeight="black">{state.homeScore} : {state.awayScore}</Text>
|
||||
<HStack>
|
||||
<Button onClick={() => setRunning((r) => !r)}>{running ? 'Stop' : 'Start'}</Button>
|
||||
<Button variant="outline" onClick={resetTimer}>Reset</Button>
|
||||
</HStack>
|
||||
<Text fontSize="2xl" fontFamily="mono">{mmss}</Text>
|
||||
</VStack>
|
||||
<VStack spacing={2}>
|
||||
{state.awayLogo ? <Image src={state.awayLogo} alt="HOS" boxSize="64px" objectFit="contain" /> : null}
|
||||
<Text fontWeight="bold" textAlign="center">{state.awayShort || 'HOS'}</Text>
|
||||
<HStack>
|
||||
<Button size="lg" onClick={() => setPartial({ awayScore: Math.max(0, (state.awayScore || 0) - 1) })}>−</Button>
|
||||
<Button size="lg" colorScheme="green" onClick={() => setPartial({ awayScore: (state.awayScore || 0) + 1 })}>+</Button>
|
||||
</HStack>
|
||||
</VStack>
|
||||
</SimpleGrid>
|
||||
</Box>
|
||||
|
||||
<Box borderWidth="1px" borderColor={borderCol} bg={cardBg} borderRadius="lg" p={3}>
|
||||
<HStack justify="space-between">
|
||||
<Text>Vybraný zápas</Text>
|
||||
<Text fontWeight="bold">{state.externalMatchId ? state.externalMatchId : '—'}</Text>
|
||||
</HStack>
|
||||
<HStack mt={2} spacing={2}>
|
||||
<Button onClick={() => setPartial({ active: true })} colorScheme="blue">Aktivovat</Button>
|
||||
<Button variant="outline" onClick={() => setPartial({ active: false })}>Deaktivovat</Button>
|
||||
<Button variant="ghost" onClick={() => setPartial({ homeScore: 0, awayScore: 0 })}>Reset skóre</Button>
|
||||
</HStack>
|
||||
</Box>
|
||||
</VStack>
|
||||
</Box>
|
||||
</AdminLayout>
|
||||
);
|
||||
};
|
||||
|
||||
export default MobileScoreboardControlPage;
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,970 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
Flex,
|
||||
FormControl,
|
||||
FormLabel,
|
||||
FormHelperText,
|
||||
Heading,
|
||||
IconButton,
|
||||
Table,
|
||||
Tbody,
|
||||
Td,
|
||||
Text,
|
||||
Th,
|
||||
Thead,
|
||||
Tr,
|
||||
useDisclosure,
|
||||
useToast,
|
||||
Badge,
|
||||
Input,
|
||||
InputGroup,
|
||||
InputLeftElement,
|
||||
HStack,
|
||||
VStack,
|
||||
Textarea,
|
||||
Modal,
|
||||
ModalOverlay,
|
||||
ModalContent,
|
||||
ModalHeader,
|
||||
ModalFooter,
|
||||
ModalBody,
|
||||
ModalCloseButton,
|
||||
useBreakpointValue,
|
||||
Switch,
|
||||
Tooltip,
|
||||
Spinner,
|
||||
InputRightElement,
|
||||
Checkbox,
|
||||
Select,
|
||||
Tabs,
|
||||
TabList,
|
||||
Tab,
|
||||
TabPanels,
|
||||
TabPanel,
|
||||
useColorModeValue,
|
||||
} from '@chakra-ui/react';
|
||||
import { AddIcon, DeleteIcon, EmailIcon, SearchIcon } from '@chakra-ui/icons';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { format } from 'date-fns';
|
||||
import { cs } from 'date-fns/locale';
|
||||
import AdminLayout from '../../layouts/AdminLayout';
|
||||
import {
|
||||
getNewsletterSubscribers,
|
||||
sendNewsletter,
|
||||
sendNewsletterTest,
|
||||
deleteSubscriber,
|
||||
toggleSubscriberStatus,
|
||||
NewsletterSubscriber,
|
||||
NewsletterSendData,
|
||||
getNewsletterStatus,
|
||||
sendNewsletterTestAdvanced,
|
||||
NewsletterTestType,
|
||||
sendNewsletterDigest,
|
||||
setNewsletterAutomation,
|
||||
previewNewsletter,
|
||||
DigestType,
|
||||
} from '../../services/admin/newsletter';
|
||||
import { getAdminSettings, updateAdminSettings, AdminSettings } from '../../services/settings';
|
||||
import { adminSendSmtpTest, AdminSmtpTestPayload } from '../../services/admin/newsletter';
|
||||
import { getRecentEmailStats, EmailStatRow, getEmailEventsForLog, EmailEventRow } from '../../services/admin/newsletter';
|
||||
|
||||
export default function NewsletterAdminPage() {
|
||||
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 hoverBg = useColorModeValue('gray.50', 'gray.700');
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [selectedSubscribers, setSelectedSubscribers] = useState<number[]>([]);
|
||||
const [newsletterData, setNewsletterData] = useState<NewsletterSendData>({
|
||||
subject: '',
|
||||
content: '',
|
||||
});
|
||||
|
||||
// SMTP test (admin)
|
||||
const [smtpHost, setSmtpHost] = useState('');
|
||||
const [smtpPort, setSmtpPort] = useState<number | ''>('');
|
||||
const [smtpUser, setSmtpUser] = useState('');
|
||||
const [smtpPass, setSmtpPass] = useState('');
|
||||
const [smtpFrom, setSmtpFrom] = useState('');
|
||||
const [smtpTo, setSmtpTo] = useState('');
|
||||
const [smtpTLS, setSmtpTLS] = useState(true);
|
||||
const [smtpSubject, setSmtpSubject] = useState('SMTP Test');
|
||||
const [smtpBody, setSmtpBody] = useState('<p>Toto je testovací e‑mail (admin SMTP debug).</p>');
|
||||
const [showSmtpPass, setShowSmtpPass] = useState(false);
|
||||
|
||||
const adminSmtpTestMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
const payload: AdminSmtpTestPayload = {
|
||||
host: smtpHost.trim(),
|
||||
port: typeof smtpPort === 'number' ? smtpPort : 0,
|
||||
username: smtpUser || undefined,
|
||||
password: smtpPass || undefined,
|
||||
from: smtpFrom.trim(),
|
||||
to: smtpTo.trim(),
|
||||
subject: smtpSubject || undefined,
|
||||
body: smtpBody || undefined,
|
||||
use_tls: smtpTLS,
|
||||
};
|
||||
return adminSendSmtpTest(payload);
|
||||
},
|
||||
onSuccess: (res: any) => {
|
||||
if (res?.ok) {
|
||||
toast({ title: 'SMTP test úspěšný', description: res?.message || 'Test email sent', status: 'success' });
|
||||
} else {
|
||||
toast({ title: 'SMTP test selhal', description: res?.error || 'Chyba při odeslání testu', status: 'error' });
|
||||
}
|
||||
},
|
||||
onError: (err: any) => {
|
||||
toast({ title: 'Chyba požadavku', description: err?.response?.data?.error || err?.message, status: 'error' });
|
||||
}
|
||||
});
|
||||
const [testEmail, setTestEmail] = useState<string>('');
|
||||
const [testEmails, setTestEmails] = useState<string>('');
|
||||
const [testType, setTestType] = useState<NewsletterTestType>('newsletter');
|
||||
const [prefsModalOpen, setPrefsModalOpen] = useState(false);
|
||||
const [editingSubscriber, setEditingSubscriber] = useState<NewsletterSubscriber | null>(null);
|
||||
const [editingPrefs, setEditingPrefs] = useState<Record<string, boolean>>({});
|
||||
// Send mode: custom body or digest template
|
||||
const [sendMode, setSendMode] = useState<'custom' | DigestType>('custom');
|
||||
const [competitions, setCompetitions] = useState<string>('');
|
||||
const [previewSubject, setPreviewSubject] = useState<string>('');
|
||||
const [previewHtml, setPreviewHtml] = useState<string>('');
|
||||
const [previewLoading, setPreviewLoading] = useState<boolean>(false);
|
||||
|
||||
|
||||
const { isOpen, onOpen, onClose } = useDisclosure();
|
||||
const testModal = useDisclosure();
|
||||
const smtpModal = useDisclosure();
|
||||
const toast = useToast();
|
||||
const queryClient = useQueryClient();
|
||||
const isMobile = useBreakpointValue({ base: true, md: false });
|
||||
|
||||
// Admin settings (for scheduling)
|
||||
const settingsQuery = useQuery({
|
||||
queryKey: ['admin', 'settings'],
|
||||
queryFn: getAdminSettings,
|
||||
});
|
||||
const settings = settingsQuery.data as AdminSettings | undefined;
|
||||
const [enableWeekly, setEnableWeekly] = useState<boolean>(!!settings?.enable_weekly);
|
||||
const [enableMatchReminders, setEnableMatchReminders] = useState<boolean>(!!settings?.enable_match_reminders);
|
||||
const [enableResults, setEnableResults] = useState<boolean>(!!settings?.enable_results);
|
||||
const [weeklyDay, setWeeklyDay] = useState<AdminSettings['newsletter_weekly_day']>(settings?.newsletter_weekly_day || 'sun');
|
||||
const [weeklyHour, setWeeklyHour] = useState<number>(typeof settings?.newsletter_weekly_hour === 'number' ? (settings!.newsletter_weekly_hour as number) : 18);
|
||||
const [reminderLead, setReminderLead] = useState<number>(typeof settings?.newsletter_reminder_lead_hours === 'number' ? (settings!.newsletter_reminder_lead_hours as number) : 48);
|
||||
const [quietStart, setQuietStart] = useState<number>(typeof settings?.newsletter_quiet_start === 'number' ? (settings!.newsletter_quiet_start as number) : 22);
|
||||
const [quietEnd, setQuietEnd] = useState<number>(typeof settings?.newsletter_quiet_end === 'number' ? (settings!.newsletter_quiet_end as number) : 7);
|
||||
|
||||
// Sync local state when settings load
|
||||
useEffect(() => {
|
||||
if (!settings) return;
|
||||
setEnableWeekly(!!settings.enable_weekly);
|
||||
setEnableMatchReminders(!!settings.enable_match_reminders);
|
||||
setEnableResults(!!settings.enable_results);
|
||||
setWeeklyDay(settings.newsletter_weekly_day || 'sun');
|
||||
setWeeklyHour(typeof settings.newsletter_weekly_hour === 'number' ? settings.newsletter_weekly_hour! : 18);
|
||||
setReminderLead(typeof settings.newsletter_reminder_lead_hours === 'number' ? settings.newsletter_reminder_lead_hours! : 48);
|
||||
setQuietStart(typeof settings.newsletter_quiet_start === 'number' ? settings.newsletter_quiet_start! : 22);
|
||||
setQuietEnd(typeof settings.newsletter_quiet_end === 'number' ? settings.newsletter_quiet_end! : 7);
|
||||
}, [settings]);
|
||||
|
||||
const saveScheduleMutation = useMutation({
|
||||
mutationFn: () => updateAdminSettings({
|
||||
enable_weekly: enableWeekly,
|
||||
enable_match_reminders: enableMatchReminders,
|
||||
enable_results: enableResults,
|
||||
newsletter_weekly_day: weeklyDay,
|
||||
newsletter_weekly_hour: weeklyHour,
|
||||
newsletter_reminder_lead_hours: reminderLead,
|
||||
newsletter_quiet_start: quietStart,
|
||||
newsletter_quiet_end: quietEnd,
|
||||
}),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['admin', 'settings'] });
|
||||
toast({ title: 'Nastavení rozesílek uloženo', status: 'success' });
|
||||
},
|
||||
onError: (err: any) => {
|
||||
toast({ title: 'Chyba při ukládání', description: err?.response?.data?.error || err?.message, status: 'error' });
|
||||
}
|
||||
});
|
||||
|
||||
// Basic client-side sanitizer for preview (defense-in-depth; backend should still sanitize)
|
||||
const sanitizeHtml = (html: string): string => {
|
||||
try {
|
||||
// Remove script/style/iframe tags entirely
|
||||
const withoutDangerousTags = html.replace(/<\s*(script|style|iframe)[^>]*>[\s\S]*?<\s*\/\s*\1\s*>/gi, '');
|
||||
// Remove event handlers like onClick, onError, etc.
|
||||
const withoutEvents = withoutDangerousTags.replace(/ on[a-zA-Z]+\s*=\s*"[^"]*"/g, '').replace(/ on[a-zA-Z]+\s*=\s*'[^']*'/g, '').replace(/ on[a-zA-Z]+\s*=\s*[^\s>]+/g, '');
|
||||
// Neutralize javascript:, data: URLs in href/src
|
||||
const neutralizedUrls = withoutEvents.replace(/(href|src)\s*=\s*(["'])\s*(javascript:|data:)/gi, '$1=$2#');
|
||||
return neutralizedUrls;
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
};
|
||||
|
||||
// Fetch subscribers
|
||||
const { data: subscribers = [], isLoading } = useQuery({
|
||||
queryKey: ['admin', 'newsletter-subscribers'],
|
||||
queryFn: getNewsletterSubscribers,
|
||||
});
|
||||
|
||||
// Filter subscribers based on search term
|
||||
const filteredSubscribers = subscribers.filter((subscriber) =>
|
||||
subscriber.email.toLowerCase().includes(searchTerm.toLowerCase())
|
||||
);
|
||||
|
||||
// Toggle subscriber status
|
||||
const toggleStatusMutation = useMutation({
|
||||
mutationFn: ({ id, isActive }: { id: number; isActive: boolean }) =>
|
||||
toggleSubscriberStatus(id, isActive),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['admin', 'newsletter-subscribers'] });
|
||||
toast({
|
||||
title: 'Stav odběratele byl aktualizován',
|
||||
status: 'success',
|
||||
duration: 3000,
|
||||
isClosable: true,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
// Delete subscriber
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: deleteSubscriber,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['admin', 'newsletter-subscribers'] });
|
||||
setSelectedSubscribers([]);
|
||||
toast({
|
||||
title: 'Odběratel byl smazán',
|
||||
status: 'success',
|
||||
duration: 3000,
|
||||
isClosable: true,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const updatePrefsMutation = useMutation({
|
||||
mutationFn: ({ id, prefs }: { id: number; prefs: Record<string, boolean> }) =>
|
||||
// lazy import to avoid cyclic imports
|
||||
import('../../services/admin/newsletter').then((m) => m.updateSubscriberPreferences(id, prefs)),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['admin', 'newsletter-subscribers'] });
|
||||
setPrefsModalOpen(false);
|
||||
toast({ title: 'Preference uloženy', status: 'success' });
|
||||
},
|
||||
onError: (err: any) => {
|
||||
toast({ title: 'Chyba při ukládání preferencí', description: err?.response?.data?.error || err?.message, status: 'error' });
|
||||
},
|
||||
});
|
||||
|
||||
// Send newsletter
|
||||
const sendNewsletterMutation = useMutation({
|
||||
mutationFn: sendNewsletter,
|
||||
onSuccess: () => {
|
||||
onClose();
|
||||
setNewsletterData({ subject: '', content: '' });
|
||||
toast({
|
||||
title: 'Newsletter byl odeslán',
|
||||
status: 'success',
|
||||
duration: 5000,
|
||||
isClosable: true,
|
||||
});
|
||||
},
|
||||
onError: (error: any) => {
|
||||
toast({
|
||||
title: 'Chyba při odesílání newsletteru',
|
||||
description: error?.response?.data?.error || error?.response?.data?.message || error?.message || 'Došlo k chybě',
|
||||
status: 'error',
|
||||
duration: 5000,
|
||||
isClosable: true,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
// Send test newsletter
|
||||
const sendTestMutation = useMutation({
|
||||
mutationFn: () => {
|
||||
const emails = testEmails
|
||||
.split(',')
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean);
|
||||
if (emails.length > 0) {
|
||||
return sendNewsletterTestAdvanced({ emails, type: testType });
|
||||
}
|
||||
if (testEmail) {
|
||||
return sendNewsletterTestAdvanced({ email: testEmail, type: testType });
|
||||
}
|
||||
return sendNewsletterTestAdvanced({ type: testType });
|
||||
},
|
||||
onSuccess: (data: { message: string; recipient?: string; recipients?: string[]; type: string }) => {
|
||||
toast({
|
||||
title: 'Test odeslán',
|
||||
description:
|
||||
data?.recipients && data.recipients.length > 0
|
||||
? `E-mail byl odeslán na ${data.recipients.join(', ')}`
|
||||
: data?.recipient
|
||||
? `E-mail byl odeslán na ${data.recipient}`
|
||||
: 'Testovací e-mail byl odeslán',
|
||||
status: 'success',
|
||||
duration: 4000,
|
||||
isClosable: true,
|
||||
});
|
||||
setTestEmail('');
|
||||
setTestEmails('');
|
||||
testModal.onClose();
|
||||
},
|
||||
onError: (error: any) => {
|
||||
toast({
|
||||
title: 'Chyba při odesílání testu',
|
||||
description: error?.response?.data?.error || error?.response?.data?.message || error?.message || 'Došlo k chybě',
|
||||
status: 'error',
|
||||
duration: 5000,
|
||||
isClosable: true,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const handleSelectAll = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
if (e.target.checked) {
|
||||
setSelectedSubscribers(filteredSubscribers.map((s) => s.id));
|
||||
} else {
|
||||
setSelectedSubscribers([]);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSelectSubscriber = (id: number, isSelected: boolean) => {
|
||||
if (isSelected) {
|
||||
setSelectedSubscribers((prev) => [...prev, id]);
|
||||
} else {
|
||||
setSelectedSubscribers((prev) => prev.filter((subId) => subId !== id));
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteSelected = () => {
|
||||
if (window.confirm('Opravdu chcete smazat vybrané odběratele?')) {
|
||||
Promise.all(selectedSubscribers.map((id) => deleteMutation.mutateAsync(id)));
|
||||
}
|
||||
};
|
||||
|
||||
const handleSendNewsletter = () => {
|
||||
sendNewsletterMutation.mutate(newsletterData);
|
||||
};
|
||||
|
||||
const formatDate = (dateString: string) => {
|
||||
return format(new Date(dateString), 'd. M. yyyy HH:mm', { locale: cs });
|
||||
};
|
||||
|
||||
// Newsletter status
|
||||
const { data: statusData } = useQuery({
|
||||
queryKey: ['admin', 'newsletter-status'],
|
||||
queryFn: getNewsletterStatus,
|
||||
});
|
||||
const automationToggle = useMutation({
|
||||
mutationFn: (enabled: boolean) => setNewsletterAutomation(enabled),
|
||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['admin', 'newsletter-status'] }),
|
||||
});
|
||||
|
||||
// Recent delivery stats
|
||||
const { data: recentStats = [], isLoading: statsLoading } = useQuery<EmailStatRow[]>({
|
||||
queryKey: ['admin', 'newsletter-stats-recent'],
|
||||
queryFn: getRecentEmailStats,
|
||||
});
|
||||
|
||||
// Events modal state
|
||||
const [eventsModalOpen, setEventsModalOpen] = useState(false);
|
||||
const [activeLog, setActiveLog] = useState<EmailStatRow | null>(null);
|
||||
const eventsQuery = useQuery<EmailEventRow[]>({
|
||||
queryKey: ['admin', 'email-events', activeLog?.id],
|
||||
queryFn: () => getEmailEventsForLog(activeLog?.id || 0),
|
||||
enabled: !!activeLog?.id && eventsModalOpen,
|
||||
});
|
||||
const [eventFilter, setEventFilter] = useState<{ open: boolean; click: boolean; spam: boolean; unsubscribe: boolean }>({ open: true, click: true, spam: true, unsubscribe: true });
|
||||
const filteredEvents = (eventsQuery.data || []).filter((ev) => eventFilter[(ev.event_type as 'open'|'click'|'spam'|'unsubscribe')] ?? true);
|
||||
|
||||
const exportCSV = () => {
|
||||
// Prevent CSV injection by prefixing dangerous leading characters
|
||||
const safeCSV = (value: any) => {
|
||||
const s = String(value ?? '');
|
||||
return /^[=+\-@]/.test(s) ? `'${s}` : s;
|
||||
};
|
||||
const rows = (eventsQuery.data || []).map((ev) => ({
|
||||
id: ev.id,
|
||||
created_at: ev.created_at,
|
||||
event_type: ev.event_type,
|
||||
url: ev.meta?.url ?? '',
|
||||
ua: ev.meta?.ua ?? '',
|
||||
ip: ev.meta?.ip ?? '',
|
||||
}));
|
||||
const header = ['id','created_at','event_type','url','ua','ip'];
|
||||
const lines = [
|
||||
header.join(','),
|
||||
...rows.map(r => [
|
||||
safeCSV(r.id),
|
||||
safeCSV(r.created_at),
|
||||
safeCSV(r.event_type),
|
||||
safeCSV(JSON.stringify(r.url)),
|
||||
safeCSV(JSON.stringify(r.ua)),
|
||||
safeCSV(JSON.stringify(r.ip))
|
||||
].join(','))
|
||||
];
|
||||
const blob = new Blob(["\ufeff" + lines.join('\n')], { type: 'text/csv;charset=utf-8;' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `email_events_${activeLog?.id}.csv`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
return (
|
||||
<AdminLayout>
|
||||
<Box p={6}>
|
||||
<Flex justify="space-between" align="center" mb={6} flexWrap="wrap" gap={4}>
|
||||
<Box>
|
||||
<Heading size="lg" mb={2}>
|
||||
Správa newsletteru
|
||||
</Heading>
|
||||
<Text color="gray.600">
|
||||
Spravujte odběratele newsletteru a rozesílejte hromadné zprávy
|
||||
</Text>
|
||||
</Box>
|
||||
<HStack spacing={3}>
|
||||
{selectedSubscribers.length > 0 && (
|
||||
<Button
|
||||
colorScheme="red"
|
||||
variant="outline"
|
||||
leftIcon={<DeleteIcon />}
|
||||
onClick={handleDeleteSelected}
|
||||
isLoading={deleteMutation.isLoading}
|
||||
>
|
||||
Smazat vybrané ({selectedSubscribers.length})
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={testModal.onOpen}
|
||||
>
|
||||
Odeslat testovací e-mail
|
||||
</Button>
|
||||
<Button variant="outline" onClick={smtpModal.onOpen}>Otestovat SMTP</Button>
|
||||
<Button
|
||||
colorScheme="blue"
|
||||
leftIcon={<EmailIcon />}
|
||||
onClick={onOpen}
|
||||
isDisabled={subscribers.length === 0}
|
||||
>
|
||||
Odeslat newsletter
|
||||
</Button>
|
||||
</HStack>
|
||||
</Flex>
|
||||
|
||||
{/* Tabs layout */}
|
||||
<Tabs colorScheme="blue" isFitted>
|
||||
<TabList>
|
||||
<Tab>Stav</Tab>
|
||||
<Tab>Plánování</Tab>
|
||||
<Tab>Statistika</Tab>
|
||||
</TabList>
|
||||
<TabPanels>
|
||||
<TabPanel p={0}>
|
||||
{/* Status panel */}
|
||||
<Box bg={cardBg} borderRadius="lg" boxShadow="sm" p={4} mb={6}>
|
||||
<Heading size="md" mb={3}>Stav rozesílek</Heading>
|
||||
<HStack spacing={6} wrap="wrap">
|
||||
<Text><b>Odběratelů:</b> {statusData?.total_subscribers ?? '—'}</Text>
|
||||
<Text><b>Aktivních:</b> {statusData?.active_subscribers ?? '—'}</Text>
|
||||
<Text><b>Další běh:</b> {statusData?.next_approximate ? format(new Date(statusData.next_approximate), 'd. M. yyyy HH:mm', { locale: cs }) : '—'}</Text>
|
||||
<Text><b>Interval:</b> {statusData?.interval_minutes ? `${statusData.interval_minutes} min` : '—'}</Text>
|
||||
<HStack>
|
||||
<Switch size="sm" isChecked={!!statusData?.newsletter_enabled} onChange={(e)=> automationToggle.mutate(e.target.checked)} />
|
||||
<Text>Automatické rozesílky</Text>
|
||||
</HStack>
|
||||
</HStack>
|
||||
{statusData?.next_approximate ? (
|
||||
<Text color="gray.600" fontSize="sm" mt={2}>
|
||||
Další automatický newsletter za {(() => {
|
||||
const diff = new Date(statusData.next_approximate).getTime() - Date.now();
|
||||
if (diff <= 0) return 'méně než minutu';
|
||||
const mins = Math.floor(diff / 60000);
|
||||
const hrs = Math.floor(mins / 60);
|
||||
const rem = mins % 60;
|
||||
return hrs > 0 ? `${hrs} h ${rem} min` : `${mins} min`;
|
||||
})()}
|
||||
</Text>
|
||||
) : null}
|
||||
{statusData?.sample_recipients?.length ? (
|
||||
<Box mt={3}>
|
||||
<Text color="gray.600" fontSize="sm">Ukázka příjemců ({statusData.sample_recipients.length}): {statusData.sample_recipients.join(', ')}</Text>
|
||||
</Box>
|
||||
) : null}
|
||||
</Box>
|
||||
</TabPanel>
|
||||
<TabPanel p={0}>
|
||||
{/* Scheduling controls */}
|
||||
<Box bg={cardBg} borderRadius="lg" boxShadow="sm" p={4} mb={6}>
|
||||
<Heading size="md" mb={3}>Plánování rozesílek</Heading>
|
||||
<Text color="gray.600" mb={4}>Nastavte, kdy se automaticky posílají jednotlivé typy newsletterů.</Text>
|
||||
{settingsQuery.isLoading ? (
|
||||
<HStack color="gray.600"><Spinner size="sm" /> <Text>Načítám nastavení…</Text></HStack>
|
||||
) : (
|
||||
<VStack align="stretch" spacing={4}>
|
||||
<HStack justify="space-between">
|
||||
<Text fontWeight="600">Týdenní přehled</Text>
|
||||
<Switch isChecked={enableWeekly} onChange={(e)=> setEnableWeekly(e.target.checked)} />
|
||||
</HStack>
|
||||
<HStack spacing={3}>
|
||||
<FormControl maxW="220px">
|
||||
<FormLabel>Den v týdnu</FormLabel>
|
||||
<Select value={weeklyDay} onChange={(e)=> setWeeklyDay(e.target.value as any)}>
|
||||
<option value="sun">Neděle</option>
|
||||
<option value="mon">Pondělí</option>
|
||||
<option value="tue">Úterý</option>
|
||||
<option value="wed">Středa</option>
|
||||
<option value="thu">Čtvrtek</option>
|
||||
<option value="fri">Pátek</option>
|
||||
<option value="sat">Sobota</option>
|
||||
</Select>
|
||||
</FormControl>
|
||||
<FormControl maxW="160px">
|
||||
<FormLabel>Hodina</FormLabel>
|
||||
<Input type="number" min={0} max={23} value={weeklyHour} onChange={(e)=> setWeeklyHour(Math.max(0, Math.min(23, Number(e.target.value)||0)))} />
|
||||
</FormControl>
|
||||
</HStack>
|
||||
|
||||
</VStack>
|
||||
)}
|
||||
</Box>
|
||||
</TabPanel>
|
||||
|
||||
{/* Analytics tab */}
|
||||
<TabPanel p={0}>
|
||||
<Box bg={cardBg} borderRadius="lg" boxShadow="sm" p={4} mb={6}>
|
||||
<Heading size="md" mb={3}>Dodání a interakce</Heading>
|
||||
{statsLoading ? (
|
||||
<HStack color="gray.600"><Spinner size="sm" /> <Text>Načítám statistiky…</Text></HStack>
|
||||
) : recentStats.length === 0 ? (
|
||||
<Text color="gray.600">Zatím nejsou k dispozici žádné záznamy.</Text>
|
||||
) : (
|
||||
<Box overflowX="auto">
|
||||
<Table size="sm" variant="simple">
|
||||
<Thead>
|
||||
<Tr>
|
||||
<Th>Čas</Th>
|
||||
<Th>Předmět</Th>
|
||||
<Th>Příjemce</Th>
|
||||
<Th isNumeric>Otevření</Th>
|
||||
<Th isNumeric>Kliknutí</Th>
|
||||
<Th isNumeric>Spam</Th>
|
||||
<Th isNumeric>Odhlášení</Th>
|
||||
<Th>Stav</Th>
|
||||
<Th>Detail</Th>
|
||||
</Tr>
|
||||
</Thead>
|
||||
<Tbody>
|
||||
{recentStats.slice(0, 50).map((row) => (
|
||||
<Tr key={row.id}>
|
||||
<Td>{format(new Date(row.created_at), 'd. M. yyyy HH:mm', { locale: cs })}</Td>
|
||||
<Td>{row.subject}</Td>
|
||||
<Td>{row.recipient}</Td>
|
||||
<Td isNumeric>{row.opens}</Td>
|
||||
<Td isNumeric>{row.clicks}</Td>
|
||||
<Td isNumeric>{row.spam}</Td>
|
||||
<Td isNumeric>{row.unsubs}</Td>
|
||||
<Td>
|
||||
<Badge colorScheme={row.status === 'failed' ? 'red' : 'green'}>{row.status}</Badge>
|
||||
</Td>
|
||||
<Td>
|
||||
<Button size="xs" variant="outline" onClick={() => { setActiveLog(row); setEventsModalOpen(true); }}>Detail</Button>
|
||||
</Td>
|
||||
</Tr>
|
||||
))}
|
||||
</Tbody>
|
||||
</Table>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
</TabPanel>
|
||||
</TabPanels>
|
||||
</Tabs>
|
||||
|
||||
{/* Subscribers table */}
|
||||
<Box bg={cardBg} borderRadius="lg" boxShadow="sm" p={4} mb={6}>
|
||||
<InputGroup maxW="md" mb={4}>
|
||||
<InputLeftElement pointerEvents="none">
|
||||
<SearchIcon color="gray.400" />
|
||||
</InputLeftElement>
|
||||
<Input
|
||||
placeholder="Hledat podle e-mailu..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
/>
|
||||
</InputGroup>
|
||||
|
||||
<Box overflowX="auto">
|
||||
<Table variant="simple">
|
||||
<Thead>
|
||||
<Tr>
|
||||
<Th w="40px">
|
||||
<input
|
||||
type="checkbox"
|
||||
onChange={handleSelectAll}
|
||||
checked={
|
||||
filteredSubscribers.length > 0 &&
|
||||
selectedSubscribers.length === filteredSubscribers.length
|
||||
}
|
||||
/>
|
||||
</Th>
|
||||
<Th>E-mail</Th>
|
||||
<Th>Stav</Th>
|
||||
<Th>Datum registrace</Th>
|
||||
<Th>Poslední změna</Th>
|
||||
<Th w="120px">Akce</Th>
|
||||
</Tr>
|
||||
</Thead>
|
||||
<Tbody>
|
||||
{isLoading ? (
|
||||
<Tr>
|
||||
<Td colSpan={6} textAlign="center" py={8}>
|
||||
Načítání...
|
||||
</Td>
|
||||
</Tr>
|
||||
) : filteredSubscribers.length === 0 ? (
|
||||
<Tr>
|
||||
<Td colSpan={6} textAlign="center" py={8} color="gray.500">
|
||||
{searchTerm ? 'Žádní odběratelé nebyli nalezeni' : 'Žádní odběratelé'}
|
||||
</Td>
|
||||
</Tr>
|
||||
) : (
|
||||
filteredSubscribers.map((subscriber) => (
|
||||
<Tr key={subscriber.id}>
|
||||
<Td>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedSubscribers.includes(subscriber.id)}
|
||||
onChange={(e) =>
|
||||
handleSelectSubscriber(subscriber.id, e.target.checked)
|
||||
}
|
||||
/>
|
||||
</Td>
|
||||
<Td>{subscriber.email}</Td>
|
||||
<Td>
|
||||
<Badge
|
||||
colorScheme={subscriber.is_active ? 'green' : 'gray'}
|
||||
>
|
||||
{subscriber.is_active ? 'Aktivní' : 'Neaktivní'}
|
||||
</Badge>
|
||||
</Td>
|
||||
<Td>
|
||||
{(() => {
|
||||
const prefs = (subscriber as any).preferences;
|
||||
if (prefs && Object.keys(prefs).length > 0) {
|
||||
return Object.keys(prefs).map((k) =>
|
||||
prefs[k] ? <Badge key={k} mr={1} colorScheme="blue">{k}</Badge> : null
|
||||
);
|
||||
}
|
||||
return <Text color="gray.500">Žádné</Text>;
|
||||
})()}
|
||||
</Td>
|
||||
<Td>{formatDate(subscriber.created_at)}</Td>
|
||||
<Td>{formatDate(subscriber.updated_at)}</Td>
|
||||
<Td>
|
||||
<HStack spacing={2}>
|
||||
<Tooltip label={subscriber.is_active ? 'Deaktivovat' : 'Aktivovat'}>
|
||||
<span>
|
||||
<Switch
|
||||
colorScheme="green"
|
||||
isChecked={subscriber.is_active}
|
||||
onChange={(e) =>
|
||||
toggleStatusMutation.mutate({
|
||||
id: subscriber.id,
|
||||
isActive: e.target.checked,
|
||||
})
|
||||
}
|
||||
isDisabled={toggleStatusMutation.isLoading}
|
||||
/>
|
||||
</span>
|
||||
</Tooltip>
|
||||
<Tooltip label="Smazat">
|
||||
<span>
|
||||
<IconButton
|
||||
aria-label="Smazat odběratele"
|
||||
icon={<DeleteIcon />}
|
||||
size="sm"
|
||||
colorScheme="red"
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
if (window.confirm('Opravdu chcete smazat tohoto odběratele?')) {
|
||||
deleteMutation.mutate(subscriber.id);
|
||||
}
|
||||
}}
|
||||
isLoading={
|
||||
deleteMutation.isLoading &&
|
||||
(deleteMutation as any).variables === subscriber.id
|
||||
}
|
||||
/>
|
||||
</span>
|
||||
</Tooltip>
|
||||
<Tooltip label="Upravit preference">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
setEditingSubscriber(subscriber);
|
||||
setEditingPrefs((subscriber as any).preferences || {});
|
||||
setPrefsModalOpen(true);
|
||||
}}
|
||||
>
|
||||
Upravit
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</HStack>
|
||||
</Td>
|
||||
</Tr>
|
||||
))
|
||||
)}
|
||||
</Tbody>
|
||||
</Table>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Send Newsletter Modal */}
|
||||
<Modal isOpen={isOpen} onClose={onClose} size="xl">
|
||||
<ModalOverlay />
|
||||
<ModalContent maxW="90vw" maxH="90vh" overflowY="auto">
|
||||
<ModalHeader>Odeslat newsletter</ModalHeader>
|
||||
<ModalCloseButton />
|
||||
<ModalBody>
|
||||
<VStack spacing={4} align="stretch">
|
||||
<Text>
|
||||
Newsletter bude odeslán všem aktivním odběratelům ({subscribers.filter(s => s.is_active).length}).
|
||||
</Text>
|
||||
|
||||
<FormControl>
|
||||
<FormLabel>Typ</FormLabel>
|
||||
<Select value={sendMode} onChange={(e)=> setSendMode(e.target.value as any)}>
|
||||
<option value="custom">Vlastní obsah</option>
|
||||
<option value="matches">Šablona: Zápasy</option>
|
||||
<option value="scores">Šablona: Výsledky</option>
|
||||
<option value="events">Šablona: Akce</option>
|
||||
<option value="blogs">Šablona: Novinky</option>
|
||||
<option value="weekly">Šablona: Týdenní přehled</option>
|
||||
</Select>
|
||||
<FormHelperText>Zvolte mezi vlastním obsahem nebo automatickou šablonou.</FormHelperText>
|
||||
</FormControl>
|
||||
|
||||
{sendMode === 'custom' && (
|
||||
<>
|
||||
<FormControl isRequired>
|
||||
<FormLabel>Předmět</FormLabel>
|
||||
<Input
|
||||
placeholder="Předmět zprávy"
|
||||
value={newsletterData.subject}
|
||||
onChange={(e) =>
|
||||
setNewsletterData({ ...newsletterData, subject: e.target.value })
|
||||
}
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
<FormControl isRequired>
|
||||
<FormLabel>Obsah zprávy</FormLabel>
|
||||
<Textarea
|
||||
placeholder="Zde napište obsah newsletteru..."
|
||||
value={newsletterData.content}
|
||||
onChange={(e) =>
|
||||
setNewsletterData({ ...newsletterData, content: e.target.value })
|
||||
}
|
||||
rows={10}
|
||||
/>
|
||||
<FormHelperText>
|
||||
Pro formátování textu můžete použít HTML značky.
|
||||
</FormHelperText>
|
||||
</FormControl>
|
||||
</>
|
||||
)}
|
||||
|
||||
{sendMode !== 'custom' && (
|
||||
<>
|
||||
<FormControl>
|
||||
<FormLabel>Filtr soutěží (volitelné)</FormLabel>
|
||||
<Input placeholder="NAPŘ. KP, I.A, I.B" value={competitions} onChange={(e)=> setCompetitions(e.target.value)} />
|
||||
<FormHelperText>Čárkou oddělený seznam kódů soutěží.</FormHelperText>
|
||||
</FormControl>
|
||||
<HStack>
|
||||
<Button variant="outline" onClick={async ()=>{
|
||||
setPreviewLoading(true);
|
||||
try {
|
||||
const prefs: any = {};
|
||||
if (sendMode === 'weekly') { prefs.blogs = true; prefs.events = true; prefs.matches = true; prefs.scores = true; } else { (prefs as any)[sendMode] = true; }
|
||||
if (competitions.trim()) { prefs.competitions = competitions.trim(); }
|
||||
const res = await previewNewsletter({ preferences: prefs });
|
||||
setPreviewSubject(res.subject);
|
||||
setPreviewHtml(res.html);
|
||||
} finally {
|
||||
setPreviewLoading(false);
|
||||
}
|
||||
}}>Náhled šablony</Button>
|
||||
{previewLoading && <Spinner size="sm" />}
|
||||
{previewSubject && <Badge colorScheme="blue">{previewSubject}</Badge>}
|
||||
</HStack>
|
||||
<Box mt={2} p={3} bg={useColorModeValue('gray.50', 'gray.900')} borderRadius="md" borderWidth="1px">
|
||||
<Box
|
||||
bg={cardBg}
|
||||
p={3}
|
||||
borderRadius="md"
|
||||
borderWidth="1px"
|
||||
dangerouslySetInnerHTML={{ __html: sanitizeHtml(previewHtml || '<em>Náhled se zobrazí zde</em>') }}
|
||||
/>
|
||||
</Box>
|
||||
</>
|
||||
)}
|
||||
|
||||
<Box mt={4} p={4} bg={useColorModeValue('gray.50', 'gray.900')} borderRadius="md">
|
||||
<Text fontWeight="bold" mb={2}>Náhled:</Text>
|
||||
<Box
|
||||
border="1px"
|
||||
borderColor="gray.200"
|
||||
p={4}
|
||||
borderRadius="md"
|
||||
bg={cardBg}
|
||||
dangerouslySetInnerHTML={{ __html: sanitizeHtml(sendMode === 'custom' ? (newsletterData.content || '<em>Náhled se zobrazí zde</em>') : (previewHtml || '<em>Náhled se zobrazí zde</em>')) }}
|
||||
/>
|
||||
</Box>
|
||||
</VStack>
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<Button variant="ghost" mr={3} onClick={onClose}>
|
||||
Zrušit
|
||||
</Button>
|
||||
{sendMode === 'custom' ? (
|
||||
<Button
|
||||
colorScheme="blue"
|
||||
onClick={handleSendNewsletter}
|
||||
isLoading={sendNewsletterMutation.isLoading}
|
||||
isDisabled={!newsletterData.subject || !newsletterData.content}
|
||||
leftIcon={<EmailIcon />}
|
||||
>
|
||||
Odeslat newsletter
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
colorScheme="blue"
|
||||
onClick={async ()=>{
|
||||
try {
|
||||
await sendNewsletterDigest(sendMode as DigestType, competitions.trim() || undefined);
|
||||
toast({ title: 'Digest odeslán', status: 'success' });
|
||||
onClose();
|
||||
setPreviewHtml(''); setPreviewSubject(''); setCompetitions(''); setSendMode('custom');
|
||||
} catch (e: any) {
|
||||
toast({ title: 'Chyba při odeslání', description: e?.response?.data?.error || e?.message, status: 'error' });
|
||||
}
|
||||
}}
|
||||
leftIcon={<EmailIcon />}
|
||||
>
|
||||
Odeslat digest
|
||||
</Button>
|
||||
)}
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
|
||||
{/* Test Email Modal */}
|
||||
<Modal isOpen={testModal.isOpen} onClose={testModal.onClose} size="md">
|
||||
<ModalOverlay />
|
||||
<ModalContent maxW="90vw" maxH="90vh" overflowY="auto">
|
||||
<ModalHeader>Odeslat testovací e-mail</ModalHeader>
|
||||
<ModalCloseButton />
|
||||
<ModalBody>
|
||||
<VStack spacing={4} align="stretch">
|
||||
<Text color="gray.600">
|
||||
Testovací e-mail bude odeslán na zadanou adresu. Pokud pole necháte prázdné, odešleme na výchozí administrátorský e-mail.
|
||||
</Text>
|
||||
<FormControl>
|
||||
<FormLabel>E-mail příjemce (volitelně)</FormLabel>
|
||||
<Input
|
||||
type="email"
|
||||
placeholder="např. admin@priklad.cz"
|
||||
value={testEmail}
|
||||
onChange={(e) => setTestEmail(e.target.value)}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormControl>
|
||||
<FormLabel>Více e-mailů (oddělené čárkou)</FormLabel>
|
||||
<Input
|
||||
placeholder="user1@priklad.cz, user2@priklad.cz"
|
||||
value={testEmails}
|
||||
onChange={(e) => setTestEmails(e.target.value)}
|
||||
/>
|
||||
<FormHelperText>Pokud vyplníte, použije se toto pole přednostně.</FormHelperText>
|
||||
</FormControl>
|
||||
<FormControl>
|
||||
<FormLabel>Typ testu</FormLabel>
|
||||
<select value={testType} onChange={(e) => setTestType(e.target.value as NewsletterTestType)}>
|
||||
<option value="newsletter">Newsletter</option>
|
||||
<option value="welcome">Uvítací</option>
|
||||
<option value="welcome_back">Uvítací (návrat)</option>
|
||||
<option value="blogs">Blogy (digest)</option>
|
||||
<option value="events">Události (digest)</option>
|
||||
<option value="matches">Zápasy (digest)</option>
|
||||
<option value="scores">Výsledky (digest)</option>
|
||||
<option value="weekly">Týdenní přehled</option>
|
||||
</select>
|
||||
</FormControl>
|
||||
</VStack>
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<Button variant="ghost" mr={3} onClick={testModal.onClose}>
|
||||
Zavřít
|
||||
</Button>
|
||||
<Button colorScheme="blue" onClick={() => sendTestMutation.mutate()} isLoading={sendTestMutation.isLoading}>
|
||||
Odeslat test
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
|
||||
{/* Preferences Modal */}
|
||||
<Modal isOpen={prefsModalOpen} onClose={() => setPrefsModalOpen(false)} size="md">
|
||||
<ModalOverlay />
|
||||
<ModalContent>
|
||||
<ModalHeader>Upravit preference</ModalHeader>
|
||||
<ModalCloseButton />
|
||||
<ModalBody>
|
||||
<VStack spacing={4} align="stretch">
|
||||
<FormControl>
|
||||
<FormLabel>Týdenní přehled</FormLabel>
|
||||
<Switch isChecked={!!editingPrefs['weekly']} onChange={(e) => setEditingPrefs({ ...editingPrefs, weekly: e.target.checked })} />
|
||||
</FormControl>
|
||||
<FormControl>
|
||||
<FormLabel>Nadcházející zápasy</FormLabel>
|
||||
<Switch isChecked={!!editingPrefs['matches']} onChange={(e) => setEditingPrefs({ ...editingPrefs, matches: e.target.checked })} />
|
||||
</FormControl>
|
||||
<FormControl>
|
||||
<FormLabel>Blog</FormLabel>
|
||||
<Switch isChecked={!!editingPrefs['blogs']} onChange={(e) => setEditingPrefs({ ...editingPrefs, blogs: e.target.checked })} />
|
||||
</FormControl>
|
||||
</VStack>
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<Button variant="ghost" mr={3} onClick={() => setPrefsModalOpen(false)}>Zrušit</Button>
|
||||
<Button
|
||||
colorScheme="blue"
|
||||
onClick={() =>
|
||||
editingSubscriber
|
||||
? updatePrefsMutation.mutate({ id: editingSubscriber.id, prefs: editingPrefs })
|
||||
: undefined
|
||||
}
|
||||
isLoading={updatePrefsMutation.isLoading}
|
||||
isDisabled={!editingSubscriber}
|
||||
>
|
||||
Uložit
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
</AdminLayout>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,585 @@
|
||||
import React, { useState, useRef, useEffect, useMemo } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
FormControl,
|
||||
FormLabel,
|
||||
Heading,
|
||||
HStack,
|
||||
IconButton,
|
||||
Image,
|
||||
Input,
|
||||
Modal,
|
||||
ModalBody,
|
||||
ModalCloseButton,
|
||||
ModalContent,
|
||||
ModalFooter,
|
||||
ModalHeader,
|
||||
ModalOverlay,
|
||||
Select,
|
||||
Spinner,
|
||||
Switch,
|
||||
Table,
|
||||
Tbody,
|
||||
Td,
|
||||
Textarea,
|
||||
Th,
|
||||
Thead,
|
||||
Tr,
|
||||
useColorModeValue,
|
||||
useDisclosure,
|
||||
useToast,
|
||||
VStack,
|
||||
SimpleGrid,
|
||||
NumberInput,
|
||||
NumberInputField,
|
||||
} from '@chakra-ui/react';
|
||||
import { FiEdit2, FiTrash2, FiPlus, FiUpload } from 'react-icons/fi';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import AdminLayout from '../../layouts/AdminLayout';
|
||||
import { Player, getPlayers, createPlayer, updatePlayer, deletePlayer } from '../../services/players';
|
||||
import { uploadFile } from '../../services/articles';
|
||||
import { translateNationality } from '../../utils/nationality';
|
||||
|
||||
type Editing = Partial<Player> & { id?: number };
|
||||
|
||||
const PlayersAdminPage: React.FC = () => {
|
||||
const cardBg = useColorModeValue('white', 'gray.800');
|
||||
const borderColor = useColorModeValue('gray.200', 'gray.700');
|
||||
const inputBg = useColorModeValue('white', 'gray.700');
|
||||
const toast = useToast();
|
||||
const normalizeImageUrl = (url?: string) => {
|
||||
if (!url || url === '') return '/logo192.png';
|
||||
// If it's already absolute, return as-is
|
||||
if (/^https?:\/\//i.test(url)) return url;
|
||||
// If it's an uploads path, prefix with API origin
|
||||
const apiUrl = process.env.REACT_APP_API_URL || 'http://localhost:8080/api/v1';
|
||||
const origin = new URL(apiUrl).origin;
|
||||
if (url.startsWith('/uploads/')) return `${origin}${url}`;
|
||||
// Fallback: treat as relative to origin
|
||||
return `${origin}${url.startsWith('/') ? '' : '/'}${url}`;
|
||||
};
|
||||
|
||||
// Hoisted helper: convert country code to flag emoji
|
||||
function countryCodeToEmoji(cc: string) {
|
||||
if (!cc) return '';
|
||||
return cc.toUpperCase().replace(/./g, (char) => String.fromCodePoint(127397 + char.charCodeAt(0)));
|
||||
}
|
||||
|
||||
// Countries for nationality dropdown
|
||||
const [countries, setCountries] = useState<Array<{ name: string; cca2: string; emoji: string }>>([]);
|
||||
// Search query for filtering nationality dropdown
|
||||
const [countryQuery, setCountryQuery] = useState<string>("");
|
||||
const countrySearchRef = useRef<HTMLInputElement | null>(null);
|
||||
const countrySelectRef = useRef<HTMLSelectElement | null>(null);
|
||||
// Simple fuzzy match scoring: higher is better
|
||||
function fuzzyScore(text: string, query: string): number {
|
||||
if (!query) return 0;
|
||||
const t = text.toLowerCase();
|
||||
const q = query.toLowerCase();
|
||||
// Exact and prefix bonuses
|
||||
if (t === q) return 1000;
|
||||
if (t.startsWith(q)) return 800 - (t.length - q.length);
|
||||
// Subsequence score
|
||||
let ti = 0, qi = 0, score = 0, streak = 0;
|
||||
while (ti < t.length && qi < q.length) {
|
||||
if (t[ti] === q[qi]) {
|
||||
streak += 1;
|
||||
score += 10 + streak; // reward consecutive matches
|
||||
qi++;
|
||||
} else {
|
||||
streak = 0;
|
||||
}
|
||||
ti++;
|
||||
}
|
||||
// Small penalty for distance
|
||||
if (qi === q.length) {
|
||||
return score - Math.abs(t.length - q.length);
|
||||
}
|
||||
return -Infinity; // not all chars matched in order
|
||||
}
|
||||
|
||||
const filteredCountries = useMemo(() => {
|
||||
const q = countryQuery.trim();
|
||||
if (!q) return countries;
|
||||
// Score by name and cca2 and take the better score
|
||||
const scored = countries
|
||||
.map((c) => ({
|
||||
c,
|
||||
s: Math.max(
|
||||
fuzzyScore(c.name, q),
|
||||
fuzzyScore(c.cca2, q)
|
||||
),
|
||||
}))
|
||||
.filter((x) => x.s > -Infinity)
|
||||
.sort((a, b) => b.s - a.s || a.c.name.localeCompare(b.c.name, 'cs', { sensitivity: 'base' }))
|
||||
.map((x) => x.c);
|
||||
return scored;
|
||||
}, [countries, countryQuery]);
|
||||
useEffect(() => {
|
||||
let mounted = true;
|
||||
(async () => {
|
||||
try {
|
||||
// Try v3.1 API first
|
||||
let json: any[] | null = null;
|
||||
const tryUrls = [
|
||||
'https://restcountries.com/v3.1/all?fields=name,cca2',
|
||||
// fallback: v2 API
|
||||
'https://restcountries.com/v2/all?fields=name,alpha2Code',
|
||||
// minimal fallback without fields (may be heavier, but used only if above fail)
|
||||
'https://restcountries.com/v3.1/all',
|
||||
];
|
||||
let lastErr: any = null;
|
||||
for (const url of tryUrls) {
|
||||
try {
|
||||
const res = await fetch(url);
|
||||
if (!res.ok) throw new Error(`countries ${res.status}`);
|
||||
const data = await res.json();
|
||||
if (Array.isArray(data)) { json = data; break; }
|
||||
} catch (e) {
|
||||
lastErr = e;
|
||||
}
|
||||
}
|
||||
if (!mounted) return;
|
||||
if (!json) throw lastErr || new Error('countries fetch failed');
|
||||
const list = (json || []).map((c: any) => {
|
||||
const name = c?.name?.common || c?.name || '';
|
||||
const cca2 = c?.cca2 || c?.alpha2Code || '';
|
||||
return {
|
||||
name,
|
||||
cca2,
|
||||
emoji: countryCodeToEmoji(cca2),
|
||||
};
|
||||
}).filter((c: any) => c.name && c.cca2).sort((a: any, b: any) => a.name.localeCompare(b.name, 'cs', { sensitivity: 'base' }));
|
||||
setCountries(list);
|
||||
} catch (err) {
|
||||
// Hard fallback list for most common nationalities used locally
|
||||
const fallback = [
|
||||
{ name: 'Czechia', cca2: 'CZ', emoji: countryCodeToEmoji('CZ') },
|
||||
{ name: 'Slovakia', cca2: 'SK', emoji: countryCodeToEmoji('SK') },
|
||||
{ name: 'Poland', cca2: 'PL', emoji: countryCodeToEmoji('PL') },
|
||||
{ name: 'Germany', cca2: 'DE', emoji: countryCodeToEmoji('DE') },
|
||||
{ name: 'Austria', cca2: 'AT', emoji: countryCodeToEmoji('AT') },
|
||||
{ name: 'Ukraine', cca2: 'UA', emoji: countryCodeToEmoji('UA') },
|
||||
{ name: 'France', cca2: 'FR', emoji: countryCodeToEmoji('FR') },
|
||||
{ name: 'Spain', cca2: 'ES', emoji: countryCodeToEmoji('ES') },
|
||||
{ name: 'Italy', cca2: 'IT', emoji: countryCodeToEmoji('IT') },
|
||||
{ name: 'England', cca2: 'GB', emoji: countryCodeToEmoji('GB') },
|
||||
];
|
||||
setCountries(fallback);
|
||||
}
|
||||
})();
|
||||
return () => { mounted = false; };
|
||||
}, []);
|
||||
|
||||
// Persist countryQuery across sessions
|
||||
useEffect(() => {
|
||||
try {
|
||||
const saved = localStorage.getItem('playersAdmin.countryQuery');
|
||||
if (saved) setCountryQuery(saved);
|
||||
} catch {}
|
||||
}, []);
|
||||
useEffect(() => {
|
||||
try {
|
||||
localStorage.setItem('playersAdmin.countryQuery', countryQuery);
|
||||
} catch {}
|
||||
}, [countryQuery]);
|
||||
|
||||
// Helper: compress image client-side, resize to max dimension and convert to JPEG to reduce size
|
||||
const compressAndUpload = async (file: File) => {
|
||||
const img = await readFileAsImage(file);
|
||||
const maxDim = 1400;
|
||||
let { width, height } = img;
|
||||
if (width > maxDim || height > maxDim) {
|
||||
const ratio = Math.min(maxDim / width, maxDim / height);
|
||||
width = Math.round(width * ratio);
|
||||
height = Math.round(height * ratio);
|
||||
}
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = width;
|
||||
canvas.height = height;
|
||||
const ctx = canvas.getContext('2d')!;
|
||||
ctx.drawImage(img, 0, 0, width, height);
|
||||
// Use JPEG for good compression; backend can accept jpeg (serving as png not mandatory)
|
||||
const blob: Blob | null = await new Promise((resolve) => canvas.toBlob(resolve as any, 'image/jpeg', 0.78));
|
||||
if (!blob) throw new Error('compression failed');
|
||||
const f = new File([blob], file.name.replace(/\.[^/.]+$/, '') + '.jpg', { type: 'image/jpeg' });
|
||||
return await uploadFile(f);
|
||||
};
|
||||
|
||||
// Hoisted helper: read file into HTMLImageElement
|
||||
function readFileAsImage(file: File) {
|
||||
return new Promise<HTMLImageElement>((resolve, reject) => {
|
||||
const fr = new FileReader();
|
||||
fr.onload = () => {
|
||||
const img = new window.Image();
|
||||
img.onload = () => resolve(img);
|
||||
img.onerror = reject;
|
||||
img.src = fr.result as string;
|
||||
};
|
||||
fr.onerror = reject;
|
||||
fr.readAsDataURL(file);
|
||||
});
|
||||
}
|
||||
|
||||
const qc = useQueryClient();
|
||||
const { data, isLoading } = useQuery({ queryKey: ['admin-players'], queryFn: getPlayers });
|
||||
|
||||
const [editing, setEditing] = useState<Editing | null>(null);
|
||||
const { isOpen, onOpen, onClose } = useDisclosure();
|
||||
|
||||
// Local state to persist partial DOB selections so the user sees what they picked
|
||||
const [dobParts, setDobParts] = useState<{ day: string; month: string; year: string }>({ day: '', month: '', year: '' });
|
||||
|
||||
const openCreate = () => { setEditing({ first_name: '', last_name: '', is_active: true, email: '', phone: '' } as any); setDobFromDateStr(''); onOpen(); };
|
||||
const openEdit = (p: Player) => { setEditing({ ...p }); setDobFromDateStr(p.date_of_birth || ''); onOpen(); };
|
||||
const closeModal = () => { setEditing(null); onClose(); };
|
||||
|
||||
const createMut = useMutation({
|
||||
mutationFn: (payload: any) => createPlayer(payload),
|
||||
onSuccess: (created: any) => {
|
||||
try {
|
||||
qc.setQueryData(['admin-players'], (old: any) => {
|
||||
const list = Array.isArray(old) ? old : (old?.data || []);
|
||||
const newList = [created, ...list];
|
||||
if (old && old.data) return { ...old, data: newList };
|
||||
return newList;
|
||||
});
|
||||
} catch (e) {}
|
||||
toast({ title: 'Hráč vytvořen', status: 'success' });
|
||||
qc.invalidateQueries({ queryKey: ['admin-players'] });
|
||||
closeModal();
|
||||
},
|
||||
onError: (e: any) => {
|
||||
const status = e?.response?.status;
|
||||
const msg = e?.response?.data?.chyba || e?.response?.data?.error || e?.message || 'Chyba';
|
||||
toast({ title: 'Vytvoření selhalo', description: status ? `HTTP ${status}: ${msg}` : msg, status: 'error' });
|
||||
},
|
||||
});
|
||||
const updateMut = useMutation({
|
||||
mutationFn: ({ id, payload }: { id: number | string; payload: any }) => updatePlayer(id, payload),
|
||||
onSuccess: () => { toast({ title: 'Hráč upraven', status: 'success' }); qc.invalidateQueries({ queryKey: ['admin-players'] }); closeModal(); },
|
||||
onError: (e: any) => {
|
||||
const status = e?.response?.status;
|
||||
const msg = e?.response?.data?.chyba || e?.response?.data?.error || e?.message || 'Chyba';
|
||||
toast({ title: 'Aktualizace selhala', description: status ? `HTTP ${status}: ${msg}` : msg, status: 'error' });
|
||||
},
|
||||
});
|
||||
const deleteMut = useMutation({
|
||||
mutationFn: (id: number) => deletePlayer(id),
|
||||
onSuccess: () => { toast({ title: 'Hráč smazán', status: 'success' }); qc.invalidateQueries({ queryKey: ['admin-players'] }); },
|
||||
onError: (e: any) => {
|
||||
const status = e?.response?.status;
|
||||
const msg = e?.response?.data?.chyba || e?.response?.data?.error || e?.message || 'Chyba';
|
||||
toast({ title: 'Smazání selhalo', description: status ? `HTTP ${status}: ${msg}` : msg, status: 'error' });
|
||||
},
|
||||
});
|
||||
|
||||
const onSubmit = async () => {
|
||||
if (!editing) return;
|
||||
const fn = (editing.first_name || '').trim();
|
||||
const ln = (editing.last_name || '').trim();
|
||||
if (!fn || !ln) {
|
||||
toast({ title: 'Jméno a příjmení jsou povinné', status: 'warning' });
|
||||
return;
|
||||
}
|
||||
// Build payload by including only present values to satisfy backend validation
|
||||
const payload: any = {
|
||||
first_name: fn,
|
||||
last_name: ln,
|
||||
};
|
||||
if (editing.date_of_birth) payload.date_of_birth = editing.date_of_birth;
|
||||
if (editing.position) payload.position = editing.position;
|
||||
if (typeof editing.jersey_number === 'number' && Number.isFinite(editing.jersey_number) && editing.jersey_number > 0) payload.jersey_number = editing.jersey_number;
|
||||
if (editing.nationality) payload.nationality = editing.nationality;
|
||||
if (typeof editing.height === 'number' && editing.height > 0) payload.height = editing.height;
|
||||
if (typeof editing.weight === 'number' && editing.weight > 0) payload.weight = editing.weight;
|
||||
if (editing.image_url) payload.image_url = editing.image_url;
|
||||
if (typeof editing.is_active === 'boolean') payload.is_active = editing.is_active;
|
||||
const email = ((editing as any).email || '').trim();
|
||||
const phone = ((editing as any).phone || '').trim();
|
||||
if (email) payload.email = email;
|
||||
if (phone) payload.phone = phone;
|
||||
try {
|
||||
if ((editing as any).id != null) {
|
||||
await updateMut.mutateAsync({ id: (editing as any).id, payload });
|
||||
} else {
|
||||
await createMut.mutateAsync(payload);
|
||||
}
|
||||
} catch (err) {
|
||||
// handled by mutation
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<AdminLayout requireAdmin={false}>
|
||||
<Heading size="lg" mb={4}>Hráči</Heading>
|
||||
|
||||
<HStack mb={4}>
|
||||
<Button leftIcon={<FiPlus />} colorScheme="blue" onClick={openCreate}>Nový hráč</Button>
|
||||
</HStack>
|
||||
|
||||
<Box bg={cardBg} borderWidth="1px" borderRadius="md" overflowX="auto">
|
||||
<Table size="sm">
|
||||
<Thead>
|
||||
<Tr>
|
||||
<Th w="80px">Fotka</Th>
|
||||
<Th>Jméno</Th>
|
||||
<Th>Pozice</Th>
|
||||
<Th>Národnost</Th>
|
||||
<Th w="120px">Číslo</Th>
|
||||
<Th w="120px">Aktivní</Th>
|
||||
<Th w="160px">Akce</Th>
|
||||
</Tr>
|
||||
</Thead>
|
||||
<Tbody>
|
||||
{isLoading && (<Tr><Td colSpan={7}>Načítám...</Td></Tr>)}
|
||||
{!isLoading && (data || []).map((p) => (
|
||||
<Tr key={p.id}>
|
||||
<Td>
|
||||
<Image src={normalizeImageUrl(p.image_url)} alt={p.first_name} boxSize="48px" objectFit="cover" borderRadius="md" />
|
||||
</Td>
|
||||
<Td>{p.first_name} {p.last_name}</Td>
|
||||
<Td>{p.position || '-'}</Td>
|
||||
<Td>{p.nationality ? translateNationality(p.nationality) : '-'}</Td>
|
||||
<Td>{p.jersey_number ?? '-'}</Td>
|
||||
<Td><Switch isChecked={!!p.is_active} onChange={() => { if (p.id != null) updateMut.mutate({ id: p.id, payload: { is_active: !p.is_active } }); }} /></Td>
|
||||
<Td>
|
||||
<HStack>
|
||||
<IconButton aria-label="Upravit" size="sm" icon={<FiEdit2 />} onClick={() => openEdit(p)} />
|
||||
<IconButton aria-label="Smazat" size="sm" colorScheme="red" icon={<FiTrash2 />} onClick={async () => { if (!confirm('Opravdu smazat záznam?')) return; if (p.id != null) await deleteMut.mutateAsync(p.id); }} />
|
||||
</HStack>
|
||||
</Td>
|
||||
</Tr>
|
||||
))}
|
||||
</Tbody>
|
||||
</Table>
|
||||
</Box>
|
||||
|
||||
<Modal isOpen={isOpen} onClose={closeModal} size="lg">
|
||||
<ModalOverlay />
|
||||
<ModalContent maxW="90vw" maxH="90vh" overflowY="auto">
|
||||
<ModalHeader>{(editing as any)?.id ? 'Upravit hráče' : 'Nový hráč'}</ModalHeader>
|
||||
<ModalCloseButton />
|
||||
<ModalBody>
|
||||
<VStack align="stretch" spacing={4}>
|
||||
<SimpleGrid columns={[1, 2]} spacing={4}>
|
||||
<FormControl isRequired>
|
||||
<FormLabel>Jméno</FormLabel>
|
||||
<Input value={editing?.first_name || ''} onChange={(e) => setEditing((p) => ({ ...(p as any), first_name: e.target.value }))} />
|
||||
</FormControl>
|
||||
<FormControl isRequired>
|
||||
<FormLabel>Příjmení</FormLabel>
|
||||
<Input value={editing?.last_name || ''} onChange={(e) => setEditing((p) => ({ ...(p as any), last_name: e.target.value }))} />
|
||||
</FormControl>
|
||||
|
||||
{/* Custom DOB picker: day / month / year (timezone-safe) */}
|
||||
<FormControl>
|
||||
<FormLabel>Datum narození</FormLabel>
|
||||
<HStack>
|
||||
<Select value={dobParts.day} onChange={(e) => updateDobPart('day', e.target.value)}>
|
||||
<option value="">Den</option>
|
||||
{Array.from({ length: 31 }).map((_, i) => <option key={i+1} value={(i+1).toString()}>{i+1}</option>)}
|
||||
</Select>
|
||||
<Select value={dobParts.month} onChange={(e) => updateDobPart('month', e.target.value)}>
|
||||
<option value="">Měsíc</option>
|
||||
{Array.from({ length: 12 }).map((_, i) => <option key={i+1} value={(i+1).toString()}>{i+1}</option>)}
|
||||
</Select>
|
||||
<Select value={dobParts.year} onChange={(e) => updateDobPart('year', e.target.value)}>
|
||||
<option value="">Rok</option>
|
||||
{Array.from({ length: 80 }).map((_, i) => { const y = new Date().getFullYear() - i; return <option key={y} value={String(y)}>{y}</option>; })}
|
||||
</Select>
|
||||
</HStack>
|
||||
<Box mt={2} fontSize="sm" color="gray.500">
|
||||
{formatDobPreview(dobParts)}
|
||||
</Box>
|
||||
</FormControl>
|
||||
|
||||
<FormControl>
|
||||
<FormLabel>Pozice</FormLabel>
|
||||
<Select value={editing?.position || ''} onChange={(e) => setEditing((p) => ({ ...(p as any), position: e.target.value }))}>
|
||||
<option value="">— vyberte pozici —</option>
|
||||
<option value="Brankář">Brankář</option>
|
||||
<option value="Obránce">Obránce</option>
|
||||
<option value="Záložník">Záložník</option>
|
||||
<option value="Útočník">Útočník</option>
|
||||
<option value="Univerzál">Univerzál</option>
|
||||
</Select>
|
||||
</FormControl>
|
||||
|
||||
<FormControl>
|
||||
<FormLabel>Číslo dresu</FormLabel>
|
||||
<NumberInput value={editing?.jersey_number ?? 0} onChange={(_, v) => setEditing((p) => ({ ...(p as any), jersey_number: Number.isFinite(v) ? v : 0 }))}>
|
||||
<NumberInputField />
|
||||
</NumberInput>
|
||||
</FormControl>
|
||||
|
||||
<FormControl>
|
||||
<FormLabel>Národnost</FormLabel>
|
||||
<VStack align="stretch" spacing={2}>
|
||||
<Input
|
||||
placeholder="Hledat zemi (např. cz, czechia)…"
|
||||
value={countryQuery}
|
||||
onChange={(e) => setCountryQuery(e.target.value)}
|
||||
ref={countrySearchRef}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'ArrowDown') {
|
||||
e.preventDefault();
|
||||
countrySelectRef.current?.focus();
|
||||
} else if (e.key === 'Escape') {
|
||||
setCountryQuery('');
|
||||
} else if (e.key === 'Enter') {
|
||||
if (filteredCountries.length === 1) {
|
||||
const only = filteredCountries[0];
|
||||
setEditing((p) => ({ ...(p as any), nationality: only.name }));
|
||||
countrySelectRef.current?.focus();
|
||||
}
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Select
|
||||
placeholder="— vyberte zemi —"
|
||||
value={editing?.nationality || ''}
|
||||
onChange={(e) => setEditing((p) => ({ ...(p as any), nationality: e.target.value }))}
|
||||
ref={countrySelectRef}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'ArrowUp' && (e.target as HTMLSelectElement).selectedIndex === 0) {
|
||||
// Move back to search
|
||||
e.preventDefault();
|
||||
countrySearchRef.current?.focus();
|
||||
}
|
||||
}}
|
||||
>
|
||||
{filteredCountries.map((c) => (
|
||||
<option key={c.cca2} value={c.name}>
|
||||
{c.emoji} {translateNationality(c.name)} ({c.name})
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
</VStack>
|
||||
</FormControl>
|
||||
|
||||
<FormControl>
|
||||
<FormLabel>Výška (cm)</FormLabel>
|
||||
<NumberInput value={editing?.height ?? 0} onChange={(_, v) => setEditing((p) => ({ ...(p as any), height: Number.isFinite(v) ? v : 0 }))}>
|
||||
<NumberInputField />
|
||||
</NumberInput>
|
||||
</FormControl>
|
||||
<FormControl>
|
||||
<FormLabel>Váha (kg)</FormLabel>
|
||||
<NumberInput value={editing?.weight ?? 0} onChange={(_, v) => setEditing((p) => ({ ...(p as any), weight: Number.isFinite(v) ? v : 0 }))}>
|
||||
<NumberInputField />
|
||||
</NumberInput>
|
||||
</FormControl>
|
||||
{/* Optional contact info (not shown publicly) */}
|
||||
<FormControl>
|
||||
<FormLabel>Email (nepovinné)</FormLabel>
|
||||
<Input type="email" value={(editing as any)?.email || ''} onChange={(e) => setEditing((p) => ({ ...(p as any), email: e.target.value }))} />
|
||||
</FormControl>
|
||||
<FormControl>
|
||||
<FormLabel>Telefon (nepovinné)</FormLabel>
|
||||
<Input type="tel" value={(editing as any)?.phone || ''} onChange={(e) => setEditing((p) => ({ ...(p as any), phone: e.target.value }))} />
|
||||
</FormControl>
|
||||
</SimpleGrid>
|
||||
|
||||
<FormControl>
|
||||
<FormLabel>Fotka</FormLabel>
|
||||
<HStack>
|
||||
<Image src={normalizeImageUrl(editing?.image_url)} alt="photo" boxSize="56px" objectFit="cover" borderRadius="md" />
|
||||
<Button as="label" type="button" leftIcon={<FiUpload />}>Nahrát
|
||||
<Input
|
||||
type="file"
|
||||
display="none"
|
||||
accept="image/*,image/svg+xml"
|
||||
onChange={async (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const f = e.target.files?.[0];
|
||||
if (!f) return;
|
||||
try {
|
||||
const res = f.type === 'image/svg+xml' ? await uploadFile(f) : await compressAndUpload(f);
|
||||
// ensure stored url is relative or absolute path expected by API
|
||||
setEditing((p) => ({ ...(p as any), image_url: res.url }));
|
||||
// reset the input so the same file can be selected again if needed
|
||||
(e.target as HTMLInputElement).value = '';
|
||||
} catch (err) {
|
||||
toast({ title: 'Nahrání selhalo', status: 'error' });
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Button>
|
||||
</HStack>
|
||||
</FormControl>
|
||||
|
||||
<FormControl display="flex" alignItems="center">
|
||||
<FormLabel mb="0">Aktivní</FormLabel>
|
||||
<Switch isChecked={!!editing?.is_active} onChange={(e) => setEditing((p) => ({ ...(p as any), is_active: e.target.checked }))} />
|
||||
</FormControl>
|
||||
</VStack>
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<Button variant="ghost" mr={3} onClick={closeModal}>Zrušit</Button>
|
||||
<Button colorScheme="blue" onClick={onSubmit} isLoading={createMut.isLoading || updateMut.isLoading}>Uložit</Button>
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
</AdminLayout>
|
||||
);
|
||||
|
||||
// Helpers for DOB to avoid timezone shifts
|
||||
function parseYmd(s?: string | null): { y: number; m: number; d: number } | null {
|
||||
if (!s) return null;
|
||||
// Accept plain date 'YYYY-MM-DD' or ISO datetime starting with that date
|
||||
const m = String(s).match(/^(\d{4})-(\d{2})-(\d{2})(?:$|T)/);
|
||||
if (!m) return null;
|
||||
const y = Number(m[1]);
|
||||
const mm = Number(m[2]);
|
||||
const dd = Number(m[3]);
|
||||
if (!y || !mm || !dd) return null;
|
||||
return { y, m: mm, d: dd };
|
||||
}
|
||||
|
||||
function setDobFromDateStr(s?: string) {
|
||||
const p = parseYmd(s || null);
|
||||
if (!p) { setDobParts({ day: '', month: '', year: '' }); return; }
|
||||
setDobParts({ day: String(p.d), month: String(p.m), year: String(p.y) });
|
||||
}
|
||||
|
||||
function formatDobPreview(parts: { day: string; month: string; year: string }): string {
|
||||
const dd = parts.day ? parts.day.toString().padStart(2, '0') : '__';
|
||||
const mm = parts.month ? parts.month.toString().padStart(2, '0') : '__';
|
||||
const yyyy = parts.year || '____';
|
||||
return `${dd}.${mm}.${yyyy}`;
|
||||
}
|
||||
|
||||
// Update DOB parts and, when complete, compose YYYY-MM-DD. Clamp day to month length.
|
||||
function updateDobPart(part: 'day'|'month'|'year', value: string) {
|
||||
setDobParts((prev) => {
|
||||
let next = { ...prev, [part]: value } as { day: string; month: string; year: string };
|
||||
// Clamp day if month/year present
|
||||
const y = Number(next.year || '0');
|
||||
const m = Number(next.month || '0');
|
||||
const d = Number(next.day || '0');
|
||||
if (y && m && d) {
|
||||
const maxDay = new Date(y, m, 0).getDate();
|
||||
if (d > maxDay) {
|
||||
next.day = String(maxDay);
|
||||
}
|
||||
}
|
||||
// Apply to editing only when all three are set
|
||||
const yy = Number(next.year || '0');
|
||||
const mm = Number(next.month || '0');
|
||||
const dd = Number(next.day || '0');
|
||||
if (yy && mm && dd) {
|
||||
const ymd = `${yy}-${String(mm).padStart(2, '0')}-${String(dd).padStart(2, '0')}`;
|
||||
setEditing((p) => ({ ...(p as any), date_of_birth: ymd }));
|
||||
} else {
|
||||
setEditing((p) => ({ ...(p as any), date_of_birth: '' }));
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
};
|
||||
|
||||
export default PlayersAdminPage;
|
||||
@@ -0,0 +1,883 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
Container,
|
||||
Heading,
|
||||
HStack,
|
||||
VStack,
|
||||
Table,
|
||||
Thead,
|
||||
Tbody,
|
||||
Tr,
|
||||
Th,
|
||||
Td,
|
||||
Badge,
|
||||
IconButton,
|
||||
useDisclosure,
|
||||
Modal,
|
||||
ModalOverlay,
|
||||
ModalContent,
|
||||
ModalHeader,
|
||||
ModalFooter,
|
||||
ModalBody,
|
||||
ModalCloseButton,
|
||||
FormControl,
|
||||
FormLabel,
|
||||
Input,
|
||||
Textarea,
|
||||
Select,
|
||||
Switch,
|
||||
useToast,
|
||||
Spinner,
|
||||
Text,
|
||||
Alert,
|
||||
AlertIcon,
|
||||
Tabs,
|
||||
TabList,
|
||||
TabPanels,
|
||||
Tab,
|
||||
TabPanel,
|
||||
NumberInput,
|
||||
NumberInputField,
|
||||
NumberInputStepper,
|
||||
NumberIncrementStepper,
|
||||
NumberDecrementStepper,
|
||||
Menu,
|
||||
MenuButton,
|
||||
MenuList,
|
||||
MenuItem,
|
||||
Stat,
|
||||
StatLabel,
|
||||
StatNumber,
|
||||
StatHelpText,
|
||||
SimpleGrid,
|
||||
Card,
|
||||
CardBody,
|
||||
Image,
|
||||
AspectRatio,
|
||||
} from '@chakra-ui/react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { AddIcon, EditIcon, DeleteIcon, ChevronDownIcon, ViewIcon, CheckIcon, CloseIcon } from '@chakra-ui/icons';
|
||||
import AdminLayout from '../../layouts/AdminLayout';
|
||||
import {
|
||||
getAdminPolls,
|
||||
createPoll,
|
||||
updatePoll,
|
||||
deletePoll,
|
||||
getPollStats,
|
||||
Poll,
|
||||
CreatePollRequest,
|
||||
UpdatePollRequest,
|
||||
PollStats,
|
||||
} from '../../services/polls';
|
||||
import { getCachedYouTube, YouTubeVideo } from '../../services/youtube';
|
||||
|
||||
const PollsAdminPage: React.FC = () => {
|
||||
const toast = useToast();
|
||||
const queryClient = useQueryClient();
|
||||
const { isOpen, onOpen, onClose } = useDisclosure();
|
||||
const { isOpen: isStatsOpen, onOpen: onStatsOpen, onClose: onStatsClose } = useDisclosure();
|
||||
|
||||
const [statusFilter, setStatusFilter] = useState<string>('');
|
||||
const [editingPoll, setEditingPoll] = useState<Poll | null>(null);
|
||||
const [selectedPollStats, setSelectedPollStats] = useState<PollStats | null>(null);
|
||||
|
||||
// Video selector state
|
||||
const [clubVideos, setClubVideos] = useState<YouTubeVideo[]>([]);
|
||||
const [loadingVideos, setLoadingVideos] = useState(false);
|
||||
|
||||
// Form state
|
||||
const [formData, setFormData] = useState<CreatePollRequest>({
|
||||
title: '',
|
||||
description: '',
|
||||
type: 'single',
|
||||
status: 'draft',
|
||||
allow_multiple: false,
|
||||
max_choices: 1,
|
||||
show_results: 'after_vote',
|
||||
require_auth: false,
|
||||
allow_guest_vote: true,
|
||||
featured: false,
|
||||
options: [
|
||||
{ text: '', display_order: 0 },
|
||||
{ text: '', display_order: 1 },
|
||||
],
|
||||
});
|
||||
|
||||
// Fetch polls
|
||||
const { data: polls, isLoading } = useQuery({
|
||||
queryKey: ['admin-polls', statusFilter],
|
||||
queryFn: () => getAdminPolls(statusFilter ? { status: statusFilter } : undefined),
|
||||
});
|
||||
|
||||
// Create mutation
|
||||
const createMutation = useMutation({
|
||||
mutationFn: createPoll,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['admin-polls'] });
|
||||
toast({
|
||||
title: 'Anketa vytvořena',
|
||||
status: 'success',
|
||||
duration: 3000,
|
||||
});
|
||||
onClose();
|
||||
resetForm();
|
||||
},
|
||||
onError: (error: any) => {
|
||||
toast({
|
||||
title: 'Chyba',
|
||||
description: error.response?.data?.error || 'Nepodařilo se vytvořit anketu',
|
||||
status: 'error',
|
||||
duration: 5000,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
// Update mutation
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: ({ id, data }: { id: number; data: UpdatePollRequest }) =>
|
||||
updatePoll(id, data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['admin-polls'] });
|
||||
toast({
|
||||
title: 'Anketa aktualizována',
|
||||
status: 'success',
|
||||
duration: 3000,
|
||||
});
|
||||
onClose();
|
||||
resetForm();
|
||||
},
|
||||
onError: (error: any) => {
|
||||
toast({
|
||||
title: 'Chyba',
|
||||
description: error.response?.data?.error || 'Nepodařilo se aktualizovat anketu',
|
||||
status: 'error',
|
||||
duration: 5000,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
// Delete mutation
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: deletePoll,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['admin-polls'] });
|
||||
toast({
|
||||
title: 'Anketa smazána',
|
||||
status: 'success',
|
||||
duration: 3000,
|
||||
});
|
||||
},
|
||||
onError: (error: any) => {
|
||||
toast({
|
||||
title: 'Chyba',
|
||||
description: error.response?.data?.error || 'Nepodařilo se smazat anketu',
|
||||
status: 'error',
|
||||
duration: 5000,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
// Stats query
|
||||
const { data: statsData, isLoading: isLoadingStats } = useQuery({
|
||||
queryKey: ['poll-stats', selectedPollStats?.poll?.id],
|
||||
queryFn: () => getPollStats(selectedPollStats!.poll.id),
|
||||
enabled: !!selectedPollStats?.poll?.id,
|
||||
});
|
||||
|
||||
const resetForm = () => {
|
||||
setFormData({
|
||||
title: '',
|
||||
description: '',
|
||||
type: 'single',
|
||||
status: 'draft',
|
||||
allow_multiple: false,
|
||||
max_choices: 1,
|
||||
show_results: 'after_vote',
|
||||
require_auth: false,
|
||||
allow_guest_vote: true,
|
||||
featured: false,
|
||||
options: [
|
||||
{ text: '', display_order: 0 },
|
||||
{ text: '', display_order: 1 },
|
||||
],
|
||||
});
|
||||
setEditingPoll(null);
|
||||
};
|
||||
|
||||
const handleOpenCreate = () => {
|
||||
resetForm();
|
||||
onOpen();
|
||||
};
|
||||
|
||||
const handleOpenEdit = (poll: Poll) => {
|
||||
setEditingPoll(poll);
|
||||
setFormData({
|
||||
title: poll.title,
|
||||
description: poll.description,
|
||||
type: poll.type,
|
||||
status: poll.status,
|
||||
start_date: poll.start_date,
|
||||
end_date: poll.end_date,
|
||||
allow_multiple: poll.allow_multiple,
|
||||
max_choices: poll.max_choices,
|
||||
show_results: poll.show_results,
|
||||
require_auth: poll.require_auth,
|
||||
allow_guest_vote: poll.allow_guest_vote,
|
||||
featured: poll.featured,
|
||||
category_id: poll.category_id,
|
||||
related_match_id: poll.related_match_id,
|
||||
related_article_id: poll.related_article_id,
|
||||
related_event_id: poll.related_event_id,
|
||||
related_video_url: poll.related_video_url,
|
||||
image_url: poll.image_url,
|
||||
options: poll.options.map(opt => ({
|
||||
text: opt.text,
|
||||
description: opt.description,
|
||||
image_url: opt.image_url,
|
||||
display_order: opt.display_order,
|
||||
player_id: opt.player_id,
|
||||
})),
|
||||
});
|
||||
onOpen();
|
||||
};
|
||||
|
||||
const handleSave = () => {
|
||||
// Validate that all options have text
|
||||
const invalidOptions = formData.options.filter(opt => !opt.text || opt.text.trim() === '');
|
||||
if (invalidOptions.length > 0) {
|
||||
toast({
|
||||
title: 'Chyba',
|
||||
description: 'Všechny možnosti musí mít vyplněný text',
|
||||
status: 'error',
|
||||
duration: 3000,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (editingPoll) {
|
||||
updateMutation.mutate({ id: editingPoll.id, data: formData });
|
||||
} else {
|
||||
createMutation.mutate(formData);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = (id: number) => {
|
||||
if (window.confirm('Opravdu chcete smazat tuto anketu?')) {
|
||||
deleteMutation.mutate(id);
|
||||
}
|
||||
};
|
||||
|
||||
const handleViewStats = async (poll: Poll) => {
|
||||
try {
|
||||
const stats = await getPollStats(poll.id);
|
||||
setSelectedPollStats(stats);
|
||||
onStatsOpen();
|
||||
} catch (error: any) {
|
||||
toast({
|
||||
title: 'Chyba',
|
||||
description: 'Nepodařilo se načíst statistiky',
|
||||
status: 'error',
|
||||
duration: 3000,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const addOption = () => {
|
||||
setFormData({
|
||||
...formData,
|
||||
options: [
|
||||
...formData.options,
|
||||
{ text: '', display_order: formData.options.length },
|
||||
],
|
||||
});
|
||||
};
|
||||
|
||||
const removeOption = (index: number) => {
|
||||
if (formData.options.length > 2) {
|
||||
setFormData({
|
||||
...formData,
|
||||
options: formData.options.filter((_, i) => i !== index),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const updateOption = (index: number, field: string, value: any) => {
|
||||
const newOptions = [...formData.options];
|
||||
newOptions[index] = { ...newOptions[index], [field]: value };
|
||||
setFormData({ ...formData, options: newOptions });
|
||||
};
|
||||
|
||||
// Load club videos when modal opens
|
||||
useEffect(() => {
|
||||
if (isOpen && clubVideos.length === 0) {
|
||||
setLoadingVideos(true);
|
||||
getCachedYouTube()
|
||||
.then((data) => {
|
||||
if (data?.videos) {
|
||||
setClubVideos(data.videos);
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
toast({
|
||||
title: 'Chyba',
|
||||
description: 'Nepodařilo se načíst videa',
|
||||
status: 'error',
|
||||
duration: 3000,
|
||||
});
|
||||
})
|
||||
.finally(() => {
|
||||
setLoadingVideos(false);
|
||||
});
|
||||
}
|
||||
}, [isOpen, clubVideos.length, toast]);
|
||||
|
||||
const getStatusBadge = (status: string) => {
|
||||
const colorMap: Record<string, string> = {
|
||||
draft: 'gray',
|
||||
active: 'green',
|
||||
closed: 'orange',
|
||||
archived: 'red',
|
||||
};
|
||||
return <Badge colorScheme={colorMap[status] || 'gray'}>{status}</Badge>;
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<AdminLayout>
|
||||
<Container maxW="7xl" py={8}>
|
||||
<VStack spacing={4}>
|
||||
<Spinner size="xl" />
|
||||
<Text>Načítání anket...</Text>
|
||||
</VStack>
|
||||
</Container>
|
||||
</AdminLayout>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<AdminLayout>
|
||||
<Container maxW="7xl" py={8}>
|
||||
<VStack spacing={6} align="stretch">
|
||||
<HStack justify="space-between">
|
||||
<Heading size="lg">Správa anket</Heading>
|
||||
<Button leftIcon={<AddIcon />} colorScheme="blue" onClick={handleOpenCreate}>
|
||||
Nová anketa
|
||||
</Button>
|
||||
</HStack>
|
||||
|
||||
<Alert status="info">
|
||||
<AlertIcon />
|
||||
<Box>
|
||||
<Text fontWeight="bold">Ankety a hlasování</Text>
|
||||
<Text fontSize="sm">
|
||||
Vytvořte interaktivní ankety pro fanoušky - Hráč zápasu, předpověď výsledku, designové ankety a další.
|
||||
</Text>
|
||||
</Box>
|
||||
</Alert>
|
||||
|
||||
<HStack>
|
||||
<Select
|
||||
value={statusFilter}
|
||||
onChange={(e) => setStatusFilter(e.target.value)}
|
||||
maxW="200px"
|
||||
>
|
||||
<option value="">Všechny stavy</option>
|
||||
<option value="draft">Koncepty</option>
|
||||
<option value="active">Aktivní</option>
|
||||
<option value="closed">Uzavřené</option>
|
||||
<option value="archived">Archivované</option>
|
||||
</Select>
|
||||
</HStack>
|
||||
|
||||
<Box overflowX="auto">
|
||||
<Table variant="simple">
|
||||
<Thead>
|
||||
<Tr>
|
||||
<Th>Název</Th>
|
||||
<Th>Typ</Th>
|
||||
<Th>Stav</Th>
|
||||
<Th>Počet hlasů</Th>
|
||||
<Th>Vytvořeno</Th>
|
||||
<Th>Akce</Th>
|
||||
</Tr>
|
||||
</Thead>
|
||||
<Tbody>
|
||||
{polls?.map((poll) => (
|
||||
<Tr key={poll.id}>
|
||||
<Td>
|
||||
<VStack align="start" spacing={0}>
|
||||
<Text fontWeight="bold">{poll.title}</Text>
|
||||
{poll.featured && <Badge colorScheme="purple">Zvýrazněná</Badge>}
|
||||
</VStack>
|
||||
</Td>
|
||||
<Td>{poll.type}</Td>
|
||||
<Td>{getStatusBadge(poll.status)}</Td>
|
||||
<Td>{poll.total_votes}</Td>
|
||||
<Td>{new Date(poll.created_at).toLocaleDateString('cs-CZ')}</Td>
|
||||
<Td>
|
||||
<HStack spacing={2}>
|
||||
<IconButton
|
||||
aria-label="Statistiky"
|
||||
icon={<ViewIcon />}
|
||||
size="sm"
|
||||
onClick={() => handleViewStats(poll)}
|
||||
/>
|
||||
<IconButton
|
||||
aria-label="Upravit"
|
||||
icon={<EditIcon />}
|
||||
size="sm"
|
||||
onClick={() => handleOpenEdit(poll)}
|
||||
/>
|
||||
<IconButton
|
||||
aria-label="Smazat"
|
||||
icon={<DeleteIcon />}
|
||||
size="sm"
|
||||
colorScheme="red"
|
||||
onClick={() => handleDelete(poll.id)}
|
||||
/>
|
||||
</HStack>
|
||||
</Td>
|
||||
</Tr>
|
||||
))}
|
||||
</Tbody>
|
||||
</Table>
|
||||
</Box>
|
||||
|
||||
{polls?.length === 0 && (
|
||||
<Alert status="info">
|
||||
<AlertIcon />
|
||||
Zatím nemáte žádné ankety. Vytvořte první anketu pomocí tlačítka výše.
|
||||
</Alert>
|
||||
)}
|
||||
</VStack>
|
||||
|
||||
{/* Create/Edit Modal */}
|
||||
<Modal isOpen={isOpen} onClose={onClose} size="4xl">
|
||||
<ModalOverlay />
|
||||
<ModalContent>
|
||||
<ModalHeader>
|
||||
{editingPoll ? 'Upravit anketu' : 'Nová anketa'}
|
||||
</ModalHeader>
|
||||
<ModalCloseButton />
|
||||
<ModalBody>
|
||||
<Tabs>
|
||||
<TabList>
|
||||
<Tab>Základní</Tab>
|
||||
<Tab>Možnosti</Tab>
|
||||
<Tab>Nastavení</Tab>
|
||||
</TabList>
|
||||
|
||||
<TabPanels>
|
||||
{/* Basic Info Tab */}
|
||||
<TabPanel>
|
||||
<VStack spacing={4}>
|
||||
<FormControl isRequired>
|
||||
<FormLabel>Název ankety</FormLabel>
|
||||
<Input
|
||||
value={formData.title}
|
||||
onChange={(e) =>
|
||||
setFormData({ ...formData, title: e.target.value })
|
||||
}
|
||||
placeholder="Např. Hráč zápasu"
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
<FormControl>
|
||||
<FormLabel>Popis</FormLabel>
|
||||
<Textarea
|
||||
value={formData.description}
|
||||
onChange={(e) =>
|
||||
setFormData({ ...formData, description: e.target.value })
|
||||
}
|
||||
placeholder="Volitelný popis ankety"
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
<SimpleGrid columns={2} spacing={4} w="full">
|
||||
<FormControl>
|
||||
<FormLabel>Typ</FormLabel>
|
||||
<Select
|
||||
value={formData.type}
|
||||
onChange={(e) =>
|
||||
setFormData({ ...formData, type: e.target.value as any })
|
||||
}
|
||||
>
|
||||
<option value="single">Jedna volba</option>
|
||||
<option value="multiple">Více voleb</option>
|
||||
<option value="rating">Hodnocení</option>
|
||||
</Select>
|
||||
</FormControl>
|
||||
|
||||
<FormControl>
|
||||
<FormLabel>Stav</FormLabel>
|
||||
<Select
|
||||
value={formData.status}
|
||||
onChange={(e) =>
|
||||
setFormData({ ...formData, status: e.target.value as any })
|
||||
}
|
||||
>
|
||||
<option value="draft">Koncept</option>
|
||||
<option value="active">Aktivní</option>
|
||||
<option value="closed">Uzavřená</option>
|
||||
<option value="archived">Archivovaná</option>
|
||||
</Select>
|
||||
</FormControl>
|
||||
</SimpleGrid>
|
||||
|
||||
<SimpleGrid columns={2} spacing={4} w="full">
|
||||
<FormControl>
|
||||
<FormLabel>Datum zahájení</FormLabel>
|
||||
<Input
|
||||
type="datetime-local"
|
||||
value={formData.start_date || ''}
|
||||
onChange={(e) =>
|
||||
setFormData({ ...formData, start_date: e.target.value })
|
||||
}
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
<FormControl>
|
||||
<FormLabel>Datum ukončení</FormLabel>
|
||||
<Input
|
||||
type="datetime-local"
|
||||
value={formData.end_date || ''}
|
||||
onChange={(e) =>
|
||||
setFormData({ ...formData, end_date: e.target.value })
|
||||
}
|
||||
/>
|
||||
</FormControl>
|
||||
</SimpleGrid>
|
||||
|
||||
<FormControl>
|
||||
<FormLabel>Video z klubového kanálu (volitelné)</FormLabel>
|
||||
<Text fontSize="sm" color="gray.500" mb={2}>
|
||||
Připojte anketu k videu z vašeho YouTube kanálu
|
||||
</Text>
|
||||
{loadingVideos ? (
|
||||
<HStack>
|
||||
<Spinner size="sm" />
|
||||
<Text fontSize="sm">Načítání videí...</Text>
|
||||
</HStack>
|
||||
) : clubVideos.length === 0 ? (
|
||||
<Alert status="info" size="sm">
|
||||
<AlertIcon />
|
||||
<Text fontSize="sm">Žádná videa nenalezena. Přidejte videa v Nastavení → Videa.</Text>
|
||||
</Alert>
|
||||
) : (
|
||||
<VStack spacing={3} align="stretch">
|
||||
<Select
|
||||
value={formData.related_video_url || ''}
|
||||
onChange={(e) =>
|
||||
setFormData({ ...formData, related_video_url: e.target.value })
|
||||
}
|
||||
placeholder="Vyberte video (nebo nechte prázdné)"
|
||||
>
|
||||
{clubVideos.map((video) => (
|
||||
<option
|
||||
key={video.video_id}
|
||||
value={`https://www.youtube.com/watch?v=${video.video_id}`}
|
||||
>
|
||||
{video.title} {video.published_text ? `(${video.published_text})` : ''}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
{formData.related_video_url && (
|
||||
<Box borderWidth="1px" borderRadius="md" p={2}>
|
||||
<Text fontSize="sm" fontWeight="semibold" mb={2}>Náhled vybraného videa:</Text>
|
||||
{(() => {
|
||||
const selectedVideo = clubVideos.find(
|
||||
v => `https://www.youtube.com/watch?v=${v.video_id}` === formData.related_video_url
|
||||
);
|
||||
if (selectedVideo) {
|
||||
return (
|
||||
<HStack spacing={3}>
|
||||
<Image
|
||||
src={selectedVideo.thumbnail_url}
|
||||
alt={selectedVideo.title}
|
||||
boxSize="120px"
|
||||
objectFit="cover"
|
||||
borderRadius="md"
|
||||
/>
|
||||
<VStack align="start" spacing={1}>
|
||||
<Text fontSize="sm" fontWeight="medium">{selectedVideo.title}</Text>
|
||||
{selectedVideo.published_text && (
|
||||
<Badge size="sm">{selectedVideo.published_text}</Badge>
|
||||
)}
|
||||
</VStack>
|
||||
</HStack>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
})()}
|
||||
</Box>
|
||||
)}
|
||||
</VStack>
|
||||
)}
|
||||
<Text fontSize="xs" color="gray.500" mt={2}>
|
||||
Poznámka: Propojení s články a aktivitami se nastavuje přímo v editoru článku/aktivity.
|
||||
</Text>
|
||||
</FormControl>
|
||||
</VStack>
|
||||
</TabPanel>
|
||||
|
||||
{/* Options Tab */}
|
||||
<TabPanel>
|
||||
<VStack spacing={4} align="stretch">
|
||||
{formData.options.map((option, index) => (
|
||||
<Card key={index}>
|
||||
<CardBody>
|
||||
<HStack align="start">
|
||||
<VStack flex={1} spacing={3}>
|
||||
<FormControl isRequired>
|
||||
<FormLabel>Možnost {index + 1}</FormLabel>
|
||||
<Input
|
||||
value={option.text}
|
||||
onChange={(e) =>
|
||||
updateOption(index, 'text', e.target.value)
|
||||
}
|
||||
placeholder="Text možnosti"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormControl>
|
||||
<FormLabel>Popis (volitelné)</FormLabel>
|
||||
<Input
|
||||
value={option.description || ''}
|
||||
onChange={(e) =>
|
||||
updateOption(index, 'description', e.target.value)
|
||||
}
|
||||
placeholder="Doplňující informace"
|
||||
/>
|
||||
</FormControl>
|
||||
</VStack>
|
||||
{formData.options.length > 2 && (
|
||||
<IconButton
|
||||
aria-label="Odstranit možnost"
|
||||
icon={<DeleteIcon />}
|
||||
colorScheme="red"
|
||||
variant="ghost"
|
||||
onClick={() => removeOption(index)}
|
||||
/>
|
||||
)}
|
||||
</HStack>
|
||||
</CardBody>
|
||||
</Card>
|
||||
))}
|
||||
|
||||
<Button
|
||||
leftIcon={<AddIcon />}
|
||||
onClick={addOption}
|
||||
variant="outline"
|
||||
colorScheme="blue"
|
||||
>
|
||||
Přidat možnost
|
||||
</Button>
|
||||
</VStack>
|
||||
</TabPanel>
|
||||
|
||||
{/* Settings Tab */}
|
||||
<TabPanel>
|
||||
<VStack spacing={4}>
|
||||
<FormControl display="flex" alignItems="center">
|
||||
<FormLabel mb="0">Povolit více voleb</FormLabel>
|
||||
<Switch
|
||||
isChecked={formData.allow_multiple}
|
||||
onChange={(e) =>
|
||||
setFormData({ ...formData, allow_multiple: e.target.checked })
|
||||
}
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
{formData.allow_multiple && (
|
||||
<FormControl>
|
||||
<FormLabel>Max. počet voleb</FormLabel>
|
||||
<NumberInput
|
||||
value={formData.max_choices}
|
||||
min={1}
|
||||
max={formData.options.length}
|
||||
onChange={(_, num) =>
|
||||
setFormData({ ...formData, max_choices: num })
|
||||
}
|
||||
>
|
||||
<NumberInputField />
|
||||
<NumberInputStepper>
|
||||
<NumberIncrementStepper />
|
||||
<NumberDecrementStepper />
|
||||
</NumberInputStepper>
|
||||
</NumberInput>
|
||||
</FormControl>
|
||||
)}
|
||||
|
||||
<FormControl>
|
||||
<FormLabel>Zobrazení výsledků</FormLabel>
|
||||
<Select
|
||||
value={formData.show_results}
|
||||
onChange={(e) =>
|
||||
setFormData({ ...formData, show_results: e.target.value as any })
|
||||
}
|
||||
>
|
||||
<option value="always">Vždy</option>
|
||||
<option value="after_vote">Po hlasování</option>
|
||||
<option value="after_end">Po ukončení</option>
|
||||
<option value="never">Nikdy (pouze admin)</option>
|
||||
</Select>
|
||||
</FormControl>
|
||||
|
||||
<FormControl display="flex" alignItems="center">
|
||||
<FormLabel mb="0">Vyžadovat přihlášení</FormLabel>
|
||||
<Switch
|
||||
isChecked={formData.require_auth}
|
||||
onChange={(e) =>
|
||||
setFormData({ ...formData, require_auth: e.target.checked })
|
||||
}
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
<FormControl display="flex" alignItems="center">
|
||||
<FormLabel mb="0">Povolit hlasování hostů</FormLabel>
|
||||
<Switch
|
||||
isChecked={formData.allow_guest_vote}
|
||||
onChange={(e) =>
|
||||
setFormData({ ...formData, allow_guest_vote: e.target.checked })
|
||||
}
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
<FormControl display="flex" alignItems="center">
|
||||
<FormLabel mb="0">Zvýraznit na hlavní stránce</FormLabel>
|
||||
<Switch
|
||||
isChecked={formData.featured}
|
||||
onChange={(e) =>
|
||||
setFormData({ ...formData, featured: e.target.checked })
|
||||
}
|
||||
/>
|
||||
</FormControl>
|
||||
</VStack>
|
||||
</TabPanel>
|
||||
</TabPanels>
|
||||
</Tabs>
|
||||
</ModalBody>
|
||||
|
||||
<ModalFooter>
|
||||
<Button variant="ghost" mr={3} onClick={onClose}>
|
||||
Zrušit
|
||||
</Button>
|
||||
<Button
|
||||
colorScheme="blue"
|
||||
onClick={handleSave}
|
||||
isLoading={createMutation.isPending || updateMutation.isPending}
|
||||
>
|
||||
{editingPoll ? 'Uložit' : 'Vytvořit'}
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
|
||||
{/* Stats Modal */}
|
||||
<Modal isOpen={isStatsOpen} onClose={onStatsClose} size="4xl">
|
||||
<ModalOverlay />
|
||||
<ModalContent>
|
||||
<ModalHeader>Statistiky ankety</ModalHeader>
|
||||
<ModalCloseButton />
|
||||
<ModalBody>
|
||||
{isLoadingStats ? (
|
||||
<VStack py={8}>
|
||||
<Spinner size="xl" />
|
||||
<Text>Načítání statistik...</Text>
|
||||
</VStack>
|
||||
) : statsData ? (
|
||||
<VStack spacing={6} align="stretch">
|
||||
<SimpleGrid columns={3} spacing={4}>
|
||||
<Stat>
|
||||
<StatLabel>Celkem hlasů</StatLabel>
|
||||
<StatNumber>{statsData.poll.total_votes}</StatNumber>
|
||||
</Stat>
|
||||
<Stat>
|
||||
<StatLabel>Přihlášení uživatelé</StatLabel>
|
||||
<StatNumber>{statsData.authenticated_votes}</StatNumber>
|
||||
<StatHelpText>
|
||||
{statsData.poll.total_votes > 0
|
||||
? `${Math.round((statsData.authenticated_votes / statsData.poll.total_votes) * 100)}%`
|
||||
: '0%'}
|
||||
</StatHelpText>
|
||||
</Stat>
|
||||
<Stat>
|
||||
<StatLabel>Hosté</StatLabel>
|
||||
<StatNumber>{statsData.guest_votes}</StatNumber>
|
||||
<StatHelpText>
|
||||
{statsData.poll.total_votes > 0
|
||||
? `${Math.round((statsData.guest_votes / statsData.poll.total_votes) * 100)}%`
|
||||
: '0%'}
|
||||
</StatHelpText>
|
||||
</Stat>
|
||||
</SimpleGrid>
|
||||
|
||||
<Box>
|
||||
<Heading size="sm" mb={4}>
|
||||
Výsledky
|
||||
</Heading>
|
||||
<VStack spacing={2} align="stretch">
|
||||
{statsData.poll.options.map((option) => {
|
||||
const percentage =
|
||||
statsData.poll.total_votes > 0
|
||||
? (option.vote_count / statsData.poll.total_votes) * 100
|
||||
: 0;
|
||||
return (
|
||||
<Box key={option.id}>
|
||||
<HStack justify="space-between" mb={1}>
|
||||
<Text fontWeight="medium">{option.text}</Text>
|
||||
<Text fontSize="sm" color="gray.500">
|
||||
{option.vote_count} hlasů ({percentage.toFixed(1)}%)
|
||||
</Text>
|
||||
</HStack>
|
||||
<Box
|
||||
w="full"
|
||||
h="8px"
|
||||
bg="gray.200"
|
||||
borderRadius="full"
|
||||
overflow="hidden"
|
||||
>
|
||||
<Box
|
||||
h="full"
|
||||
bg="blue.500"
|
||||
w={`${percentage}%`}
|
||||
transition="width 0.3s"
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
</VStack>
|
||||
</Box>
|
||||
|
||||
{statsData.votes_by_day.length > 0 && (
|
||||
<Box>
|
||||
<Heading size="sm" mb={4}>
|
||||
Hlasy podle dnů
|
||||
</Heading>
|
||||
<VStack spacing={2} align="stretch">
|
||||
{statsData.votes_by_day.map((day) => (
|
||||
<HStack key={day.date} justify="space-between">
|
||||
<Text>{new Date(day.date).toLocaleDateString('cs-CZ')}</Text>
|
||||
<Badge>{day.count} hlasů</Badge>
|
||||
</HStack>
|
||||
))}
|
||||
</VStack>
|
||||
</Box>
|
||||
)}
|
||||
</VStack>
|
||||
) : null}
|
||||
</ModalBody>
|
||||
|
||||
<ModalFooter>
|
||||
<Button onClick={onStatsClose}>Zavřít</Button>
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
</Container>
|
||||
</AdminLayout>
|
||||
);
|
||||
};
|
||||
|
||||
export default PollsAdminPage;
|
||||
@@ -0,0 +1,254 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
Box,
|
||||
Heading,
|
||||
Text,
|
||||
Stack,
|
||||
Button,
|
||||
Stat,
|
||||
StatLabel,
|
||||
StatNumber,
|
||||
StatHelpText,
|
||||
SimpleGrid,
|
||||
useToast,
|
||||
Card,
|
||||
CardHeader,
|
||||
CardBody,
|
||||
Badge,
|
||||
Divider,
|
||||
Modal,
|
||||
ModalOverlay,
|
||||
ModalContent,
|
||||
ModalHeader,
|
||||
ModalCloseButton,
|
||||
ModalBody,
|
||||
ModalFooter,
|
||||
Textarea,
|
||||
HStack,
|
||||
VStack,
|
||||
} from '@chakra-ui/react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { getPrefetchStatus, triggerPrefetch, PrefetchStatus } from '../../services/admin/prefetch';
|
||||
import AdminLayout from '../../layouts/AdminLayout';
|
||||
import api, { API_URL } from '../../services/api';
|
||||
|
||||
const PrefetchAdminPage: React.FC = () => {
|
||||
const toast = useToast();
|
||||
const qc = useQueryClient();
|
||||
// RAW cache viewer state
|
||||
const [rawOpen, setRawOpen] = React.useState<boolean>(false);
|
||||
const [rawSel, setRawSel] = React.useState<string>('');
|
||||
const [rawLoading, setRawLoading] = React.useState<boolean>(false);
|
||||
const [rawError, setRawError] = React.useState<string | null>(null);
|
||||
const [rawText, setRawText] = React.useState<string>('');
|
||||
|
||||
// Load list of available cache files (admin)
|
||||
const { data: rawList, isError: rawListError, error: rawListErrorMsg } = useQuery<{ files: Array<{ label: string; path: string; size_bytes?: number; mod_time?: string }>}>({
|
||||
queryKey: ['admin', 'cache', 'list'],
|
||||
queryFn: async () => {
|
||||
const res = await api.get('/admin/cache/list');
|
||||
return res.data;
|
||||
},
|
||||
staleTime: 30_000,
|
||||
retry: 1,
|
||||
});
|
||||
|
||||
const fetchRaw = async (path: string) => {
|
||||
setRawLoading(true);
|
||||
setRawError(null);
|
||||
setRawText('');
|
||||
try {
|
||||
const res = await api.get(`/admin/cache/file?path=${encodeURIComponent(path)}`, {
|
||||
transformResponse: [(data) => data], // Get raw text response
|
||||
});
|
||||
const txt = res.data;
|
||||
try {
|
||||
const obj = JSON.parse(txt);
|
||||
setRawText(JSON.stringify(obj, null, 2));
|
||||
} catch {
|
||||
setRawText(txt);
|
||||
}
|
||||
} catch (e: any) {
|
||||
setRawError(e?.message || 'Nelze načíst data');
|
||||
} finally {
|
||||
setRawLoading(false);
|
||||
}
|
||||
};
|
||||
const { data: status, isLoading, isFetching } = useQuery<PrefetchStatus>({
|
||||
queryKey: ['admin', 'prefetch', 'status'],
|
||||
queryFn: getPrefetchStatus,
|
||||
refetchInterval: 30_000, // keep page live
|
||||
});
|
||||
|
||||
const trigger = useMutation({
|
||||
mutationFn: triggerPrefetch,
|
||||
onSuccess: async () => {
|
||||
toast({ title: 'Prefetch spuštěn', status: 'success' });
|
||||
await qc.invalidateQueries({ queryKey: ['admin', 'prefetch', 'status'] });
|
||||
},
|
||||
onError: (err: any) => {
|
||||
toast({ title: 'Spuštění prefetch selhalo', description: String(err?.message || err), status: 'error' });
|
||||
},
|
||||
});
|
||||
|
||||
const last = status?.lastUpdated ? new Date(status.lastUpdated) : null;
|
||||
const next = status?.nextApproximate ? new Date(status.nextApproximate) : null;
|
||||
|
||||
return (
|
||||
<AdminLayout>
|
||||
<Box>
|
||||
<Heading size="lg" mb={4}>Prefetch & Cache</Heading>
|
||||
<Text color="gray.600" mb={6}>
|
||||
Na pozadí běží úloha, která pravidelně stahuje JSON snapshoty z veřejných API pro rychlejší načítání stránek. Zde uvidíte aktuální plán a můžete spustit ruční stažení.
|
||||
</Text>
|
||||
|
||||
<SimpleGrid columns={{ base: 1, md: 3 }} spacing={4} mb={6}>
|
||||
<Card>
|
||||
<CardHeader><Text fontWeight="bold">Režim</Text></CardHeader>
|
||||
<CardBody>
|
||||
<Stat>
|
||||
<StatLabel>Aktuální režim</StatLabel>
|
||||
<StatNumber>
|
||||
{status?.fastMode ? (
|
||||
<Badge colorScheme="green">Rychlý (během zápasu)</Badge>
|
||||
) : (
|
||||
<Badge>Normální</Badge>
|
||||
)}
|
||||
</StatNumber>
|
||||
<StatHelpText>V době konání zápasů se automaticky přepne do rychlého režimu.</StatHelpText>
|
||||
</Stat>
|
||||
</CardBody>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader><Text fontWeight="bold">Poslední aktualizace</Text></CardHeader>
|
||||
<CardBody>
|
||||
<Stat>
|
||||
<StatLabel>Poslední prefetch</StatLabel>
|
||||
<StatNumber fontSize="lg">
|
||||
{last ? last.toLocaleString() : 'Unknown'}
|
||||
</StatNumber>
|
||||
<StatHelpText>{isFetching ? 'Obnovuji…' : 'Aktuální'}</StatHelpText>
|
||||
</Stat>
|
||||
</CardBody>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader><Text fontWeight="bold">Další spuštění</Text></CardHeader>
|
||||
<CardBody>
|
||||
<Stat>
|
||||
<StatLabel>Přibližně</StatLabel>
|
||||
<StatNumber fontSize="lg">
|
||||
{next ? next.toLocaleString() : '—'}
|
||||
</StatNumber>
|
||||
<StatHelpText>
|
||||
Interval: {status?.intervalMinutes ?? 30} min
|
||||
</StatHelpText>
|
||||
</Stat>
|
||||
</CardBody>
|
||||
</Card>
|
||||
</SimpleGrid>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<Stack direction={{ base: 'column', sm: 'row' }} align="center" justify="space-between">
|
||||
<Text fontWeight="bold">Ovládání</Text>
|
||||
<Stack direction="row" spacing={3}>
|
||||
<Button
|
||||
colorScheme="blue"
|
||||
isLoading={trigger.isLoading}
|
||||
onClick={() => trigger.mutate()}
|
||||
>
|
||||
Spustit stažení
|
||||
</Button>
|
||||
<Button variant="outline" onClick={() => qc.invalidateQueries({ queryKey: ['admin', 'prefetch', 'status'] })}>
|
||||
Obnovit stav
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setRawOpen(true);
|
||||
const first = rawList?.files?.[0];
|
||||
if (first) {
|
||||
setRawSel(first.path);
|
||||
fetchRaw(first.path);
|
||||
} else if (rawListError) {
|
||||
setRawError('Nelze načíst seznam souborů');
|
||||
} else if (!rawList?.files || rawList.files.length === 0) {
|
||||
setRawError('Žádné cache soubory nebyly nalezeny');
|
||||
}
|
||||
}}
|
||||
isDisabled={rawListError && !rawList}
|
||||
>
|
||||
Zobrazit RAW data
|
||||
</Button>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</CardHeader>
|
||||
<Divider />
|
||||
<CardBody>
|
||||
<Text color="gray.600">
|
||||
Ruční spuštění zahájí na pozadí obnovu všech veřejných endpointů a zdrojů FAČR. Nezablokuje uživatelské rozhraní.
|
||||
</Text>
|
||||
</CardBody>
|
||||
</Card>
|
||||
|
||||
{/* RAW viewer modal */}
|
||||
<Modal isOpen={rawOpen} onClose={() => setRawOpen(false)} size="6xl" scrollBehavior="inside">
|
||||
<ModalOverlay />
|
||||
<ModalContent>
|
||||
<ModalHeader>RAW data (prefetch & cache)</ModalHeader>
|
||||
<ModalCloseButton />
|
||||
<ModalBody>
|
||||
{rawListError ? (
|
||||
<Box p={4} textAlign="center">
|
||||
<Text color="red.500" mb={2}>Chyba při načítání seznamu souborů</Text>
|
||||
<Text color="gray.500" fontSize="sm">{String(rawListErrorMsg)}</Text>
|
||||
</Box>
|
||||
) : !rawList?.files || rawList.files.length === 0 ? (
|
||||
<Box p={4} textAlign="center">
|
||||
<Text color="gray.500">Žádné cache soubory nebyly nalezeny</Text>
|
||||
<Text fontSize="sm" color="gray.400" mt={2}>Zkuste spustit prefetch stažení nejprve</Text>
|
||||
</Box>
|
||||
) : (
|
||||
<SimpleGrid columns={{ base: 1, md: 4 }} spacing={4}>
|
||||
<VStack align="stretch" spacing={2} gridColumn={{ base: '1', md: 'span 1' }}>
|
||||
{rawList.files.map((f) => (
|
||||
<Button
|
||||
key={f.path}
|
||||
variant={rawSel === f.path ? 'solid' : 'outline'}
|
||||
onClick={() => { setRawSel(f.path); fetchRaw(f.path); }}
|
||||
justifyContent="flex-start"
|
||||
size="sm"
|
||||
>
|
||||
<Text noOfLines={1} fontSize="xs" textAlign="left" w="full">
|
||||
{f.label}
|
||||
</Text>
|
||||
</Button>
|
||||
))}
|
||||
</VStack>
|
||||
<Box gridColumn={{ base: '1', md: 'span 3' }}>
|
||||
<HStack justify="space-between" mb={2}>
|
||||
<Text fontWeight="semibold" fontSize="sm" noOfLines={1}>{rawSel || 'Vyberte soubor'}</Text>
|
||||
<HStack>
|
||||
<Button size="sm" variant="ghost" onClick={() => fetchRaw(rawSel)} isLoading={rawLoading} isDisabled={!rawSel}>Obnovit</Button>
|
||||
<Button size="sm" as="a" href={`${API_URL}/admin/cache/file?path=${encodeURIComponent(rawSel)}`} target="_blank" rel="noreferrer" isDisabled={!rawSel}>Otevřít v nové záložce</Button>
|
||||
</HStack>
|
||||
</HStack>
|
||||
{rawError && <Box color="red.500" mb={2} p={2} bg="red.50" borderRadius="md">{rawError}</Box>}
|
||||
<Textarea value={rawText} onChange={() => {}} readOnly fontFamily="mono" rows={24} fontSize="xs" />
|
||||
</Box>
|
||||
</SimpleGrid>
|
||||
)}
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<Button onClick={() => setRawOpen(false)}>Zavřít</Button>
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
</Box>
|
||||
</AdminLayout>
|
||||
);
|
||||
};
|
||||
|
||||
export default PrefetchAdminPage;
|
||||
@@ -0,0 +1,605 @@
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
FormControl,
|
||||
FormLabel,
|
||||
Input,
|
||||
NumberInput,
|
||||
NumberInputField,
|
||||
Select,
|
||||
SimpleGrid,
|
||||
HStack,
|
||||
VStack,
|
||||
Heading,
|
||||
Divider,
|
||||
Image,
|
||||
useToast,
|
||||
Text,
|
||||
Switch,
|
||||
Badge,
|
||||
Tabs,
|
||||
TabList,
|
||||
TabPanels,
|
||||
Tab,
|
||||
TabPanel,
|
||||
useColorModeValue,
|
||||
} from '@chakra-ui/react';
|
||||
import { CopyIcon } from '@chakra-ui/icons';
|
||||
import AdminLayout from '@/layouts/AdminLayout';
|
||||
import ScoreboardPreview from '@/components/scoreboard/ScoreboardPreview';
|
||||
import {
|
||||
getScoreboardState,
|
||||
saveScoreboardState,
|
||||
updateAdminScoreboard,
|
||||
ScoreboardState,
|
||||
deriveShort,
|
||||
derivePrimaryFromLogo,
|
||||
ScoreboardTheme,
|
||||
startTimer,
|
||||
pauseTimer,
|
||||
resetTimer,
|
||||
} from '@/services/scoreboard';
|
||||
import { useFacrApi } from '@/hooks/useFacrApi';
|
||||
import { SearchResult } from '@/services/facr/types';
|
||||
import { API_URL } from '@/services/api';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { AdminMatch, fetchAdminMatches } from '@/services/adminMatches';
|
||||
import { getFacrClubInfoCache } from '@/services/facr/cache';
|
||||
|
||||
const themes: ScoreboardTheme[] = ['classic', 'pill', 'var1', 'var2', 'var3', 'var4'];
|
||||
|
||||
const resolveLogoUrl = (u?: string | null) => {
|
||||
if (!u) return undefined;
|
||||
if (u.startsWith('/uploads') || u.startsWith('/dist') || u.startsWith('/api/')) return u;
|
||||
if (/^https?:\/\//i.test(u)) {
|
||||
const base = (API_URL || '').replace(/\/$/, '');
|
||||
return `${base}/proxy/image?url=${encodeURIComponent(u)}`;
|
||||
}
|
||||
return u;
|
||||
};
|
||||
|
||||
const ScoreboardAdminPage: React.FC = () => {
|
||||
const cardBg = useColorModeValue('white', 'gray.800');
|
||||
const borderColor = useColorModeValue('gray.200', 'gray.700');
|
||||
const inputBg = useColorModeValue('white', 'gray.700');
|
||||
const [state, setState] = useState<ScoreboardState | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const toast = useToast();
|
||||
const [activeTab, setActiveTab] = useState<number>(0); // 0 upcoming, 1 recent
|
||||
|
||||
// Club search inline (home/away target)
|
||||
const [clubQuery, setClubQuery] = useState('');
|
||||
const [assignTo, setAssignTo] = useState<'home' | 'away'>('home');
|
||||
const { searchClubs, searchResults, searchLoading, searchError } = useFacrApi();
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
const s = await getScoreboardState();
|
||||
setState(s);
|
||||
setLoading(false);
|
||||
})();
|
||||
}, []);
|
||||
|
||||
// Poll while timer is running to reflect live time
|
||||
useEffect(() => {
|
||||
if (!state?.running) return;
|
||||
let mounted = true;
|
||||
const id = setInterval(async () => {
|
||||
try {
|
||||
const s = await getScoreboardState();
|
||||
if (mounted) setState(s);
|
||||
} catch {}
|
||||
}, 1000);
|
||||
return () => {
|
||||
mounted = false;
|
||||
clearInterval(id);
|
||||
};
|
||||
}, [state?.running]);
|
||||
|
||||
// Load matches for linking
|
||||
const { data: adminMatches = [] } = useQuery<AdminMatch[]>({
|
||||
queryKey: ['admin-matches'],
|
||||
queryFn: fetchAdminMatches,
|
||||
staleTime: 60_000,
|
||||
});
|
||||
|
||||
// Load competitions/matches from cached FACR blob
|
||||
const { data: facrCache } = useQuery<any>({
|
||||
queryKey: ['facr-club-info-cache'],
|
||||
queryFn: getFacrClubInfoCache,
|
||||
staleTime: 120_000,
|
||||
refetchInterval: 120_000,
|
||||
});
|
||||
|
||||
// Normalize matches from FACR cache to AdminMatch-ish shape
|
||||
const facrMatches: AdminMatch[] = useMemo(() => {
|
||||
const out: AdminMatch[] = [];
|
||||
try {
|
||||
const comps = facrCache?.competitions || [];
|
||||
for (const c of comps) {
|
||||
const cname = String(c?.name || c?.code || '').trim();
|
||||
const matches = c?.matches || [];
|
||||
for (const m of matches) {
|
||||
out.push({
|
||||
match_id: m?.match_id || m?.id || '',
|
||||
id: m?.match_id || m?.id || '',
|
||||
home: m?.home || '',
|
||||
away: m?.away || '',
|
||||
date_time: m?.date_time || m?.kickoff || '',
|
||||
home_logo_url: m?.home_logo_url || '',
|
||||
away_logo_url: m?.away_logo_url || '',
|
||||
competition: cname,
|
||||
league: cname,
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
return out;
|
||||
}, [facrCache]);
|
||||
|
||||
// Build competition list from both sources
|
||||
const competitions = React.useMemo(() => {
|
||||
const set = new Set<string>();
|
||||
for (const m of adminMatches) {
|
||||
const comp = String(m.competition || m.league || '').trim();
|
||||
if (comp) set.add(comp);
|
||||
}
|
||||
for (const m of facrMatches) {
|
||||
const comp = String((m as any).competition || (m as any).league || '').trim();
|
||||
if (comp) set.add(comp);
|
||||
}
|
||||
return ['Vše', ...Array.from(set)];
|
||||
}, [adminMatches, facrMatches]);
|
||||
|
||||
const [compFilter, setCompFilter] = useState<string>('Vše');
|
||||
const [matchQuery, setMatchQuery] = useState('');
|
||||
|
||||
const parsedMatches = React.useMemo(() => {
|
||||
const parseTs = (m: AdminMatch) => {
|
||||
const dt = String(m.date_time || m.date || '').slice(0, 16);
|
||||
// Accept RFC3339 or "YYYY-MM-DD HH:MM"
|
||||
let t = Date.parse(dt);
|
||||
if (isNaN(t) && dt.includes(' ')) {
|
||||
t = Date.parse(dt.replace(' ', 'T') + ':00');
|
||||
}
|
||||
return t;
|
||||
};
|
||||
const now = Date.now();
|
||||
let list: AdminMatch[] = [...(adminMatches || []), ...facrMatches];
|
||||
if (compFilter && compFilter !== 'Vše') {
|
||||
list = list.filter((m) => String((m as any).competition || (m as any).league || '').trim() === compFilter);
|
||||
}
|
||||
if (matchQuery.trim()) {
|
||||
const q = matchQuery.trim().toLowerCase();
|
||||
list = list.filter((m) =>
|
||||
String(m.home || '').toLowerCase().includes(q) ||
|
||||
String(m.away || '').toLowerCase().includes(q) ||
|
||||
String((m as any).competition || (m as any).league || '').toLowerCase().includes(q)
|
||||
);
|
||||
}
|
||||
const withTs = list.map((m) => ({ m, ts: parseTs(m) }));
|
||||
const upcoming = withTs
|
||||
.filter((x) => typeof x.ts === 'number' && !isNaN(x.ts) && x.ts >= now)
|
||||
.sort((a, b) => a.ts - b.ts)
|
||||
.map((x) => x.m);
|
||||
const recent = withTs
|
||||
.filter((x) => typeof x.ts === 'number' && !isNaN(x.ts) && x.ts < now)
|
||||
.sort((a, b) => b.ts - a.ts)
|
||||
.map((x) => x.m);
|
||||
return { upcoming, recent };
|
||||
}, [adminMatches, facrMatches, compFilter, matchQuery]);
|
||||
|
||||
useEffect(() => {
|
||||
const q = clubQuery.trim();
|
||||
if (!q) return;
|
||||
const t = setTimeout(() => {
|
||||
searchClubs(q).catch(() => {});
|
||||
}, 400);
|
||||
return () => clearTimeout(t);
|
||||
}, [clubQuery, searchClubs]);
|
||||
|
||||
const setPartial = async (patch: Partial<ScoreboardState>) => {
|
||||
const next = await saveScoreboardState(patch);
|
||||
setState(next);
|
||||
};
|
||||
|
||||
const applyMatch = async (m: AdminMatch) => {
|
||||
if (!state) return;
|
||||
// Populate names, logos and short codes
|
||||
const homeName = String(m.home || m.home_team || '').trim();
|
||||
const awayName = String(m.away || m.away_team || '').trim();
|
||||
const homeLogo = resolveLogoUrl(m.home_logo_url || '') || '';
|
||||
const awayLogo = resolveLogoUrl(m.away_logo_url || '') || '';
|
||||
const updates: Partial<ScoreboardState> = {
|
||||
homeName,
|
||||
awayName,
|
||||
homeShort: deriveShort(homeName),
|
||||
awayShort: deriveShort(awayName),
|
||||
homeLogo: homeLogo || state.homeLogo,
|
||||
awayLogo: awayLogo || state.awayLogo,
|
||||
externalMatchId: String(m.match_id || m.id || ''),
|
||||
};
|
||||
// Try to detect colors from logos
|
||||
const [cHome, cAway] = await Promise.all([
|
||||
derivePrimaryFromLogo(homeLogo || state.homeLogo),
|
||||
derivePrimaryFromLogo(awayLogo || state.awayLogo),
|
||||
]);
|
||||
if (cHome) updates.primaryColor = cHome;
|
||||
if (cAway) updates.secondaryColor = cAway;
|
||||
const next = await saveScoreboardState(updates);
|
||||
setState(next);
|
||||
toast({ title: 'Zápas vybrán', description: 'Základní údaje byly předvyplněny.', status: 'success' });
|
||||
};
|
||||
|
||||
const applyClub = async (club: SearchResult) => {
|
||||
const logo = resolveLogoUrl(club.logo_url) || undefined;
|
||||
const color = await derivePrimaryFromLogo(logo || undefined);
|
||||
if (assignTo === 'home') {
|
||||
await setPartial({
|
||||
homeName: club.name || 'DOMÁCÍ',
|
||||
homeShort: deriveShort(club.name || ''),
|
||||
homeLogo: logo,
|
||||
primaryColor: color || state?.primaryColor,
|
||||
});
|
||||
} else {
|
||||
await setPartial({
|
||||
awayName: club.name || 'HOSTÉ',
|
||||
awayShort: deriveShort(club.name || ''),
|
||||
awayLogo: logo,
|
||||
secondaryColor: color || state?.secondaryColor,
|
||||
});
|
||||
}
|
||||
toast({ title: `Nastaveno pro ${assignTo === 'home' ? 'domácí' : 'hosty'}`, status: 'success' });
|
||||
};
|
||||
|
||||
if (loading || !state) return (
|
||||
<AdminLayout>
|
||||
<Box>Načítání…</Box>
|
||||
</AdminLayout>
|
||||
);
|
||||
|
||||
return (
|
||||
<AdminLayout>
|
||||
<Box>
|
||||
<Heading size="lg" mb={2}>Tabule (Scoreboard)</Heading>
|
||||
<Text color="gray.500" mb={2}>Napojte tabuli na konkrétní zápas a ovládejte skóre v reálném čase. Pokud je tabule aktivní a propojená se zápasem, propisujeme živé skóre i do veřejné homepage.</Text>
|
||||
<HStack spacing={3} mb={4}>
|
||||
<Badge colorScheme="green">OBS</Badge>
|
||||
{(() => { const href = (typeof window !== 'undefined' ? window.location.origin.replace(/\/$/, '') : '') + '/overlay/scoreboard'; return (
|
||||
<>
|
||||
<Button as="a" href={href} target="_blank" rel="noreferrer">Otevřít overlay</Button>
|
||||
<HStack spacing={2}>
|
||||
<Text fontSize="sm" color="gray.500">Veřejná URL pro OBS: {href}</Text>
|
||||
<Button size="sm" leftIcon={<CopyIcon />} onClick={() => { try { navigator.clipboard.writeText(href); } catch (_) {} }}>Kopírovat</Button>
|
||||
</HStack>
|
||||
</>
|
||||
); })()}
|
||||
</HStack>
|
||||
|
||||
{/* Match linking (initial element) */}
|
||||
<Box borderWidth="1px" borderRadius="lg" p={4} bg={cardBg} mb={6}>
|
||||
<Heading size="md" mb={3}>Napojení na zápas (FAČR)</Heading>
|
||||
<SimpleGrid columns={{ base: 1, md: 3 }} spacing={3} mb={3}>
|
||||
<FormControl>
|
||||
<FormLabel>Soutěž</FormLabel>
|
||||
<Select value={compFilter} onChange={(e) => setCompFilter(e.target.value)}>
|
||||
{competitions.map((c) => (<option key={c} value={c}>{c}</option>))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
<FormControl>
|
||||
<FormLabel>Hledat zápas</FormLabel>
|
||||
<Input placeholder="Hledat podle názvu týmu nebo soutěže" value={matchQuery} onChange={(e)=>setMatchQuery(e.target.value)} />
|
||||
</FormControl>
|
||||
<FormControl display="flex" alignItems="center">
|
||||
<FormLabel mb={0}>Tabule aktivní</FormLabel>
|
||||
<Switch isChecked={!!state?.active} onChange={async (e) => {
|
||||
const next = await saveScoreboardState({ active: e.target.checked });
|
||||
setState(next);
|
||||
}} />
|
||||
{state?.externalMatchId ? <Badge ml={3} colorScheme="blue">Zápas: {state.externalMatchId}</Badge> : <Badge ml={3}>Bez zápasu</Badge>}
|
||||
</FormControl>
|
||||
</SimpleGrid>
|
||||
|
||||
<Tabs index={activeTab} onChange={setActiveTab} variant="enclosed" size="sm">
|
||||
<TabList>
|
||||
<Tab>Nadcházející</Tab>
|
||||
<Tab>Nedávné</Tab>
|
||||
</TabList>
|
||||
<TabPanels>
|
||||
<TabPanel px={0}>
|
||||
<VStack align="stretch" spacing={2} maxH="320px" overflowY="auto">
|
||||
{parsedMatches.upcoming.map((m) => (
|
||||
<HStack key={`${m.match_id || m.id}`} spacing={3} p={2} borderWidth="1px" borderRadius="md" _hover={{ bg: 'gray.50' }}>
|
||||
{m.home_logo_url ? <Image src={resolveLogoUrl(m.home_logo_url)} alt={String(m.home)} boxSize="28px" objectFit="contain" /> : null}
|
||||
<Text fontWeight="semibold">{String(m.home)} vs {String(m.away)}</Text>
|
||||
<Badge ml="auto">{String(m.date_time || m.date || '').slice(0,16).replace('T',' ')}</Badge>
|
||||
<Button size="sm" onClick={() => applyMatch(m)}>Vybrat zápas</Button>
|
||||
</HStack>
|
||||
))}
|
||||
</VStack>
|
||||
</TabPanel>
|
||||
<TabPanel px={0}>
|
||||
<VStack align="stretch" spacing={2} maxH="320px" overflowY="auto">
|
||||
{parsedMatches.recent.map((m) => (
|
||||
<HStack key={`${m.match_id || m.id}`} spacing={3} p={2} borderWidth="1px" borderRadius="md" _hover={{ bg: 'gray.50' }}>
|
||||
{m.home_logo_url ? <Image src={resolveLogoUrl(m.home_logo_url)} alt={String(m.home)} boxSize="28px" objectFit="contain" /> : null}
|
||||
<Text fontWeight="semibold">{String(m.home)} vs {String(m.away)}</Text>
|
||||
<Badge ml="auto" colorScheme="purple">{String(m.date_time || m.date || '').slice(0,16).replace('T',' ')}</Badge>
|
||||
<Button size="sm" variant="outline" onClick={() => applyMatch(m)}>Vybrat zápas</Button>
|
||||
</HStack>
|
||||
))}
|
||||
</VStack>
|
||||
</TabPanel>
|
||||
</TabPanels>
|
||||
</Tabs>
|
||||
|
||||
<HStack mt={3}>
|
||||
<Button
|
||||
colorScheme="blue"
|
||||
onClick={async () => {
|
||||
if (!state?.externalMatchId) { toast({ title: 'Zvolte zápas', description: 'Nejprve vyberte zápas ze seznamu výše.', status: 'warning' }); return; }
|
||||
const next = await saveScoreboardState({ active: true });
|
||||
setState(next);
|
||||
toast({ title: 'Tabule aktivována', description: 'Živá data se budou propsat i na homepage.', status: 'success' });
|
||||
}}
|
||||
>Aktivovat pro vybraný zápas</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={async () => {
|
||||
const next = await saveScoreboardState({ externalMatchId: '', active: false });
|
||||
setState(next);
|
||||
toast({ title: 'Odpojeno', description: 'Tabule odpojena od zápasu.', status: 'info' });
|
||||
}}
|
||||
>Odpojit od zápasu</Button>
|
||||
</HStack>
|
||||
</Box>
|
||||
|
||||
{/* Live preview */}
|
||||
<Box display="flex" justifyContent="center" mb={6}>
|
||||
<ScoreboardPreview state={state} />
|
||||
</Box>
|
||||
|
||||
<SimpleGrid columns={{ base: 1, md: 2 }} spacing={6}>
|
||||
<Box>
|
||||
<Heading size="md" mb={3}>Základní údaje</Heading>
|
||||
<SimpleGrid columns={2} spacing={4}>
|
||||
<FormControl>
|
||||
<FormLabel>Domácí tým</FormLabel>
|
||||
<Input
|
||||
value={state.homeName}
|
||||
onChange={async (e) => {
|
||||
const v = e.target.value;
|
||||
await setPartial({ homeName: v, homeShort: state.homeShort?.trim() ? state.homeShort : deriveShort(v) });
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormControl>
|
||||
<FormLabel>Zkratka domácích</FormLabel>
|
||||
<Input
|
||||
value={state.homeShort || ''}
|
||||
maxLength={3}
|
||||
onChange={async (e) => {
|
||||
await setPartial({ homeShort: e.target.value.toUpperCase().slice(0,3) });
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormControl>
|
||||
<FormLabel>Hostující tým</FormLabel>
|
||||
<Input
|
||||
value={state.awayName}
|
||||
onChange={async (e) => {
|
||||
const v = e.target.value;
|
||||
await setPartial({ awayName: v, awayShort: state.awayShort?.trim() ? state.awayShort : deriveShort(v) });
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormControl>
|
||||
<FormLabel>Zkratka hostů</FormLabel>
|
||||
<Input
|
||||
value={state.awayShort || ''}
|
||||
maxLength={3}
|
||||
onChange={async (e) => {
|
||||
await setPartial({ awayShort: e.target.value.toUpperCase().slice(0,3) });
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormControl>
|
||||
<FormLabel>Logo domácích (URL)</FormLabel>
|
||||
<Input
|
||||
value={state.homeLogo || ''}
|
||||
onChange={async (e) => {
|
||||
await setPartial({ homeLogo: e.target.value });
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormControl>
|
||||
<FormLabel>Logo hostů (URL)</FormLabel>
|
||||
<Input
|
||||
value={state.awayLogo || ''}
|
||||
onChange={async (e) => {
|
||||
await setPartial({ awayLogo: e.target.value });
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormControl>
|
||||
<FormLabel>Skóre domácích</FormLabel>
|
||||
<NumberInput value={state.homeScore} min={0} onChange={async (_, n) => setPartial({ homeScore: Number.isFinite(n) ? n : 0 })}>
|
||||
<NumberInputField />
|
||||
</NumberInput>
|
||||
</FormControl>
|
||||
<FormControl>
|
||||
<FormLabel>Skóre hostů</FormLabel>
|
||||
<NumberInput value={state.awayScore} min={0} onChange={async (_, n) => setPartial({ awayScore: Number.isFinite(n) ? n : 0 })}>
|
||||
<NumberInputField />
|
||||
</NumberInput>
|
||||
</FormControl>
|
||||
<FormControl>
|
||||
<FormLabel>Délka poločasu (min)</FormLabel>
|
||||
<NumberInput value={state.halfLength} min={1} max={60} onChange={async (_, n) => setPartial({ halfLength: Number.isFinite(n) ? n : 45 })}>
|
||||
<NumberInputField />
|
||||
</NumberInput>
|
||||
</FormControl>
|
||||
<FormControl>
|
||||
<FormLabel>Styl</FormLabel>
|
||||
<Select value={state.theme} onChange={async (e) => setPartial({ theme: e.target.value as ScoreboardTheme })}>
|
||||
{themes.map((t) => (
|
||||
<option key={t} value={t}>{t}</option>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
</SimpleGrid>
|
||||
</Box>
|
||||
|
||||
<Box>
|
||||
<Heading size="md" mb={3}>Barvy a vyhledání klubu</Heading>
|
||||
<SimpleGrid columns={2} spacing={4}>
|
||||
<FormControl>
|
||||
<FormLabel>Barva domácích</FormLabel>
|
||||
<Input type="color" value={state.primaryColor || '#1e3a8a'} onChange={async (e) => setPartial({ primaryColor: e.target.value })} />
|
||||
</FormControl>
|
||||
<FormControl>
|
||||
<FormLabel>Barva hostů</FormLabel>
|
||||
<Input type="color" value={state.secondaryColor || '#2563eb'} onChange={async (e) => setPartial({ secondaryColor: e.target.value })} />
|
||||
</FormControl>
|
||||
</SimpleGrid>
|
||||
|
||||
<Divider my={4} />
|
||||
<HStack spacing={3} align="flex-start">
|
||||
<Select value={assignTo} onChange={(e) => setAssignTo((e.target.value as 'home'|'away') || 'home')} maxW="160px">
|
||||
<option value="home">Nastavit domácí</option>
|
||||
<option value="away">Nastavit hosty</option>
|
||||
</Select>
|
||||
<Input placeholder="Hledat klub (FAČR)" value={clubQuery} onChange={(e) => setClubQuery(e.target.value)} />
|
||||
<Button isLoading={searchLoading} onClick={() => clubQuery.trim() && searchClubs(clubQuery.trim())}>Hledat</Button>
|
||||
</HStack>
|
||||
{searchError ? (
|
||||
<Text color="red.500" mt={2}>Chyba vyhledávání: {searchError.message}</Text>
|
||||
) : null}
|
||||
<VStack align="stretch" spacing={2} mt={3} maxH="260px" overflowY="auto">
|
||||
{searchResults?.slice(0, 8).map((r) => (
|
||||
<HStack key={`${r.club_type}-${r.club_id}`} spacing={3} p={2} borderWidth="1px" borderRadius="md" _hover={{ bg: 'gray.50' }} cursor="pointer" onClick={() => applyClub(r)}>
|
||||
{r.logo_url ? <Image src={resolveLogoUrl(r.logo_url)} alt={r.name} boxSize="28px" objectFit="contain" /> : null}
|
||||
<Box>
|
||||
<Text fontWeight="medium">{r.name}</Text>
|
||||
<Text fontSize="sm" color="gray.500">{r.club_type}</Text>
|
||||
</Box>
|
||||
</HStack>
|
||||
))}
|
||||
</VStack>
|
||||
</Box>
|
||||
</SimpleGrid>
|
||||
|
||||
<Divider my={6} />
|
||||
|
||||
<HStack spacing={3}>
|
||||
<Button onClick={() => setPartial({ homeScore: (state.homeScore || 0) + 1 })}>+ Gól DOM</Button>
|
||||
<Button onClick={() => setPartial({ homeScore: Math.max(0, (state.homeScore || 0) - 1) })}>− Gól DOM</Button>
|
||||
<Button onClick={() => setPartial({ awayScore: (state.awayScore || 0) + 1 })}>+ Gól HOS</Button>
|
||||
<Button onClick={() => setPartial({ awayScore: Math.max(0, (state.awayScore || 0) - 1) })}>− Gól HOS</Button>
|
||||
<Button variant="outline" onClick={() => setPartial({ homeScore: 0, awayScore: 0 })}>Reset skóre</Button>
|
||||
</HStack>
|
||||
|
||||
<Divider my={6} />
|
||||
|
||||
{/* Timer controls */}
|
||||
<Box borderWidth="1px" borderRadius="lg" p={4} bg={cardBg} mb={6}>
|
||||
<Heading size="md" mb={3}>Časovač</Heading>
|
||||
<HStack spacing={4} align="center" flexWrap="wrap">
|
||||
<Text fontSize="4xl" fontFamily="mono" minW="120px">{state.timer || '00:00'}</Text>
|
||||
{state.running ? (
|
||||
<Badge colorScheme="green">Běží</Badge>
|
||||
) : (
|
||||
<Badge>Stojí</Badge>
|
||||
)}
|
||||
<HStack>
|
||||
<Button colorScheme="green" onClick={async () => {
|
||||
await startTimer();
|
||||
const s = await getScoreboardState();
|
||||
setState(s);
|
||||
}}>Start</Button>
|
||||
<Button onClick={async () => {
|
||||
await pauseTimer();
|
||||
const s = await getScoreboardState();
|
||||
setState(s);
|
||||
}}>Pauza</Button>
|
||||
<Button variant="outline" onClick={async () => {
|
||||
await resetTimer();
|
||||
const s = await getScoreboardState();
|
||||
setState(s);
|
||||
}}>Reset</Button>
|
||||
</HStack>
|
||||
</HStack>
|
||||
<HStack mt={3} spacing={3} align="center">
|
||||
<FormControl maxW="160px" isDisabled={!!state.running}>
|
||||
<FormLabel>Nastavit čas (MM:SS)</FormLabel>
|
||||
<Input
|
||||
value={state.timer || '00:00'}
|
||||
onChange={async (e) => {
|
||||
const v = e.target.value.trim();
|
||||
// allow edit only when not running
|
||||
if (!state.running) {
|
||||
const next = await saveScoreboardState({ timer: v });
|
||||
setState(next);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
</HStack>
|
||||
</Box>
|
||||
|
||||
<Heading size="md" mb={3}>Import / Export</Heading>
|
||||
<HStack spacing={4} align="center" flexWrap="wrap">
|
||||
<Button
|
||||
onClick={() => {
|
||||
try {
|
||||
const blob = new Blob([JSON.stringify(state, null, 2)], { type: 'application/json' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = 'scoreboard-state.json';
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
} catch (e) {
|
||||
toast({ title: 'Export selhal', status: 'error' });
|
||||
}
|
||||
}}
|
||||
>Exportovat JSON</Button>
|
||||
<Button as="label">
|
||||
Importovat JSON
|
||||
<Input type="file" accept="application/json" display="none" onChange={async (e) => {
|
||||
try {
|
||||
const f = e.target.files?.[0];
|
||||
if (!f) return;
|
||||
const text = await f.text();
|
||||
const data = JSON.parse(text);
|
||||
// Patch via admin API when possible
|
||||
await updateAdminScoreboard(data);
|
||||
setState(await getScoreboardState());
|
||||
toast({ title: 'Import dokončen', status: 'success' });
|
||||
} catch (err: any) {
|
||||
toast({ title: 'Import selhal', description: err?.message || 'Neplatný soubor', status: 'error' });
|
||||
} finally {
|
||||
try { if (e.target) (e.target as HTMLInputElement).value = ''; } catch {}
|
||||
}
|
||||
}} />
|
||||
</Button>
|
||||
</HStack>
|
||||
|
||||
<Divider my={8} />
|
||||
<Heading size="md" mb={2}>Návod k použití (CZ)</Heading>
|
||||
<VStack align="start" spacing={2} color="gray.700">
|
||||
<Text>1) V horní části vyberte soutěž, případně vyhledejte zápas podle názvu týmu. Klikněte na „Vybrat zápas“.</Text>
|
||||
<Text>2) Pole „Domácí/Hosté“, loga a zkratky se předvyplní. Barvy se pokusíme odhadnout z log.</Text>
|
||||
<Text>3) Zapněte přepínač „Tabule aktivní“ nebo použijte tlačítko „Aktivovat pro vybraný zápas“.</Text>
|
||||
<Text>4) Skóre upravujte tlačítky níže nebo ručním zadáním. Změny se ukládají průběžně.</Text>
|
||||
<Text>5) Pokud je tabule aktivní a propojená se zápasem, živé skóre se propisuje do veřejné homepage (nejbližší zápas) a do JSON cache.</Text>
|
||||
<Text>6) Přepněte vzhled tabule (styl) dle potřeby. Pro přenos do streamu použijte veřejnou adresu /overlay/scoreboard.</Text>
|
||||
<Text>Tip: Při importu JSON použijte předchozí export, formát je kompatibilní.</Text>
|
||||
</VStack>
|
||||
</Box>
|
||||
</AdminLayout>
|
||||
);
|
||||
};
|
||||
|
||||
export default ScoreboardAdminPage;
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,313 @@
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
Heading,
|
||||
HStack,
|
||||
IconButton,
|
||||
Image,
|
||||
Input,
|
||||
Link,
|
||||
Modal,
|
||||
ModalBody,
|
||||
ModalCloseButton,
|
||||
ModalContent,
|
||||
ModalFooter,
|
||||
ModalHeader,
|
||||
ModalOverlay,
|
||||
Table,
|
||||
Tbody,
|
||||
Td,
|
||||
Th,
|
||||
Thead,
|
||||
Tr,
|
||||
FormControl,
|
||||
FormLabel,
|
||||
Switch,
|
||||
useDisclosure,
|
||||
useToast,
|
||||
VStack,
|
||||
useColorModeValue,
|
||||
Text,
|
||||
Select,
|
||||
NumberInput,
|
||||
NumberInputField,
|
||||
Badge,
|
||||
} from '@chakra-ui/react';
|
||||
import { useState } from 'react';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { FiEdit2, FiPlus, FiTrash2, FiUpload, FiExternalLink } from 'react-icons/fi';
|
||||
import AdminLayout from '../../layouts/AdminLayout';
|
||||
import { Sponsor, getSponsors, createSponsor, updateSponsor, deleteSponsor } from '../../services/sponsors';
|
||||
import { uploadFile } from '../../services/articles';
|
||||
|
||||
const SponsorsAdminPage: React.FC = () => {
|
||||
const cardBg = useColorModeValue('white', 'gray.800');
|
||||
const borderColor = useColorModeValue('gray.200', 'gray.700');
|
||||
const inputBg = useColorModeValue('white', 'gray.700');
|
||||
const normalizeImageUrl = (url?: string) => {
|
||||
if (!url || url === '') return '/logo192.png';
|
||||
if (/^https?:\/\//i.test(url)) return url;
|
||||
const apiUrl = process.env.REACT_APP_API_URL || 'http://localhost:8080/api/v1';
|
||||
const origin = new URL(apiUrl).origin;
|
||||
if (url.startsWith('/uploads/')) return `${origin}${url}`;
|
||||
return `${origin}${url.startsWith('/') ? '' : '/'}${url}`;
|
||||
};
|
||||
const toast = useToast();
|
||||
const qc = useQueryClient();
|
||||
const { data, isLoading } = useQuery({ queryKey: ['admin-sponsors'], queryFn: getSponsors });
|
||||
|
||||
const [editing, setEditing] = useState<Partial<Sponsor> | null>(null);
|
||||
const { isOpen, onOpen, onClose } = useDisclosure();
|
||||
|
||||
const openCreate = () => { setEditing({ name: '', is_active: true, tier: 'standard', display_order: 0 }); onOpen(); };
|
||||
const openEdit = (s: Sponsor) => { setEditing({ ...s }); onOpen(); };
|
||||
const closeModal = () => { setEditing(null); onClose(); };
|
||||
|
||||
const createMut = useMutation({
|
||||
mutationFn: (payload: any) => createSponsor(payload),
|
||||
onSuccess: (created: any) => {
|
||||
// If backend returned the created sponsor, merge into cache for immediate UI update
|
||||
try {
|
||||
qc.setQueryData(['admin-sponsors'], (old: any) => {
|
||||
const list = Array.isArray(old) ? old : (old?.data || []);
|
||||
const newList = [created, ...list];
|
||||
// if cache shape is { data, total, page }, attempt to preserve
|
||||
if (old && old.data) return { ...old, data: newList };
|
||||
return newList;
|
||||
});
|
||||
} catch (e) {
|
||||
// ignore cache update errors
|
||||
}
|
||||
toast({ title: 'Sponzor vytvořen', status: 'success' });
|
||||
qc.invalidateQueries({ queryKey: ['admin-sponsors'] });
|
||||
closeModal();
|
||||
},
|
||||
onError: (e: any) => toast({ title: 'Vytvoření selhalo', description: e?.response?.data?.chyba || 'Chyba', status: 'error' }),
|
||||
});
|
||||
const updateMut = useMutation({
|
||||
mutationFn: ({ id, payload }: { id: number | string; payload: any }) => updateSponsor(id, payload),
|
||||
onSuccess: (updated: any) => {
|
||||
// Optimistically merge returned sponsor into cache to avoid transient undefined fields
|
||||
try {
|
||||
qc.setQueryData(['admin-sponsors'], (old: any) => {
|
||||
const list = Array.isArray(old) ? old : (old?.data || []);
|
||||
const newList = (list || []).map((it: any) => (it?.id === updated?.id ? { ...it, ...updated } : it));
|
||||
if (old && old.data) return { ...old, data: newList };
|
||||
return newList;
|
||||
});
|
||||
} catch {}
|
||||
toast({ title: 'Sponzor aktualizován', status: 'success' });
|
||||
qc.invalidateQueries({ queryKey: ['admin-sponsors'] });
|
||||
closeModal();
|
||||
},
|
||||
onError: (e: any) => toast({ title: 'Aktualizace selhala', description: e?.response?.data?.chyba || 'Chyba', status: 'error' }),
|
||||
});
|
||||
const deleteMut = useMutation({
|
||||
mutationFn: (id: number) => deleteSponsor(id),
|
||||
onSuccess: () => { toast({ title: 'Sponzor smazán', status: 'success' }); qc.invalidateQueries({ queryKey: ['admin-sponsors'] }); },
|
||||
onError: (e: any) => toast({ title: 'Smazání selhalo', description: e?.response?.data?.chyba || 'Chyba', status: 'error' }),
|
||||
});
|
||||
|
||||
const onSubmit = async () => {
|
||||
if (!editing) return;
|
||||
const payload = {
|
||||
name: editing.name || '',
|
||||
website_url: editing.website_url || '',
|
||||
logo_url: editing.logo_url || '',
|
||||
is_active: editing.is_active ?? true,
|
||||
tier: editing.tier || 'standard',
|
||||
display_order: editing.display_order ?? 0,
|
||||
};
|
||||
if ((editing as any).id != null) {
|
||||
await updateMut.mutateAsync({ id: (editing as any).id, payload });
|
||||
} else {
|
||||
await createMut.mutateAsync(payload);
|
||||
}
|
||||
};
|
||||
|
||||
const onUpload = async (file?: File | null) => {
|
||||
if (!file) return;
|
||||
try {
|
||||
const res = await uploadFile(file);
|
||||
const apiUrl = process.env.REACT_APP_API_URL || 'http://localhost:8080/api/v1';
|
||||
const apiOrigin = new URL(apiUrl).origin;
|
||||
// If backend returned an absolute URL pointing to the dev host (same origin as app), rewrite to API origin
|
||||
let url = res.url || '';
|
||||
try {
|
||||
const parsed = new URL(url, window.location.origin);
|
||||
const appOrigin = window.location.origin;
|
||||
if (parsed.origin === appOrigin) {
|
||||
// replace with API origin while keeping path
|
||||
url = apiOrigin + parsed.pathname + parsed.search + parsed.hash;
|
||||
}
|
||||
} catch (e) {
|
||||
// ignore
|
||||
}
|
||||
setEditing((prev) => ({ ...(prev || {}), logo_url: url }));
|
||||
toast({ title: 'Logo nahráno', status: 'success' });
|
||||
} catch (err) {
|
||||
toast({ title: 'Nahrání loga selhalo', status: 'error' });
|
||||
}
|
||||
};
|
||||
|
||||
const toggleActive = async (s: Sponsor) => {
|
||||
if (s.id == null) return;
|
||||
try {
|
||||
await updateMut.mutateAsync({ id: s.id, payload: { is_active: !s.is_active } });
|
||||
toast({ title: 'Stav sponzora aktualizován', status: 'success' });
|
||||
} catch (err) {
|
||||
toast({ title: 'Aktualizace selhala', status: 'error' });
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<AdminLayout requireAdmin={false}>
|
||||
<Box>
|
||||
<HStack justify="space-between" mb={4}>
|
||||
<Heading size="lg">Správa sponzorů</Heading>
|
||||
<Button leftIcon={<FiPlus />} colorScheme="blue" onClick={openCreate}>
|
||||
Nový sponzor
|
||||
</Button>
|
||||
</HStack>
|
||||
<Text color="gray.500" mb={6}>
|
||||
Správa sponzorů a partnerů klubu. Můžete přidávat, upravovat a odebírat sponzory, kteří se zobrazují na webu.
|
||||
</Text>
|
||||
|
||||
<Box
|
||||
bg={useColorModeValue('white', 'gray.800')}
|
||||
borderWidth="1px"
|
||||
borderRadius="lg"
|
||||
overflowX="auto"
|
||||
boxShadow="sm"
|
||||
mb={6}
|
||||
>
|
||||
<Table size="sm">
|
||||
<Thead>
|
||||
<Tr>
|
||||
<Th w="80px">Logo</Th>
|
||||
<Th>Název</Th>
|
||||
<Th>Úroveň</Th>
|
||||
<Th>Pořadí</Th>
|
||||
<Th>Web</Th>
|
||||
<Th w="120px">Aktivní</Th>
|
||||
<Th w="160px">Akce</Th>
|
||||
</Tr>
|
||||
</Thead>
|
||||
<Tbody>
|
||||
{isLoading && (<Tr><Td colSpan={7}>Načítám...</Td></Tr>)}
|
||||
{!isLoading && (data || []).map((s) => (
|
||||
<Tr key={s.id}>
|
||||
<Td>
|
||||
<Image src={normalizeImageUrl(s.logo_url)} alt={s.name} boxSize="48px" objectFit="contain" />
|
||||
</Td>
|
||||
<Td>{s.name}</Td>
|
||||
<Td>
|
||||
<Badge colorScheme={s.tier === 'general' ? 'green' : 'blue'}>
|
||||
{s.tier === 'general' ? 'Hlavní partner' : 'Partner'}
|
||||
</Badge>
|
||||
</Td>
|
||||
<Td>{s.display_order ?? 0}</Td>
|
||||
<Td>
|
||||
{s.website_url ? (
|
||||
<HStack>
|
||||
<Link href={s.website_url} color="blue.500" isExternal fontSize="sm">{s.website_url}</Link>
|
||||
<FiExternalLink />
|
||||
</HStack>
|
||||
) : '-'}
|
||||
</Td>
|
||||
<Td>
|
||||
<Switch isChecked={!!s.is_active} onChange={() => toggleActive(s)} />
|
||||
</Td>
|
||||
<Td>
|
||||
<HStack spacing={2}>
|
||||
<IconButton
|
||||
aria-label="Upravit"
|
||||
icon={<FiEdit2 />}
|
||||
size="sm"
|
||||
onClick={() => openEdit(s)}
|
||||
/>
|
||||
<IconButton
|
||||
aria-label="Smazat"
|
||||
icon={<FiTrash2 />}
|
||||
size="sm"
|
||||
colorScheme="red"
|
||||
variant="outline"
|
||||
onClick={async () => {
|
||||
if (!window.confirm(`Opravdu chcete smazat sponzora "${s.name}"?`)) return;
|
||||
try {
|
||||
await deleteMut.mutateAsync(s.id as number);
|
||||
toast({ title: 'Sponzor smazán', status: 'success' });
|
||||
} catch (err) {
|
||||
toast({ title: 'Smazání sponzora selhalo', status: 'error' });
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</HStack>
|
||||
</Td>
|
||||
</Tr>
|
||||
))}
|
||||
</Tbody>
|
||||
</Table>
|
||||
</Box>
|
||||
|
||||
<Modal isOpen={isOpen} onClose={closeModal} size="lg">
|
||||
<ModalOverlay />
|
||||
<ModalContent>
|
||||
<ModalHeader>{(editing as any)?.id ? 'Upravit sponzora' : 'Nový sponzor'}</ModalHeader>
|
||||
<ModalCloseButton />
|
||||
<ModalBody>
|
||||
<VStack align="stretch" spacing={4}>
|
||||
<FormControl isRequired>
|
||||
<FormLabel>Název</FormLabel>
|
||||
<Input value={editing?.name || ''} onChange={(e) => setEditing((p) => ({ ...(p as any), name: e.target.value }))} />
|
||||
</FormControl>
|
||||
<FormControl>
|
||||
<FormLabel>Web (URL)</FormLabel>
|
||||
<Input value={editing?.website_url || ''} onChange={(e) => setEditing((p) => ({ ...(p as any), website_url: e.target.value }))} />
|
||||
</FormControl>
|
||||
<FormControl>
|
||||
<FormLabel>Logo</FormLabel>
|
||||
<HStack>
|
||||
<Image src={normalizeImageUrl(editing?.logo_url)} alt="logo" boxSize="56px" objectFit="contain" />
|
||||
<Button as="label" type="button" leftIcon={<FiUpload />}>Upload
|
||||
<Input type="file" display="none" accept="image/*" onChange={async (e) => {
|
||||
const file = e.target.files?.[0];
|
||||
await onUpload(file);
|
||||
// reset input to allow selecting the same file again
|
||||
(e.target as HTMLInputElement).value = '';
|
||||
}} />
|
||||
</Button>
|
||||
</HStack>
|
||||
</FormControl>
|
||||
<FormControl>
|
||||
<FormLabel>Úroveň partnera</FormLabel>
|
||||
<Select value={editing?.tier || 'standard'} onChange={(e) => setEditing((p) => ({ ...(p as any), tier: e.target.value }))}>
|
||||
<option value="general">Hlavní partner</option>
|
||||
<option value="standard">Partner</option>
|
||||
</Select>
|
||||
</FormControl>
|
||||
<FormControl>
|
||||
<FormLabel>Pořadí zobrazení</FormLabel>
|
||||
<NumberInput value={editing?.display_order ?? 0} onChange={(val) => setEditing((p) => ({ ...(p as any), display_order: parseInt(val) || 0 }))} min={0}>
|
||||
<NumberInputField />
|
||||
</NumberInput>
|
||||
<Text fontSize="xs" color="gray.500" mt={1}>Menší číslo = vyšší pozice</Text>
|
||||
</FormControl>
|
||||
<FormControl display="flex" alignItems="center">
|
||||
<FormLabel mb="0">Aktivní</FormLabel>
|
||||
<Switch isChecked={!!editing?.is_active} onChange={(e) => setEditing((p) => ({ ...(p as any), is_active: e.target.checked }))} />
|
||||
</FormControl>
|
||||
</VStack>
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<Button variant="ghost" mr={3} onClick={closeModal}>Zrušit</Button>
|
||||
<Button colorScheme="blue" onClick={onSubmit} isLoading={createMut.isLoading || updateMut.isLoading}>Uložit</Button>
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
</Box>
|
||||
</AdminLayout>
|
||||
);
|
||||
};
|
||||
|
||||
export default SponsorsAdminPage;
|
||||
@@ -0,0 +1,158 @@
|
||||
import { Heading, Text, Box, Spinner, Alert, AlertIcon, Table, Thead, Tbody, Tr, Th, Td, VStack, Select, HStack, Badge } from '@chakra-ui/react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useMemo, useState } from 'react';
|
||||
import AdminLayout from '../../layouts/AdminLayout';
|
||||
import { assetUrl } from '../../utils/url';
|
||||
import { TeamLogo } from '../../components/common/TeamLogo';
|
||||
|
||||
type TableRow = {
|
||||
rank?: string;
|
||||
team?: string;
|
||||
team_id?: string;
|
||||
team_logo_url?: string;
|
||||
played?: string;
|
||||
wins?: string;
|
||||
draws?: string;
|
||||
losses?: string;
|
||||
score?: string;
|
||||
points?: string;
|
||||
};
|
||||
|
||||
const StandingsAdminPage: React.FC = () => {
|
||||
const { data, isLoading, error } = useQuery<any>({
|
||||
queryKey: ['facr-tables-cache'],
|
||||
queryFn: async () => {
|
||||
const apiUrl = process.env.REACT_APP_API_URL || 'http://localhost:8080/api/v1';
|
||||
const origin = new URL(apiUrl).origin;
|
||||
const url = `${origin}/cache/prefetch/facr_tables.json`;
|
||||
const res = await fetch(url, { headers: { 'Cache-Control': 'no-cache' } });
|
||||
if (!res.ok) throw new Error(`Failed to load cache: ${res.status}`);
|
||||
return res.json();
|
||||
},
|
||||
staleTime: 5 * 60 * 1000,
|
||||
});
|
||||
|
||||
const competitions: any[] = Array.isArray(data?.competitions) ? data!.competitions : [];
|
||||
|
||||
// Optional category/code switcher based on competition code
|
||||
const [code, setCode] = useState<string>('');
|
||||
const options = useMemo(() => {
|
||||
const items = competitions.map((c) => ({ code: c.code, name: c.name }));
|
||||
const unique = new Map(items.map((i) => [i.code, i]));
|
||||
return Array.from(unique.values());
|
||||
}, [competitions]);
|
||||
const filtered = code ? competitions.filter((c) => c.code === code) : competitions;
|
||||
|
||||
return (
|
||||
<AdminLayout>
|
||||
<Heading size="lg" mb={2}>Standings (FAČR)</Heading>
|
||||
<Text mb={4}>Read-only view of standings from FACR cache. Fast and offline-friendly.</Text>
|
||||
|
||||
{isLoading && (
|
||||
<VStack align="start" spacing={3} mb={6}>
|
||||
<Spinner />
|
||||
<Text>Načítám tabulky…</Text>
|
||||
</VStack>
|
||||
)}
|
||||
|
||||
{Boolean(error) && (
|
||||
<Alert status="error" variant="left-accent" mb={4}>
|
||||
<AlertIcon />
|
||||
Nepodařilo se načíst data z cache.
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{!isLoading && !error && (
|
||||
<HStack mb={4} spacing={3} align="center">
|
||||
<Text color="gray.600">Soutěž:</Text>
|
||||
<Select value={code} onChange={(e) => setCode(e.target.value)} maxW="260px" size="sm">
|
||||
<option value="">Vše</option>
|
||||
{options.map((o) => (
|
||||
<option key={o.code} value={o.code}>{o.code} — {o.name}</option>
|
||||
))}
|
||||
</Select>
|
||||
<Badge colorScheme="gray" variant="subtle">{filtered.length} soutěží</Badge>
|
||||
</HStack>
|
||||
)}
|
||||
|
||||
{!isLoading && !error && filtered.map((comp) => {
|
||||
const rows: TableRow[] = comp?.table?.overall || [];
|
||||
return (
|
||||
<Box key={comp.id} mb={8}>
|
||||
<Heading size="md" mb={3}>{comp.name}</Heading>
|
||||
<Box
|
||||
overflowX="auto"
|
||||
borderWidth="1px"
|
||||
borderRadius="md"
|
||||
w="full"
|
||||
maxW="100%"
|
||||
sx={{
|
||||
WebkitOverflowScrolling: 'touch',
|
||||
'th, td': { whiteSpace: 'nowrap' },
|
||||
}}
|
||||
>
|
||||
<Table size="sm" sx={{ width: 'max-content' }}>
|
||||
<Thead>
|
||||
<Tr>
|
||||
<Th>#</Th>
|
||||
<Th>Tým</Th>
|
||||
<Th isNumeric>Z</Th>
|
||||
<Th isNumeric>V</Th>
|
||||
<Th isNumeric>R</Th>
|
||||
<Th isNumeric>P</Th>
|
||||
<Th isNumeric>Skóre</Th>
|
||||
<Th isNumeric>Body</Th>
|
||||
</Tr>
|
||||
</Thead>
|
||||
<Tbody>
|
||||
{rows.map((r, idx) => (
|
||||
<Tr key={`${comp.id}-${idx}`}>
|
||||
<Td width="40px">{r.rank}</Td>
|
||||
<Td>
|
||||
<HStack spacing={2} align="center">
|
||||
<TeamLogo
|
||||
teamId={r.team_id}
|
||||
teamName={r.team}
|
||||
facrLogo={r.team_logo_url}
|
||||
size="small"
|
||||
alt={r.team}
|
||||
objectFit="contain"
|
||||
fallbackIcon={
|
||||
<Box
|
||||
w="20px"
|
||||
h="20px"
|
||||
bg="gray.200"
|
||||
borderRadius="md"
|
||||
display="flex"
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
color="gray.400"
|
||||
fontSize="xs"
|
||||
fontWeight="bold"
|
||||
>
|
||||
{r.team?.substring(0, 2).toUpperCase() || '??'}
|
||||
</Box>
|
||||
}
|
||||
/>
|
||||
<Text as="span">{r.team}</Text>
|
||||
</HStack>
|
||||
</Td>
|
||||
<Td isNumeric>{r.played}</Td>
|
||||
<Td isNumeric>{r.wins}</Td>
|
||||
<Td isNumeric>{r.draws}</Td>
|
||||
<Td isNumeric>{r.losses}</Td>
|
||||
<Td isNumeric>{r.score}</Td>
|
||||
<Td isNumeric fontWeight="bold">{r.points}</Td>
|
||||
</Tr>
|
||||
))}
|
||||
</Tbody>
|
||||
</Table>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
</AdminLayout>
|
||||
);
|
||||
};
|
||||
|
||||
export default StandingsAdminPage;
|
||||
@@ -0,0 +1,692 @@
|
||||
import { batchFetchLogosFromSportLogosAPI } from '../../utils/sportLogosAPI';
|
||||
import {
|
||||
Heading,
|
||||
Text,
|
||||
Box,
|
||||
Spinner,
|
||||
Alert,
|
||||
AlertIcon,
|
||||
Table,
|
||||
Thead,
|
||||
Tbody,
|
||||
Tr,
|
||||
Th,
|
||||
Td,
|
||||
VStack,
|
||||
Button,
|
||||
HStack,
|
||||
Drawer,
|
||||
DrawerOverlay,
|
||||
DrawerContent,
|
||||
DrawerHeader,
|
||||
DrawerBody,
|
||||
DrawerFooter,
|
||||
FormControl,
|
||||
FormLabel,
|
||||
Input,
|
||||
InputGroup,
|
||||
InputRightElement,
|
||||
List,
|
||||
ListItem,
|
||||
Badge,
|
||||
useToast,
|
||||
SimpleGrid,
|
||||
useColorModeValue,
|
||||
ButtonGroup,
|
||||
Tag,
|
||||
TagLabel,
|
||||
Tooltip,
|
||||
Image,
|
||||
Tabs,
|
||||
TabList,
|
||||
Tab,
|
||||
TabPanels,
|
||||
TabPanel,
|
||||
Flex,
|
||||
Wrap
|
||||
} from '@chakra-ui/react';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import AdminLayout from '../../layouts/AdminLayout';
|
||||
import { searchClubs, uploadImage, putTeamLogoOverride, fetchTeamLogoOverrides, fetchLogoAsBlob, uploadToLogaSportcreative } from '../../services/adminMatches';
|
||||
import { getFacrTablesCache } from '../../services/facr/cache';
|
||||
import { assetUrl } from '../../utils/url';
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { TeamLogo } from '../../components/common/TeamLogo';
|
||||
|
||||
type TableRow = {
|
||||
rank?: string;
|
||||
team?: string;
|
||||
team_id?: string;
|
||||
team_logo_url?: string;
|
||||
played?: string;
|
||||
wins?: string;
|
||||
draws?: string;
|
||||
losses?: string;
|
||||
score?: string;
|
||||
points?: string;
|
||||
};
|
||||
|
||||
const TeamsAdminPage = () => {
|
||||
const toast = useToast();
|
||||
const queryClient = useQueryClient();
|
||||
const { data, isLoading, error } = useQuery<any>({
|
||||
queryKey: ['facr-tables-cache'],
|
||||
queryFn: () => getFacrTablesCache(),
|
||||
staleTime: 5 * 60 * 1000,
|
||||
});
|
||||
|
||||
const competitions: any[] = Array.isArray(data?.competitions) ? data!.competitions : [];
|
||||
// Backend origin (used to resolve relative URLs like /uploads/...)
|
||||
const apiUrl = process.env.REACT_APP_API_URL || 'http://localhost:8080/api/v1';
|
||||
const backendOrigin = new URL(apiUrl).origin;
|
||||
|
||||
// Load public/admin overrides map to apply on cache-fed view
|
||||
const { data: overrides = {} } = useQuery({
|
||||
queryKey: ['teamLogoOverrides'],
|
||||
queryFn: fetchTeamLogoOverrides,
|
||||
staleTime: 5 * 60 * 1000,
|
||||
});
|
||||
|
||||
// Fetch logos from logoapi.sportcreative.eu for all teams
|
||||
const [sportLogosMap, setSportLogosMap] = useState<Record<string, string>>({});
|
||||
const [sportLogosLoading, setSportLogosLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!competitions || competitions.length === 0) return;
|
||||
|
||||
// Extract all unique team IDs from all competitions
|
||||
const teamIds = new Set<string>();
|
||||
for (const comp of competitions) {
|
||||
const rows: TableRow[] = comp?.table?.overall || [];
|
||||
for (const r of rows) {
|
||||
if (r.team_id) teamIds.add(r.team_id);
|
||||
}
|
||||
}
|
||||
|
||||
if (teamIds.size === 0) return;
|
||||
|
||||
// Fetch logos from logoapi.sportcreative.eu
|
||||
setSportLogosLoading(true);
|
||||
batchFetchLogosFromSportLogosAPI(Array.from(teamIds))
|
||||
.then((map) => setSportLogosMap(map))
|
||||
.catch((err) => console.error('Failed to fetch sport logos:', err))
|
||||
.finally(() => setSportLogosLoading(false));
|
||||
}, [competitions]);
|
||||
const normalize = (s: string) => {
|
||||
let out = String(s || '');
|
||||
// Normalize diacritics and case
|
||||
out = out
|
||||
.normalize('NFD')
|
||||
.replace(/[\u0300-\u036f]/g, '')
|
||||
.toLowerCase();
|
||||
// Unify various dash characters to a simple hyphen
|
||||
out = out.replace(/[\u2012\u2013\u2014\u2015\u2212]/g, '-');
|
||||
// Remove legal suffixes like ", z.s." / ", z. s." / " z.s." / "o.s." at end
|
||||
out = out.replace(/[,\s]*(z\.?\s*s\.?|o\.?\s*s\.?)\s*$/g, '');
|
||||
// Remove organization phrases/prefixes anywhere (keep core locality/name)
|
||||
const orgPhrases = [
|
||||
'fotbalovy klub',
|
||||
'sportovni klub',
|
||||
'telovychovna jednota',
|
||||
'skolni sportovni klub',
|
||||
'fotbal',
|
||||
'futsal',
|
||||
];
|
||||
for (const phrase of orgPhrases) {
|
||||
const re = new RegExp(`(^|\b)${phrase}(\b|$)`, 'g');
|
||||
out = out.replace(re, ' ');
|
||||
}
|
||||
// Remove common short prefixes (tokens) like FC, FK, MFK, TJ, SK, SFC, AFK at word boundaries
|
||||
out = out.replace(/\b(1\.)?\s*(sfc|afc|fc|fk|mfk|tj|sk|afk)\b\.?/g, ' ');
|
||||
// Remove punctuation except hyphen
|
||||
out = out.replace(/[\.,!;:()\[\]{}]/g, ' ');
|
||||
// Collapse multiple spaces and trim
|
||||
out = out.replace(/\s+/g, ' ').trim();
|
||||
return out;
|
||||
};
|
||||
const byName: Record<string, string> = (overrides as any)?.by_name || {};
|
||||
const byNameNormalized = useMemo(() => {
|
||||
const idx: Record<string, string> = {};
|
||||
for (const k of Object.keys(byName)) {
|
||||
idx[normalize(k)] = byName[k];
|
||||
}
|
||||
return idx;
|
||||
}, [byName]);
|
||||
const getLogo = (teamName?: string, teamId?: string, original?: string) => {
|
||||
if (!teamName) return assetUrl('/dist/img/logo-club-empty.svg') as string;
|
||||
|
||||
// Priority 1: Try logoapi.sportcreative.eu if we have a team ID
|
||||
if (teamId && sportLogosMap[teamId]) {
|
||||
return sportLogosMap[teamId];
|
||||
}
|
||||
|
||||
// Priority 2: Try exact match from local overrides
|
||||
let overrideUrl = byName[teamName];
|
||||
if (!overrideUrl) {
|
||||
// Fallback: diacritics-insensitive + case-insensitive + trimmed match
|
||||
const norm = normalize(teamName);
|
||||
overrideUrl = byNameNormalized[norm];
|
||||
}
|
||||
|
||||
// Priority 3: Use override if found
|
||||
if (overrideUrl) {
|
||||
// Resolve against backend for relative assets
|
||||
if (typeof overrideUrl === 'string' && overrideUrl.startsWith('/')) {
|
||||
return assetUrl(overrideUrl) as string;
|
||||
}
|
||||
return overrideUrl;
|
||||
}
|
||||
|
||||
// Priority 4: Use FACR original
|
||||
if (original) {
|
||||
return original;
|
||||
}
|
||||
|
||||
// Final fallback: empty logo
|
||||
return '/dist/img/logo-club-empty.svg';
|
||||
};
|
||||
|
||||
// View mode: 'table' per competition, or 'grid' of unique teams across competitions
|
||||
const [viewMode, setViewMode] = useState<'table' | 'grid'>('table');
|
||||
// Selected competition for quick switching (only applies in table mode)
|
||||
const [selectedCompIndex, setSelectedCompIndex] = useState<number>(0);
|
||||
// Text filter for club/team name
|
||||
const [filter, setFilter] = useState('');
|
||||
const [filterDebounced, setFilterDebounced] = useState('');
|
||||
useEffect(() => {
|
||||
const t = setTimeout(() => setFilterDebounced(filter), 250);
|
||||
return () => clearTimeout(t);
|
||||
}, [filter]);
|
||||
|
||||
// Build a deduplicated list of teams across all competitions
|
||||
type TeamAggregate = {
|
||||
key: string; // normalized key
|
||||
name: string; // representative name
|
||||
logo: string;
|
||||
variants: string[]; // all raw names found
|
||||
};
|
||||
const allTeamsUnique: TeamAggregate[] = useMemo(() => {
|
||||
const map: Record<string, TeamAggregate> = {};
|
||||
for (const comp of competitions) {
|
||||
const rows: TableRow[] = comp?.table?.overall || [];
|
||||
for (const r of rows) {
|
||||
const teamName = (r.team || '').trim();
|
||||
if (!teamName) continue;
|
||||
const key = normalize(teamName);
|
||||
const logo = getLogo(teamName, r.team_id, r.team_logo_url);
|
||||
if (!map[key]) {
|
||||
map[key] = { key, name: teamName, logo, variants: [teamName] };
|
||||
} else {
|
||||
map[key].variants.push(teamName);
|
||||
// Update logo - prefer non-empty logos
|
||||
const currentIsEmpty = !map[key].logo || /logo-club-empty\.svg$/.test(String(map[key].logo));
|
||||
const newIsNotEmpty = logo && !/logo-club-empty\.svg$/.test(String(logo));
|
||||
if (currentIsEmpty && newIsNotEmpty) {
|
||||
map[key].logo = logo as string;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Sort by representative name
|
||||
return Object.values(map).sort((a, b) => a.name.localeCompare(b.name, 'cs', { sensitivity: 'base' }));
|
||||
}, [competitions, getLogo]);
|
||||
|
||||
// Fast lookup from normalized name to variant list
|
||||
const variantsByKey = useMemo(() => {
|
||||
const m: Record<string, string[]> = {};
|
||||
for (const t of allTeamsUnique) {
|
||||
m[t.key] = Array.from(new Set(t.variants));
|
||||
}
|
||||
return m;
|
||||
}, [allTeamsUnique]);
|
||||
|
||||
// Drawer state for editing a team's logo/name
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [selected, setSelected] = useState<{ compId?: string; teamName: string; teamLogoUrl?: string; variantNames?: string[] } | null>(null);
|
||||
const [form, setForm] = useState({
|
||||
external_team_id: '',
|
||||
team_name: '',
|
||||
logo_url: '',
|
||||
});
|
||||
const fileRef = useRef<HTMLInputElement | null>(null);
|
||||
const [uploadedFile, setUploadedFile] = useState<File | null>(null);
|
||||
const [externalUploadStatus, setExternalUploadStatus] = useState<'idle' | 'uploading' | 'success' | 'error'>('idle');
|
||||
const [externalUploadError, setExternalUploadError] = useState<string | null>(null);
|
||||
|
||||
// Club search
|
||||
const [query, setQuery] = useState('');
|
||||
const [debounced, setDebounced] = useState('');
|
||||
useEffect(() => {
|
||||
const t = setTimeout(() => setDebounced(query), 300);
|
||||
return () => clearTimeout(t);
|
||||
}, [query]);
|
||||
const { data: searchResults = [] } = useQuery({
|
||||
queryKey: ['club-search', debounced],
|
||||
queryFn: () => searchClubs(debounced),
|
||||
enabled: debounced.trim().length >= 2,
|
||||
});
|
||||
|
||||
const onOpenEdit = (teamName: string, teamLogoUrl?: string, variantNames?: string[]) => {
|
||||
// If variants not explicitly provided (e.g., from table view), compute from normalized key
|
||||
let v = variantNames;
|
||||
if (!v || v.length === 0) {
|
||||
const key = normalize(teamName || '');
|
||||
v = variantsByKey[key] || [];
|
||||
}
|
||||
setSelected({ teamName, teamLogoUrl, variantNames: v });
|
||||
setForm({ external_team_id: '', team_name: teamName || '', logo_url: teamLogoUrl || '' });
|
||||
setQuery(teamName || '');
|
||||
setIsOpen(true);
|
||||
};
|
||||
|
||||
const onSave = useMutation({
|
||||
mutationFn: async () => {
|
||||
if (!form.external_team_id) {
|
||||
throw new Error('Vyberte tým ze seznamu vyhledávání (chybí ID).');
|
||||
}
|
||||
let logoUrl = (form.logo_url || '').trim();
|
||||
const primaryName = (selected?.teamName || form.team_name || '').trim();
|
||||
// All variants to update (deduped), always include the primary name
|
||||
const names = Array.from(new Set([primaryName, ...((selected?.variantNames || []) as string[])]))
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
// Save override for each variant name so editing one updates all duplicates
|
||||
await Promise.all(
|
||||
names.map((n) => putTeamLogoOverride(form.external_team_id, n, logoUrl))
|
||||
);
|
||||
|
||||
// Also upload to logoapi.sportcreative.eu (non-blocking, best-effort)
|
||||
if (logoUrl) {
|
||||
setExternalUploadStatus('uploading');
|
||||
setExternalUploadError(null);
|
||||
|
||||
try {
|
||||
let logoFileToUpload: File | Blob | null = uploadedFile;
|
||||
|
||||
// If no file was uploaded but we have a logo URL, fetch it as blob
|
||||
if (!logoFileToUpload && logoUrl) {
|
||||
logoFileToUpload = await fetchLogoAsBlob(logoUrl);
|
||||
}
|
||||
|
||||
if (logoFileToUpload) {
|
||||
// Upload to the logo service (loga.sportcreative.eu)
|
||||
const logaResult = await uploadToLogaSportcreative(
|
||||
form.external_team_id,
|
||||
logoFileToUpload,
|
||||
{
|
||||
filename: `${form.external_team_id}.${logoFileToUpload instanceof File ? logoFileToUpload.name.split('.').pop() : 'png'}`,
|
||||
clubName: form.team_name || selected?.teamName || 'Neznámý klub'
|
||||
}
|
||||
);
|
||||
|
||||
if (logaResult.success) {
|
||||
setExternalUploadStatus('success');
|
||||
// Use the URL from loga.sportcreative.eu
|
||||
if (logaResult.url) {
|
||||
logoUrl = logaResult.url;
|
||||
}
|
||||
} else {
|
||||
setExternalUploadStatus('error');
|
||||
setExternalUploadError(logaResult.error || 'Nepodařilo se nahrát logo');
|
||||
}
|
||||
} else {
|
||||
setExternalUploadStatus('error');
|
||||
setExternalUploadError('Could not fetch logo file');
|
||||
}
|
||||
} catch (error: any) {
|
||||
setExternalUploadStatus('error');
|
||||
setExternalUploadError(error?.message || 'Upload failed');
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
},
|
||||
onSuccess: () => {
|
||||
let description = 'Změna byla aplikována na všechny duplicitní varianty.';
|
||||
if (externalUploadStatus === 'success') {
|
||||
description += ' Logo bylo úspěšně nahráno.';
|
||||
} else if (externalUploadStatus === 'error') {
|
||||
description += ` ⚠️ ${externalUploadError || 'Nepodařilo se nahrát logo'}`;
|
||||
}
|
||||
|
||||
toast({
|
||||
title: 'Uloženo',
|
||||
description,
|
||||
status: externalUploadStatus === 'error' ? 'warning' : 'success',
|
||||
duration: 6000,
|
||||
isClosable: true,
|
||||
});
|
||||
|
||||
setIsOpen(false);
|
||||
setSelected(null);
|
||||
setUploadedFile(null);
|
||||
setExternalUploadStatus('idle');
|
||||
setExternalUploadError(null);
|
||||
queryClient.invalidateQueries({ queryKey: ['facr-tables-cache'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['teamLogoOverrides'] });
|
||||
},
|
||||
onError: (e: any) => {
|
||||
toast({ title: 'Uložení selhalo', description: e?.message || 'Zkuste to znovu', status: 'error' });
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<AdminLayout requireAdmin={false}>
|
||||
<Box>
|
||||
<HStack justify="space-between" mb={4}>
|
||||
<Heading size="lg">Správa týmů</Heading>
|
||||
</HStack>
|
||||
<Text color="gray.500" mb={4} fontSize="sm">
|
||||
Přehled tabulek z FAČR. Kompaktní zobrazení s rychlým přepínáním soutěží.
|
||||
</Text>
|
||||
<Flex mb={4} gap={3} align="center" wrap="wrap">
|
||||
<ButtonGroup isAttached size="sm" variant="outline">
|
||||
<Button
|
||||
colorScheme={viewMode === 'table' ? 'blue' : 'gray'}
|
||||
variant={viewMode === 'table' ? 'solid' : 'outline'}
|
||||
onClick={() => setViewMode('table')}
|
||||
size="sm"
|
||||
>
|
||||
Tabulky
|
||||
</Button>
|
||||
<Button
|
||||
colorScheme={viewMode === 'grid' ? 'blue' : 'gray'}
|
||||
variant={viewMode === 'grid' ? 'solid' : 'outline'}
|
||||
onClick={() => setViewMode('grid')}
|
||||
size="sm"
|
||||
>
|
||||
Mřížka
|
||||
</Button>
|
||||
</ButtonGroup>
|
||||
<InputGroup maxW={{ base: '100%', md: '300px' }} size="sm">
|
||||
<Input placeholder="Filtrovat podle názvu týmu…" value={filter} onChange={(e) => setFilter(e.target.value)} />
|
||||
{filter && (
|
||||
<InputRightElement width="3rem">
|
||||
<Button size="xs" variant="ghost" onClick={() => setFilter('')}>Vymazat</Button>
|
||||
</InputRightElement>
|
||||
)}
|
||||
</InputGroup>
|
||||
{sportLogosLoading && (
|
||||
<Badge colorScheme="blue" fontSize="xs">
|
||||
<HStack spacing={1}>
|
||||
<Spinner size="xs" />
|
||||
<Text>Načítám loga z logoapi.sportcreative.eu...</Text>
|
||||
</HStack>
|
||||
</Badge>
|
||||
)}
|
||||
{!sportLogosLoading && Object.keys(sportLogosMap).length > 0 && (
|
||||
<Badge colorScheme="green" fontSize="xs">
|
||||
✓ {Object.keys(sportLogosMap).length} log z logoapi.sportcreative.eu
|
||||
</Badge>
|
||||
)}
|
||||
</Flex>
|
||||
|
||||
{isLoading && (
|
||||
<VStack align="start" spacing={3} mb={6}>
|
||||
<Spinner />
|
||||
<Text>Načítám tabulky…</Text>
|
||||
</VStack>
|
||||
)}
|
||||
|
||||
{Boolean(error) && (
|
||||
<Alert status="error" variant="left-accent" mb={4}>
|
||||
<AlertIcon />
|
||||
Nepodařilo se načíst data z cache.
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{!isLoading && !error && viewMode === 'table' && (
|
||||
<Box>
|
||||
<Tabs
|
||||
index={selectedCompIndex}
|
||||
onChange={setSelectedCompIndex}
|
||||
variant="soft-rounded"
|
||||
colorScheme="blue"
|
||||
size="sm"
|
||||
>
|
||||
<TabList mb={4} overflowX="auto" overflowY="hidden" flexWrap="nowrap" pb={2}>
|
||||
{competitions.map((comp, idx) => (
|
||||
<Tab
|
||||
key={comp.id}
|
||||
fontSize="xs"
|
||||
px={3}
|
||||
py={1.5}
|
||||
minW="fit-content"
|
||||
whiteSpace="nowrap"
|
||||
>
|
||||
{comp.name}
|
||||
</Tab>
|
||||
))}
|
||||
</TabList>
|
||||
<TabPanels>
|
||||
{competitions.map((comp) => {
|
||||
const rows: TableRow[] = comp?.table?.overall || [];
|
||||
const f = normalize(filterDebounced);
|
||||
const rowsFiltered = f ? rows.filter((r) => normalize(r.team || '').includes(f)) : rows;
|
||||
return (
|
||||
<TabPanel key={comp.id} p={0}>
|
||||
<Box
|
||||
overflowX="auto"
|
||||
borderWidth="1px"
|
||||
borderRadius="md"
|
||||
w="full"
|
||||
sx={{
|
||||
WebkitOverflowScrolling: 'touch',
|
||||
}}
|
||||
>
|
||||
<Table size="sm" variant="simple">
|
||||
<Thead bg={useColorModeValue('gray.50', 'gray.700')}>
|
||||
<Tr>
|
||||
<Th w="40px" fontSize="xs" py={2}>#</Th>
|
||||
<Th fontSize="xs" py={2}>Tým</Th>
|
||||
<Th isNumeric w="45px" fontSize="xs" py={2}>Z</Th>
|
||||
<Th isNumeric w="45px" fontSize="xs" py={2}>V</Th>
|
||||
<Th isNumeric w="45px" fontSize="xs" py={2}>R</Th>
|
||||
<Th isNumeric w="45px" fontSize="xs" py={2}>P</Th>
|
||||
<Th isNumeric w="70px" fontSize="xs" py={2}>Skóre</Th>
|
||||
<Th isNumeric w="50px" fontSize="xs" py={2}>Body</Th>
|
||||
<Th w="90px" fontSize="xs" py={2}>Akce</Th>
|
||||
</Tr>
|
||||
</Thead>
|
||||
<Tbody>
|
||||
{rowsFiltered.map((r, idx) => (
|
||||
<Tr key={`${comp.id}-${idx}`} _hover={{ bg: useColorModeValue('gray.50', 'gray.700') }}>
|
||||
<Td py={1.5} fontSize="xs">{r.rank}</Td>
|
||||
<Td py={1.5}>
|
||||
<HStack spacing={2} align="center">
|
||||
<TeamLogo
|
||||
teamId={(r as any).team_id}
|
||||
teamName={r.team}
|
||||
facrLogo={r.team_logo_url}
|
||||
size="small"
|
||||
alt={r.team}
|
||||
objectFit="contain"
|
||||
/>
|
||||
<Text fontSize="xs" noOfLines={1}>{r.team}</Text>
|
||||
</HStack>
|
||||
</Td>
|
||||
<Td isNumeric py={1.5} fontSize="xs">{r.played}</Td>
|
||||
<Td isNumeric py={1.5} fontSize="xs">{r.wins}</Td>
|
||||
<Td isNumeric py={1.5} fontSize="xs">{r.draws}</Td>
|
||||
<Td isNumeric py={1.5} fontSize="xs">{r.losses}</Td>
|
||||
<Td isNumeric py={1.5} fontSize="xs">{r.score}</Td>
|
||||
<Td isNumeric py={1.5} fontSize="xs" fontWeight="bold">{r.points}</Td>
|
||||
<Td py={1.5}>
|
||||
<Button size="xs" fontSize="xs" onClick={() => onOpenEdit(r.team || '', r.team_logo_url)}>Upravit</Button>
|
||||
</Td>
|
||||
</Tr>
|
||||
))}
|
||||
{rowsFiltered.length === 0 && (
|
||||
<Tr>
|
||||
<Td colSpan={9} py={4}>
|
||||
<Text color="gray.500" fontSize="sm" textAlign="center">Žádný tým neodpovídá filtru.</Text>
|
||||
</Td>
|
||||
</Tr>
|
||||
)}
|
||||
</Tbody>
|
||||
</Table>
|
||||
</Box>
|
||||
</TabPanel>
|
||||
);
|
||||
})}
|
||||
</TabPanels>
|
||||
</Tabs>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{!isLoading && !error && viewMode === 'grid' && (
|
||||
<Box>
|
||||
{(() => {
|
||||
const f = normalize(filterDebounced);
|
||||
const source = f
|
||||
? allTeamsUnique.filter((t) => normalize(t.name).includes(f) || (t.variants || []).some(v => normalize(v).includes(f)))
|
||||
: allTeamsUnique;
|
||||
return (
|
||||
<SimpleGrid columns={{ base: 2, sm: 3, md: 4, lg: 6 }} spacing={3}>
|
||||
{source.map((t) => (
|
||||
<Box
|
||||
key={t.key}
|
||||
borderWidth="1px"
|
||||
borderRadius="md"
|
||||
p={2.5}
|
||||
_hover={{ boxShadow: 'md', borderColor: 'blue.300' }}
|
||||
transition="all 0.2s"
|
||||
>
|
||||
<VStack align="stretch" spacing={2}>
|
||||
<HStack justify="space-between" align="start">
|
||||
<HStack spacing={2} flex={1} minW={0}>
|
||||
<Image src={t.logo} alt={t.name} boxSize="24px" objectFit="contain" flexShrink={0} />
|
||||
<Text fontSize="xs" noOfLines={2} fontWeight="medium">{t.name}</Text>
|
||||
</HStack>
|
||||
{t.variants.length > 1 && (
|
||||
<Tooltip label={`Varianty: ${Array.from(new Set(t.variants)).join(', ')}`} hasArrow>
|
||||
<Tag size="sm" colorScheme="purple" variant="subtle" fontSize="xs">
|
||||
<TagLabel>{t.variants.length}</TagLabel>
|
||||
</Tag>
|
||||
</Tooltip>
|
||||
)}
|
||||
</HStack>
|
||||
<Button size="xs" fontSize="xs" onClick={() => onOpenEdit(t.name, t.logo, t.variants)} w="full">Upravit</Button>
|
||||
</VStack>
|
||||
</Box>
|
||||
))}
|
||||
{source.length === 0 && (
|
||||
<Box gridColumn={{ base: 'span 2', sm: 'span 3', md: 'span 4', lg: 'span 6' }}>
|
||||
<Text color="gray.500" fontSize="sm" textAlign="center" py={8}>Žádný tým neodpovídá filtru.</Text>
|
||||
</Box>
|
||||
)}
|
||||
</SimpleGrid>
|
||||
);
|
||||
})()}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<Drawer isOpen={isOpen} placement="right" onClose={() => { setIsOpen(false); setSelected(null); }} size="md">
|
||||
<DrawerOverlay />
|
||||
<DrawerContent>
|
||||
<DrawerHeader>Upravit logo týmu</DrawerHeader>
|
||||
<DrawerBody>
|
||||
<VStack align="stretch" spacing={4}>
|
||||
<FormControl>
|
||||
<FormLabel>Hledat tým (FAČR)</FormLabel>
|
||||
<InputGroup>
|
||||
<Input value={query} onChange={(e) => { setQuery(e.target.value); }} placeholder="Zadejte název týmu" />
|
||||
<InputRightElement width="8rem">
|
||||
<Button size="xs" onClick={() => fileRef.current?.click()}>Nahrát logo</Button>
|
||||
</InputRightElement>
|
||||
</InputGroup>
|
||||
{searchResults.length > 0 && (
|
||||
<Box
|
||||
mt={4}
|
||||
bg={useColorModeValue('white', 'gray.800')}
|
||||
borderWidth="1px"
|
||||
borderRadius="lg"
|
||||
overflowX="auto"
|
||||
boxShadow="sm"
|
||||
mb={6}
|
||||
>
|
||||
<List spacing={0}>
|
||||
{searchResults.map((r: any) => (
|
||||
<ListItem
|
||||
key={r.id}
|
||||
px={3}
|
||||
py={2}
|
||||
_hover={{ bg: 'gray.50', cursor: 'pointer' }}
|
||||
onClick={() => {
|
||||
setForm((f) => ({ ...f, external_team_id: r.id, team_name: r.name, logo_url: r.logo_url || f.logo_url }));
|
||||
setQuery(r.name);
|
||||
}}
|
||||
>
|
||||
<HStack justify="space-between" spacing={3}>
|
||||
<HStack spacing={3} maxW="80%">
|
||||
{r.logo_url ? (
|
||||
<Image src={r.logo_url} alt={r.name} boxSize="20px" objectFit="contain" />
|
||||
) : (
|
||||
<Badge colorScheme="gray">bez loga</Badge>
|
||||
)}
|
||||
<Text noOfLines={1}>{r.name}</Text>
|
||||
</HStack>
|
||||
{r.logo_url && <Badge colorScheme="green">logo</Badge>}
|
||||
</HStack>
|
||||
</ListItem>
|
||||
))}
|
||||
</List>
|
||||
</Box>
|
||||
)}
|
||||
<input ref={fileRef} type="file" accept="image/png,image/svg+xml,image/jpeg,application/pdf" style={{ display: 'none' }} onChange={async (e) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
try {
|
||||
const { url } = await uploadImage(file);
|
||||
setForm((f) => ({ ...f, logo_url: url }));
|
||||
setUploadedFile(file); // Store for later upload to external API
|
||||
toast({ title: 'Logo nahráno', status: 'success' });
|
||||
} catch (err: any) {
|
||||
toast({ title: 'Nahrávání selhalo', description: err?.message || 'Zkuste to znovu', status: 'error' });
|
||||
} finally {
|
||||
if (fileRef.current) fileRef.current.value = '' as any;
|
||||
}
|
||||
}} />
|
||||
</FormControl>
|
||||
|
||||
<FormControl>
|
||||
<FormLabel>Název týmu</FormLabel>
|
||||
<Input value={form.team_name} onChange={(e) => setForm({ ...form, team_name: e.target.value })} />
|
||||
</FormControl>
|
||||
|
||||
<FormControl>
|
||||
<FormLabel>Logo URL</FormLabel>
|
||||
<Input value={form.logo_url} onChange={(e) => setForm({ ...form, logo_url: e.target.value })} />
|
||||
</FormControl>
|
||||
{selected?.variantNames && selected.variantNames.length > 1 && (
|
||||
<Alert status="info">
|
||||
<AlertIcon />
|
||||
Upravíte také duplicitní názvy: {Array.from(new Set(selected.variantNames)).join(', ')}
|
||||
</Alert>
|
||||
)}
|
||||
{form.logo_url && (
|
||||
<Alert status="info" variant="left-accent">
|
||||
<AlertIcon />
|
||||
<VStack align="start" spacing={1}>
|
||||
<Text fontSize="sm" fontWeight="medium">Logo bude automaticky nahráno na logoapi.sportcreative.eu</Text>
|
||||
<Text fontSize="xs" color="gray.600">Toto poskytuje zálohu a sdílení log mezi aplikacemi.</Text>
|
||||
</VStack>
|
||||
</Alert>
|
||||
)}
|
||||
</VStack>
|
||||
</DrawerBody>
|
||||
<DrawerFooter>
|
||||
<HStack>
|
||||
<Button variant="outline" onClick={() => { setIsOpen(false); setSelected(null); }}>Zrušit</Button>
|
||||
<Button colorScheme="blue" isLoading={onSave.isPending} onClick={() => onSave.mutate()}>Uložit</Button>
|
||||
</HStack>
|
||||
</DrawerFooter>
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
</Box>
|
||||
</AdminLayout>
|
||||
);
|
||||
};
|
||||
|
||||
export default TeamsAdminPage;
|
||||
@@ -0,0 +1,430 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Table,
|
||||
Thead,
|
||||
Tbody,
|
||||
Tr,
|
||||
Th,
|
||||
Td,
|
||||
Button,
|
||||
useDisclosure,
|
||||
useToast,
|
||||
Badge,
|
||||
IconButton,
|
||||
Menu,
|
||||
MenuButton,
|
||||
MenuList,
|
||||
MenuItem,
|
||||
useColorModeValue,
|
||||
Modal,
|
||||
ModalOverlay,
|
||||
ModalContent,
|
||||
ModalHeader,
|
||||
ModalFooter,
|
||||
ModalBody,
|
||||
ModalCloseButton,
|
||||
FormControl,
|
||||
FormLabel,
|
||||
Input,
|
||||
Select,
|
||||
VStack,
|
||||
HStack,
|
||||
Text,
|
||||
Switch,
|
||||
FormHelperText,
|
||||
Heading,
|
||||
Spinner,
|
||||
Alert,
|
||||
AlertIcon,
|
||||
AlertTitle,
|
||||
AlertDescription,
|
||||
} from '@chakra-ui/react';
|
||||
import { AddIcon, DeleteIcon, EditIcon, HamburgerIcon } from '@chakra-ui/icons';
|
||||
import { useAuth } from '../../contexts/AuthContext';
|
||||
import api from '../../services/api';
|
||||
import AdminLayout from '../../layouts/AdminLayout';
|
||||
|
||||
interface User {
|
||||
id: string;
|
||||
email: string;
|
||||
name: string;
|
||||
role: 'admin' | 'editor';
|
||||
isActive: boolean;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
const UsersAdminPage = () => {
|
||||
const [users, setUsers] = useState<User[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const { isOpen, onOpen, onClose } = useDisclosure();
|
||||
const [selectedUser, setSelectedUser] = useState<User | null>(null);
|
||||
const [formData, setFormData] = useState({
|
||||
name: '',
|
||||
email: '',
|
||||
password: '',
|
||||
currentPassword: '',
|
||||
role: 'editor' as 'admin' | 'editor',
|
||||
isActive: true,
|
||||
});
|
||||
const toast = useToast();
|
||||
const bgColor = useColorModeValue('white', 'gray.800');
|
||||
const borderColor = useColorModeValue('gray.200', 'gray.700');
|
||||
const { user: authUser } = useAuth();
|
||||
|
||||
const fetchUsers = async () => {
|
||||
try {
|
||||
const response = await api.get('/admin/users');
|
||||
setUsers(response.data);
|
||||
} catch (error) {
|
||||
console.error('Error fetching users:', error);
|
||||
toast({
|
||||
title: 'Error',
|
||||
description: 'Failed to fetch users',
|
||||
status: 'error',
|
||||
duration: 5000,
|
||||
isClosable: true,
|
||||
});
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchUsers();
|
||||
}, []);
|
||||
|
||||
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>) => {
|
||||
const { name, value } = e.target;
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
[name]: name === 'isActive' ? (e.target as HTMLInputElement).checked : value
|
||||
}));
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setIsSubmitting(true);
|
||||
|
||||
try {
|
||||
if (selectedUser) {
|
||||
// Update existing user
|
||||
// If editing an admin account, require current password confirmation
|
||||
const payload: any = {
|
||||
name: formData.name,
|
||||
email: formData.email,
|
||||
role: formData.role,
|
||||
isActive: formData.isActive,
|
||||
};
|
||||
if (selectedUser.role === 'admin') {
|
||||
if (!formData.currentPassword) {
|
||||
throw new Error('Pro úpravu administrátorského účtu zadejte prosím současné heslo.');
|
||||
}
|
||||
payload.current_password = formData.currentPassword;
|
||||
}
|
||||
await api.put(`/admin/users/${selectedUser.id}`, payload);
|
||||
toast({
|
||||
title: 'Success',
|
||||
description: 'User updated successfully',
|
||||
status: 'success',
|
||||
duration: 3000,
|
||||
isClosable: true,
|
||||
});
|
||||
} else {
|
||||
// Create new user
|
||||
await api.post('/admin/users', formData);
|
||||
toast({
|
||||
title: 'Success',
|
||||
description: 'User created successfully',
|
||||
status: 'success',
|
||||
duration: 3000,
|
||||
isClosable: true,
|
||||
});
|
||||
}
|
||||
onClose();
|
||||
fetchUsers();
|
||||
resetForm();
|
||||
} catch (error: any) {
|
||||
console.error('Error saving user:', error);
|
||||
toast({
|
||||
title: 'Error',
|
||||
description: error.response?.data?.error || error.response?.data?.message || 'Failed to save user',
|
||||
status: 'error',
|
||||
duration: 5000,
|
||||
isClosable: true,
|
||||
});
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (userId: string) => {
|
||||
const u = users.find(x => x.id === userId);
|
||||
if (u?.role === 'admin') {
|
||||
toast({ title: 'Zakázáno', description: 'Admin uživatele nelze smazat.', status: 'warning' });
|
||||
return;
|
||||
}
|
||||
if (authUser && String(authUser.id) === String(userId)) {
|
||||
toast({ title: 'Zakázáno', description: 'Nemůžete smazat sám sebe.', status: 'warning' });
|
||||
return;
|
||||
}
|
||||
if (window.confirm('Are you sure you want to delete this user?')) {
|
||||
try {
|
||||
await api.delete(`/admin/users/${userId}`);
|
||||
toast({
|
||||
title: 'Success',
|
||||
description: 'User deleted successfully',
|
||||
status: 'success',
|
||||
duration: 3000,
|
||||
isClosable: true,
|
||||
});
|
||||
fetchUsers();
|
||||
} catch (error: any) {
|
||||
console.error('Error deleting user:', error);
|
||||
toast({
|
||||
title: 'Error',
|
||||
description: error.response?.data?.error || error.response?.data?.message || 'Failed to delete user',
|
||||
status: 'error',
|
||||
duration: 5000,
|
||||
isClosable: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const resetForm = () => {
|
||||
setFormData({
|
||||
name: '',
|
||||
email: '',
|
||||
password: '',
|
||||
currentPassword: '',
|
||||
role: 'editor',
|
||||
isActive: true,
|
||||
});
|
||||
setSelectedUser(null);
|
||||
};
|
||||
|
||||
const openEditModal = (user: User) => {
|
||||
setSelectedUser(user);
|
||||
setFormData({
|
||||
name: user.name,
|
||||
email: user.email,
|
||||
password: '',
|
||||
currentPassword: '',
|
||||
role: user.role,
|
||||
isActive: user.isActive,
|
||||
});
|
||||
onOpen();
|
||||
};
|
||||
|
||||
const openCreateModal = () => {
|
||||
resetForm();
|
||||
onOpen();
|
||||
};
|
||||
|
||||
return (
|
||||
<AdminLayout>
|
||||
<Box>
|
||||
<HStack justify="space-between" mb={4}>
|
||||
<Heading size="lg">Správa uživatelů</Heading>
|
||||
<Button leftIcon={<AddIcon />} colorScheme="blue" onClick={openCreateModal}>
|
||||
Přidat uživatele
|
||||
</Button>
|
||||
</HStack>
|
||||
<Text color="gray.500" mb={6}>
|
||||
Správa uživatelských účtů a jejich oprávnění. <strong>Editor</strong> může vytvářet a upravovat články a aktivity. <strong>Admin</strong> má přístup ke všem funkcím.
|
||||
</Text>
|
||||
|
||||
<Box bg={bgColor} borderRadius="md" boxShadow="sm" overflowX="auto">
|
||||
<Table variant="simple">
|
||||
<Thead>
|
||||
<Tr>
|
||||
<Th>Name</Th>
|
||||
<Th>Email</Th>
|
||||
<Th>Role</Th>
|
||||
<Th>Status</Th>
|
||||
<Th>Created</Th>
|
||||
<Th>Actions</Th>
|
||||
</Tr>
|
||||
</Thead>
|
||||
<Tbody>
|
||||
{users.map((user) => (
|
||||
<Tr key={user.id}>
|
||||
<Td>{user.name}</Td>
|
||||
<Td>{user.email}</Td>
|
||||
<Td>
|
||||
<Badge colorScheme={user.role === 'admin' ? 'purple' : 'blue'}>
|
||||
{user.role === 'admin' ? 'Admin' : 'Editor'}
|
||||
</Badge>
|
||||
</Td>
|
||||
<Td>
|
||||
<Badge colorScheme={user.isActive ? 'green' : 'red'}>
|
||||
{user.isActive ? 'Active' : 'Inactive'}
|
||||
</Badge>
|
||||
</Td>
|
||||
<Td>{new Date(user.createdAt).toLocaleDateString()}</Td>
|
||||
<Td>
|
||||
<Menu>
|
||||
<MenuButton
|
||||
as={IconButton}
|
||||
aria-label="Options"
|
||||
icon={<HamburgerIcon />}
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
/>
|
||||
<MenuList>
|
||||
<MenuItem
|
||||
icon={<EditIcon />}
|
||||
onClick={() => openEditModal(user)}
|
||||
>
|
||||
Edit
|
||||
</MenuItem>
|
||||
<MenuItem onClick={async () => {
|
||||
try {
|
||||
await api.post(`/admin/users/${user.id}/reset-password`);
|
||||
toast({ title: 'Hotovo', description: 'Instrukce pro obnovení hesla byly odeslány.', status: 'success' });
|
||||
} catch (e: any) {
|
||||
const errorMsg = e?.response?.data?.message || e?.response?.data?.error || e?.message || 'Nelze odeslat reset hesla';
|
||||
const errorDetails = e?.response?.data?.details;
|
||||
toast({
|
||||
title: 'Chyba při odesílání resetu hesla',
|
||||
description: errorDetails ? `${errorMsg}\n\n${errorDetails}` : errorMsg,
|
||||
status: 'error',
|
||||
duration: 10000,
|
||||
isClosable: true,
|
||||
});
|
||||
}
|
||||
}}>
|
||||
Odeslat reset hesla
|
||||
</MenuItem>
|
||||
{user.role !== 'admin' && String(authUser?.id) !== String(user.id) && (
|
||||
<MenuItem
|
||||
icon={<DeleteIcon />}
|
||||
color="red.500"
|
||||
onClick={() => handleDelete(user.id)}
|
||||
>
|
||||
Delete
|
||||
</MenuItem>
|
||||
)}
|
||||
</MenuList>
|
||||
</Menu>
|
||||
</Td>
|
||||
</Tr>
|
||||
))}
|
||||
</Tbody>
|
||||
</Table>
|
||||
</Box>
|
||||
|
||||
{/* Add/Edit User Modal */}
|
||||
<Modal isOpen={isOpen} onClose={onClose} size="lg">
|
||||
<ModalOverlay />
|
||||
<ModalContent as="form" onSubmit={handleSubmit}>
|
||||
<ModalHeader>
|
||||
{selectedUser ? 'Edit User' : 'Add New User'}
|
||||
</ModalHeader>
|
||||
<ModalCloseButton />
|
||||
<ModalBody pb={6}>
|
||||
<VStack spacing={4}>
|
||||
<FormControl isRequired>
|
||||
<FormLabel>Full Name</FormLabel>
|
||||
<Input
|
||||
name="name"
|
||||
value={formData.name}
|
||||
onChange={handleInputChange}
|
||||
placeholder="Enter full name"
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
<FormControl isRequired>
|
||||
<FormLabel>Email</FormLabel>
|
||||
<Input
|
||||
type="email"
|
||||
name="email"
|
||||
value={formData.email}
|
||||
onChange={handleInputChange}
|
||||
placeholder="Enter email"
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
{!selectedUser && (
|
||||
<FormControl isRequired={!selectedUser}>
|
||||
<FormLabel>Password</FormLabel>
|
||||
<Input
|
||||
type="password"
|
||||
name="password"
|
||||
value={formData.password}
|
||||
onChange={handleInputChange}
|
||||
placeholder="Enter password"
|
||||
minLength={8}
|
||||
/>
|
||||
<FormHelperText>
|
||||
Password must be at least 8 characters long
|
||||
</FormHelperText>
|
||||
</FormControl>
|
||||
)}
|
||||
|
||||
{selectedUser && selectedUser.role === 'admin' && (
|
||||
<FormControl>
|
||||
<FormLabel>Současné heslo (potvrzení)</FormLabel>
|
||||
<Input
|
||||
type="password"
|
||||
name="currentPassword"
|
||||
value={formData.currentPassword}
|
||||
onChange={handleInputChange}
|
||||
placeholder="Zadejte současné heslo administrátora"
|
||||
/>
|
||||
<FormHelperText>Pro úpravu administrátorského účtu je nutné zadat současné heslo.</FormHelperText>
|
||||
</FormControl>
|
||||
)}
|
||||
|
||||
<FormControl>
|
||||
<FormLabel>Role</FormLabel>
|
||||
<Select
|
||||
name="role"
|
||||
value={formData.role}
|
||||
onChange={handleInputChange}
|
||||
>
|
||||
<option value="editor" disabled={!!selectedUser && selectedUser.role === 'admin'}>Editor</option>
|
||||
<option value="admin">Admin</option>
|
||||
</Select>
|
||||
{selectedUser?.role === 'admin' && (
|
||||
<FormHelperText>Administrátorský účet nelze degradovat na editora přes tuto obrazovku.</FormHelperText>
|
||||
)}
|
||||
</FormControl>
|
||||
|
||||
<FormControl display="flex" alignItems="center">
|
||||
<FormLabel mb="0" mr={2}>
|
||||
Active
|
||||
</FormLabel>
|
||||
<Switch
|
||||
name="isActive"
|
||||
isChecked={formData.isActive}
|
||||
onChange={handleInputChange}
|
||||
colorScheme="blue"
|
||||
/>
|
||||
</FormControl>
|
||||
</VStack>
|
||||
</ModalBody>
|
||||
|
||||
<ModalFooter>
|
||||
<Button variant="ghost" mr={3} onClick={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
colorScheme="blue"
|
||||
type="submit"
|
||||
isLoading={isSubmitting}
|
||||
loadingText="Saving..."
|
||||
>
|
||||
{selectedUser ? 'Update' : 'Create'} User
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
</Box>
|
||||
</AdminLayout>
|
||||
);
|
||||
};
|
||||
|
||||
export default UsersAdminPage;
|
||||
Reference in New Issue
Block a user