mirror of
https://github.com/Dvorinka/MyClubServer.git
synced 2026-06-03 18:22: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>
|
||||
|
||||
@@ -11,6 +11,7 @@ export const HOMEPAGE_IMPLEMENTED_ELEMENTS = [
|
||||
'matches', // Upcoming/recent matches
|
||||
'table', // League standings table
|
||||
'team', // Players scroller
|
||||
'gallery', // Photo gallery albums from Zonerama
|
||||
'videos', // Videos section
|
||||
'merch', // Merchandise/fanshop
|
||||
'newsletter',// Newsletter subscription
|
||||
|
||||
@@ -121,7 +121,6 @@ const AboutPage: React.FC = () => {
|
||||
borderColor={borderSubtle}
|
||||
w="100%"
|
||||
>
|
||||
<h1>About MyClub</h1>
|
||||
<Box flex="1" minW={0}>
|
||||
<Heading as="h3" size="sm" mb={c.description ? 1 : 0}>
|
||||
{c.name}
|
||||
@@ -154,14 +153,14 @@ const AboutPage: React.FC = () => {
|
||||
return (
|
||||
<MainLayout>
|
||||
<Helmet>
|
||||
<title>{settings?.club_name ? `About | ${settings.club_name}` : 'About MyClub'}</title>
|
||||
<meta name="description" content="Information about our club, competitions, upcoming matches and categories." />
|
||||
<title>{settings?.club_name ? `O klubu | ${settings.club_name}` : 'O klubu'}</title>
|
||||
<meta name="description" content="Informace o našem klubu, soutěžích, nadcházejících zápasech a rubrikách." />
|
||||
{settings?.club_logo_url && <meta property="og:image" content={settings.club_logo_url} />}
|
||||
</Helmet>
|
||||
<Container maxW="container.lg" py={8}>
|
||||
<Box textAlign="center" py={6}>
|
||||
<Heading size="xl" mb={2}>About MyClub</Heading>
|
||||
<Text color={textSecondary}>This page is not set up yet. Here is an overview of the club, competitions and categories.</Text>
|
||||
<Heading size="xl" mb={2}>O klubu</Heading>
|
||||
<Text color={textSecondary}>Tato stránka ještě není nastavena. Zde je přehled klubu, soutěží a rubrik.</Text>
|
||||
</Box>
|
||||
|
||||
{/* Matches slider by competition (FACR) */}
|
||||
@@ -325,9 +324,9 @@ const AboutPage: React.FC = () => {
|
||||
<MainLayout>
|
||||
<Helmet>
|
||||
<title>{seoTitle || clubName}</title>
|
||||
<meta name="description" content={seoDesc || `Information about our club, competitions, upcoming matches and categories.`} />
|
||||
<meta name="description" content={seoDesc || `Informace o našem klubu, soutěžích, nadcházejících zápasech a rubrikách.`} />
|
||||
<meta property="og:title" content={seoTitle || clubName} />
|
||||
<meta property="og:description" content={seoDesc || `Information about our club, competitions, upcoming matches and categories.`} />
|
||||
<meta property="og:description" content={seoDesc || `Informace o našem klubu, soutěžích, nadcházejících zápasech a rubrikách.`} />
|
||||
{clubLogo && <meta property="og:image" content={clubLogo} />}
|
||||
</Helmet>
|
||||
<Container maxW="container.lg" py={8}>
|
||||
|
||||
@@ -974,11 +974,20 @@ const CalendarPage: React.FC = () => {
|
||||
const dt = new Date(`${selected.match.date}T${(selected.match.time || '00:00')}:00`);
|
||||
const isPast = Date.now() >= dt.getTime();
|
||||
const hasScore = Boolean(selected.match.score);
|
||||
if (!hasScore && !isPast && modalCountdown.countdownString) {
|
||||
|
||||
// For future matches, always show countdown or "vs" - never the score
|
||||
if (!isPast) {
|
||||
if (modalCountdown.countdownString) {
|
||||
return (
|
||||
<Badge colorScheme="orange" fontSize="md" px={3} py={1}>za {modalCountdown.countdownString}</Badge>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Badge colorScheme="orange" fontSize="md" px={3} py={1}>za {modalCountdown.countdownString}</Badge>
|
||||
<Badge colorScheme="gray" fontSize="md" px={3} py={1}>vs</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
// For past matches, show score or "vs"
|
||||
return (
|
||||
<Badge colorScheme={hasScore ? (getSentiment(selected.match)?.color || 'gray') : 'gray'} fontSize="md" px={3} py={1}>
|
||||
{hasScore ? selected.match.score : 'vs'}
|
||||
|
||||
+100
-75
@@ -11,6 +11,7 @@ import BlogCardsScroller from '../components/home/BlogCardsScroller';
|
||||
import BlogSwiper from '../components/home/BlogSwiper';
|
||||
import VideosSection from '../components/home/VideosSection';
|
||||
import MerchSection from '../components/home/MerchSection';
|
||||
import GallerySection from '../components/home/GallerySection';
|
||||
import { getArticles as apiGetArticles, Article as ApiArticle } from '../services/articles';
|
||||
import { getCompetitionAliasesPublic, CompetitionAlias } from '../services/competitionAliases';
|
||||
import NewsletterSubscribe from '../components/newsletter/NewsletterSubscribe';
|
||||
@@ -1371,16 +1372,16 @@ const HomePage: React.FC = () => {
|
||||
<a href={`/news/${featured[0].slug || featured[0].id}`} className="hero-card big" style={{ textDecoration: 'none' }}>
|
||||
<div className="bg" style={{ backgroundImage: `url(${assetUrl(featured[0].image) || '/images/news/placeholder.jpg'})` }} />
|
||||
<div className="overlay">
|
||||
<div style={{ opacity: 0.8, fontSize: '0.8rem', color: 'var(--text-on-primary)' }}>{featured[0].category || 'Aktuality'}</div>
|
||||
<h2 style={{ margin: '4px 0 0 0', color: 'var(--text-on-primary)' }}>{featured[0].title}</h2>
|
||||
<div style={{ opacity: 0.9, fontSize: '0.8rem', color: '#ffffff' }}>{featured[0].category || 'Aktuality'}</div>
|
||||
<h2 style={{ margin: '4px 0 0 0', color: '#ffffff' }}>{featured[0].title}</h2>
|
||||
</div>
|
||||
</a>
|
||||
) : (
|
||||
<a href="/news" className="hero-card big" style={{ textDecoration: 'none' }}>
|
||||
<div className="bg" style={{ backgroundImage: `url('/images/news/placeholder.jpg')` }} />
|
||||
<div className="overlay">
|
||||
<div style={{ opacity: 0.8, fontSize: '0.8rem', color: 'var(--text-on-primary)' }}>Aktuality</div>
|
||||
<h2 style={{ margin: '4px 0 0 0', color: 'var(--text-on-primary)' }}>Nejnovější titulek</h2>
|
||||
<div style={{ opacity: 0.9, fontSize: '0.8rem', color: '#ffffff' }}>Aktuality</div>
|
||||
<h2 style={{ margin: '4px 0 0 0', color: '#ffffff' }}>Nejnovější titulek</h2>
|
||||
</div>
|
||||
</a>
|
||||
)}
|
||||
@@ -1389,8 +1390,8 @@ const HomePage: React.FC = () => {
|
||||
<a key={n.id} href={`/news/${n.slug || n.id}`} className="hero-card small" style={{ textDecoration: 'none' }}>
|
||||
<div className="bg" style={{ backgroundImage: `url(${assetUrl(n.image) || '/images/news/placeholder.jpg'})` }} />
|
||||
<div className="overlay">
|
||||
<div style={{ opacity: 0.8, fontSize: '0.8rem', color: 'var(--text-on-primary)' }}>{n.category || 'Aktuality'}</div>
|
||||
<h3 style={{ margin: '4px 0 0 0', color: 'var(--text-on-primary)' }}>{n.title}</h3>
|
||||
<div style={{ opacity: 0.9, fontSize: '0.8rem', color: '#ffffff' }}>{n.category || 'Aktuality'}</div>
|
||||
<h3 style={{ margin: '4px 0 0 0', color: '#ffffff' }}>{n.title}</h3>
|
||||
</div>
|
||||
</a>
|
||||
))}
|
||||
@@ -1586,79 +1587,94 @@ const HomePage: React.FC = () => {
|
||||
{/* Competition tables moved into right column below */}
|
||||
|
||||
{/* Standings: tabs per competition (only FACR), clicking row opens ClubModal */}
|
||||
{isVisible('table', true) && (
|
||||
<section data-element="table" className="standings" style={{ marginTop: 32 }}>
|
||||
<div>
|
||||
<div className="section-head" style={{ marginTop: 0 }}>
|
||||
<h3>Další aktuality</h3>
|
||||
</div>
|
||||
<div className="blog-list">
|
||||
{news.length > 0 ? news.slice(0, 4).map((n) => (
|
||||
<a key={n.id} href={`/news/${n.slug || n.id}`} className="card" style={{ textDecoration: 'none', color: 'inherit' }}>
|
||||
<div className="thumb" style={{ backgroundImage: `url(${assetUrl(n.image) || '/images/news/placeholder.jpg'})` }} />
|
||||
<div>
|
||||
<h4>{n.title}</h4>
|
||||
<div style={{ color: 'var(--dark-gray)', fontSize: '0.9rem' }}>{n.excerpt}</div>
|
||||
</div>
|
||||
</a>
|
||||
)) : (
|
||||
<div style={{ padding: '24px', textAlign: 'center', color: 'var(--dark-gray)', background: 'var(--bg-soft)', borderRadius: '12px' }}>
|
||||
<p>Zatím nejsou k dispozici žádné aktuality.</p>
|
||||
{isVisible('table', true) && (() => {
|
||||
// Match standings to current competition by name instead of assuming same index
|
||||
const currentCompetition = facrCompetitions[matchesTab];
|
||||
const currentCompetitionName = currentCompetition?.name || '';
|
||||
const matchingStanding = standings.find((s: any) => s.name === currentCompetitionName);
|
||||
|
||||
const hasStandingsForCurrentTab = matchingStanding && (
|
||||
(matchingStanding.table && matchingStanding.table.length > 0) ||
|
||||
(matchingStanding.rows && matchingStanding.rows.length > 0)
|
||||
);
|
||||
|
||||
return (
|
||||
<section
|
||||
data-element="table"
|
||||
className="standings"
|
||||
data-variant={hasStandingsForCurrentTab ? undefined : 'standard'}
|
||||
style={{ marginTop: 32 }}
|
||||
>
|
||||
<div>
|
||||
<div className="section-head" style={{ marginTop: 0 }}>
|
||||
<h3>Další aktuality</h3>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{news.length > 0 && (
|
||||
<div style={{ marginTop: 12 }}>
|
||||
<a className="btn" href="/news">Zobrazit všechny aktuality</a>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<div className="table-card">
|
||||
<div className="section-head" style={{ marginTop: 0, marginBottom: 12 }}>
|
||||
<h3>Tabulky</h3>
|
||||
<a href="/tabulky" className="see-all" style={{ fontSize: '0.85rem' }}>Zobrazit vše <FiArrowRight size={14} /></a>
|
||||
</div>
|
||||
{standings.length > 0 ? (
|
||||
<div className="standings">
|
||||
{(standings[matchesTab]?.table || standings[matchesTab]?.rows || []).slice(0,8).map((row: any, idx: number) => {
|
||||
const handleClick = () => {
|
||||
const clubData = {
|
||||
team: row.team?.name ?? row.team ?? row.club ?? '-',
|
||||
team_id: row.team_id || '',
|
||||
team_logo_url: row.team_logo_url,
|
||||
rank: row.position ?? row.pos ?? row.rank ?? idx+1,
|
||||
played: row.played ?? row.matches ?? '-',
|
||||
wins: row.wins ?? row.win ?? '-',
|
||||
draws: row.draws ?? row.draw ?? '-',
|
||||
losses: row.losses ?? row.loss ?? '-',
|
||||
score: row.score ?? '-',
|
||||
points: row.points ?? row.pts ?? '-',
|
||||
};
|
||||
setSelectedClub(clubData);
|
||||
setIsModalOpen(true);
|
||||
};
|
||||
return (
|
||||
<div key={idx} className="standing-row" onClick={handleClick}>
|
||||
<div className="pos">#{row.position ?? row.pos ?? row.rank ?? idx+1}</div>
|
||||
<div className="team">
|
||||
{row.team_logo_url && (
|
||||
<img src={assetUrl(row.team_logo_url)} alt={row.team?.name ?? row.team ?? row.club ?? '-'} />
|
||||
)}
|
||||
<span className="name">{row.team?.name ?? row.team ?? row.club ?? '-'}</span>
|
||||
</div>
|
||||
<div className="pts">{row.points ?? row.pts ?? '-'}</div>
|
||||
<div className="blog-list">
|
||||
{news.length > 0 ? news.slice(0, 4).map((n) => (
|
||||
<a key={n.id} href={`/news/${n.slug || n.id}`} className="card" style={{ textDecoration: 'none', color: 'inherit' }}>
|
||||
<div className="thumb" style={{ backgroundImage: `url(${assetUrl(n.image) || '/images/news/placeholder.jpg'})` }} />
|
||||
<div>
|
||||
<h4>{n.title}</h4>
|
||||
<div style={{ color: 'var(--dark-gray)', fontSize: '0.9rem' }}>{n.excerpt}</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</a>
|
||||
)) : (
|
||||
<div style={{ padding: '24px', textAlign: 'center', color: 'var(--dark-gray)', background: 'var(--bg-soft)', borderRadius: '12px' }}>
|
||||
<p>Zatím nejsou k dispozici žádné aktuality.</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{news.length > 0 && (
|
||||
<div style={{ marginTop: 12 }}>
|
||||
<a className="btn" href="/news">Zobrazit všechny aktuality</a>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{hasStandingsForCurrentTab && (
|
||||
<div>
|
||||
<div className="table-card">
|
||||
<div className="section-head" style={{ marginTop: 0, marginBottom: 12 }}>
|
||||
<h3>Tabulky</h3>
|
||||
<a href="/tabulky" className="see-all" style={{ fontSize: '0.85rem' }}>Zobrazit vše <FiArrowRight size={14} /></a>
|
||||
</div>
|
||||
<div className="standings">
|
||||
{(matchingStanding?.table || matchingStanding?.rows || []).slice(0,8).map((row: any, idx: number) => {
|
||||
const handleClick = () => {
|
||||
const clubData = {
|
||||
team: row.team?.name ?? row.team ?? row.club ?? '-',
|
||||
team_id: row.team_id || '',
|
||||
team_logo_url: row.team_logo_url,
|
||||
rank: row.position ?? row.pos ?? row.rank ?? idx+1,
|
||||
played: row.played ?? row.matches ?? '-',
|
||||
wins: row.wins ?? row.win ?? '-',
|
||||
draws: row.draws ?? row.draw ?? '-',
|
||||
losses: row.losses ?? row.loss ?? '-',
|
||||
score: row.score ?? '-',
|
||||
points: row.points ?? row.pts ?? '-',
|
||||
};
|
||||
setSelectedClub(clubData);
|
||||
setIsModalOpen(true);
|
||||
};
|
||||
return (
|
||||
<div key={idx} className="standing-row" onClick={handleClick}>
|
||||
<div className="pos">#{row.position ?? row.pos ?? row.rank ?? idx+1}</div>
|
||||
<div className="team">
|
||||
{row.team_logo_url && (
|
||||
<img src={assetUrl(row.team_logo_url)} alt={row.team?.name ?? row.team ?? row.club ?? '-'} />
|
||||
)}
|
||||
<span className="name">{row.team?.name ?? row.team ?? row.club ?? '-'}</span>
|
||||
</div>
|
||||
<div className="pts">{row.points ?? row.pts ?? '-'}</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ color: 'var(--dark-gray)', padding: '16px 0', textAlign: 'center' }}>Zde se zobrazí tabulky podle soutěží.</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
})()}
|
||||
|
||||
{/* Players scroller (optional) */}
|
||||
{players.length > 0 && isVisible('team', false) && (
|
||||
@@ -1695,6 +1711,15 @@ const HomePage: React.FC = () => {
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Gallery */}
|
||||
{isVisible('gallery', false) && (
|
||||
<section data-element="gallery" style={{ marginTop: 32, marginBottom: 32 }}>
|
||||
<div style={{ maxWidth: 1200, margin: '0 auto', padding: '0 12px' }}>
|
||||
<GallerySection />
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Videos */}
|
||||
{isVisible('videos', false) && (
|
||||
<section data-element="videos" style={{ marginTop: 32, marginBottom: 32 }}>
|
||||
|
||||
@@ -36,6 +36,8 @@ const MatchesPage: React.FC = () => {
|
||||
const [aliases, setAliases] = useState<CompetitionAlias[]>([]);
|
||||
const [aliasMap, setAliasMap] = useState<Record<string, { alias: string; original_name?: string; display_order?: number }>>({});
|
||||
const [sortAscending, setSortAscending] = useState<boolean>(true); // true = oldest first, false = newest first
|
||||
const [displayedMatchesCount, setDisplayedMatchesCount] = useState<Record<number, number>>({}); // Track displayed matches per competition index
|
||||
const MATCHES_PER_PAGE = 12; // Number of matches to load initially and per load more click
|
||||
|
||||
// Dark mode colors
|
||||
const bgColor = useColorModeValue('#f8f9fb', '#0f1115');
|
||||
@@ -78,11 +80,29 @@ const MatchesPage: React.FC = () => {
|
||||
};
|
||||
|
||||
// Sentiment helpers for win/draw/loss detection
|
||||
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);
|
||||
x = x.replace(/\b(mestsky|m\.?f\.?k\.?|mfk|tj|sk|sokol|fotbalovy|fotbalový|fotbalovy\s+klub|fotbalovy\s+klub)\b/g, '');
|
||||
return x.replace(/\s+/g, ' ').trim();
|
||||
};
|
||||
|
||||
const isClubTeam = (team: string) => {
|
||||
const normalize = (s: string) => s.toLowerCase().trim();
|
||||
const a = normalize(team);
|
||||
const b = normalize(clubName || '');
|
||||
return a.includes(b) || b.includes(a);
|
||||
try {
|
||||
const a = stripPrefixes(team);
|
||||
const b = stripPrefixes(clubName || '');
|
||||
if (!a || !b) return false;
|
||||
// Allow equality or suffix match (handles prefixes like TJ, SK, etc.)
|
||||
return a === b || a.endsWith(b) || b.endsWith(a);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const parseScore = (score?: string | null): { h: number; a: number } | null => {
|
||||
@@ -143,6 +163,27 @@ const MatchesPage: React.FC = () => {
|
||||
return sorted;
|
||||
}, [facrCompetitions, sortAscending]);
|
||||
|
||||
// Initialize displayed matches count when competitions change
|
||||
useEffect(() => {
|
||||
const initialCounts: Record<number, number> = {};
|
||||
sortedCompetitions.forEach((_, index) => {
|
||||
if (displayedMatchesCount[index] === undefined) {
|
||||
initialCounts[index] = MATCHES_PER_PAGE;
|
||||
}
|
||||
});
|
||||
if (Object.keys(initialCounts).length > 0) {
|
||||
setDisplayedMatchesCount(prev => ({ ...prev, ...initialCounts }));
|
||||
}
|
||||
}, [sortedCompetitions.length]);
|
||||
|
||||
// Handle load more for a specific competition
|
||||
const handleLoadMore = (competitionIndex: number) => {
|
||||
setDisplayedMatchesCount(prev => ({
|
||||
...prev,
|
||||
[competitionIndex]: (prev[competitionIndex] || MATCHES_PER_PAGE) + MATCHES_PER_PAGE
|
||||
}));
|
||||
};
|
||||
|
||||
// Get all upcoming matches for countdown tracking
|
||||
const upcomingMatches = useMemo(() => {
|
||||
if (activeTab >= sortedCompetitions.length) return [];
|
||||
@@ -416,6 +457,7 @@ const MatchesPage: React.FC = () => {
|
||||
Žádné zápasy k zobrazení
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div
|
||||
style={{
|
||||
display: 'grid',
|
||||
@@ -423,7 +465,7 @@ const MatchesPage: React.FC = () => {
|
||||
gap: 16,
|
||||
}}
|
||||
>
|
||||
{c.matches.map((m: MatchItem, idx: number) => {
|
||||
{c.matches.slice(0, displayedMatchesCount[compIdx] || MATCHES_PER_PAGE).map((m: MatchItem, idx: number) => {
|
||||
const matchTime = new Date(`${m.date}T${(m.time || '00:00')}:00`).getTime();
|
||||
const currentTime = Date.now();
|
||||
const isFuture = matchTime > currentTime;
|
||||
@@ -607,6 +649,45 @@ const MatchesPage: React.FC = () => {
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{c.matches.length > (displayedMatchesCount[compIdx] || MATCHES_PER_PAGE) && (
|
||||
<div style={{ textAlign: 'center', marginTop: 32 }}>
|
||||
<button
|
||||
onClick={() => handleLoadMore(compIdx)}
|
||||
style={{
|
||||
padding: '14px 32px',
|
||||
background: 'var(--primary-color, #3b82f6)',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: 12,
|
||||
fontWeight: 700,
|
||||
fontSize: '1rem',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.3s cubic-bezier(0.4, 0, 0.2, 1)',
|
||||
boxShadow: '0 4px 12px rgba(59, 130, 246, 0.3)',
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: 10
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.transform = 'translateY(-2px)';
|
||||
e.currentTarget.style.boxShadow = '0 8px 20px rgba(59, 130, 246, 0.4)';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.transform = 'translateY(0)';
|
||||
e.currentTarget.style.boxShadow = '0 4px 12px rgba(59, 130, 246, 0.3)';
|
||||
}}
|
||||
>
|
||||
<span>Načíst další zápasy</span>
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<polyline points="6 9 12 15 18 9"></polyline>
|
||||
</svg>
|
||||
</button>
|
||||
<div style={{ marginTop: 12, fontSize: '0.875rem', color: textSecondary, fontWeight: 600 }}>
|
||||
Zobrazeno {Math.min(displayedMatchesCount[compIdx] || MATCHES_PER_PAGE, c.matches.length)} z {c.matches.length} zápasů
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</TabPanel>
|
||||
))}
|
||||
|
||||
@@ -49,6 +49,7 @@ import { api } from '../../services/api';
|
||||
// Removed react-datepicker to prevent crash; using native date/time inputs instead
|
||||
import { getPublicSettings } from '../../services/settings';
|
||||
import PollLinker from '../../components/admin/PollLinker';
|
||||
import FilePreview from '../../components/common/FilePreview';
|
||||
import { facrApi } from '../../services/facr/facrApi';
|
||||
import { getCompetitionAliasesPublic } from '../../services/competitionAliases';
|
||||
import MapLinkImporter from '../../components/admin/MapLinkImporter';
|
||||
@@ -885,10 +886,27 @@ const AdminActivitiesPage: React.FC = () => {
|
||||
<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 colSpan={3} p={2}>
|
||||
<HStack justify="space-between" w="full">
|
||||
<Box flex={1}>
|
||||
<FilePreview
|
||||
url={att.url}
|
||||
name={att.name}
|
||||
mimeType={att.mime_type}
|
||||
size={att.size}
|
||||
/>
|
||||
</Box>
|
||||
<Button
|
||||
size="xs"
|
||||
variant="outline"
|
||||
colorScheme="red"
|
||||
flexShrink={0}
|
||||
ml={2}
|
||||
onClick={() => setEditing(prev => ({ ...(prev as any), attachments: ((prev as any).attachments || []).filter((_: any, i: number) => i !== idx) }))}
|
||||
>
|
||||
Odebrat
|
||||
</Button>
|
||||
</HStack>
|
||||
</Td>
|
||||
</Tr>
|
||||
))}
|
||||
@@ -901,8 +919,19 @@ const AdminActivitiesPage: React.FC = () => {
|
||||
</FormControl>
|
||||
</HStack>
|
||||
|
||||
{/* Poll Linker */}
|
||||
{editing?.id && <PollLinker eventId={editing.id} />}
|
||||
{/* Poll Section */}
|
||||
<Box mt={6} pt={4} borderTopWidth="1px" borderColor={borderColor}>
|
||||
<Heading size="sm" mb={3}>Anketa</Heading>
|
||||
{editing?.id ? (
|
||||
<PollLinker eventId={editing.id} />
|
||||
) : (
|
||||
<Box bg={useColorModeValue('blue.50', 'blue.900')} p={4} borderRadius="md" borderWidth="1px" borderColor="blue.200">
|
||||
<Text fontSize="sm" color={useColorModeValue('blue.700', 'blue.200')}>
|
||||
💡 Nejprve uložte aktivitu, poté budete moci vytvořit nebo připojit anketu přímo zde.
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<HStack w="100%" justify="space-between">
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
Textarea, Icon, useBreakpointValue, InputGroup, InputLeftElement,
|
||||
ButtonGroup, Spinner, Heading, Td, Th, Thead, Tr, Tbody, Table, Switch,
|
||||
Select, Badge, Tabs, TabList, TabPanels, Tab, TabPanel, Accordion, AccordionItem,
|
||||
AccordionButton, AccordionPanel, AccordionIcon, AspectRatio, Link
|
||||
AccordionButton, AccordionPanel, AccordionIcon, AspectRatio, Link, Alert, AlertIcon
|
||||
} from '@chakra-ui/react';
|
||||
import { FiEdit2, FiTrash2, FiPlus, FiSearch, FiUpload, FiExternalLink, FiVideo, FiX, FiRefreshCcw } from 'react-icons/fi';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
@@ -23,6 +23,7 @@ import { facrApi } from '../../services/facr/facrApi';
|
||||
import AlbumPhotoPicker from '../../components/admin/AlbumPhotoPicker';
|
||||
import PollLinker from '../../components/admin/PollLinker';
|
||||
import ThumbnailPreview from '../../components/common/ThumbnailPreview';
|
||||
import FilePreview from '../../components/common/FilePreview';
|
||||
|
||||
import { getCachedYouTube, YouTubeVideo } from '../../services/youtube';
|
||||
|
||||
@@ -42,25 +43,36 @@ const MatchLinkBadge: React.FC<{ articleId: number }> = ({ articleId }) => {
|
||||
queryKey: ['facr-cached-match', mid],
|
||||
enabled: !!mid,
|
||||
staleTime: 60_000,
|
||||
retry: false,
|
||||
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, { cache: 'no-cache' });
|
||||
if (!res.ok) return null as any;
|
||||
const json = await res.json();
|
||||
const comps = Array.isArray(json?.competitions) ? json.competitions : [];
|
||||
for (const c of comps) {
|
||||
const matches = Array.isArray(c.matches) ? c.matches : [];
|
||||
for (const m of matches) {
|
||||
const id = String(m.match_id || m.id);
|
||||
if (id === String(mid)) return { ...m, competitionName: c.name };
|
||||
try {
|
||||
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, { cache: 'no-cache' });
|
||||
if (!res.ok) return null;
|
||||
const json = await res.json();
|
||||
const comps = Array.isArray(json?.competitions) ? json.competitions : [];
|
||||
for (const c of comps) {
|
||||
const matches = Array.isArray(c.matches) ? c.matches : [];
|
||||
for (const m of matches) {
|
||||
const id = String(m.match_id || m.id);
|
||||
if (id === String(mid)) return { ...m, competitionName: c.name };
|
||||
}
|
||||
}
|
||||
return null;
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch FACR match data:', error);
|
||||
return null;
|
||||
}
|
||||
return null as any;
|
||||
}
|
||||
});
|
||||
|
||||
// Guard against errors
|
||||
if (facrQ.isError || linkQ.isError) {
|
||||
return <Badge colorScheme="red">Chyba načítání</Badge>;
|
||||
}
|
||||
|
||||
const m: any = facrQ.data;
|
||||
const scoreText = m ? (m.score || (m.result_home!=null && m.result_away!=null ? `${m.result_home}:${m.result_away}` : 'vs')) : '';
|
||||
const hasScore = !!m && !!scoreText && scoreText !== 'vs';
|
||||
@@ -149,7 +161,7 @@ const ArticlesAdminPage = () => {
|
||||
seo_description?: string;
|
||||
og_image_url?: string;
|
||||
slugModified?: boolean;
|
||||
category?: { id?: number; name?: string };
|
||||
// category is inherited from Article, no need to redefine
|
||||
category_id?: number;
|
||||
category_name?: string;
|
||||
}
|
||||
@@ -216,7 +228,8 @@ const ArticlesAdminPage = () => {
|
||||
const [linkedMatchTitle, setLinkedMatchTitle] = useState<string>('');
|
||||
const [matchIdInput, setMatchIdInput] = useState<string>('');
|
||||
const [matchOptions, setMatchOptions] = useState<Array<{ id: string; label: string; date?: string; competition?: string; home?: string; away?: string; score?: string }>>([]);
|
||||
const [matchSearch, setMatchSearch] = useState<string>('');
|
||||
const [matchSearch, setMatchSearch] = useState('');
|
||||
const [activeTabIndex, setActiveTabIndex] = useState(0);
|
||||
const [matchDateFilter, setMatchDateFilter] = useState<string>('');
|
||||
const [tempMatchLink, setTempMatchLink] = useState<string>(''); // Temporary storage for new articles
|
||||
|
||||
@@ -383,12 +396,29 @@ const ArticlesAdminPage = () => {
|
||||
content: currentContent + '\n' + photosHTML,
|
||||
};
|
||||
});
|
||||
|
||||
// REUSE ALBUM: Also populate the album in Media tab so it doesn't need to be fetched twice
|
||||
// Map album photos to the format used in zAlbumPhotos state
|
||||
const mappedPhotos = albumInfo.photos?.map((p: any) => ({
|
||||
id: p.id,
|
||||
page_url: p.page_url,
|
||||
image_1500: p.image_1500 || '',
|
||||
title: p.title || '',
|
||||
})) || photos.map(p => ({
|
||||
id: p.id,
|
||||
page_url: p.page_url,
|
||||
image_1500: p.image_1500 || '',
|
||||
title: '',
|
||||
}));
|
||||
|
||||
setZAlbumLink(albumInfo.url || '');
|
||||
setZAlbumPhotos(mappedPhotos);
|
||||
|
||||
toast({
|
||||
title: 'Album přidáno',
|
||||
description: `${photos.length} fotografií vloženo do článku`,
|
||||
description: `${photos.length} fotografií vloženo do článku. Album dostupné také v sekci Média.`,
|
||||
status: 'success',
|
||||
duration: 3000
|
||||
duration: 4000
|
||||
});
|
||||
} catch (error: any) {
|
||||
toast({
|
||||
@@ -441,20 +471,8 @@ const ArticlesAdminPage = () => {
|
||||
}
|
||||
}, [zAlbumLink, toast]);
|
||||
|
||||
// When editing an existing article, load current match link to reflect state
|
||||
React.useEffect(() => {
|
||||
(async () => {
|
||||
try {
|
||||
const id = (editing as any)?.id;
|
||||
if (!id) { setLinkedMatchId(''); return; }
|
||||
const link = await getArticleMatchLink(id);
|
||||
const mid = (link as any)?.external_match_id || '';
|
||||
setLinkedMatchId(mid);
|
||||
if (mid) setMatchIdInput(String(mid));
|
||||
} catch {}
|
||||
})();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [(editing as any)?.id]);
|
||||
// Match link is now included in article data, no need to fetch separately
|
||||
// The openEdit function handles setting it from the article data
|
||||
|
||||
const filteredMatchOptions = useMemo(() => {
|
||||
let opts = matchOptions;
|
||||
@@ -507,11 +525,14 @@ const ArticlesAdminPage = () => {
|
||||
});
|
||||
}
|
||||
|
||||
// Sort by date (newest first)
|
||||
// Sort by proximity to current date (recent matches first)
|
||||
const now = Date.now();
|
||||
opts = opts.sort((a, b) => {
|
||||
const dateA = new Date(a.date || 0).getTime();
|
||||
const dateB = new Date(b.date || 0).getTime();
|
||||
return dateB - dateA; // Newest first
|
||||
const diffA = Math.abs(now - dateA);
|
||||
const diffB = Math.abs(now - dateB);
|
||||
return diffA - diffB; // Closest to today first
|
||||
});
|
||||
|
||||
return opts;
|
||||
@@ -548,32 +569,72 @@ const ArticlesAdminPage = () => {
|
||||
const aiMut = useMutation({
|
||||
mutationFn: () => generateBlogAI({ prompt: aiPrompt, audience: aiAudience, min_words: aiMinWords }),
|
||||
onSuccess: (res) => {
|
||||
console.log('AI blog response:', res);
|
||||
|
||||
// Insert AI output into the editing state
|
||||
const aiTitle = res.title || '';
|
||||
const aiTitle = String(res?.title || '').trim();
|
||||
const aiSlug = String(res?.slug || '').trim();
|
||||
const aiHtml = String(res?.html || '').trim();
|
||||
|
||||
if (!aiTitle || !aiHtml) {
|
||||
console.error('AI response missing title or html:', res);
|
||||
toast({
|
||||
title: 'AI odpověď neúplná',
|
||||
description: 'AI nevrátila všechny požadované údaje. Zkuste to prosím znovu.',
|
||||
status: 'warning'
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const { seoTitle, seoDescription } = generateSeoMetadata(aiTitle);
|
||||
setEditing((prev) => ({
|
||||
...(prev || {}),
|
||||
title: aiTitle,
|
||||
content: res.html || '',
|
||||
content: aiHtml,
|
||||
// store slug into editing (accepted by backend payload)
|
||||
...(res.slug ? { slug: res.slug } as any : {}),
|
||||
...(aiSlug ? { slug: aiSlug } as any : {}),
|
||||
seo_title: seoTitle,
|
||||
seo_description: seoDescription,
|
||||
}));
|
||||
toast({ title: 'Článek hotov', description: 'AI rozvinula váš text a vyplnila název, slug a obsah.', status: 'success' });
|
||||
|
||||
// Clear AI prompt and switch to Základní tab to show results
|
||||
setAiPrompt('');
|
||||
setActiveTabIndex(1); // Switch to "Základní" tab
|
||||
|
||||
toast({ title: 'Článek hotov', description: 'AI rozvinula váš text a vyplnila název, slug a obsah. Zkontrolujte výsledek v záložce Základní.', status: 'success', duration: 5000 });
|
||||
},
|
||||
onError: (e: any) => {
|
||||
console.error('AI generation error:', e);
|
||||
toast({ title: 'Generování selhalo', description: e?.response?.data?.error || e?.message || 'Zkuste to prosím znovu.', status: 'error' });
|
||||
},
|
||||
});
|
||||
|
||||
const openCreate = () => {
|
||||
setEditing({ title: '', content: '', featured: false, published: true } as any);
|
||||
setActiveTabIndex(0); // Start on AI tab for new articles
|
||||
setAiPrompt(''); // Clear AI prompt
|
||||
onOpen();
|
||||
};
|
||||
|
||||
const openEdit = (a: Article) => {
|
||||
setEditing({ ...a, category_name: (a as any)?.category?.name });
|
||||
setEditing({
|
||||
...a,
|
||||
category_name: a.category?.name || a.category_name || ''
|
||||
});
|
||||
|
||||
// If match_link is already in the article data, set it immediately
|
||||
if ((a as any)?.match_link?.external_match_id) {
|
||||
const matchId = String((a as any).match_link.external_match_id);
|
||||
setLinkedMatchId(matchId);
|
||||
setMatchIdInput(matchId);
|
||||
} else {
|
||||
// Clear match link state if not present
|
||||
setLinkedMatchId('');
|
||||
setMatchIdInput('');
|
||||
}
|
||||
|
||||
setActiveTabIndex(1); // Start on Základní tab for editing
|
||||
setAiPrompt(''); // Clear AI prompt
|
||||
onOpen();
|
||||
};
|
||||
|
||||
@@ -811,6 +872,18 @@ const ArticlesAdminPage = () => {
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if content contains raw AI JSON (invalid state)
|
||||
const contentText = String(editing.content || '').trim();
|
||||
if (contentText.includes('"title":') && contentText.includes('"slug":') && contentText.includes('"html":')) {
|
||||
toast({
|
||||
title: 'Neplatný obsah',
|
||||
description: 'Obsah obsahuje nezpracovanou AI odpověď. Zkuste AI generování znovu nebo použijte záložku "Základní" pro ruční vytvoření.',
|
||||
status: 'error',
|
||||
duration: 8000
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Auto-generate image if missing
|
||||
let imageUrl = (editing as any).image_url as string | undefined;
|
||||
@@ -850,6 +923,10 @@ const ArticlesAdminPage = () => {
|
||||
...((editing as any).youtube_video_title ? { youtube_video_title: (editing as any).youtube_video_title } : {}),
|
||||
...((editing as any).youtube_video_url ? { youtube_video_url: (editing as any).youtube_video_url } : {}),
|
||||
...((editing as any).youtube_video_thumbnail ? { youtube_video_thumbnail: (editing as any).youtube_video_thumbnail } : {}),
|
||||
// Persist attachments when present
|
||||
...(Array.isArray((editing as any)?.attachments) && (editing as any).attachments.length > 0
|
||||
? { attachments: (editing as any).attachments.map((a: any) => ({ name: a.name, url: a.url, mime_type: a.mime_type, size: a.size })) }
|
||||
: {}),
|
||||
} as CreateArticlePayload;
|
||||
|
||||
// Log the payload for debugging
|
||||
@@ -973,14 +1050,15 @@ const ArticlesAdminPage = () => {
|
||||
<Tr>
|
||||
<Th>Obrázek</Th>
|
||||
<Th>Titulek</Th>
|
||||
<Th>Publikováno</Th>
|
||||
<Th>Kategorie</Th>
|
||||
<Th>⭐ Primární</Th>
|
||||
<Th>Zápas</Th>
|
||||
<Th isNumeric>Akce</Th>
|
||||
</Tr>
|
||||
</Thead>
|
||||
<Tbody>
|
||||
{isLoading && (
|
||||
<Tr><Td colSpan={5}><Spinner size="sm" /></Td></Tr>
|
||||
<Tr><Td colSpan={6}><Spinner size="sm" /></Td></Tr>
|
||||
)}
|
||||
{!isLoading && articles.map((a) => (
|
||||
<Tr key={a.id}>
|
||||
@@ -992,11 +1070,47 @@ const ArticlesAdminPage = () => {
|
||||
previewSize="350px"
|
||||
/>
|
||||
</Td>
|
||||
<Td>{a.title}</Td>
|
||||
<Td>{a.published ? 'Ano' : 'Ne'}</Td>
|
||||
<Td>
|
||||
<VStack align="start" spacing={0}>
|
||||
<Text fontWeight="medium">{a.title}</Text>
|
||||
<Text fontSize="xs" color="gray.500">
|
||||
{a.published ? '✓ Publikováno' : '○ Koncept'}
|
||||
</Text>
|
||||
</VStack>
|
||||
</Td>
|
||||
<Td>
|
||||
<Badge colorScheme="blue" fontSize="xs">
|
||||
{a.category?.name || a.category_name || 'Bez kategorie'}
|
||||
</Badge>
|
||||
</Td>
|
||||
<Td>
|
||||
<Switch
|
||||
size="sm"
|
||||
isChecked={!!a.featured}
|
||||
onChange={async (e) => {
|
||||
try {
|
||||
await updateArticle(a.id, { featured: e.target.checked });
|
||||
qc.invalidateQueries({ queryKey: ['admin-articles'] });
|
||||
qc.invalidateQueries({ queryKey: ['articles'] });
|
||||
qc.invalidateQueries({ queryKey: ['featured-articles'] });
|
||||
toast({
|
||||
title: e.target.checked ? 'Článek nastaven jako primární' : 'Článek odstraněn z primárních',
|
||||
status: 'success',
|
||||
duration: 2000
|
||||
});
|
||||
} catch (error: any) {
|
||||
toast({
|
||||
title: 'Chyba při aktualizaci',
|
||||
description: error?.response?.data?.error || 'Nepodařilo se změnit stav',
|
||||
status: 'error'
|
||||
});
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Td>
|
||||
<Td><MatchLinkBadge articleId={a.id} /></Td>
|
||||
<Td isNumeric>
|
||||
<HStack>
|
||||
<HStack spacing={1}>
|
||||
<IconButton aria-label="Upravit" size="sm" icon={<FiEdit2 />} onClick={() => openEdit(a)} />
|
||||
<IconButton aria-label="Smazat" size="sm" colorScheme="red" icon={<FiTrash2 />} onClick={() => handleDeleteArticle(a)} />
|
||||
</HStack>
|
||||
@@ -1020,13 +1134,14 @@ const ArticlesAdminPage = () => {
|
||||
<ModalHeader>{(editing as any)?.id ? 'Upravit článek' : 'Nový článek'}</ModalHeader>
|
||||
<ModalCloseButton />
|
||||
<ModalBody maxH="calc(90vh - 120px)" overflowY="auto">
|
||||
<Tabs variant="enclosed" colorScheme="blue" isFitted>
|
||||
<Tabs variant="enclosed" colorScheme="blue" isFitted index={activeTabIndex} onChange={(index) => setActiveTabIndex(index)}>
|
||||
<TabList>
|
||||
<Tab>AI</Tab>
|
||||
<Tab>Základní</Tab>
|
||||
<Tab>Obsah</Tab>
|
||||
<Tab>Média</Tab>
|
||||
<Tab>SEO</Tab>
|
||||
<Tab>Anketa</Tab>
|
||||
</TabList>
|
||||
<TabPanels>
|
||||
{/* AI first */}
|
||||
@@ -1113,14 +1228,28 @@ const ArticlesAdminPage = () => {
|
||||
<Text color="orange.500" fontSize="sm" mt={1}>⚠️ Kategorie je povinná</Text>
|
||||
)}
|
||||
</FormControl>
|
||||
<FormControl display="flex" alignItems="center">
|
||||
<FormLabel mb="0">Primární na úvodní stránce</FormLabel>
|
||||
<Switch
|
||||
isChecked={!!(editing as any)?.featured}
|
||||
isDisabled={featSwitchLoading}
|
||||
onChange={(e) => handleFeaturedToggle(e.target.checked)}
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
{/* Featured toggle - prominent display */}
|
||||
<Box
|
||||
borderWidth="2px"
|
||||
borderRadius="lg"
|
||||
p={4}
|
||||
bg={useColorModeValue('orange.50', 'orange.900')}
|
||||
borderColor={useColorModeValue('orange.300', 'orange.600')}
|
||||
>
|
||||
<FormControl display="flex" alignItems="center" justifyContent="space-between">
|
||||
<Box>
|
||||
<FormLabel mb="1" fontSize="lg" fontWeight="bold">⭐ Primární na úvodní stránce</FormLabel>
|
||||
<Text fontSize="sm" color={useColorModeValue('gray.600', 'gray.400')}>Zvýrazní článek jako hlavní příspěvek na domovské stránce</Text>
|
||||
</Box>
|
||||
<Switch
|
||||
size="lg"
|
||||
isChecked={!!(editing as any)?.featured}
|
||||
isDisabled={featSwitchLoading}
|
||||
onChange={(e) => handleFeaturedToggle(e.target.checked)}
|
||||
/>
|
||||
</FormControl>
|
||||
</Box>
|
||||
|
||||
{/* Propojení se zápasem (vylepšený visual picker) */}
|
||||
<Box borderWidth="1px" borderRadius="lg" p={4} bg={useColorModeValue('blue.50', 'blue.900')} borderColor="blue.200">
|
||||
@@ -1318,8 +1447,23 @@ const ArticlesAdminPage = () => {
|
||||
<InputLeftElement pointerEvents="none">
|
||||
<Icon as={FiSearch} color="gray.400" />
|
||||
</InputLeftElement>
|
||||
<Input placeholder="https://eu.zonerama.com/…" value={zAlbumLink} onChange={(e) => setZAlbumLink(e.target.value)} />
|
||||
<Input
|
||||
placeholder="https://eu.zonerama.com/…"
|
||||
value={zAlbumLink}
|
||||
onChange={(e) => setZAlbumLink(e.target.value)}
|
||||
bg={zAlbumPhotos.length > 0 ? useColorModeValue('green.50', 'green.900') : undefined}
|
||||
/>
|
||||
</InputGroup>
|
||||
<FormHelperText fontSize="xs">
|
||||
{zAlbumPhotos.length > 0
|
||||
? `✓ Album načteno (${zAlbumPhotos.length} fotografií). Můžete vložit jiné album nebo vybrat fotky níže.`
|
||||
: 'Vložte odkaz na album, nebo album se automaticky načte při výběru fotografií v sekci Obsah.'}
|
||||
</FormHelperText>
|
||||
{(editing as any)?.gallery_album_url && zAlbumLink && (editing as any).gallery_album_url === zAlbumLink && (
|
||||
<Text fontSize="xs" color="blue.600" fontWeight="bold" mt={1}>
|
||||
🔗 Album propojeno s článkem (zobrazeno v sekci Obsah)
|
||||
</Text>
|
||||
)}
|
||||
</FormControl>
|
||||
<HStack>
|
||||
<Button size="sm" onClick={fetchAlbumByLink} isLoading={zLoading}>Načíst album</Button>
|
||||
@@ -1444,8 +1588,136 @@ const ArticlesAdminPage = () => {
|
||||
</HStack>
|
||||
</FormControl>
|
||||
|
||||
{/* Poll Linker */}
|
||||
{editing?.id && <PollLinker articleId={editing.id} />}
|
||||
{/* File Attachments */}
|
||||
<FormControl>
|
||||
<FormLabel fontWeight="bold">Přílohy</FormLabel>
|
||||
<FormHelperText mb={2}>
|
||||
Přidejte dokumenty, obrázky nebo jiné soubory k článku (PDF, Word, Excel, PowerPoint, obrázky, ZIP)
|
||||
</FormHelperText>
|
||||
<HStack>
|
||||
<Button as="label" leftIcon={<FiUpload />} colorScheme="teal" variant="outline">
|
||||
Nahrát soubory
|
||||
<Input
|
||||
type="file"
|
||||
display="none"
|
||||
multiple
|
||||
accept=".pdf,.doc,.docx,.xls,.xlsx,.ppt,.pptx,.jpg,.jpeg,.png,.gif,.webp,.txt,.zip,.rar"
|
||||
onChange={async (e) => {
|
||||
const files = Array.from(e.target.files || []);
|
||||
if (files.length === 0) return;
|
||||
|
||||
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);
|
||||
setEditing(prev => ({
|
||||
...(prev || {}),
|
||||
attachments: [
|
||||
...((prev as any)?.attachments || []),
|
||||
{
|
||||
name: f.name,
|
||||
url: (res as any).url,
|
||||
mime_type: f.type,
|
||||
size: f.size
|
||||
}
|
||||
]
|
||||
} as any));
|
||||
toast({
|
||||
title: 'Soubor nahrán',
|
||||
description: f.name,
|
||||
status: 'success',
|
||||
duration: 2000
|
||||
});
|
||||
} catch (err: any) {
|
||||
toast({
|
||||
title: 'Chyba při nahrávání',
|
||||
description: `Soubor "${f.name}": ${err?.message || 'Neznámá chyba'}`,
|
||||
status: 'error',
|
||||
duration: 4000
|
||||
});
|
||||
}
|
||||
}
|
||||
// Reset input
|
||||
(e.target as HTMLInputElement).value = '';
|
||||
}}
|
||||
/>
|
||||
</Button>
|
||||
</HStack>
|
||||
<Box mt={2}>
|
||||
{Array.isArray((editing as any)?.attachments) && (editing as any).attachments.length > 0 ? (
|
||||
<Table size="sm" variant="simple">
|
||||
<Thead>
|
||||
<Tr>
|
||||
<Th>Název</Th>
|
||||
<Th>Velikost</Th>
|
||||
<Th>Akce</Th>
|
||||
</Tr>
|
||||
</Thead>
|
||||
<Tbody>
|
||||
{((editing as any).attachments as any[]).map((att: any, idx: number) => (
|
||||
<Tr key={idx}>
|
||||
<Td colSpan={3} p={2}>
|
||||
<HStack justify="space-between" w="full">
|
||||
<Box flex={1}>
|
||||
<FilePreview
|
||||
url={att.url}
|
||||
name={att.name}
|
||||
mimeType={att.mime_type}
|
||||
size={att.size}
|
||||
/>
|
||||
</Box>
|
||||
<Button
|
||||
size="xs"
|
||||
variant="outline"
|
||||
colorScheme="red"
|
||||
flexShrink={0}
|
||||
ml={2}
|
||||
onClick={() => setEditing(prev => ({
|
||||
...(prev as any),
|
||||
attachments: ((prev as any).attachments || []).filter((_: any, i: number) => i !== idx)
|
||||
}))}
|
||||
>
|
||||
Odebrat
|
||||
</Button>
|
||||
</HStack>
|
||||
</Td>
|
||||
</Tr>
|
||||
))}
|
||||
</Tbody>
|
||||
</Table>
|
||||
) : (
|
||||
<Box color="gray.500" fontSize="sm">Žádné přílohy</Box>
|
||||
)}
|
||||
</Box>
|
||||
</FormControl>
|
||||
|
||||
</VStack>
|
||||
</TabPanel>
|
||||
|
||||
@@ -1492,6 +1764,35 @@ const ArticlesAdminPage = () => {
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
</TabPanel>
|
||||
|
||||
{/* Anketa (Poll) Tab */}
|
||||
<TabPanel>
|
||||
<VStack align="stretch" spacing={4}>
|
||||
<Box borderWidth="1px" borderRadius="md" p={4} bg={useColorModeValue('purple.50', 'purple.900')}>
|
||||
<Heading as="h3" size="sm" mb={2}>📊 Ankety k článku</Heading>
|
||||
<Text fontSize="sm" color="gray.700" mb={3}>
|
||||
Vytvořte nebo připojte ankety přímo k tomuto článku. Ankety se zobrazí automaticky na konci článku a čtenáři mohou hlasovat.
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
{editing?.id ? (
|
||||
<PollLinker articleId={editing.id} onPollsChanged={() => {
|
||||
// Invalidate queries to refresh polls
|
||||
qc.invalidateQueries({ queryKey: ['linked-polls'] });
|
||||
}} />
|
||||
) : (
|
||||
<Alert status="info" borderRadius="md">
|
||||
<AlertIcon />
|
||||
<VStack align="start" spacing={1}>
|
||||
<Text fontWeight="semibold">Nejprve uložte článek</Text>
|
||||
<Text fontSize="sm">
|
||||
Pro vytvoření nebo propojení ankety nejprve uložte článek tlačítkem "Uložit" níže. Poté se vrátíte do úprav a budete moci přidat ankety.
|
||||
</Text>
|
||||
</VStack>
|
||||
</Alert>
|
||||
)}
|
||||
</VStack>
|
||||
</TabPanel>
|
||||
</TabPanels>
|
||||
</Tabs>
|
||||
</ModalBody>
|
||||
|
||||
@@ -168,12 +168,22 @@ html {
|
||||
position: absolute;
|
||||
left: 0; right: 0; bottom: 0;
|
||||
padding: 18px;
|
||||
background: linear-gradient(to bottom, transparent 0%, rgba(0,0,0,0.3) 40%, rgba(0,0,0,0.75) 100%);
|
||||
color: var(--text-on-primary, #fff);
|
||||
background: linear-gradient(
|
||||
to bottom,
|
||||
transparent 0%,
|
||||
color-mix(in srgb, var(--primary) 60%, transparent) 40%,
|
||||
color-mix(in srgb, var(--primary) 92%, black) 100%
|
||||
);
|
||||
color: #ffffff;
|
||||
transition: padding 0.3s ease, background 0.3s ease;
|
||||
}
|
||||
.hero-card:hover .overlay {
|
||||
background: linear-gradient(to bottom, transparent 0%, rgba(0,0,0,0.4) 40%, rgba(0,0,0,0.85) 100%);
|
||||
background: linear-gradient(
|
||||
to bottom,
|
||||
transparent 0%,
|
||||
color-mix(in srgb, var(--primary) 70%, transparent) 30%,
|
||||
color-mix(in srgb, var(--primary) 95%, black) 100%
|
||||
);
|
||||
}
|
||||
|
||||
.next-match {
|
||||
@@ -424,7 +434,8 @@ html {
|
||||
.blog-list .card:hover {
|
||||
transform: translateY(-4px);
|
||||
box-shadow: 0 12px 32px rgba(0,0,0,0.12),
|
||||
0 4px 12px rgba(0,0,0,0.08);
|
||||
0 4px 12px rgba(0,0,0,0.08),
|
||||
0 0 0 2px var(--primary);
|
||||
border-color: var(--primary);
|
||||
}
|
||||
|
||||
|
||||
@@ -14,7 +14,18 @@ export interface AIGenerateBlogResp {
|
||||
|
||||
export async function generateBlogAI(payload: AIGenerateBlogReq): Promise<AIGenerateBlogResp> {
|
||||
const { data } = await api.post<AIGenerateBlogResp>('/ai/blog/generate', payload);
|
||||
return data;
|
||||
|
||||
// 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');
|
||||
}
|
||||
}
|
||||
|
||||
return parsedData;
|
||||
}
|
||||
|
||||
export interface AIGenerateAboutReq {
|
||||
@@ -34,5 +45,16 @@ export interface AIGenerateAboutResp {
|
||||
|
||||
export async function generateAboutAI(payload: AIGenerateAboutReq): Promise<AIGenerateAboutResp> {
|
||||
const { data } = await api.post<AIGenerateAboutResp>('/ai/about/generate', payload);
|
||||
return data;
|
||||
|
||||
// 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');
|
||||
}
|
||||
}
|
||||
|
||||
return parsedData;
|
||||
}
|
||||
|
||||
@@ -21,6 +21,8 @@ export interface Article {
|
||||
image_url?: string;
|
||||
author?: { id: number; first_name?: string; last_name?: string; email: string };
|
||||
category_id?: number;
|
||||
category?: { id: number; name: string; description?: string; slug?: string; created_at?: string; updated_at?: string };
|
||||
category_name?: string;
|
||||
published?: boolean;
|
||||
featured?: boolean;
|
||||
created_at?: string;
|
||||
@@ -41,6 +43,7 @@ export interface Article {
|
||||
youtube_video_title?: string;
|
||||
youtube_video_url?: string;
|
||||
youtube_video_thumbnail?: string;
|
||||
attachments?: Array<{ name: string; url: string; mime_type?: string; size?: number }>;
|
||||
}
|
||||
|
||||
// --- Article ⇄ Match link ---
|
||||
@@ -139,6 +142,7 @@ export interface CreateArticlePayload {
|
||||
youtube_video_title?: string;
|
||||
youtube_video_url?: string;
|
||||
youtube_video_thumbnail?: string;
|
||||
attachments?: Array<{ name: string; url: string; mime_type?: string; size?: number }>;
|
||||
}
|
||||
|
||||
export async function createArticle(payload: CreateArticlePayload) {
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
import api from './api';
|
||||
|
||||
export interface ImageProcessRequest {
|
||||
image_url: string;
|
||||
operation?: string;
|
||||
width?: number;
|
||||
height?: number;
|
||||
crop_x?: number;
|
||||
crop_y?: number;
|
||||
crop_width?: number;
|
||||
crop_height?: number;
|
||||
rotation?: number;
|
||||
flip_h?: boolean;
|
||||
flip_v?: boolean;
|
||||
brightness?: number;
|
||||
contrast?: number;
|
||||
saturation?: number;
|
||||
blur?: number;
|
||||
sharpen?: number;
|
||||
grayscale?: boolean;
|
||||
quality?: number;
|
||||
}
|
||||
|
||||
export interface QuickEditRequest {
|
||||
image_url: string;
|
||||
width?: number;
|
||||
rotation?: number;
|
||||
flip_h?: boolean;
|
||||
flip_v?: boolean;
|
||||
brightness?: number;
|
||||
contrast?: number;
|
||||
saturation?: number;
|
||||
grayscale?: boolean;
|
||||
quality?: number;
|
||||
}
|
||||
|
||||
export interface ImageProcessResponse {
|
||||
url: string;
|
||||
format?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Process image with various operations (crop, resize, filters, etc.)
|
||||
*/
|
||||
export const processImage = async (request: ImageProcessRequest): Promise<ImageProcessResponse> => {
|
||||
const response = await api.post('/image-processing/process', request);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
/**
|
||||
* Quick edit for common operations in one call
|
||||
*/
|
||||
export const quickEditImage = async (request: QuickEditRequest): Promise<ImageProcessResponse> => {
|
||||
const response = await api.post('/image-processing/quick-edit', request);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
/**
|
||||
* Crop and upload image file
|
||||
*/
|
||||
export const cropAndUpload = async (
|
||||
file: File,
|
||||
cropData?: { x: number; y: number; width: number; height: number },
|
||||
quality = 85,
|
||||
maxWidth = 1500
|
||||
): Promise<ImageProcessResponse> => {
|
||||
const formData = new FormData();
|
||||
formData.append('image', file);
|
||||
|
||||
if (cropData) {
|
||||
formData.append('crop_data', JSON.stringify(cropData));
|
||||
}
|
||||
|
||||
formData.append('quality', quality.toString());
|
||||
formData.append('max_width', maxWidth.toString());
|
||||
|
||||
const response = await api.post('/image-processing/crop-upload', formData, {
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data',
|
||||
},
|
||||
});
|
||||
|
||||
return response.data;
|
||||
};
|
||||
|
||||
/**
|
||||
* Resize image to specific width (maintains aspect ratio)
|
||||
*/
|
||||
export const resizeImage = async (imageUrl: string, width: number, quality = 85): Promise<ImageProcessResponse> => {
|
||||
return quickEditImage({
|
||||
image_url: imageUrl,
|
||||
width,
|
||||
quality,
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Apply filters to image
|
||||
*/
|
||||
export const applyFilters = async (
|
||||
imageUrl: string,
|
||||
filters: {
|
||||
brightness?: number;
|
||||
contrast?: number;
|
||||
saturation?: number;
|
||||
blur?: number;
|
||||
grayscale?: boolean;
|
||||
},
|
||||
quality = 85
|
||||
): Promise<ImageProcessResponse> => {
|
||||
return quickEditImage({
|
||||
image_url: imageUrl,
|
||||
...filters,
|
||||
quality,
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Rotate image (90, 180, 270 degrees)
|
||||
*/
|
||||
export const rotateImage = async (imageUrl: string, rotation: number, quality = 85): Promise<ImageProcessResponse> => {
|
||||
return quickEditImage({
|
||||
image_url: imageUrl,
|
||||
rotation,
|
||||
quality,
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Flip image horizontally or vertically
|
||||
*/
|
||||
export const flipImage = async (
|
||||
imageUrl: string,
|
||||
flipH: boolean,
|
||||
flipV: boolean,
|
||||
quality = 85
|
||||
): Promise<ImageProcessResponse> => {
|
||||
return quickEditImage({
|
||||
image_url: imageUrl,
|
||||
flip_h: flipH,
|
||||
flip_v: flipV,
|
||||
quality,
|
||||
});
|
||||
};
|
||||
@@ -310,69 +310,48 @@
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
/* Dark Mode Support */
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.ql-toolbar.ql-snow {
|
||||
background: linear-gradient(to bottom, #2d3748 0%, #1a202c 100%);
|
||||
border-bottom-color: #4a5568 !important;
|
||||
}
|
||||
/* Prevent White Text on White Background */
|
||||
.ql-editor [style*="color: rgb(255, 255, 255)"],
|
||||
.ql-editor [style*="color: white"],
|
||||
.ql-editor [style*="color: #fff"],
|
||||
.ql-editor [style*="color: #ffffff"],
|
||||
.ql-editor [style*="color: rgb(255,255,255)"],
|
||||
.ql-editor [style*="color: rgba(255, 255, 255"],
|
||||
.ql-editor [style*="color: rgba(255,255,255"] {
|
||||
color: #1a202c !important;
|
||||
}
|
||||
|
||||
.ql-toolbar.ql-snow .ql-stroke {
|
||||
stroke: #cbd5e0;
|
||||
}
|
||||
/* Ensure all text elements have visible colors */
|
||||
.ql-editor p,
|
||||
.ql-editor span,
|
||||
.ql-editor div,
|
||||
.ql-editor li {
|
||||
color: #2d3748;
|
||||
}
|
||||
|
||||
.ql-toolbar.ql-snow .ql-fill {
|
||||
fill: #cbd5e0;
|
||||
}
|
||||
.ql-editor h1,
|
||||
.ql-editor h2,
|
||||
.ql-editor h3,
|
||||
.ql-editor h4,
|
||||
.ql-editor h5,
|
||||
.ql-editor h6 {
|
||||
color: #1a202c;
|
||||
}
|
||||
|
||||
.ql-toolbar.ql-snow button:hover {
|
||||
background-color: rgba(99, 179, 237, 0.2);
|
||||
}
|
||||
.ql-editor strong,
|
||||
.ql-editor b {
|
||||
color: #1a202c;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.ql-toolbar.ql-snow button.ql-active {
|
||||
background-color: #2c5aa0;
|
||||
}
|
||||
/* Ensure container is always white background */
|
||||
.ql-container.ql-snow {
|
||||
background-color: white !important;
|
||||
}
|
||||
|
||||
.ql-container.ql-snow {
|
||||
background-color: #1a202c;
|
||||
}
|
||||
|
||||
.ql-editor {
|
||||
color: #e2e8f0;
|
||||
}
|
||||
|
||||
.ql-editor.ql-blank::before {
|
||||
color: #4a5568;
|
||||
}
|
||||
|
||||
.ql-editor h1,
|
||||
.ql-editor h2,
|
||||
.ql-editor h3 {
|
||||
color: #f7fafc;
|
||||
}
|
||||
|
||||
.ql-editor blockquote {
|
||||
background-color: #2d3748;
|
||||
color: #cbd5e0;
|
||||
}
|
||||
|
||||
.ql-editor code {
|
||||
background-color: #2d3748;
|
||||
color: #fc8181;
|
||||
}
|
||||
|
||||
.ql-editor pre {
|
||||
background-color: #1a202c;
|
||||
}
|
||||
|
||||
.ql-editor table th {
|
||||
background-color: #2d3748;
|
||||
}
|
||||
|
||||
.ql-editor table td,
|
||||
.ql-editor table th {
|
||||
border-color: #4a5568;
|
||||
}
|
||||
.ql-editor {
|
||||
background-color: white !important;
|
||||
color: #2d3748 !important;
|
||||
}
|
||||
|
||||
/* Responsive Adjustments */
|
||||
|
||||
Reference in New Issue
Block a user