This commit is contained in:
Tomas Dvorak
2025-11-02 01:04:02 +01:00
parent ac886502e0
commit b9cea0cd77
153 changed files with 43713 additions and 1700 deletions
@@ -103,14 +103,31 @@ const AdminActivitiesPage: React.FC = () => {
onSave: async (data) => {
// If event has ID, update it
if (data.id) {
return await updateEvent(data.id, data);
try {
return await updateEvent(data.id, data);
} catch (e: any) {
const status = e?.response?.status;
if (status === 404) {
if (data.title?.trim() && data.start_time) {
const created = await createEvent(data);
if (created?.id) {
setEditing(prev => ({ ...prev, id: created.id } as any));
setDraftKey(`draft-activity-${created.id}`);
try { localStorage.removeItem('draft-activity-new'); } catch {}
}
return created;
}
}
throw e;
}
}
// If no ID and has title, create as draft
// If no ID and has minimal required fields, create as draft
if (data.title?.trim() && data.start_time) {
const created = await createEvent(data);
// Update editing state with new ID
if (created?.id) {
setEditing(prev => ({ ...prev, id: created.id } as any));
setDraftKey(`draft-activity-${created.id}`);
try { localStorage.removeItem('draft-activity-new'); } catch {}
}
return created;
}
@@ -258,11 +275,16 @@ const AdminActivitiesPage: React.FC = () => {
const handleRecoverDraft = () => {
const draft = loadDraft<Partial<Event>>(draftKey);
if (draft) {
setEditing(draft);
const isNewDraft = draftKey === 'draft-activity-new';
const restored: any = { ...draft };
if (isNewDraft && restored.id) {
delete restored.id;
}
setEditing(restored);
// Restore location if present
if ((draft as any)?.latitude && (draft as any)?.longitude) {
setLocationLat((draft as any).latitude);
setLocationLng((draft as any).longitude);
if ((restored as any)?.latitude && (restored as any)?.longitude) {
setLocationLat((restored as any).latitude);
setLocationLng((restored as any).longitude);
}
onOpen();
}
@@ -334,12 +356,15 @@ const AdminActivitiesPage: React.FC = () => {
try {
setAiLoading(true);
const e = editing || {};
// Build a helpful Czech prompt including known fields
const stripHtml = (s: string) => String(s || '').replace(/<[^>]*>/g, ' ').replace(/\s+/g, ' ').trim();
const lines: string[] = [];
const clubName = String(settingsQ?.data?.club_name || '').trim();
if (clubName) lines.push(`Klub: ${clubName}`);
if (e.type) lines.push(`Typ: ${e.type}`);
if (e.description) lines.push(`Poznámky: ${e.description}`);
if (!aiOverwrite && e.description) {
const plain = stripHtml(e.description as any);
if (plain) lines.push(`Stávající text (pro kontext): ${plain}`);
}
const base = lines.join('\n');
const toneText = aiTone === 'informative'
? 'neutrálním, věcným a stručným stylem (bez nadsázky)'
@@ -347,12 +372,12 @@ const AdminActivitiesPage: React.FC = () => {
? 'formálním a profesionálním stylem (bez příkras)'
: 'přátelským, ale věcným a stručným stylem (bez nadsázky)';
const safeUserPrompt = (aiPrompt || 'Napiš krátkou neutrální pozvánku na klubovou aktivitu.').trim();
const constraints = 'Nevkládej datum ani místo (lokalitu) do textu. Neuváděj konkrétní čas nebo adresu. Vyhýbej se superlativům, hyperbolám a marketingovým frázím. Nepoužívej slova jako „neopakovatelný“, „epický“, „úchvatný“ apod. Preferuj 12 krátké odstavce nebo stručné odrážky. Dbej na věcný a střízlivý tón.';
const prompt = `${safeUserPrompt}\n\nPiš ${toneText}, česky, s důrazem na jasnost a pozvánku k účasti. ${constraints}\nDetaily:\n${base}`.trim();
const constraints = 'Nevkládej datum ani místo (lokalitu) do textu. Neuváděj konkrétní čas nebo adresu. Vyhýbej se superlativům, hyperbolám a marketingovým frázím. Nepoužívej slova jako „neopakovatelný“, „epický“, „úchvatný“ apod. Preferuj 23 krátké odstavce NEBO stručný seznam s odrážkami. Používej HTML značky ul/li pro odrážky a strong pro zvýraznění. Bez nadpisů (nepoužívej H1/H2). Dbej na věcný a střízlivý tón.';
const prompt = `${safeUserPrompt}\n\nPiš ${toneText}, česky, s důrazem na jasnost a pozvánku k účasti. ${constraints}\nCílová délka: 80120 slov.\nDetaily:\n${base}`.trim();
const { data } = await api.post('/ai/blog/generate', {
prompt,
audience: clubName ? `Fanoušci klubu ${clubName}, oznámení/pozvánka` : 'Fanoušci klubu, oznámení/pozvánka',
min_words: 60,
min_words: 100,
});
// Handle potential JSON string response from AI (defensive parsing)
@@ -535,7 +560,7 @@ const AdminActivitiesPage: React.FC = () => {
<Tr><Td colSpan={8}>Načítání</Td></Tr>
)}
{!isLoading && events.map(ev => (
<Tr key={ev.id}>
<Tr key={ev.id} opacity={ev.is_public ? 1 : 0.6}>
<Td>
{(ev as any).image_url ? (
<ThumbnailPreview
+165 -61
View File
@@ -26,7 +26,7 @@ import PollLinker from '../../components/admin/PollLinker';
import ThumbnailPreview from '../../components/common/ThumbnailPreview';
import FilePreview from '../../components/common/FilePreview';
import SaveStatusIndicator from '../../components/common/SaveStatusIndicator';
import DraftRecoveryModal from '../../components/common/DraftRecoveryModal';
import { useAutoSave, loadDraft, getDraftMetadata } from '../../hooks/useAutoSave';
import { getCachedYouTube, YouTubeVideo } from '../../services/youtube';
@@ -91,15 +91,16 @@ const MatchLinkBadge: React.FC<{ articleId: number }> = ({ articleId }) => {
const label = m
? `${String(m.home || m.home_team || '')} ${String(scoreText)} ${String(m.away || m.away_team || '')}`
: `ID: ${String(mid)}`;
const linkHref = (m && (m.facr_link || m.report_url)) ? String(m.facr_link || m.report_url) : '';
return (
<HStack spacing={2}>
<Badge colorScheme={color as any} title={m?.competitionName ? String(m.competitionName) : undefined}>Zápas: {label}</Badge>
{m?.report_url ? (
{linkHref ? (
<IconButton
aria-label="Otevřít FACR"
aria-label="Otevřít zápas na fotbal.cz"
size="xs"
as="a"
href={String(m.report_url)}
href={linkHref}
target="_blank"
rel="noopener noreferrer"
icon={<FiExternalLink />}
@@ -180,6 +181,7 @@ const ArticlesAdminPage = () => {
const [editing, setEditing] = useState<EditingArticle | null>(null);
const [showDraftRecovery, setShowDraftRecovery] = useState(false);
const [draftKey, setDraftKey] = useState<string>('');
const [localDraft, setLocalDraft] = useState<EditingArticle | null>(null);
@@ -268,7 +270,33 @@ const ArticlesAdminPage = () => {
onSave: async (data) => {
// If article has ID, update it as draft
if (data.id) {
return await updateArticle(data.id, { ...data as any, published: false });
try {
return await updateArticle(data.id, { ...data as any, published: false });
} catch (e: any) {
const status = e?.response?.status;
if (status === 404 && data.title?.trim()) {
const payload: CreateArticlePayload = {
title: data.title || 'Koncept článku',
content: data.content || '',
image_url: data.image_url || '',
category_name: data.category_name,
published: false,
slug: data.slug || '',
seo_title: data.seo_title || '',
seo_description: data.seo_description || '',
og_image_url: data.og_image_url || '',
featured: data.featured || false,
};
const created = await createArticle(payload);
if (created?.id) {
setEditing(prev => ({ ...prev, id: created.id } as any));
setDraftKey(`draft-article-${created.id}`);
try { localStorage.removeItem('draft-article-new'); } catch {}
}
return created;
}
throw e;
}
}
// If no ID, create as draft
if (data.title?.trim()) {
@@ -277,7 +305,7 @@ const ArticlesAdminPage = () => {
content: data.content || '',
image_url: data.image_url || '',
category_name: data.category_name,
published: false, // Always save as draft
published: false,
slug: data.slug || '',
seo_title: data.seo_title || '',
seo_description: data.seo_description || '',
@@ -285,9 +313,10 @@ const ArticlesAdminPage = () => {
featured: data.featured || false,
};
const created = await createArticle(payload);
// Update editing state with new ID
if (created?.id) {
setEditing(prev => ({ ...prev, id: created.id } as any));
setDraftKey(`draft-article-${created.id}`);
try { localStorage.removeItem('draft-article-new'); } catch {}
}
return created;
}
@@ -298,16 +327,28 @@ const ArticlesAdminPage = () => {
enabled: isOpen && editing !== null,
});
// Check for draft on component mount
React.useEffect(() => {
const key = 'draft-article-new';
setDraftKey(key);
const metadata = getDraftMetadata(key);
if (metadata && metadata.age < 1440) { // Less than 24 hours old
setShowDraftRecovery(true);
}
// Load local new-draft and expose in list (no popup)
const refreshLocalDraft = React.useCallback(() => {
try {
const key = 'draft-article-new';
const metadata = getDraftMetadata(key);
if (metadata && metadata.age < 1440) {
const d = loadDraft<EditingArticle>(key);
if (d) {
const restored: any = { ...d };
if (restored.id) delete restored.id;
setLocalDraft(restored);
return;
}
}
} catch {}
setLocalDraft(null);
}, []);
React.useEffect(() => {
refreshLocalDraft();
}, [refreshLocalDraft]);
// Fetch cached Zonerama gallery from prefetch
const fetchCachedGallery = useCallback(async () => {
try {
@@ -661,7 +702,7 @@ const ArticlesAdminPage = () => {
return;
}
const { seoTitle, seoDescription } = generateSeoMetadata(aiTitle);
const { seoTitle, seoDescription } = generateSeoMetadata(aiTitle, aiHtml);
setEditing((prev) => ({
...(prev || {}),
title: aiTitle,
@@ -685,20 +726,17 @@ const ArticlesAdminPage = () => {
});
const openCreate = () => {
// Check for existing draft
const key = 'draft-article-new';
setDraftKey(key);
const metadata = getDraftMetadata(key);
if (metadata && metadata.age < 1440) {
// Show recovery modal
setShowDraftRecovery(true);
if (localDraft) {
setEditing(localDraft);
setActiveTabIndex(1);
} else {
// No draft, start fresh
setEditing({ title: '', content: '', featured: false, published: false } as any);
setActiveTabIndex(0); // Start on AI tab for new articles
setAiPrompt(''); // Clear AI prompt
onOpen();
setActiveTabIndex(0);
}
setAiPrompt('');
onOpen();
};
const openEdit = (a: Article) => {
@@ -733,14 +771,20 @@ const ArticlesAdminPage = () => {
setMatchIdInput('');
setEditing(null);
onClose();
refreshLocalDraft();
};
// Draft recovery handlers
const handleRecoverDraft = () => {
const draft = loadDraft<EditingArticle>(draftKey);
if (draft) {
setEditing(draft);
setActiveTabIndex(1); // Go to Základní tab
const isNewDraft = draftKey === 'draft-article-new';
const restored: any = { ...draft };
if (isNewDraft && restored.id) {
delete restored.id;
}
setEditing(restored);
setActiveTabIndex(1);
onOpen();
}
setShowDraftRecovery(false);
@@ -859,15 +903,45 @@ const ArticlesAdminPage = () => {
[deleteMut, toast]
);
const generateSeoMetadata = (title: string) => {
const baseTitle = title ? `${title} | ${process.env.REACT_APP_SITE_NAME || 'Fotbalový klub'}` : (process.env.REACT_APP_SITE_NAME || 'Fotbalový klub');
const description = title
? `Přečtěte si více o ${title.toLowerCase()}. Aktuální informace, novinky a zajímavosti z našeho fotbalového klubu.`
: 'Oficiální stránky našeho fotbalového klubu. Aktuality, zápasy, výsledky a další informace.';
const generateSeoMetadata = (title: string, content?: string) => {
const clubName = String(settingsQ.data?.club_name || process.env.REACT_APP_SITE_NAME || 'Fotbalový klub');
const baseTitle = title ? `${title} | ${clubName}` : clubName;
const toPlain = (html?: string): string => {
try {
const div = document.createElement('div');
div.innerHTML = String(html || '');
return (div.textContent || div.innerText || '').replace(/\s+/g, ' ').trim();
} catch {
return String(html || '').replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim();
}
};
const makeExcerpt = (text: string, limit = 28): string => {
if (!text) return '';
const words = text.split(' ').filter(Boolean);
const excerpt = words.slice(0, limit).join(' ');
return words.length > limit ? `${excerpt}...` : excerpt;
};
let description = '';
const src = toPlain(content);
if (src) {
description = makeExcerpt(src, 28);
}
if (!description) {
const t = (title || '').trim();
description = t ? `Přečtěte si více: ${t}.` : `Aktuální informace z klubu ${clubName}.`;
}
if (description.length > 160) {
description = description.slice(0, 157).replace(/\s+\S*$/, '') + '...';
}
return {
seoTitle: baseTitle,
seoDescription: description.length > 160 ? description.substring(0, 157) + '...' : description
seoDescription: description
};
};
@@ -897,7 +971,7 @@ const ArticlesAdminPage = () => {
const handleTitleChange = (title: string) => {
if (!editing) return;
const { seoTitle, seoDescription } = generateSeoMetadata(title);
const { seoTitle, seoDescription } = generateSeoMetadata(title, (editing as any)?.content);
setEditing(prev => ({
...(prev as any),
title,
@@ -1232,8 +1306,49 @@ const ArticlesAdminPage = () => {
{isLoading && (
<Tr><Td colSpan={6}><Spinner size="sm" /></Td></Tr>
)}
{!isLoading && localDraft && (
<Tr key="local-draft" opacity={0.6}>
<Td>
<ThumbnailPreview
src={assetUrl((localDraft as any).image_url) || '/dist/img/logo-club-empty.svg'}
alt={(localDraft as any).title || 'Koncept'}
size="48px"
previewSize="350px"
/>
</Td>
<Td>
<VStack align="start" spacing={0}>
<Text fontWeight="medium">{(localDraft as any).title || 'Bez názvu (koncept)'}</Text>
<Text fontSize="xs" color="gray.500">Koncept (lokálně uložený)</Text>
</VStack>
</Td>
<Td>
<Badge colorScheme="gray" fontSize="xs">
{(localDraft as any).category_name || 'Bez kategorie'}
</Badge>
</Td>
<Td>
<Switch size="sm" isChecked={!!(localDraft as any).featured} isDisabled />
</Td>
<Td>
<Badge colorScheme="gray">Koncept</Badge>
</Td>
<Td isNumeric>
<HStack spacing={1} justify="flex-end">
<IconButton aria-label="Upravit koncept" size="sm" icon={<FiEdit2 />} onClick={openCreate} />
<IconButton
aria-label="Smazat koncept"
size="sm"
colorScheme="red"
icon={<FiTrash2 />}
onClick={() => { try { localStorage.removeItem('draft-article-new'); } catch {} setLocalDraft(null); toast({ title: 'Koncept odstraněn', status: 'success', duration: 2000 }); }}
/>
</HStack>
</Td>
</Tr>
)}
{!isLoading && articles.map((a) => (
<Tr key={a.id}>
<Tr key={a.id} opacity={a.published ? 1 : 0.6}>
<Td>
<ThumbnailPreview
src={assetUrl(a.image_url) || '/dist/img/logo-club-empty.svg'}
@@ -1769,20 +1884,7 @@ const ArticlesAdminPage = () => {
</VStack>
</Box>
{/* OG Image for Social Sharing */}
<FormControl>
<FormLabel>OG obrázek pro sdílení (volitelné)</FormLabel>
<FormHelperText mb={2}>
Speciální obrázek pro sdílení na sociálních sítích. Pokud není nastaveno, použije se titulní obrázek.
</FormHelperText>
<HStack>
<Image src={assetUrl((editing as any)?.og_image_url) || assetUrl(editing?.image_url) || '/dist/img/logo-club-empty.svg'} alt="og" boxSize="80px" objectFit="cover" borderRadius="md" />
<Button as="label" leftIcon={<FiUpload />} variant="outline">
Nahrát OG obrázek
<Input type="file" display="none" accept="image/*" onChange={(e) => onUploadOg(e.target.files?.[0])} />
</Button>
</HStack>
</FormControl>
{/* File Attachments */}
<FormControl>
@@ -2009,6 +2111,19 @@ const ArticlesAdminPage = () => {
/>
<FormHelperText fontSize="xs">Automaticky generováno z obsahu článku</FormHelperText>
</FormControl>
<FormControl>
<FormLabel>OG obrázek pro sdílení (volitelné)</FormLabel>
<FormHelperText mb={2}>
Speciální obrázek pro sdílení na sociálních sítích. Pokud není nastaveno, použije se titulní obrázek.
</FormHelperText>
<HStack>
<Image src={assetUrl((editing as any)?.og_image_url) || assetUrl(editing?.image_url) || '/dist/img/logo-club-empty.svg'} alt="og" boxSize="80px" objectFit="cover" borderRadius="md" />
<Button as="label" leftIcon={<FiUpload />} variant="outline">
Nahrát OG obrázek
<Input type="file" display="none" accept="image/*" onChange={(e) => onUploadOg(e.target.files?.[0])} />
</Button>
</HStack>
</FormControl>
</VStack>
</AccordionPanel>
</AccordionItem>
@@ -2223,17 +2338,6 @@ const ArticlesAdminPage = () => {
</Modal>
{/* Draft Recovery Modal */}
<DraftRecoveryModal
isOpen={showDraftRecovery}
onClose={() => setShowDraftRecovery(false)}
onRecover={handleRecoverDraft}
onDiscard={handleDiscardDraft}
onDeleteOnly={handleDeleteOnly}
draftAge={getDraftMetadata(draftKey)?.age || null}
entityType="článek"
/>
</AdminLayout>
);
};
+35 -19
View File
@@ -1,10 +1,10 @@
import React, { useState, useRef } from 'react';
import React, { useState, useRef, useEffect } from 'react';
import { Box, Button, FormControl, FormLabel, Heading, HStack, IconButton, Image, Input, Modal, ModalBody, ModalCloseButton, ModalContent, ModalFooter, ModalHeader, ModalOverlay, Spinner, Table, Tbody, Td, Th, Thead, Tr, useColorModeValue, useDisclosure, useToast, VStack, Select, Text, Switch, Badge, Alert, AlertIcon, AlertTitle, AlertDescription, Divider, Grid, GridItem } from '@chakra-ui/react';
import { FiPlus, FiEdit2, FiTrash2, FiUpload, FiAlertCircle, FiCheckCircle } from 'react-icons/fi';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import AdminLayout from '../../layouts/AdminLayout';
import { Banner as AdminBanner, getBanners, createBanner, updateBanner, deleteBanner } from '../../services/banners';
import { uploadFile } from '../../services/articles';
import { uploadFile, getArticles } from '../../services/articles';
import { assetUrl } from '../../utils/url';
// Banner placement presets with dimensions and descriptions
@@ -19,15 +19,6 @@ type BannerPreset = {
};
const BANNER_PRESETS: BannerPreset[] = [
{
value: 'homepage_top',
label: 'Hlavní banner (Homepage - vrchol)',
description: 'Hlavní reklamní plocha nahoře, zobrazena všem návštěvníkům',
width: 1200,
height: 200,
aspectRatio: 6,
position: 'top'
},
{
value: 'homepage_middle',
label: 'Střední banner (Homepage - střed)',
@@ -39,8 +30,8 @@ const BANNER_PRESETS: BannerPreset[] = [
},
{
value: 'homepage_sidebar',
label: 'Postranní banner (Homepage - sidebar)',
description: 'Menší banner v pravém postranním panelu',
label: 'Postranní banner (Homepage - okraj obrazovky)',
description: 'Menší banner ukotvený u levého/pravého okraje obrazovky (nastavitelné v editoru: Sidebar varianta vlevo/vpravo)',
width: 300,
height: 250,
aspectRatio: 1.2,
@@ -86,6 +77,7 @@ const BannersAdminPage: React.FC = () => {
const [imageResolution, setImageResolution] = useState<{ width: number; height: number } | null>(null);
const [recommendedPlacements, setRecommendedPlacements] = useState<BannerPreset[]>([]);
const [uploadingImage, setUploadingImage] = useState(false);
const [hasArticles, setHasArticles] = useState<boolean>(true);
const fileInputRef = useRef<HTMLInputElement>(null);
const { isOpen, onOpen, onClose } = useDisclosure();
@@ -142,6 +134,18 @@ const BannersAdminPage: React.FC = () => {
onClose();
};
// Determine if at least one published article exists to allow "Banner v článcích"
useEffect(() => {
(async () => {
try {
const resp = await getArticles({ page: 1, page_size: 1, published: true });
setHasArticles(((resp?.total ?? 0) > 0) || ((resp?.data?.length ?? 0) > 0));
} catch {
setHasArticles(true); // fail-open so UI is not unnecessarily blocked
}
})();
}, []);
const createMut = useMutation({
mutationFn: (payload: any) => createBanner(payload),
onSuccess: () => { toast({ title: 'Banner vytvořen', status: 'success' }); qc.invalidateQueries({ queryKey: ['admin-banners'] }); closeModal(); },
@@ -277,7 +281,7 @@ const BannersAdminPage: React.FC = () => {
{!isLoading && banners.map((b: AdminBanner) => {
const preset = getPreset((b as any).placement);
return (
<Tr key={b.id}>
<Tr key={b.id} opacity={b.is_active ? 1 : 0.6}>
<Td>
<Image src={assetUrl((b as any).image_url) || '/logo192.png'} alt={b.name} boxSize="56px" objectFit="contain" bg={inputBg} borderRadius="md" />
</Td>
@@ -402,11 +406,23 @@ const BannersAdminPage: React.FC = () => {
}}
>
<option value=""> vyberte umístění </option>
{BANNER_PRESETS.map(preset => (
<option key={preset.value} value={preset.value}>
{preset.label} ({preset.width}×{preset.height})
</option>
))}
{BANNER_PRESETS.map(preset => {
const isArticleInline = preset.value === 'article_inline';
const disabled = isArticleInline && !hasArticles;
const label = isArticleInline && !hasArticles
? `${preset.label} — nelze použít (na webu zatím není žádný článek)`
: `${preset.label} (${preset.width}×${preset.height})`;
return (
<option
key={preset.value}
value={preset.value}
disabled={disabled}
title={isArticleInline && !hasArticles ? 'Tuto pozici lze použít až když existuje alespoň 1 publikovaný článek.' : preset.description}
>
{label}
</option>
);
})}
</Select>
{editing?.placement && (() => {
const preset = getPreset((editing as any).placement);
@@ -0,0 +1,178 @@
import React from 'react';
import AdminLayout from '../../layouts/AdminLayout';
import { Box, Heading, HStack, VStack, Button, Select, Input, Table, Thead, Tbody, Tr, Th, Td, Text, Badge, IconButton, useToast, Modal, ModalOverlay, ModalContent, ModalHeader, ModalBody, ModalFooter, ModalCloseButton, useDisclosure, FormControl, FormLabel, NumberInput, NumberInputField } from '@chakra-ui/react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { adminListComments, adminUpdateCommentStatus, adminBanUser, adminListUnbanRequests, adminResolveUnban } from '../../services/admin/comments';
import { deleteComment } from '../../services/comments';
import { FiTrash2 } from 'react-icons/fi';
const CommentsAdminPage: React.FC = () => {
const [status, setStatus] = React.useState<string>('');
const [targetType, setTargetType] = React.useState<string>('');
const [targetId, setTargetId] = React.useState<string>('');
const [userId, setUserId] = React.useState<string>('');
const [page, setPage] = React.useState<number>(1);
const toast = useToast();
const qc = useQueryClient();
const listQ = useQuery({
queryKey: ['admin-comments', { status, targetType, targetId, userId, page }],
queryFn: () => adminListComments({ status: status as any, target_type: targetType, target_id: targetId, user_id: userId, page, page_size: 50 }),
keepPreviousData: true,
});
const unbanQ = useQuery({
queryKey: ['admin-unban-requests'],
queryFn: adminListUnbanRequests,
});
const updateStatusMut = useMutation({
mutationFn: (args: { id: number; s: 'visible'|'hidden' }) => adminUpdateCommentStatus(args.id, args.s),
onSuccess: async () => { await qc.invalidateQueries({ queryKey: ['admin-comments'] }); },
});
const deleteMut = useMutation({
mutationFn: (id: number) => deleteComment(id),
onSuccess: async () => { await qc.invalidateQueries({ queryKey: ['admin-comments'] }); toast({ status: 'success', title: 'Smazáno' }); },
});
const [banUserId, setBanUserId] = React.useState<number | null>(null);
const banModal = useDisclosure();
const [banReason, setBanReason] = React.useState<string>('Porušení pravidel diskuse');
const [banHours, setBanHours] = React.useState<number>(0);
const banMut = useMutation({
mutationFn: () => adminBanUser(banUserId || 0, banReason, banHours),
onSuccess: async () => { banModal.onClose(); setBanUserId(null); toast({ status: 'success', title: 'Uživatel zablokován' }); },
});
const resolveUnbanMut = useMutation({
mutationFn: (args: { id: number; action: 'approve'|'reject' }) => adminResolveUnban(args.id, args.action),
onSuccess: async () => { await qc.invalidateQueries({ queryKey: ['admin-unban-requests'] }); toast({ status: 'success', title: 'Vyřízeno' }); },
});
const items = listQ.data?.items || [];
return (
<AdminLayout>
<Box>
<Heading size="md" mb={4}>Komentáře (moderace)</Heading>
<VStack align="stretch" spacing={3} mb={4}>
<HStack>
<Select placeholder="Status" value={status} onChange={(e) => { setStatus(e.target.value); setPage(1); }} maxW="200px">
<option value="visible">Viditelné</option>
<option value="hidden">Skryté</option>
</Select>
<Select placeholder="Typ cíle" value={targetType} onChange={(e) => { setTargetType(e.target.value); setPage(1); }} maxW="220px">
<option value="article">Článek</option>
<option value="event">Aktivita</option>
<option value="gallery_album">Galerie</option>
<option value="youtube_video">YouTube video</option>
</Select>
<Input placeholder="Target ID" value={targetId} onChange={(e) => { setTargetId(e.target.value); setPage(1); }} maxW="200px" />
<Input placeholder="User ID" value={userId} onChange={(e) => { setUserId(e.target.value); setPage(1); }} maxW="200px" />
</HStack>
</VStack>
<Box borderWidth="1px" borderRadius="md" overflowX="auto">
<Table size="sm">
<Thead>
<Tr>
<Th>ID</Th>
<Th>Uživatel</Th>
<Th>Cíl</Th>
<Th>Obsah</Th>
<Th>Spam</Th>
<Th>Status</Th>
<Th>Akce</Th>
</Tr>
</Thead>
<Tbody>
{items.map((c) => (
<Tr key={c.id}>
<Td>#{c.id}</Td>
<Td>#{c.user?.id} {c.user?.first_name} {c.user?.last_name}</Td>
<Td><Badge>{c.target_type}</Badge> <Text as="span">{c.target_id}</Text></Td>
<Td maxW="420px"><Text noOfLines={2}>{c.content}</Text></Td>
<Td>{(c as any).spam_score ? <Badge colorScheme={(c as any).spam_score > 0.5 ? 'orange' : 'green'}>{(c as any).spam_score.toFixed(2)}</Badge> : '-'}</Td>
<Td>
<HStack>
<Button size="xs" variant={c.status === 'visible' ? 'solid' : 'outline'} onClick={() => updateStatusMut.mutate({ id: c.id, s: 'visible' })}>Viditelné</Button>
<Button size="xs" variant={c.status === 'hidden' ? 'solid' : 'outline'} onClick={() => updateStatusMut.mutate({ id: c.id, s: 'hidden' })}>Skryté</Button>
</HStack>
</Td>
<Td>
<HStack>
<IconButton aria-label="Smazat" size="xs" icon={<FiTrash2 />} onClick={() => deleteMut.mutate(c.id)} />
<Button size="xs" variant="outline" onClick={() => { setBanUserId(c.user?.id as any); banModal.onOpen(); }}>Ban</Button>
</HStack>
</Td>
</Tr>
))}
</Tbody>
</Table>
</Box>
<Heading size="sm" mt={6} mb={2}>Žádosti o odblokování</Heading>
<Box borderWidth="1px" borderRadius="md" overflowX="auto">
<Table size="sm">
<Thead>
<Tr>
<Th>ID</Th>
<Th>Uživatel</Th>
<Th>Text</Th>
<Th>Status</Th>
<Th>Akce</Th>
</Tr>
</Thead>
<Tbody>
{(unbanQ.data?.items || []).map((r) => (
<Tr key={r.id}>
<Td>#{r.id}</Td>
<Td>#{r.user_id}</Td>
<Td maxW="480px"><Text noOfLines={2}>{r.message}</Text></Td>
<Td><Badge>{r.status}</Badge></Td>
<Td>
<HStack>
<Button size="xs" colorScheme="green" variant="outline" onClick={() => resolveUnbanMut.mutate({ id: r.id, action: 'approve' })}>Povolit</Button>
<Button size="xs" colorScheme="red" variant="outline" onClick={() => resolveUnbanMut.mutate({ id: r.id, action: 'reject' })}>Zamítnout</Button>
</HStack>
</Td>
</Tr>
))}
</Tbody>
</Table>
</Box>
{/* Ban modal */}
<Modal isOpen={banModal.isOpen} onClose={banModal.onClose} isCentered>
<ModalOverlay />
<ModalContent>
<ModalHeader>Zablokovat uživatele #{banUserId}</ModalHeader>
<ModalCloseButton />
<ModalBody>
<VStack align="stretch" spacing={3}>
<FormControl>
<FormLabel>Důvod</FormLabel>
<Input value={banReason} onChange={(e) => setBanReason(e.target.value)} />
</FormControl>
<FormControl>
<FormLabel>Doba (hodiny) 0 = trvale</FormLabel>
<NumberInput min={0} value={banHours} onChange={(v) => setBanHours(Number(v) || 0)}>
<NumberInputField />
</NumberInput>
</FormControl>
</VStack>
</ModalBody>
<ModalFooter>
<HStack>
<Button onClick={banModal.onClose}>Zrušit</Button>
<Button colorScheme="red" isLoading={banMut.isPending} onClick={() => banMut.mutate()}>Zablokovat</Button>
</HStack>
</ModalFooter>
</ModalContent>
</Modal>
</Box>
</AdminLayout>
);
};
export default CommentsAdminPage;
@@ -0,0 +1,209 @@
import React from 'react';
import AdminLayout from '../../layouts/AdminLayout';
import {
Box,
Heading,
HStack,
VStack,
Button,
Input,
Select,
Table,
Thead,
Tbody,
Tr,
Th,
Td,
Text,
Badge,
IconButton,
useToast,
Switch,
NumberInput,
NumberInputField,
Image,
Divider,
} from '@chakra-ui/react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import {
adminListRewards,
adminCreateReward,
adminUpdateReward,
adminDeleteReward,
adminListRedemptions,
adminUpdateRedemptionStatus,
AdminRewardItem,
AdminRedemption,
} from '../../services/admin/engagement';
import { FiTrash2 } from 'react-icons/fi';
const EngagementAdminPage: React.FC = () => {
const toast = useToast();
const qc = useQueryClient();
const rewardsQ = useQuery({
queryKey: ['admin-engagement-rewards'],
queryFn: () => adminListRewards(),
});
const redemptionsQ = useQuery({
queryKey: ['admin-engagement-redemptions'],
queryFn: () => adminListRedemptions(),
});
const [form, setForm] = React.useState({
name: '',
type: 'avatar_static',
cost_points: 50,
image_url: '',
stock: 0,
active: true,
});
const createMut = useMutation({
mutationFn: () => adminCreateReward(form),
onSuccess: async () => {
setForm({ name: '', type: 'avatar_static', cost_points: 50, image_url: '', stock: 0, active: true });
await qc.invalidateQueries({ queryKey: ['admin-engagement-rewards'] });
toast({ status: 'success', title: 'Odměna vytvořena' });
},
onError: (e: any) => toast({ status: 'error', title: e?.response?.data?.error || 'Chyba při vytváření odměny' }),
});
const updateMut = useMutation({
mutationFn: (args: { id: number; body: Partial<AdminRewardItem> }) => adminUpdateReward(args.id, args.body as any),
onSuccess: async () => { await qc.invalidateQueries({ queryKey: ['admin-engagement-rewards'] }); toast({ status: 'success', title: 'Aktualizováno' }); },
});
const deleteMut = useMutation({
mutationFn: (id: number) => adminDeleteReward(id),
onSuccess: async () => { await qc.invalidateQueries({ queryKey: ['admin-engagement-rewards'] }); toast({ status: 'success', title: 'Smazáno' }); },
});
const redStatusMut = useMutation({
mutationFn: (args: { id: number; action: 'approve'|'reject'|'fulfill' }) => adminUpdateRedemptionStatus(args.id, args.action),
onSuccess: async () => { await qc.invalidateQueries({ queryKey: ['admin-engagement-redemptions'] }); toast({ status: 'success', title: 'Status aktualizován' }); },
});
const rewards = rewardsQ.data || [];
const redemptions = redemptionsQ.data || [];
return (
<AdminLayout>
<Box>
<Heading size="md" mb={4}>Odměny & Úspěchy</Heading>
<VStack align="stretch" spacing={4}>
<Box>
<Heading size="sm" mb={2}>Vytvořit novou odměnu</Heading>
<VStack align="stretch" spacing={3} borderWidth="1px" borderRadius="md" p={3}>
<HStack>
<Input placeholder="Název" value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} maxW="280px" />
<Select value={form.type} onChange={(e) => setForm({ ...form, type: e.target.value })} maxW="220px">
<option value="avatar_static">Avatar (statický)</option>
<option value="avatar_animated">Avatar (animovaný)</option>
<option value="merch_coupon">Merch kupon</option>
<option value="custom">Vlastní</option>
</Select>
<NumberInput value={form.cost_points} min={0} maxW="180px" onChange={(v) => setForm({ ...form, cost_points: Number(v) || 0 })}>
<NumberInputField placeholder="Body" />
</NumberInput>
<NumberInput value={form.stock} min={0} maxW="160px" onChange={(v) => setForm({ ...form, stock: Number(v) || 0 })}>
<NumberInputField placeholder="Sklad" />
</NumberInput>
<Input placeholder="Obrázek URL" value={form.image_url} onChange={(e) => setForm({ ...form, image_url: e.target.value })} />
<HStack>
<Text>Aktivní</Text>
<Switch isChecked={form.active} onChange={(e) => setForm({ ...form, active: e.target.checked })} />
</HStack>
<Button colorScheme="blue" onClick={() => createMut.mutate()} isLoading={createMut.isPending} isDisabled={!form.name.trim()}>Vytvořit</Button>
</HStack>
</VStack>
</Box>
<Divider />
<Box>
<Heading size="sm" mb={2}>Odměny</Heading>
<Box borderWidth="1px" borderRadius="md" overflowX="auto">
<Table size="sm">
<Thead>
<Tr>
<Th>ID</Th>
<Th>Název</Th>
<Th>Typ</Th>
<Th>Body</Th>
<Th>Sklad</Th>
<Th>Obrázek</Th>
<Th>Aktivní</Th>
<Th>Akce</Th>
</Tr>
</Thead>
<Tbody>
{rewards.map((r: AdminRewardItem) => (
<Tr key={r.id}>
<Td>#{r.id}</Td>
<Td>{r.name}</Td>
<Td><Badge>{r.type}</Badge></Td>
<Td>
<NumberInput size="sm" value={r.cost_points} min={0} maxW="120px" onChange={(v) => updateMut.mutate({ id: r.id, body: { cost_points: Number(v) || 0 } })}>
<NumberInputField />
</NumberInput>
</Td>
<Td>
<NumberInput size="sm" value={r.stock || 0} min={0} maxW="100px" onChange={(v) => updateMut.mutate({ id: r.id, body: { stock: Number(v) || 0 } })}>
<NumberInputField />
</NumberInput>
</Td>
<Td>{r.image_url ? <Image src={r.image_url} alt={r.name} boxSize="40px" objectFit="cover" borderRadius="md" /> : '-'}</Td>
<Td>
<Switch isChecked={!!r.active} onChange={(e) => updateMut.mutate({ id: r.id, body: { active: e.target.checked } })} />
</Td>
<Td>
<IconButton aria-label="Smazat" size="xs" icon={<FiTrash2 />} onClick={() => deleteMut.mutate(r.id)} />
</Td>
</Tr>
))}
</Tbody>
</Table>
</Box>
</Box>
<Box>
<Heading size="sm" mt={6} mb={2}>Uplatnění odměn</Heading>
<Box borderWidth="1px" borderRadius="md" overflowX="auto">
<Table size="sm">
<Thead>
<Tr>
<Th>ID</Th>
<Th>Uživatel</Th>
<Th>Odměna</Th>
<Th>Status</Th>
<Th>Akce</Th>
</Tr>
</Thead>
<Tbody>
{redemptions.map((d: AdminRedemption) => (
<Tr key={d.id}>
<Td>#{d.id}</Td>
<Td>#{d.user_id}</Td>
<Td>#{d.reward_id}</Td>
<Td><Badge>{d.status}</Badge></Td>
<Td>
<HStack>
<Button size="xs" variant="outline" onClick={() => redStatusMut.mutate({ id: d.id, action: 'approve' })}>Schválit</Button>
<Button size="xs" variant="outline" colorScheme="red" onClick={() => redStatusMut.mutate({ id: d.id, action: 'reject' })}>Zamítnout</Button>
<Button size="xs" variant="outline" colorScheme="green" onClick={() => redStatusMut.mutate({ id: d.id, action: 'fulfill' })}>Vydat</Button>
</HStack>
</Td>
</Tr>
))}
</Tbody>
</Table>
</Box>
</Box>
</VStack>
</Box>
</AdminLayout>
);
};
export default EngagementAdminPage;
@@ -45,6 +45,7 @@ import {
Divider,
Code,
Icon,
Progress,
} from '@chakra-ui/react';
import { useState, useMemo } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
@@ -62,6 +63,7 @@ import {
refreshFileTracking,
formatFileSize,
getFileIcon,
getStorageUsage,
} from '../../services/files';
import { API_URL } from '../../services/api';
import { assetUrl } from '../../utils/url';
@@ -103,6 +105,12 @@ const FilesAdminPage: React.FC = () => {
queryFn: getDuplicateFiles,
});
// Storage usage
const { data: storageUsage } = useQuery({
queryKey: ['admin-files-usage'],
queryFn: getStorageUsage,
});
// Delete mutation
const deleteMutation = useMutation({
mutationFn: ({ id, force }: { id: number; force: boolean }) => deleteFile(id, force),
@@ -111,6 +119,7 @@ const FilesAdminPage: React.FC = () => {
qc.invalidateQueries({ queryKey: ['admin-files'] });
qc.invalidateQueries({ queryKey: ['admin-files-unused'] });
qc.invalidateQueries({ queryKey: ['admin-files-duplicates'] });
qc.invalidateQueries({ queryKey: ['admin-files-usage'] });
onDeleteClose();
setDeleteTarget(null);
setForceDelete(false);
@@ -144,6 +153,7 @@ const FilesAdminPage: React.FC = () => {
qc.invalidateQueries({ queryKey: ['admin-files'] });
qc.invalidateQueries({ queryKey: ['admin-files-unused'] });
qc.invalidateQueries({ queryKey: ['admin-files-duplicates'] });
qc.invalidateQueries({ queryKey: ['admin-files-usage'] });
},
onError: () => {
toast({ title: 'Chyba při skenování', status: 'error' });
@@ -307,6 +317,37 @@ const FilesAdminPage: React.FC = () => {
</HStack>
</HStack>
{storageUsage && (
<VStack align="stretch" spacing={2}>
{(storageUsage.status === 'warn' || storageUsage.status === 'critical') && (
<Alert status={storageUsage.status === 'critical' ? 'error' : 'warning'} borderRadius="md">
<AlertIcon />
<Box>
<AlertTitle>
{storageUsage.status === 'critical' ? 'Úložiště téměř plné' : 'Dochází místo v úložišti'}
</AlertTitle>
<AlertDescription>
Využito {storageUsage.percent.toFixed(1)}% ({formatFileSize(storageUsage.used_bytes)} z {formatFileSize(storageUsage.quota_bytes)}).
</AlertDescription>
</Box>
</Alert>
)}
<HStack>
<Text fontWeight="medium">Využití úložiště</Text>
<Spacer />
<Text fontSize="sm" color="gray.500">
{formatFileSize(storageUsage.used_bytes)} / {formatFileSize(storageUsage.quota_bytes)} ({storageUsage.percent.toFixed(1)}%)
</Text>
</HStack>
<Progress
value={Math.min(100, storageUsage.percent)}
colorScheme={storageUsage.status === 'critical' ? 'red' : storageUsage.status === 'warn' ? 'orange' : 'blue'}
height="10px"
borderRadius="md"
/>
</VStack>
)}
<Tabs colorScheme="blue" variant="enclosed">
<TabList>
<Tab>Všechny soubory ({allFiles.length})</Tab>
+43 -111
View File
@@ -25,12 +25,7 @@ import {
FormLabel,
Input,
Stack,
InputGroup,
InputRightElement,
List,
ListItem,
FormErrorMessage,
Image,
useBreakpointValue,
Wrap,
WrapItem,
@@ -39,15 +34,15 @@ import {
} from '@chakra-ui/react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import AdminLayout from '../../layouts/AdminLayout';
import { putMatchOverride, fetchTeamLogoOverrides } from '../../services/adminMatches';
import { putMatchOverride } from '../../services/adminMatches';
import { getPublicSettings } from '../../services/settings';
import { useEffect, useMemo, useRef, useState } from 'react';
import { useSearchParams } from 'react-router-dom';
import { parse, format } from 'date-fns';
import { assetUrl } from '../../utils/url';
import { batchFetchLogosFromSportLogosAPI } from '../../utils/sportLogosAPI';
import { API_URL } from '../../services/api';
import TeamLogo from '../../components/common/TeamLogo';
import { getCompetitionAliasesPublic } from '../../services/competitionAliases';
const MatchesAdminPage = () => {
const queryClient = useQueryClient();
@@ -57,16 +52,8 @@ const MatchesAdminPage = () => {
const [form, setForm] = useState({
venue_override: '',
date_time_edit: '',
notes: '',
});
const { data: overrides = {} } = useQuery({
queryKey: ['teamLogoOverrides'],
queryFn: fetchTeamLogoOverrides,
staleTime: 5 * 60 * 1000,
});
const overridesById: Record<string, { name?: string; logo_url?: string }> = (overrides as any)?.by_id || {};
const normalizeName = (s: string) => {
let out = String(s || '');
out = out
@@ -91,64 +78,8 @@ const MatchesAdminPage = () => {
out = out.replace(/\s+/g, ' ').trim();
return out;
};
const byName: Record<string, string> = (overrides as any)?.by_name || {};
const byNameNormalized = useMemo(() => {
const idx: Record<string, string> = {};
for (const k of Object.keys(byName)) idx[normalizeName(k)] = byName[k];
return idx;
}, [byName]);
// Build name index from overrides by_id for cases where team_id is missing in cached data
const overridesNameIndex = useMemo(() => {
const idx: Record<string, { id: string; name: string; logo_url: string }> = {};
try {
for (const [id, v] of Object.entries(overridesById)) {
const name = String((v as any)?.name || '').trim();
const logo = String((v as any)?.logo_url || '').trim();
if (!name) continue;
const norm = normalizeName(name);
if (!norm) continue;
idx[norm] = { id, name, logo_url: logo };
}
} catch {}
return idx;
}, [overridesById]);
const [sportLogosMap, setSportLogosMap] = useState<Record<string, string>>({});
const getLogo = (teamName?: string, teamId?: string, facrOriginal?: string) => {
if (!teamName) return assetUrl('/dist/img/logo-club-empty.svg') as string;
// 0) Admin override by team ID takes precedence
if (teamId && overridesById[teamId] && overridesById[teamId]?.logo_url) {
const u = String(overridesById[teamId].logo_url);
if (u.startsWith('/')) return assetUrl(u) as string;
return u;
}
// 0.5) If no ID, but override exists for normalized name, use it
try {
const hit = overridesNameIndex[normalizeName(teamName)];
if (hit && hit.logo_url) {
const u = String(hit.logo_url);
if (u.startsWith('/')) return assetUrl(u) as string;
return u;
}
} catch {}
// 1) LogoAPI map by team ID
if (teamId && sportLogosMap[String(teamId)]) return sportLogosMap[String(teamId)];
// 2) Local/legacy overrides by name
let overrideUrl = byName[teamName];
if (!overrideUrl) overrideUrl = byNameNormalized[normalizeName(teamName)];
if (overrideUrl) {
if (overrideUrl.startsWith('/')) return assetUrl(overrideUrl) as string;
return overrideUrl;
}
// 3) FACR original if provided
if (facrOriginal) return facrOriginal;
// Fallback placeholder
return '/dist/img/logo-club-empty.svg';
};
// Team name/logo editing removed
const { data: matches = [], isLoading, error } = useQuery<any[], Error>({
@@ -201,23 +132,7 @@ const MatchesAdminPage = () => {
}));
},
});
useEffect(() => {
if (!Array.isArray(matches) || matches.length === 0) return;
const ids = new Set<string>();
for (const m of matches as any[]) {
if (m.home_id) ids.add(String(m.home_id));
if (m.away_id) ids.add(String(m.away_id));
}
if (ids.size === 0) return;
(async () => {
try {
const map = await batchFetchLogosFromSportLogosAPI(Array.from(ids));
setSportLogosMap(map);
} catch (e) {
console.warn('Failed to batch fetch logos:', e);
}
})();
}, [matches]);
// Filters
const [teamFilter, setTeamFilter] = useState('');
@@ -273,13 +188,37 @@ const MatchesAdminPage = () => {
const ts = stripPrefixes(team);
return !!clubNorm && (t.includes(clubNorm) || ts.includes(clubStrip) || t.endsWith(clubStrip) || clubStrip.endsWith(ts));
};
// Load competition aliases (ordered by display_order in backend)
const { data: compAliases = [] } = useQuery({
queryKey: ['competition-aliases-public'],
queryFn: getCompetitionAliasesPublic,
});
const competitionOptions = useMemo(() => {
const set = new Set<string>();
for (const m of matches) {
if (m.competitionName) set.add(String(m.competitionName));
}
return Array.from(set).sort((a, b) => a.localeCompare(b));
}, [matches]);
const arr = Array.from(set);
const getOrder = (name: string): number => {
if (!Array.isArray(compAliases) || compAliases.length === 0) return Number.MAX_SAFE_INTEGER;
const n = normalizeName(name);
for (let i = 0; i < compAliases.length; i++) {
const al: any = compAliases[i] as any;
const a1 = normalizeName(String(al.alias || ''));
const a2 = normalizeName(String(al.original_name || ''));
if ((a1 && (n.includes(a1) || a1.includes(n))) || (a2 && (n.includes(a2) || a2.includes(n)))) {
return i;
}
}
return Number.MAX_SAFE_INTEGER;
};
return arr.sort((a, b) => {
const oa = getOrder(a);
const ob = getOrder(b);
if (oa !== ob) return oa - ob;
return a.localeCompare(b);
});
}, [matches, compAliases]);
const filteredMatches = matches.filter((m: any) => {
// team filter
const teamOk = normalizedTeam
@@ -440,7 +379,6 @@ const MatchesAdminPage = () => {
const payload: any = {
venue_override: form.venue_override,
date_time_override: form.date_time_edit,
notes: form.notes,
};
Object.keys(payload).forEach((k) => {
if (payload[k as keyof typeof payload] === '') payload[k as keyof typeof payload] = null;
@@ -481,7 +419,6 @@ const MatchesAdminPage = () => {
setForm({
venue_override: m.venue || '',
date_time_edit: localStr,
notes: '',
});
setIsOpen(true);
};
@@ -902,11 +839,12 @@ const MatchesAdminPage = () => {
</Td>
<Td>
<HStack spacing={2}>
<Image
src={getLogo(m.home || m.home_team || '', m.home_id, m.home_logo_url)}
<TeamLogo
teamId={m.home_id ? String(m.home_id) : undefined}
teamName={m.home || m.home_team || ''}
facrLogo={m.home_logo_url}
size="small"
alt={m.home || m.home_team || ''}
boxSize="24px"
objectFit="contain"
loading="lazy"
decoding="async"
draggable={false}
@@ -922,11 +860,12 @@ const MatchesAdminPage = () => {
</Td>
<Td>
<HStack spacing={2}>
<Image
src={getLogo(m.away || m.away_team || '', m.away_id, m.away_logo_url)}
<TeamLogo
teamId={m.away_id ? String(m.away_id) : undefined}
teamName={m.away || m.away_team || ''}
facrLogo={m.away_logo_url}
size="small"
alt={m.away || m.away_team || ''}
boxSize="24px"
objectFit="contain"
loading="lazy"
decoding="async"
draggable={false}
@@ -992,14 +931,7 @@ const MatchesAdminPage = () => {
{/* Team name/logo editing removed */}
<FormControl>
<FormLabel>Poznámka</FormLabel>
<Input
placeholder="Libovolná poznámka (interní)"
value={form.notes}
onChange={(e) => setForm((f) => ({ ...f, notes: e.target.value }))}
/>
</FormControl>
</Stack>
)}
</DrawerBody>
+10 -3
View File
@@ -40,7 +40,7 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import AdminLayout from '../../layouts/AdminLayout';
import { Player, getPlayers, createPlayer, updatePlayer, deletePlayer } from '../../services/players';
import { uploadFile } from '../../services/articles';
import { translateNationality } from '../../utils/nationality';
import { translateNationality, getCountryFlag } from '../../utils/nationality';
import ThumbnailPreview from '../../components/common/ThumbnailPreview';
import { assetUrl } from '../../utils/url';
@@ -376,7 +376,7 @@ const PlayersAdminPage: React.FC = () => {
<Tbody>
{isLoading && (<Tr><Td colSpan={7}>Načítám...</Td></Tr>)}
{!isLoading && (data || []).map((p) => (
<Tr key={p.id}>
<Tr key={p.id} opacity={p.is_active ? 1 : 0.6}>
<Td>
<ThumbnailPreview
src={assetUrl(p.image_url) || '/logo192.png'}
@@ -388,7 +388,14 @@ const PlayersAdminPage: React.FC = () => {
</Td>
<Td>{p.first_name} {p.last_name}</Td>
<Td>{p.position || '-'}</Td>
<Td>{p.nationality ? translateNationality(p.nationality) : '-'}</Td>
<Td>
{p.nationality ? (
<HStack spacing={2}>
<span>{getCountryFlag(p.nationality)}</span>
<span>{translateNationality(p.nationality)}</span>
</HStack>
) : '-'}
</Td>
<Td>{p.jersey_number ?? '-'}</Td>
<Td><Switch isChecked={!!p.is_active} onChange={() => { if (p.id != null) updateMut.mutate({ id: p.id, payload: { is_active: !p.is_active } }); }} /></Td>
<Td>
+156 -9
View File
@@ -66,12 +66,18 @@ import {
updatePoll,
deletePoll,
getPollStats,
getPollVotes,
Poll,
CreatePollRequest,
UpdatePollRequest,
PollStats,
PollVote,
} from '../../services/polls';
import { getCachedYouTube, YouTubeVideo } from '../../services/youtube';
import { Doughnut, Line, Bar } from 'react-chartjs-2';
import { Chart as ChartJS, ArcElement, Tooltip, Legend, CategoryScale, LinearScale, PointElement, LineElement, BarElement } from 'chart.js';
ChartJS.register(ArcElement, Tooltip, Legend, CategoryScale, LinearScale, PointElement, LineElement, BarElement);
const PollsAdminPage: React.FC = () => {
const toast = useToast();
@@ -187,6 +193,40 @@ const PollsAdminPage: React.FC = () => {
enabled: !!selectedPollStats?.poll?.id,
});
// Votes list (admin details)
const { data: votesData, isLoading: isLoadingVotes } = useQuery<PollVote[]>({
queryKey: ['poll-votes', selectedPollStats?.poll?.id],
queryFn: () => getPollVotes(selectedPollStats!.poll.id),
enabled: !!selectedPollStats?.poll?.id,
});
const exportVotesCSV = () => {
if (!votesData) return;
const header = ['id','poll_id','option_id','option_text','user_id','user_email','user_first_name','user_last_name','voter_name','voter_email','session_token','created_at'];
const rows = votesData.map(v => [
v.id,
v.poll_id,
v.option_id,
JSON.stringify(v.option_text || ''),
v.user_id ?? '',
v.user_email || '',
v.user_first_name || '',
v.user_last_name || '',
v.voter_name || '',
v.voter_email || '',
v.session_token || '',
v.created_at,
]);
const csv = [header.join(','), ...rows.map(r => r.join(','))].join('\n');
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `poll_${selectedPollStats?.poll?.id || ''}_votes.csv`;
a.click();
URL.revokeObjectURL(url);
};
const resetForm = () => {
setFormData({
title: '',
@@ -514,7 +554,7 @@ const PollsAdminPage: React.FC = () => {
</Thead>
<Tbody>
{polls?.map((poll) => (
<Tr key={poll.id}>
<Tr key={poll.id} opacity={poll.status === 'draft' ? 0.6 : 1}>
<Td>
<VStack align="start" spacing={0}>
<Text fontWeight="bold">{poll.title}</Text>
@@ -1010,16 +1050,123 @@ const PollsAdminPage: React.FC = () => {
<Heading size="sm" mb={4}>
Hlasy podle dnů
</Heading>
<VStack spacing={2} align="stretch">
{(statsData.votes_by_day || []).map((day) => (
<HStack key={day.date} justify="space-between">
<Text>{new Date(day.date).toLocaleDateString('cs-CZ')}</Text>
<Badge>{day.count} hlasů</Badge>
</HStack>
))}
</VStack>
<SimpleGrid columns={{ base: 1, md: 2 }} spacing={6}>
<Box>
<VStack spacing={2} align="stretch">
{(statsData.votes_by_day || []).map((day) => (
<HStack key={day.date} justify="space-between">
<Text>{new Date(day.date).toLocaleDateString('cs-CZ')}</Text>
<Badge>{day.count} hlasů</Badge>
</HStack>
))}
</VStack>
</Box>
<Box>
<Line
data={{
labels: (statsData.votes_by_day || []).map(d => new Date(d.date).toLocaleDateString('cs-CZ')),
datasets: [
{
label: 'Hlasy',
data: (statsData.votes_by_day || []).map(d => d.count),
borderColor: '#3182ce',
backgroundColor: 'rgba(49,130,206,0.2)',
tension: 0.3,
},
],
}}
options={{ responsive: true, plugins: { legend: { display: false } } }}
/>
</Box>
</SimpleGrid>
</Box>
)}
<SimpleGrid columns={{ base: 1, md: 2 }} spacing={6}>
<Box>
<Heading size="sm" mb={3}>Složení hlasujících</Heading>
<Doughnut
data={{
labels: ['Přihlášení', 'Hosté'],
datasets: [
{
data: [statsData.authenticated_votes, statsData.guest_votes],
backgroundColor: ['#2f855a', '#718096'],
},
],
}}
options={{ plugins: { legend: { position: 'bottom' } } }}
/>
</Box>
<Box>
<Heading size="sm" mb={3}>Rozdělení hlasů podle možností</Heading>
<Bar
data={{
labels: (statsData.poll.options || []).map(o => o.text),
datasets: [
{
label: 'Hlasy',
data: (statsData.poll.options || []).map(o => o.vote_count),
backgroundColor: '#3182ce',
},
],
}}
options={{
responsive: true,
plugins: { legend: { display: false } },
scales: { y: { beginAtZero: true, ticks: { precision: 0 } } },
}}
/>
</Box>
</SimpleGrid>
<Box>
<HStack justify="space-between" mb={3}>
<Heading size="sm">Hlasující</Heading>
<Button size="sm" onClick={exportVotesCSV} isDisabled={!votesData || votesData.length === 0}>Export CSV</Button>
</HStack>
{isLoadingVotes ? (
<HStack><Spinner size="sm" /><Text>Načítání hlasů...</Text></HStack>
) : (votesData && votesData.length > 0) ? (
<Box overflowX="auto">
<Table size="sm" variant="simple">
<Thead>
<Tr>
<Th>Datum</Th>
<Th>Jméno</Th>
<Th>E-mail</Th>
<Th>Typ</Th>
<Th>Možnost</Th>
<Th>Session</Th>
</Tr>
</Thead>
<Tbody>
{votesData.slice(0, 100).map((v) => {
const name = v.voter_name || ((v.user_first_name || '') + ' ' + (v.user_last_name || '')).trim();
const email = v.voter_email || v.user_email || '';
const type = v.user_id ? 'Přihlášený' : 'Host';
const session = (v.session_token || '').slice(-8);
return (
<Tr key={v.id}>
<Td>{new Date(v.created_at).toLocaleString('cs-CZ')}</Td>
<Td>{name || '-'}</Td>
<Td>{email || '-'}</Td>
<Td><Badge colorScheme={v.user_id ? 'green' : 'gray'}>{type}</Badge></Td>
<Td>{v.option_text}</Td>
<Td>{session}</Td>
</Tr>
);
})}
</Tbody>
</Table>
{votesData.length > 100 && (
<Text fontSize="xs" color="gray.500" mt={2}>Zobrazeno 100 z {votesData.length} hlasů</Text>
)}
</Box>
) : (
<Text fontSize="sm" color="gray.500">Žádné hlasy k zobrazení.</Text>
)}
</Box>
</VStack>
) : null}
</ModalBody>
@@ -184,7 +184,7 @@ const SponsorsAdminPage: React.FC = () => {
<Tbody>
{isLoading && (<Tr><Td colSpan={7}>Načítám...</Td></Tr>)}
{!isLoading && (data || []).map((s) => (
<Tr key={s.id}>
<Tr key={s.id} opacity={s.is_active ? 1 : 0.6}>
<Td>
<Image src={normalizeImageUrl(s.logo_url)} alt={s.name} boxSize="48px" objectFit="contain" />
</Td>
+76 -6
View File
@@ -193,6 +193,9 @@ const TeamsAdminPage = () => {
}
return idx;
}, [byName]);
const byNamePairs = useMemo(() => {
return Object.keys(byName || {}).map((k) => ({ keyNorm: normalize(k), url: byName[k] }));
}, [byName]);
const getLogo = (teamName?: string, teamId?: string, original?: string) => {
if (!teamName) return assetUrl('/dist/img/logo-club-empty.svg') as string;
@@ -204,9 +207,27 @@ const TeamsAdminPage = () => {
}
// Priority 0.5: Try match by override name when team_id is missing
try {
const hit = overridesNameIndex[normalize(teamName)];
if (hit && hit.logo_url) {
const u = String(hit.logo_url);
const norm = normalize(teamName);
let hit = overridesNameIndex[norm];
if (!hit) {
// Suffix/containment match: allow sponsor words before/after core name
for (const [keyNorm, val] of Object.entries(overridesNameIndex)) {
if (!keyNorm) continue;
if (norm.endsWith(keyNorm) || keyNorm.endsWith(norm)) { hit = val as any; break; }
}
}
if (!hit) {
const norm2 = normalize(teamName);
const t1 = norm2.split(' ')[0];
if (t1 && t1.length >= 5) {
for (const [keyNorm, val] of Object.entries(overridesNameIndex)) {
const k1 = String(keyNorm).split(' ')[0];
if (k1 === t1) { hit = val as any; break; }
}
}
}
if (hit && (hit as any).logo_url) {
const u = String((hit as any).logo_url);
if (u.startsWith('/')) return assetUrl(u) as string;
return u;
}
@@ -217,6 +238,14 @@ const TeamsAdminPage = () => {
const norm = normalize(teamName);
overrideUrl = byNameNormalized[norm];
}
if (!overrideUrl) {
// Suffix/containment against normalized keys
const norm = normalize(teamName);
for (const { keyNorm, url } of byNamePairs) {
if (!keyNorm) continue;
if (norm.endsWith(keyNorm) || keyNorm.endsWith(norm)) { overrideUrl = url; break; }
}
}
if (overrideUrl) {
if (typeof overrideUrl === 'string' && overrideUrl.startsWith('/')) {
return assetUrl(overrideUrl) as string;
@@ -245,9 +274,25 @@ const TeamsAdminPage = () => {
// If no ID, but override exists for the normalized name, use canonical override name
try {
if (teamName) {
const hit = overridesNameIndex[normalize(teamName)];
if (hit && hit.name) {
return hit.name;
const norm = normalize(teamName);
let hit = overridesNameIndex[norm];
if (!hit) {
for (const [keyNorm, val] of Object.entries(overridesNameIndex)) {
if (!keyNorm) continue;
if (norm.endsWith(keyNorm) || keyNorm.endsWith(norm)) { hit = val as any; break; }
}
}
if (!hit) {
const t1 = norm.split(' ')[0];
if (t1 && t1.length >= 5) {
for (const [keyNorm, val] of Object.entries(overridesNameIndex)) {
const k1 = String(keyNorm).split(' ')[0];
if (k1 === t1) { hit = val as any; break; }
}
}
}
if (hit && (hit as any).name) {
return (hit as any).name as string;
}
}
} catch {}
@@ -407,6 +452,7 @@ const TeamsAdminPage = () => {
} catch {}
if (logoUrl) {
let uploadAttempted = false;
let shouldUpload = Boolean(uploadedFile);
try {
const abs = logoUrl.startsWith('/') ? new URL(logoUrl, backendOrigin).toString() : logoUrl;
@@ -426,6 +472,7 @@ const TeamsAdminPage = () => {
} catch {}
if (shouldUpload) {
uploadAttempted = true;
setExternalUploadStatus('uploading');
setExternalUploadError(null);
try {
@@ -447,6 +494,17 @@ const TeamsAdminPage = () => {
if (logaResult.url) {
logoUrl = logaResult.url;
}
try {
let confirmedUrl: string | null = null;
for (let i = 0; i < 10; i++) {
confirmedUrl = await fetchLogoFromLogoAPI(form.external_team_id, primaryName);
if (confirmedUrl) break;
await new Promise((r) => setTimeout(r, 700));
}
if (confirmedUrl) {
logoUrl = confirmedUrl;
}
} catch {}
} else {
setExternalUploadStatus('error');
setExternalUploadError(logaResult.error || 'Nepodařilo se nahrát logo');
@@ -460,6 +518,18 @@ const TeamsAdminPage = () => {
setExternalUploadError(error?.message || 'Upload failed');
}
}
if (uploadAttempted) {
try {
const abs = logoUrl.startsWith('/') ? new URL(logoUrl, backendOrigin).toString() : logoUrl;
const host = new URL(abs).hostname.toLowerCase();
if (host !== 'logoapi.sportcreative.eu') {
throw new Error('Externí upload loga ještě není dostupný. Zkuste uložit znovu za chvíli.');
}
} catch (e: any) {
throw new Error(e?.message || 'Externí upload loga selhal');
}
}
}
await putTeamLogoOverride(form.external_team_id, primaryName, logoUrl);