This commit is contained in:
Tomas Dvorak
2025-10-19 17:16:57 +02:00
parent e9a63073e5
commit 77213f4e83
76 changed files with 9728 additions and 935 deletions
+20 -1
View File
@@ -516,7 +516,26 @@ const Navbar = () => {
let NAV_LINKS: NavLink[] = useMemo(() => {
if (!navLoading && dynamicNavItems.length > 0) {
// Use dynamic navigation from API
return dynamicNavItems.map(convertToNavLink);
const navLinks = dynamicNavItems.map(convertToNavLink);
// Inject categories into "Články" or "Blog" navigation item if it exists
if (categoryItems.length > 0) {
const articlesIndex = navLinks.findIndex(link =>
link.label === 'Články' ||
link.label === 'Blog' ||
link.to === '/blog'
);
if (articlesIndex !== -1) {
// Add or merge categories into the articles navigation item
navLinks[articlesIndex] = {
...navLinks[articlesIndex],
items: categoryItems
};
}
}
return navLinks;
}
// Fallback to hardcoded navigation
+294 -55
View File
@@ -17,9 +17,18 @@ import {
useColorModeValue,
Collapse,
Divider,
Input,
Textarea,
Switch,
Tabs,
TabList,
TabPanels,
Tab,
TabPanel,
} from '@chakra-ui/react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { AddIcon, DeleteIcon, ChevronDownIcon, ChevronUpIcon } from '@chakra-ui/icons';
import { AddIcon, DeleteIcon, ChevronDownIcon, ChevronUpIcon, CloseIcon } from '@chakra-ui/icons';
import { FiPlus } from 'react-icons/fi';
import { getPolls, createPoll, updatePoll, Poll, CreatePollRequest } from '../../services/polls';
interface PollLinkerProps {
@@ -37,6 +46,25 @@ const PollLinker: React.FC<PollLinkerProps> = ({ articleId, eventId, onPollsChan
const queryClient = useQueryClient();
const [isExpanded, setIsExpanded] = useState(false);
const [selectedPollId, setSelectedPollId] = useState<string>('');
const [showCreateForm, setShowCreateForm] = useState(false);
// Poll creation form state
const [newPollData, setNewPollData] = useState<CreatePollRequest>({
title: '',
description: '',
type: 'single',
status: 'active',
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 },
],
});
const bgBox = useColorModeValue('gray.50', 'gray.700');
const borderColor = useColorModeValue('gray.200', 'gray.600');
@@ -115,6 +143,38 @@ const PollLinker: React.FC<PollLinkerProps> = ({ articleId, eventId, onPollsChan
},
});
// Mutation to create new poll
const createPollMutation = useMutation({
mutationFn: async (pollData: CreatePollRequest) => {
// Add relation to article or event
const createData = { ...pollData };
if (articleId) createData.related_article_id = articleId;
if (eventId) createData.related_event_id = eventId;
return createPoll(createData);
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['linked-polls'] });
queryClient.invalidateQueries({ queryKey: ['all-admin-polls'] });
toast({
title: 'Anketa vytvořena a propojena',
status: 'success',
duration: 3000,
});
setShowCreateForm(false);
resetNewPollForm();
if (onPollsChanged) onPollsChanged();
},
onError: (error: any) => {
toast({
title: 'Chyba při vytváření ankety',
description: error.response?.data?.error || 'Nepodařilo se vytvořit anketu',
status: 'error',
duration: 5000,
});
},
});
const handleLinkPoll = () => {
if (!selectedPollId) {
toast({
@@ -134,6 +194,82 @@ const PollLinker: React.FC<PollLinkerProps> = ({ articleId, eventId, onPollsChan
}
};
const resetNewPollForm = () => {
setNewPollData({
title: '',
description: '',
type: 'single',
status: 'active',
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 },
],
});
};
const handleCreatePoll = () => {
// Validate form
if (!newPollData.title.trim()) {
toast({
title: 'Chybí název ankety',
status: 'warning',
duration: 3000,
});
return;
}
const validOptions = newPollData.options.filter(opt => opt.text.trim());
if (validOptions.length < 2) {
toast({
title: 'Anketa musí mít alespoň 2 možnosti',
status: 'warning',
duration: 3000,
});
return;
}
// Submit with only valid options
createPollMutation.mutate({
...newPollData,
options: validOptions,
});
};
const addOption = () => {
setNewPollData(prev => ({
...prev,
options: [...prev.options, { text: '', display_order: prev.options.length }],
}));
};
const removeOption = (index: number) => {
if (newPollData.options.length <= 2) {
toast({
title: 'Anketa musí mít alespoň 2 možnosti',
status: 'warning',
duration: 2000,
});
return;
}
setNewPollData(prev => ({
...prev,
options: prev.options.filter((_, i) => i !== index).map((opt, idx) => ({ ...opt, display_order: idx })),
}));
};
const updateOption = (index: number, text: string) => {
setNewPollData(prev => ({
...prev,
options: prev.options.map((opt, i) => i === index ? { ...opt, text } : opt),
}));
};
// Filter out polls that are already linked
const linkedPollIds = new Set(linkedPolls?.map(p => p.id) || []);
const availablePolls = allPolls?.filter(p => !linkedPollIds.has(p.id)) || [];
@@ -225,61 +361,164 @@ const PollLinker: React.FC<PollLinkerProps> = ({ articleId, eventId, onPollsChan
<Divider />
{isLoadingAll ? (
<HStack justify="center" py={4}>
<Spinner size="sm" />
</HStack>
) : availablePolls.length > 0 ? (
<VStack spacing={3} align="stretch">
<Text fontSize="xs" fontWeight="bold" color="gray.500">
Připojit existující anketu:
</Text>
<HStack>
<Select
value={selectedPollId}
onChange={(e) => setSelectedPollId(e.target.value)}
placeholder="Vyberte anketu..."
size="sm"
flex={1}
>
{availablePolls.map((poll) => (
<option key={poll.id} value={poll.id}>
{poll.title} ({poll.status}) - {poll.total_votes} hlasů
</option>
))}
</Select>
<Button
leftIcon={<AddIcon />}
onClick={handleLinkPoll}
size="sm"
colorScheme="blue"
isLoading={linkPollMutation.isPending}
isDisabled={!selectedPollId}
>
Připojit
</Button>
</HStack>
</VStack>
) : (
<Alert status="warning" size="sm">
<AlertIcon />
<Text fontSize="sm">
Žádné dostupné ankety. Vytvořte novou v{' '}
<Button
as="a"
href="/admin/ankety"
target="_blank"
variant="link"
size="sm"
colorScheme="blue"
>
správě anket
</Button>
</Text>
</Alert>
)}
<Tabs size="sm" variant="enclosed">
<TabList>
<Tab>Propojit existující</Tab>
<Tab>Vytvořit novou</Tab>
</TabList>
<Text fontSize="xs" color="gray.500">
<TabPanels>
{/* Tab 1: Link existing poll */}
<TabPanel px={0} py={3}>
{isLoadingAll ? (
<HStack justify="center" py={4}>
<Spinner size="sm" />
</HStack>
) : availablePolls.length > 0 ? (
<VStack spacing={3} align="stretch">
<HStack>
<Select
value={selectedPollId}
onChange={(e) => setSelectedPollId(e.target.value)}
placeholder="Vyberte anketu..."
size="sm"
flex={1}
>
{availablePolls.map((poll) => (
<option key={poll.id} value={poll.id}>
{poll.title} ({poll.status}) - {poll.total_votes} hlasů
</option>
))}
</Select>
<Button
leftIcon={<AddIcon />}
onClick={handleLinkPoll}
size="sm"
colorScheme="blue"
isLoading={linkPollMutation.isPending}
isDisabled={!selectedPollId}
>
Připojit
</Button>
</HStack>
</VStack>
) : (
<Alert status="info" size="sm">
<AlertIcon />
<Text fontSize="sm">Žádné dostupné ankety. Vytvořte novou v druhé záložce.</Text>
</Alert>
)}
</TabPanel>
{/* Tab 2: Create new poll */}
<TabPanel px={0} py={3}>
<VStack spacing={3} align="stretch">
<FormControl isRequired>
<FormLabel fontSize="sm">Název ankety</FormLabel>
<Input
size="sm"
placeholder="např. Dorazíš na příští trénink?"
value={newPollData.title}
onChange={(e) => setNewPollData(prev => ({ ...prev, title: e.target.value }))}
/>
</FormControl>
<FormControl>
<FormLabel fontSize="sm">Popis (volitelné)</FormLabel>
<Textarea
size="sm"
placeholder="Doplňující informace k anketě..."
rows={2}
value={newPollData.description}
onChange={(e) => setNewPollData(prev => ({ ...prev, description: e.target.value }))}
/>
</FormControl>
<FormControl>
<FormLabel fontSize="sm">Typ ankety</FormLabel>
<Select
size="sm"
value={newPollData.type}
onChange={(e) => setNewPollData(prev => ({ ...prev, type: e.target.value as any }))}
>
<option value="single">Jedna odpověď</option>
<option value="multiple">Více odpovědí</option>
</Select>
</FormControl>
<FormControl>
<FormLabel fontSize="sm">Možnosti (min. 2)</FormLabel>
<VStack spacing={2} align="stretch">
{newPollData.options.map((option, index) => (
<HStack key={index}>
<Input
size="sm"
placeholder={`Možnost ${index + 1}`}
value={option.text}
onChange={(e) => updateOption(index, e.target.value)}
/>
{newPollData.options.length > 2 && (
<IconButton
aria-label="Odebrat možnost"
icon={<CloseIcon />}
size="sm"
variant="ghost"
colorScheme="red"
onClick={() => removeOption(index)}
/>
)}
</HStack>
))}
<Button
size="sm"
variant="outline"
leftIcon={<AddIcon />}
onClick={addOption}
>
Přidat možnost
</Button>
</VStack>
</FormControl>
<HStack spacing={4}>
<FormControl display="flex" alignItems="center">
<FormLabel fontSize="sm" mb="0" mr={2}>
Povolit hlasování hostů
</FormLabel>
<Switch
size="sm"
isChecked={newPollData.allow_guest_vote}
onChange={(e) => setNewPollData(prev => ({ ...prev, allow_guest_vote: e.target.checked }))}
/>
</FormControl>
<FormControl display="flex" alignItems="center">
<FormLabel fontSize="sm" mb="0" mr={2}>
Aktivní
</FormLabel>
<Switch
size="sm"
isChecked={newPollData.status === 'active'}
onChange={(e) => setNewPollData(prev => ({ ...prev, status: e.target.checked ? 'active' : 'draft' }))}
/>
</FormControl>
</HStack>
<Button
colorScheme="green"
size="sm"
leftIcon={<AddIcon />}
onClick={handleCreatePoll}
isLoading={createPollMutation.isPending}
>
Vytvořit anketu
</Button>
</VStack>
</TabPanel>
</TabPanels>
</Tabs>
<Text fontSize="xs" color="gray.500" mt={2}>
💡 Tip: Ankety se zobrazí automaticky na konci {articleId ? 'článku' : 'aktivity'}
</Text>
</VStack>
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+16 -7
View File
@@ -116,21 +116,30 @@ export const MatchModal: React.FC<MatchModalProps> = ({ isOpen, match, onClose,
<Text fontWeight="semibold" noOfLines={1} textAlign="center">{sanitizeClubName(match.home) || 'Domácí'}</Text>
</VStack>
<VStack spacing={1} minW="120px">
{hasScore ? (
{!matchStarted ? (
// Future match - show countdown or vs
isActive && countdownString ? (
<>
<Text fontSize="lg" color="gray.600">Začátek za</Text>
<Text fontSize="2xl" fontWeight="bold">{countdownString}</Text>
</>
) : (
<>
<Text fontSize="2xl" fontWeight="bold">vs</Text>
</>
)
) : hasScore ? (
// Match finished with score
<>
<Text fontSize="2xl" fontWeight="bold">{match.score}</Text>
<Text fontSize="sm" color="gray.600">Skončeno</Text>
</>
) : matchStarted ? (
) : (
// Match started but no score yet
<>
<Text fontSize="2xl" fontWeight="bold">:</Text>
<Text fontSize="sm" color="green.600">Probíhá</Text>
</>
) : (
<>
<Text fontSize="lg" color="gray.600">Začátek za</Text>
<Text fontSize="2xl" fontWeight="bold">{countdownString || '—'}</Text>
</>
)}
{(match.competition || match.competitionName) && (
<Badge colorScheme="blue" variant="subtle">{match.competition || match.competitionName}</Badge>
@@ -6,7 +6,7 @@ import { api } from '../../services/api';
import { Widget } from './Widget';
import { format, parseISO } from 'date-fns';
import { cs } from 'date-fns/locale';
import { Article } from '../../types';
import { Article } from '../../services/articles';
export const ArticlesWidget = () => {
const { data: articles = [], isLoading, error } = useQuery<Article[]>({
@@ -15,13 +15,13 @@ export const ArticlesWidget = () => {
try {
const { data } = await api.get('/articles', {
params: {
limit: 3,
include: 'author',
sort: '-createdAt',
page: 1,
page_size: 3,
published: true
}
});
return data.data || [];
// Backend returns { items, total, page, page_size }
return data.items || [];
} catch (err) {
console.error('Error fetching articles:', err);
return [];
@@ -81,9 +81,9 @@ export const ArticlesWidget = () => {
overflow="hidden"
position="relative"
>
{article.imageUrl ? (
{article.image_url ? (
<Image
src={article.imageUrl}
src={article.image_url}
alt={article.title}
width="100%"
height="100%"
@@ -109,12 +109,16 @@ export const ArticlesWidget = () => {
<HStack spacing={3} fontSize="xs" color="gray.500">
<HStack spacing={1}>
<Icon as={FaUser} boxSize={3} />
<Text>{article.author.name}</Text>
<Text>
{article.author?.first_name && article.author?.last_name
? `${article.author.first_name} ${article.author.last_name}`
: article.author?.email || 'Autor'}
</Text>
</HStack>
<HStack spacing={1}>
<Icon as={FaCalendarAlt} boxSize={3} />
<Text>
{format(parseISO(article.createdAt), 'd. M. yyyy', {
{article.created_at && format(parseISO(article.created_at), 'd. M. yyyy', {
locale: cs,
})}
</Text>