mirror of
https://github.com/Dvorinka/MyClubServer.git
synced 2026-06-04 10:42:57 +00:00
dev day #79
This commit is contained in:
@@ -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 1–2 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 2–3 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: 80–120 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
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user