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
@@ -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">
+357 -56
View File
@@ -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>