mirror of
https://github.com/Dvorinka/MyClubServer.git
synced 2026-06-04 10:42:57 +00:00
dev day #65
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user