mirror of
https://github.com/Dvorinka/MyClubServer.git
synced 2026-06-04 02:32:57 +00:00
dev day #75
This commit is contained in:
@@ -21,11 +21,13 @@ import {
|
||||
Icon,
|
||||
Badge,
|
||||
} from '@chakra-ui/react';
|
||||
import { FiSave, FiEye, FiCode, FiLayout, FiZap } from 'react-icons/fi';
|
||||
import { FiSave, FiEye, FiCode, FiLayout, FiZap, FiPlus, FiTrash } from 'react-icons/fi';
|
||||
import AdminLayout from '../../layouts/AdminLayout';
|
||||
import RichTextEditor from '../../components/common/RichTextEditor';
|
||||
import api from '../../services/api';
|
||||
import { generateAboutAI } from '../../services/ai';
|
||||
import { usePublicSettings } from '../../hooks/usePublicSettings';
|
||||
import { getCategories, CategoryItem } from '../../services/categories';
|
||||
|
||||
type AboutPageData = {
|
||||
id?: number;
|
||||
@@ -40,11 +42,17 @@ type AboutPageData = {
|
||||
|
||||
const AboutAdminPage: React.FC = () => {
|
||||
const toast = useToast();
|
||||
const { data: settings } = usePublicSettings();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [aiGenerating, setAiGenerating] = useState(false);
|
||||
const [aiPrompt, setAiPrompt] = useState('');
|
||||
const [aiAudience, setAiAudience] = useState('Fanoušci klubu');
|
||||
const [categories, setCategories] = useState<CategoryItem[]>([]);
|
||||
const [foundationYear, setFoundationYear] = useState<string>('');
|
||||
const [timelineEvents, setTimelineEvents] = useState<Array<{ year: string; title: string }>>([
|
||||
{ year: '', title: '' },
|
||||
]);
|
||||
const [data, setData] = useState<AboutPageData>({
|
||||
title: '',
|
||||
subtitle: '',
|
||||
@@ -57,6 +65,7 @@ const AboutAdminPage: React.FC = () => {
|
||||
|
||||
useEffect(() => {
|
||||
loadData();
|
||||
loadCategories();
|
||||
}, []);
|
||||
|
||||
const loadData = async () => {
|
||||
@@ -73,6 +82,13 @@ const AboutAdminPage: React.FC = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const loadCategories = async () => {
|
||||
try {
|
||||
const cats = await getCategories();
|
||||
setCategories(cats || []);
|
||||
} catch {}
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!data.title.trim()) {
|
||||
toast({ title: 'Chyba', description: 'Vyplňte název stránky', status: 'warning' });
|
||||
@@ -122,10 +138,28 @@ const AboutAdminPage: React.FC = () => {
|
||||
|
||||
setAiGenerating(true);
|
||||
try {
|
||||
const clubName = settings?.club_name || '';
|
||||
const catsText = categories && categories.length
|
||||
? `Rubriky (kategorie): ${categories.map((c) => `${c.name}${c.description ? ` – ${c.description}` : ''}`).join('; ')}`
|
||||
: '';
|
||||
const styleIntro = `Zvolený styl: ${data.style}.`;
|
||||
const timelineDetails = data.style === 'timeline'
|
||||
? `\nČasová osa – dodatečné informace:\nRok založení: ${foundationYear || 'neuvedeno'}.\nKlíčové milníky (rok: událost):\n${timelineEvents
|
||||
.filter((e) => e.year.trim() || e.title.trim())
|
||||
.map((e) => `- ${e.year.trim() || '????'}: ${e.title.trim() || ''}`)
|
||||
.join('\n')}`
|
||||
: '';
|
||||
const extraGuidelines = data.style === 'timeline'
|
||||
? 'Piš chronologicky, používej podnadpisy (h3) s rokem, pod nimi krátký odstavec. Kde se hodí, vlož seznamy (ul/li).'
|
||||
: 'Rozděl text do sekcí s h2/h3 a odstavci. Kde se hodí, vlož seznamy (ul/li).';
|
||||
|
||||
const fullPrompt = `${aiPrompt.trim()}\n\nInformace o klubu:\nNázev klubu: ${clubName || 'Fotbalový klub'}.\n${catsText}\n${styleIntro}${timelineDetails}\n\nPokyny pro výstup: ${extraGuidelines}`;
|
||||
|
||||
const result = await generateAboutAI({
|
||||
prompt: aiPrompt,
|
||||
prompt: fullPrompt,
|
||||
audience: aiAudience,
|
||||
style: data.style,
|
||||
club_name: clubName,
|
||||
});
|
||||
setData((prev) => ({
|
||||
...prev,
|
||||
@@ -208,6 +242,68 @@ const AboutAdminPage: React.FC = () => {
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
<FormControl>
|
||||
<FormLabel>Styl stránky</FormLabel>
|
||||
<Select
|
||||
value={data.style}
|
||||
onChange={(e) =>
|
||||
setData((prev) => ({ ...prev, style: e.target.value as any }))
|
||||
}
|
||||
>
|
||||
{Object.entries(styleDescriptions).map(([key, { name }]) => (
|
||||
<option key={key} value={key}>
|
||||
{name}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
<Text fontSize="sm" color="gray.600" mt={2}>
|
||||
{styleDescriptions[data.style]?.desc}
|
||||
</Text>
|
||||
</FormControl>
|
||||
|
||||
{data.style === 'timeline' && (
|
||||
<Box borderWidth="1px" borderRadius="md" p={4} bg="gray.50">
|
||||
<Heading size="sm" mb={3}>Časová osa – podklady</Heading>
|
||||
<VStack align="stretch" spacing={3}>
|
||||
<FormControl>
|
||||
<FormLabel>Rok založení</FormLabel>
|
||||
<Input
|
||||
type="text"
|
||||
value={foundationYear}
|
||||
onChange={(e) => setFoundationYear(e.target.value)}
|
||||
placeholder="např. 1932"
|
||||
/>
|
||||
</FormControl>
|
||||
<Box>
|
||||
<HStack justify="space-between" mb={2}>
|
||||
<Text fontWeight="semibold">Klíčové milníky</Text>
|
||||
<Button size="sm" leftIcon={<FiPlus />} onClick={() => setTimelineEvents((prev) => [...prev, { year: '', title: '' }])}>Přidat milník</Button>
|
||||
</HStack>
|
||||
<VStack align="stretch" spacing={2}>
|
||||
{timelineEvents.map((ev, idx) => (
|
||||
<HStack key={idx} spacing={2} align="stretch">
|
||||
<Input
|
||||
placeholder="Rok"
|
||||
value={ev.year}
|
||||
onChange={(e) => setTimelineEvents((prev) => prev.map((it, i) => i === idx ? { ...it, year: e.target.value } : it))}
|
||||
maxW="120px"
|
||||
/>
|
||||
<Input
|
||||
placeholder="Událost / popis"
|
||||
value={ev.title}
|
||||
onChange={(e) => setTimelineEvents((prev) => prev.map((it, i) => i === idx ? { ...it, title: e.target.value } : it))}
|
||||
/>
|
||||
<Button aria-label="Odebrat" size="sm" colorScheme="red" variant="outline" onClick={() => setTimelineEvents((prev) => prev.filter((_, i) => i !== idx))}>
|
||||
<Icon as={FiTrash} />
|
||||
</Button>
|
||||
</HStack>
|
||||
))}
|
||||
</VStack>
|
||||
</Box>
|
||||
</VStack>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Hero image removed: About page uses club logo and name from settings */}
|
||||
|
||||
<Box
|
||||
|
||||
@@ -480,7 +480,7 @@ const AdminActivitiesPage: React.FC = () => {
|
||||
/>
|
||||
) : (
|
||||
<ChakraImage
|
||||
src={settingsQ.data?.club_logo_url || '/dist/img/logo-club-empty.svg'}
|
||||
src={assetUrl(settingsQ.data?.club_logo_url) || assetUrl('/dist/img/logo-club-empty.svg') || '/dist/img/logo-club-empty.svg'}
|
||||
alt="No image"
|
||||
boxSize="48px"
|
||||
objectFit="contain"
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import AdminLayout from '../../layouts/AdminLayout';
|
||||
import { Box, Heading, Button, SimpleGrid, FormControl, FormLabel, Input, HStack, VStack, Text, IconButton, useToast, Divider, Textarea, Switch, NumberInput, NumberInputField } from '@chakra-ui/react';
|
||||
import { Box, Heading, Button, SimpleGrid, FormControl, FormLabel, Input, HStack, VStack, Text, IconButton, useToast, Divider, Textarea, Switch, NumberInput, NumberInputField, Image, Badge, useColorModeValue } from '@chakra-ui/react';
|
||||
import { getClothingAdmin, createClothing, updateClothing, deleteClothing, ClothingItem } from '../../services/clothing';
|
||||
import { FiPlus, FiTrash2, FiSave } from 'react-icons/fi';
|
||||
import { FiPlus, FiTrash2, FiSave, FiExternalLink } from 'react-icons/fi';
|
||||
|
||||
const emptyItem: Partial<ClothingItem> = {
|
||||
title: '',
|
||||
@@ -15,6 +15,77 @@ const emptyItem: Partial<ClothingItem> = {
|
||||
display_order: 0
|
||||
};
|
||||
|
||||
const PreviewCard: React.FC<{ item: Partial<ClothingItem> }> = ({ item }) => {
|
||||
const cardBg = useColorModeValue('white', 'gray.800');
|
||||
const borderColor = useColorModeValue('gray.200', 'gray.700');
|
||||
return (
|
||||
<Box
|
||||
role="group"
|
||||
bg={cardBg}
|
||||
borderWidth="1px"
|
||||
borderColor={borderColor}
|
||||
borderRadius="xl"
|
||||
overflow="hidden"
|
||||
transition="all 0.3s"
|
||||
_hover={{ transform: 'translateY(-4px)', boxShadow: 'lg' }}
|
||||
>
|
||||
<Box position="relative" paddingTop="100%" overflow="hidden">
|
||||
<Image
|
||||
src={item.image_url}
|
||||
alt={item.title || 'Náhled produktu'}
|
||||
position="absolute"
|
||||
top={0}
|
||||
left={0}
|
||||
width="100%"
|
||||
height="100%"
|
||||
objectFit="cover"
|
||||
fallbackSrc="/images/placeholder-clothing.jpg"
|
||||
/>
|
||||
{item.url && (
|
||||
<Box
|
||||
position="absolute"
|
||||
top={2}
|
||||
right={2}
|
||||
bg="white"
|
||||
borderRadius="full"
|
||||
p={2}
|
||||
opacity={0}
|
||||
_groupHover={{ opacity: 1 }}
|
||||
transition="opacity 0.2s"
|
||||
>
|
||||
<FiExternalLink size={16} />
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<VStack align="stretch" p={4} spacing={2}>
|
||||
<Heading as="h3" size="sm" noOfLines={2}>
|
||||
{item.title || 'Název produktu'}
|
||||
</Heading>
|
||||
{item.description && (
|
||||
<Text fontSize="sm" color="gray.600" noOfLines={2}>
|
||||
{item.description}
|
||||
</Text>
|
||||
)}
|
||||
<HStack justify="space-between" mt={2}>
|
||||
{item.price && item.price > 0 ? (
|
||||
<Badge colorScheme="blue" fontSize="md" px={2} py={1}>
|
||||
{item.price} {item.currency || 'Kč'}
|
||||
</Badge>
|
||||
) : (
|
||||
<Box />
|
||||
)}
|
||||
{item.url && (
|
||||
<Text fontSize="xs" color="blue.500">
|
||||
Zobrazit →
|
||||
</Text>
|
||||
)}
|
||||
</HStack>
|
||||
</VStack>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
const AdminMerchPage: React.FC = () => {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
@@ -213,6 +284,13 @@ const AdminMerchPage: React.FC = () => {
|
||||
rows={2}
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
<Box mt={4}>
|
||||
<Heading size="xs" mb={2}>Náhled veřejné karty</Heading>
|
||||
<Box maxW="360px">
|
||||
<PreviewCard item={it} />
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
))}
|
||||
{items.length === 0 && (
|
||||
|
||||
@@ -178,7 +178,7 @@ const AdminVideosPage: React.FC = () => {
|
||||
setSelectedIds((prev) => ({ ...prev, [id]: !prev[id] }));
|
||||
};
|
||||
|
||||
const importSelected = () => {
|
||||
const importSelected = async () => {
|
||||
const selected = ytVideos.filter((v) => selectedIds[v.video_id]);
|
||||
if (selected.length === 0) {
|
||||
toast({ status: 'info', title: 'Nic k importu', description: 'Vyberte alespoň jedno video.' });
|
||||
@@ -203,10 +203,30 @@ const AdminVideosPage: React.FC = () => {
|
||||
}
|
||||
return merged;
|
||||
});
|
||||
// If currently in auto mode, switch to manual so the preview reflects newly added items
|
||||
if (videosSource !== 'manual') {
|
||||
setVideosSource('manual');
|
||||
try {
|
||||
await updateAdminSettings({ videos_source: 'manual' });
|
||||
toast({ status: 'info', title: 'Přepnuto na ruční správu', description: 'Nově přidaná videa se budou používat. Nezapomeňte uložit seznam.', duration: 3500 });
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
toast({ status: 'success', title: 'Videa přidána', description: `${selected.length} videí bylo přidáno do seznamu.` });
|
||||
};
|
||||
|
||||
const addItem = () => setItems((prev) => [...prev, { ...emptyItem }]);
|
||||
const addItem = async () => {
|
||||
setItems((prev) => [...prev, { ...emptyItem }]);
|
||||
if (videosSource !== 'manual') {
|
||||
setVideosSource('manual');
|
||||
try {
|
||||
await updateAdminSettings({ videos_source: 'manual' });
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
};
|
||||
const removeItem = (idx: number) => setItems((prev) => prev.filter((_, i) => i !== idx));
|
||||
const updateField = (idx: number, key: keyof AdminVideoItem, val: string) => {
|
||||
setItems((prev) => prev.map((it, i) => i === idx ? { ...it, [key]: val } : it));
|
||||
@@ -341,13 +361,13 @@ const AdminVideosPage: React.FC = () => {
|
||||
Použijte Scraper API. Zadejte handle (např. <code>@FotbalKunovice</code>) nebo URL kanálu a načtěte videa z karty „Videa“.
|
||||
Služba: <Link href="https://youtube.tdvorak.dev/" isExternal color="blue.500">https://youtube.tdvorak.dev/</Link>
|
||||
</Text>
|
||||
<HStack align="start" spacing={3}>
|
||||
<HStack align="start" spacing={3} flexWrap="wrap">
|
||||
<FormControl maxW={{ base: '100%', md: '400px' }}>
|
||||
<FormLabel>Kanál (handle nebo URL)</FormLabel>
|
||||
<Input id="admin-videos-channel-input" placeholder="@FCBizoniUH nebo https://www.youtube.com/@FCBizoniUH/videos" value={channelInput} onChange={(e) => setChannelInput(e.target.value)} />
|
||||
</FormControl>
|
||||
<Button onClick={fetchChannelVideos} isLoading={ytLoading} variant="outline">Načíst videa</Button>
|
||||
<Button colorScheme="green" onClick={importSelected} isDisabled={ytVideos.length === 0}>Přidat vybraná</Button>
|
||||
<Button onClick={fetchChannelVideos} isLoading={ytLoading} variant="outline" flexShrink={0} minW="max-content">Načíst videa</Button>
|
||||
<Button colorScheme="green" onClick={importSelected} isDisabled={ytVideos.length === 0} flexShrink={0} minW="max-content">Přidat vybraná</Button>
|
||||
</HStack>
|
||||
{ytError && (
|
||||
<Alert status="error" mt={3} borderRadius="md">
|
||||
@@ -387,9 +407,9 @@ const AdminVideosPage: React.FC = () => {
|
||||
<HStack justify="space-between" align="center" mb={2} flexWrap="wrap">
|
||||
<Heading size="sm">Náhled: všechna videa (aktivní zdroj)</Heading>
|
||||
{videosSource === 'auto' && (
|
||||
<HStack spacing={2}>
|
||||
<Input size="sm" placeholder="Filtrovat podle názvu" value={filter} onChange={(e) => setFilter(e.target.value)} />
|
||||
<Button size="sm" onClick={refreshAuto} isLoading={autoLoading} variant="outline">Aktualizovat cache</Button>
|
||||
<HStack spacing={2} flexWrap="wrap">
|
||||
<Input size="sm" placeholder="Filtrovat podle názvu" value={filter} onChange={(e) => setFilter(e.target.value)} width={{ base: '100%', md: '260px' }} />
|
||||
<Button size="sm" onClick={refreshAuto} isLoading={autoLoading} variant="outline" flexShrink={0} minW="max-content">Aktualizovat cache</Button>
|
||||
</HStack>
|
||||
)}
|
||||
</HStack>
|
||||
|
||||
@@ -229,6 +229,7 @@ const ArticlesAdminPage = () => {
|
||||
|
||||
|
||||
const { isOpen, onOpen, onClose } = useDisclosure();
|
||||
const [isPending, startTransition] = React.useTransition();
|
||||
const [aiPrompt, setAiPrompt] = useState('');
|
||||
const [aiAudience, setAiAudience] = useState('Fanoušci klubu');
|
||||
const [aiMinWords, setAiMinWords] = useState<number>(500);
|
||||
@@ -1329,7 +1330,7 @@ const ArticlesAdminPage = () => {
|
||||
</ModalHeader>
|
||||
<ModalCloseButton />
|
||||
<ModalBody maxH="calc(90vh - 120px)" overflowY="auto">
|
||||
<Tabs variant="enclosed" colorScheme="blue" isFitted index={activeTabIndex} onChange={(index) => setActiveTabIndex(index)}>
|
||||
<Tabs variant="enclosed" colorScheme="blue" isFitted index={activeTabIndex} onChange={(index) => setActiveTabIndex(index)} isLazy lazyBehavior="unmount">
|
||||
<TabList>
|
||||
<Tab>AI</Tab>
|
||||
<Tab>Základní</Tab>
|
||||
@@ -1355,7 +1356,7 @@ const ArticlesAdminPage = () => {
|
||||
rows={8}
|
||||
placeholder="Napište svůj text s důležitými informacemi. Příklad: Dnes naše mužstvo zvládlo důležitý zápas proti TJ Sokol Příbram. Konečný výsledek 3:1 pro nás. První poloasu jsme dominovali, Jana Novák dal dva góly. Ve druhé poloasu sice soupeř snížil, ale Petr Černý svým třetím gólem rozhodl. AI váš text rozšíří, přidá strukturu a doplní kontext pokud je krátký."
|
||||
value={aiPrompt}
|
||||
onChange={(e) => setAiPrompt(e.target.value)}
|
||||
onChange={(e) => startTransition(() => setAiPrompt(e.target.value))}
|
||||
fontSize="md"
|
||||
bg={inputBg}
|
||||
/>
|
||||
|
||||
@@ -3,7 +3,7 @@ import { Box, Button, FormControl, FormLabel, Heading, HStack, IconButton, Image
|
||||
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 { Sponsor, getSponsors, createSponsor, updateSponsor, deleteSponsor } from '../../services/sponsors';
|
||||
import { Banner as AdminBanner, getBanners, createBanner, updateBanner, deleteBanner } from '../../services/banners';
|
||||
import { uploadFile } from '../../services/articles';
|
||||
import { assetUrl } from '../../utils/url';
|
||||
|
||||
@@ -15,7 +15,7 @@ type BannerPreset = {
|
||||
width: number;
|
||||
height: number;
|
||||
aspectRatio: number;
|
||||
position: 'top' | 'middle' | 'sidebar' | 'footer' | 'article';
|
||||
position: 'top' | 'middle' | 'sidebar' | 'footer' | 'article' | 'under_table';
|
||||
};
|
||||
|
||||
const BANNER_PRESETS: BannerPreset[] = [
|
||||
@@ -63,6 +63,15 @@ const BANNER_PRESETS: BannerPreset[] = [
|
||||
height: 90,
|
||||
aspectRatio: 8.09,
|
||||
position: 'article'
|
||||
},
|
||||
{
|
||||
value: 'homepage_under_table',
|
||||
label: 'Pod tabulkou (Homepage)',
|
||||
description: 'Banner pod sekcí Tabulky na titulní stránce',
|
||||
width: 970,
|
||||
height: 90,
|
||||
aspectRatio: 10.78,
|
||||
position: 'under_table'
|
||||
}
|
||||
];
|
||||
|
||||
@@ -72,8 +81,8 @@ const BannersAdminPage: React.FC = () => {
|
||||
const inputBg = useColorModeValue('white', 'gray.700');
|
||||
const toast = useToast();
|
||||
const qc = useQueryClient();
|
||||
const { data, isLoading } = useQuery({ queryKey: ['admin-banners'], queryFn: getSponsors });
|
||||
const [editing, setEditing] = useState<Partial<Sponsor> | null>(null);
|
||||
const { data, isLoading } = useQuery<AdminBanner[]>(['admin-banners'], () => getBanners());
|
||||
const [editing, setEditing] = useState<Partial<AdminBanner> | null>(null);
|
||||
const [imageResolution, setImageResolution] = useState<{ width: number; height: number } | null>(null);
|
||||
const [recommendedPlacements, setRecommendedPlacements] = useState<BannerPreset[]>([]);
|
||||
const [uploadingImage, setUploadingImage] = useState(false);
|
||||
@@ -117,7 +126,7 @@ const BannersAdminPage: React.FC = () => {
|
||||
setRecommendedPlacements([]);
|
||||
onOpen();
|
||||
};
|
||||
const openEdit = (s: Sponsor) => {
|
||||
const openEdit = (s: AdminBanner) => {
|
||||
setEditing({ ...s });
|
||||
setImageResolution(null);
|
||||
setRecommendedPlacements([]);
|
||||
@@ -134,17 +143,17 @@ const BannersAdminPage: React.FC = () => {
|
||||
};
|
||||
|
||||
const createMut = useMutation({
|
||||
mutationFn: (payload: any) => createSponsor(payload),
|
||||
mutationFn: (payload: any) => createBanner(payload),
|
||||
onSuccess: () => { toast({ title: 'Banner vytvořen', status: 'success' }); qc.invalidateQueries({ queryKey: ['admin-banners'] }); closeModal(); },
|
||||
onError: (e: any) => toast({ title: 'Vytvoření selhalo', description: e?.response?.data?.message || 'Chyba', status: 'error' }),
|
||||
});
|
||||
const updateMut = useMutation({
|
||||
mutationFn: ({ id, payload }: { id: number | string; payload: any }) => updateSponsor(id, payload),
|
||||
mutationFn: ({ id, payload }: { id: number | string; payload: any }) => updateBanner(id, payload),
|
||||
onSuccess: () => { toast({ title: 'Banner upraven', status: 'success' }); qc.invalidateQueries({ queryKey: ['admin-banners'] }); closeModal(); },
|
||||
onError: (e: any) => toast({ title: 'Aktualizace selhala', description: e?.response?.data?.message || 'Chyba', status: 'error' }),
|
||||
});
|
||||
const deleteMut = useMutation({
|
||||
mutationFn: (id: number | string) => deleteSponsor(id),
|
||||
mutationFn: (id: number | string) => deleteBanner(id),
|
||||
onSuccess: () => { toast({ title: 'Banner smazán', status: 'success' }); qc.invalidateQueries({ queryKey: ['admin-banners'] }); },
|
||||
onError: (e: any) => toast({ title: 'Smazání selhalo', description: e?.response?.data?.message || 'Chyba', status: 'error' }),
|
||||
});
|
||||
@@ -153,8 +162,8 @@ const BannersAdminPage: React.FC = () => {
|
||||
if (!editing) return;
|
||||
const payload = {
|
||||
name: editing.name || '',
|
||||
logo_url: editing.logo_url,
|
||||
website_url: editing.website_url,
|
||||
image_url: (editing as any).image_url,
|
||||
click_url: (editing as any).click_url,
|
||||
is_active: editing.is_active ?? true,
|
||||
placement: (editing as any).placement || '',
|
||||
width: (editing as any).width || undefined,
|
||||
@@ -192,7 +201,7 @@ const BannersAdminPage: React.FC = () => {
|
||||
const res = await uploadFile(file);
|
||||
|
||||
// Update editing state with uploaded URL
|
||||
setEditing((prev) => ({ ...(prev || {}), logo_url: res.url }));
|
||||
setEditing((prev) => ({ ...(prev || {}), image_url: res.url }));
|
||||
|
||||
// If no placement selected yet, auto-select the best recommendation
|
||||
if (!editing?.placement && recommended.length > 0) {
|
||||
@@ -265,17 +274,17 @@ const BannersAdminPage: React.FC = () => {
|
||||
{isLoading && (
|
||||
<Tr><Td colSpan={6} textAlign="center"><Spinner size="sm" mr={2} />Načítání…</Td></Tr>
|
||||
)}
|
||||
{!isLoading && banners.map((b) => {
|
||||
{!isLoading && banners.map((b: AdminBanner) => {
|
||||
const preset = getPreset((b as any).placement);
|
||||
return (
|
||||
<Tr key={b.id}>
|
||||
<Td>
|
||||
<Image src={assetUrl(b.logo_url) || '/logo192.png'} alt={b.name} boxSize="56px" objectFit="contain" bg={inputBg} borderRadius="md" />
|
||||
<Image src={assetUrl((b as any).image_url) || '/logo192.png'} alt={b.name} boxSize="56px" objectFit="contain" bg={inputBg} borderRadius="md" />
|
||||
</Td>
|
||||
<Td>
|
||||
<Text fontWeight="500">{b.name}</Text>
|
||||
{b.website_url && (
|
||||
<Text fontSize="xs" color="gray.500" noOfLines={1}>{b.website_url}</Text>
|
||||
{(b as any).click_url && (
|
||||
<Text fontSize="xs" color="gray.500" noOfLines={1}>{(b as any).click_url}</Text>
|
||||
)}
|
||||
</Td>
|
||||
<Td>
|
||||
@@ -323,7 +332,7 @@ const BannersAdminPage: React.FC = () => {
|
||||
</FormControl>
|
||||
<FormControl>
|
||||
<FormLabel>Odkaz (po kliku)</FormLabel>
|
||||
<Input type="url" value={editing?.website_url || ''} onChange={(e) => setEditing((prev) => ({ ...(prev as any), website_url: e.target.value }))} placeholder="https://partner.cz" />
|
||||
<Input type="url" value={(editing as any)?.click_url || ''} onChange={(e) => setEditing((prev) => ({ ...(prev as any), click_url: e.target.value }))} placeholder="https://partner.cz" />
|
||||
</FormControl>
|
||||
{/* Image resolution info */}
|
||||
{imageResolution && (
|
||||
@@ -430,7 +439,7 @@ const BannersAdminPage: React.FC = () => {
|
||||
<FormLabel>Obrázek banneru</FormLabel>
|
||||
<VStack align="stretch" spacing={3}>
|
||||
{/* Preview */}
|
||||
{editing?.logo_url && (() => {
|
||||
{(editing as any)?.image_url && (() => {
|
||||
const preset = getPreset((editing as any)?.placement);
|
||||
const previewWidth = preset ? Math.min(preset.width, 600) : 300;
|
||||
const previewHeight = preset ? (previewWidth / preset.aspectRatio) : 150;
|
||||
@@ -446,7 +455,7 @@ const BannersAdminPage: React.FC = () => {
|
||||
bg={inputBg}
|
||||
>
|
||||
<Image
|
||||
src={assetUrl(editing?.logo_url) || '/logo192.png'}
|
||||
src={assetUrl((editing as any)?.image_url) || '/logo192.png'}
|
||||
alt="banner preview"
|
||||
width={`${previewWidth}px`}
|
||||
height={`${previewHeight}px`}
|
||||
@@ -475,7 +484,7 @@ const BannersAdminPage: React.FC = () => {
|
||||
isLoading={uploadingImage}
|
||||
loadingText="Nahrávání..."
|
||||
>
|
||||
{editing?.logo_url ? 'Změnit obrázek' : 'Nahrát obrázek'}
|
||||
{(editing as any)?.image_url ? 'Změnit obrázek' : 'Nahrát obrázek'}
|
||||
<Input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
@@ -489,7 +498,7 @@ const BannersAdminPage: React.FC = () => {
|
||||
{uploadingImage && <Spinner size="sm" />}
|
||||
</HStack>
|
||||
|
||||
{!editing?.logo_url && (
|
||||
{!((editing as any)?.image_url) && (
|
||||
<Alert status="warning" fontSize="xs">
|
||||
<AlertIcon boxSize="12px" />
|
||||
<Text fontSize="xs">Nahrajte obrázek pro automatické doporučení umístění</Text>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import React, { useState, useEffect, useMemo } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
@@ -33,7 +33,6 @@ import {
|
||||
Badge,
|
||||
HStack,
|
||||
VStack,
|
||||
useDisclosure,
|
||||
AlertDialog,
|
||||
AlertDialogBody,
|
||||
AlertDialogFooter,
|
||||
@@ -41,11 +40,10 @@ import {
|
||||
AlertDialogContent,
|
||||
AlertDialogOverlay,
|
||||
SimpleGrid,
|
||||
Divider,
|
||||
FormHelperText,
|
||||
useColorModeValue,
|
||||
} from '@chakra-ui/react';
|
||||
import { FiEdit, FiTrash2, FiPlus, FiUser } from 'react-icons/fi';
|
||||
import { FiEdit, FiTrash2, FiPlus, FiUser, FiUpload } from 'react-icons/fi';
|
||||
import AdminLayout from '../../layouts/AdminLayout';
|
||||
import {
|
||||
getContacts,
|
||||
@@ -56,13 +54,12 @@ import {
|
||||
getContactCategories,
|
||||
ContactCategory,
|
||||
} from '../../services/contactInfo';
|
||||
import api, { uploadImage } from '../../services/api';
|
||||
import { uploadImage } from '../../services/api';
|
||||
import { getImageUrl } from '../../utils/imageUtils';
|
||||
import { getAdminSettings, updateAdminSettings, AdminSettings, PublicSettings } from '../../services/settings';
|
||||
import MapLinkImporter from '../../components/admin/MapLinkImporter';
|
||||
import { MapCoordinates } from '../../utils/mapUrlParser';
|
||||
import ContactMap from '../../components/home/ContactMap';
|
||||
import MapStyleSelector from '../../components/admin/MapStyleSelector';
|
||||
import { getFacrTablesCache } from '../../services/facr/cache';
|
||||
|
||||
const ContactsAdminPage: React.FC = () => {
|
||||
const cardBg = useColorModeValue('white', 'gray.800');
|
||||
@@ -100,6 +97,8 @@ const ContactsAdminPage: React.FC = () => {
|
||||
const [uploadingImage, setUploadingImage] = useState(false);
|
||||
const [settings, setSettings] = useState<AdminSettings>({});
|
||||
const [savingSettings, setSavingSettings] = useState(false);
|
||||
const [facrCompetitions, setFacrCompetitions] = useState<any[]>([]);
|
||||
const fileInputRef = React.useRef<HTMLInputElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
loadData();
|
||||
@@ -109,12 +108,14 @@ const ContactsAdminPage: React.FC = () => {
|
||||
const loadData = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const [contactsData, categoriesData] = await Promise.all([
|
||||
const [contactsData, categoriesData, facrData] = await Promise.all([
|
||||
getContacts(),
|
||||
getContactCategories(),
|
||||
getFacrTablesCache(),
|
||||
]);
|
||||
setContacts(contactsData);
|
||||
setCategories(categoriesData);
|
||||
setFacrCompetitions(Array.isArray(facrData?.competitions) ? facrData!.competitions : []);
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: 'Chyba při načítání',
|
||||
@@ -127,6 +128,31 @@ const ContactsAdminPage: React.FC = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const clubCompetitionNames = useMemo(() => {
|
||||
try {
|
||||
const names = new Set<string>();
|
||||
for (const comp of facrCompetitions || []) {
|
||||
const n = String(comp?.name || '').trim();
|
||||
if (n) names.add(n);
|
||||
}
|
||||
return Array.from(names);
|
||||
} catch {
|
||||
return [] as string[];
|
||||
}
|
||||
}, [facrCompetitions]);
|
||||
|
||||
const filteredContactCategories = useMemo(() => {
|
||||
try {
|
||||
if (!Array.isArray(categories)) return [] as ContactCategory[];
|
||||
if ((clubCompetitionNames || []).length === 0) return categories;
|
||||
const setNames = new Set(clubCompetitionNames.map((s) => String(s)));
|
||||
const filtered = categories.filter((c) => setNames.has(String(c.name)));
|
||||
return filtered.length > 0 ? filtered : categories;
|
||||
} catch {
|
||||
return categories;
|
||||
}
|
||||
}, [categories, clubCompetitionNames]);
|
||||
|
||||
// Contact handlers
|
||||
const openContactModal = (contact?: Contact) => {
|
||||
if (contact) {
|
||||
@@ -526,6 +552,9 @@ const ContactsAdminPage: React.FC = () => {
|
||||
currentLongitude={settings.location_longitude}
|
||||
currentZoom={settings.map_zoom_level}
|
||||
mapStyle={settings.map_style || 'positron'}
|
||||
onMapStyleChange={(value: string) => {
|
||||
setSettings((prev) => ({ ...prev, map_style: value as PublicSettings['map_style'] }));
|
||||
}}
|
||||
clubPrimaryColor={settings.primary_color}
|
||||
clubSecondaryColor={settings.accent_color}
|
||||
clubName={settings.club_name}
|
||||
@@ -598,43 +627,7 @@ const ContactsAdminPage: React.FC = () => {
|
||||
</SimpleGrid>
|
||||
</Box>
|
||||
|
||||
<Box bg={cardBg} p={6} borderRadius="lg" borderWidth="1px" borderColor={borderColor}>
|
||||
<MapStyleSelector
|
||||
value={settings.map_style || 'positron'}
|
||||
onChange={(value) => {
|
||||
setSettings((prev) => ({ ...prev, map_style: value as PublicSettings['map_style'] }));
|
||||
}}
|
||||
clubPrimaryColor={settings.primary_color}
|
||||
clubSecondaryColor={settings.accent_color}
|
||||
showPreview={true}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{/* Live Map Preview with Current Coordinates */}
|
||||
{settings.location_latitude && settings.location_longitude && (
|
||||
<Box bg={cardBg} p={6} borderRadius="lg" borderWidth="1px" borderColor={borderColor}>
|
||||
<VStack align="stretch" spacing={3}>
|
||||
<HStack justify="space-between">
|
||||
<Heading size="md">Náhled vaší mapy</Heading>
|
||||
<Badge colorScheme="green">Aktuální poloha</Badge>
|
||||
</HStack>
|
||||
<Text fontSize="sm" color={textSecondary}>
|
||||
Toto je náhled mapy s vaší aktuální polohou a vybraným stylem. Takto se zobrazí návštěvníkům na webu.
|
||||
</Text>
|
||||
<ContactMap
|
||||
latitude={settings.location_latitude}
|
||||
longitude={settings.location_longitude}
|
||||
zoom={settings.map_zoom_level || 15}
|
||||
address={`${settings.contact_address || ''}${settings.contact_city ? ', ' + settings.contact_city : ''}`}
|
||||
clubName={settings.club_name}
|
||||
mapStyle={settings.map_style || 'positron'}
|
||||
clubPrimaryColor={settings.primary_color}
|
||||
clubSecondaryColor={settings.accent_color}
|
||||
height={400}
|
||||
/>
|
||||
</VStack>
|
||||
</Box>
|
||||
)}
|
||||
{/* Map style selection is integrated into the section above; single unified preview */}
|
||||
|
||||
<Box bg={infoBg} p={4} borderRadius="md" borderWidth="1px" borderColor={infoBorder}>
|
||||
<HStack justify="space-between" align="center">
|
||||
@@ -700,13 +693,13 @@ const ContactsAdminPage: React.FC = () => {
|
||||
}
|
||||
>
|
||||
<option value="">Bez přiřazení</option>
|
||||
{categories.map((cat) => (
|
||||
{filteredContactCategories.map((cat) => (
|
||||
<option key={cat.id} value={cat.id}>
|
||||
{cat.name}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
<FormHelperText fontSize="xs">Přiřaďte kontakt ke konkrétní kategorii</FormHelperText>
|
||||
<FormHelperText fontSize="xs">Přiřaďte kontakt ke konkrétní kategorii (podle soutěží klubu)</FormHelperText>
|
||||
</FormControl>
|
||||
|
||||
<FormControl>
|
||||
@@ -730,10 +723,26 @@ const ContactsAdminPage: React.FC = () => {
|
||||
|
||||
<FormControl>
|
||||
<FormLabel>Fotografie</FormLabel>
|
||||
<HStack>
|
||||
<Button
|
||||
leftIcon={<FiUpload />}
|
||||
variant="outline"
|
||||
colorScheme="blue"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
isLoading={uploadingImage}
|
||||
>
|
||||
Nahrát fotografii
|
||||
</Button>
|
||||
{contactForm.image_url && (
|
||||
<Badge colorScheme="green">Nahráno</Badge>
|
||||
)}
|
||||
</HStack>
|
||||
<Input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
onChange={handleImageUpload}
|
||||
display="none"
|
||||
disabled={uploadingImage}
|
||||
/>
|
||||
{contactForm.image_url && (
|
||||
@@ -757,17 +766,6 @@ const ContactsAdminPage: React.FC = () => {
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
<FormControl>
|
||||
<FormLabel>Pořadí zobrazení</FormLabel>
|
||||
<Input
|
||||
type="number"
|
||||
value={contactForm.display_order}
|
||||
onChange={(e) =>
|
||||
setContactForm({ ...contactForm, display_order: parseInt(e.target.value) || 0 })
|
||||
}
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
<FormControl display="flex" alignItems="center">
|
||||
<FormLabel mb="0">Aktivní</FormLabel>
|
||||
<Switch
|
||||
|
||||
@@ -64,6 +64,7 @@ import {
|
||||
getFileIcon,
|
||||
} from '../../services/files';
|
||||
import { API_URL } from '../../services/api';
|
||||
import { assetUrl } from '../../utils/url';
|
||||
|
||||
const FilesAdminPage: React.FC = () => {
|
||||
const toast = useToast();
|
||||
@@ -187,9 +188,8 @@ const FilesAdminPage: React.FC = () => {
|
||||
};
|
||||
|
||||
const getImageUrl = (url: string) => {
|
||||
if (url.startsWith('http')) return url;
|
||||
const origin = new URL(API_URL, typeof window !== 'undefined' ? window.location.origin : 'http://localhost:3000').origin;
|
||||
return `${origin}${url}`;
|
||||
const full = assetUrl(url);
|
||||
return full || url;
|
||||
};
|
||||
|
||||
// Mime type options
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState, useMemo } from 'react';
|
||||
import { useState, useMemo, useEffect } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
@@ -54,6 +54,7 @@ import {
|
||||
import Pagination from '../../components/common/Pagination';
|
||||
import MessageDetailModal from '../../components/admin/MessageDetailModal';
|
||||
import ConfirmationDialog from '../../components/common/ConfirmationDialog';
|
||||
import { useAuth } from '../../contexts/AuthContext';
|
||||
|
||||
export default function MessagesAdminPage() {
|
||||
const cardBg = useColorModeValue('white', 'gray.800');
|
||||
@@ -88,6 +89,8 @@ export default function MessagesAdminPage() {
|
||||
} = useDisclosure();
|
||||
|
||||
const [forwardAllEmail, setForwardAllEmail] = useState('');
|
||||
const [saveForwardDefault, setSaveForwardDefault] = useState<boolean>(true);
|
||||
const { user } = useAuth();
|
||||
|
||||
const [selectedMessage, setSelectedMessage] = useState<ContactMessage | null>(null);
|
||||
const toast = useToast();
|
||||
@@ -148,7 +151,8 @@ export default function MessagesAdminPage() {
|
||||
});
|
||||
|
||||
const forwardAllMutation = useMutation({
|
||||
mutationFn: forwardAllMessages,
|
||||
mutationFn: (payload: { emails: string | string[]; saveDefault?: boolean }) =>
|
||||
forwardAllMessages(payload.emails, { saveDefault: payload.saveDefault }),
|
||||
onSuccess: (data) => {
|
||||
toast({
|
||||
title: 'Zprávy se přeposílají',
|
||||
@@ -207,9 +211,20 @@ export default function MessagesAdminPage() {
|
||||
});
|
||||
return;
|
||||
}
|
||||
forwardAllMutation.mutate(forwardAllEmail);
|
||||
forwardAllMutation.mutate({ emails: forwardAllEmail, saveDefault: saveForwardDefault });
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (isForwardAllOpen) {
|
||||
// Prefill with current user's email if empty
|
||||
if (!forwardAllEmail && user?.email) {
|
||||
setForwardAllEmail(user.email);
|
||||
}
|
||||
// Default to saving as auto-forward unless user opts out
|
||||
setSaveForwardDefault(true);
|
||||
}
|
||||
}, [isForwardAllOpen, user?.email]);
|
||||
|
||||
const handleSelectAll = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
if (e.target.checked) {
|
||||
setSelectedMessages(data?.data.map((msg) => msg.id) || []);
|
||||
@@ -482,11 +497,19 @@ export default function MessagesAdminPage() {
|
||||
<FormLabel>E-mailová adresa</FormLabel>
|
||||
<Input
|
||||
type="email"
|
||||
placeholder="prijemce@email.cz"
|
||||
placeholder="např. ja@klub.cz, info@klub.cz"
|
||||
value={forwardAllEmail}
|
||||
onChange={(e) => setForwardAllEmail(e.target.value)}
|
||||
/>
|
||||
</FormControl>
|
||||
<HStack w="full" justify="space-between">
|
||||
<Checkbox
|
||||
isChecked={saveForwardDefault}
|
||||
onChange={(e) => setSaveForwardDefault(e.target.checked)}
|
||||
>
|
||||
Uložit jako výchozí (automaticky přeposílat nové zprávy)
|
||||
</Checkbox>
|
||||
</HStack>
|
||||
</VStack>
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
|
||||
@@ -45,6 +45,7 @@ import {
|
||||
Flex,
|
||||
Textarea,
|
||||
Collapse,
|
||||
Icon,
|
||||
} from '@chakra-ui/react';
|
||||
import AdminLayout from '../../layouts/AdminLayout';
|
||||
import {
|
||||
@@ -68,6 +69,26 @@ import {
|
||||
FaLinkedin,
|
||||
FaDiscord,
|
||||
FaTwitch,
|
||||
FaHome,
|
||||
FaInfoCircle,
|
||||
FaCalendarAlt,
|
||||
FaFutbol,
|
||||
FaUsers,
|
||||
FaTable,
|
||||
FaNewspaper,
|
||||
FaVideo,
|
||||
FaCamera,
|
||||
FaSearch,
|
||||
FaBars,
|
||||
FaCog,
|
||||
FaHandshake,
|
||||
FaEnvelope,
|
||||
FaUserShield,
|
||||
FaFolder,
|
||||
FaBook,
|
||||
FaTshirt,
|
||||
FaLink,
|
||||
FaPoll,
|
||||
} from 'react-icons/fa';
|
||||
// Using simple up/down buttons instead of drag-drop for better compatibility
|
||||
import {
|
||||
@@ -137,6 +158,31 @@ const SOCIAL_PLATFORMS = [
|
||||
{ value: 'twitch', label: 'Twitch', icon: FaTwitch },
|
||||
];
|
||||
|
||||
const NAV_ICON_OPTIONS = [
|
||||
{ value: 'FaHome', label: 'Domů', icon: FaHome },
|
||||
{ value: 'FaInfoCircle', label: 'O klubu', icon: FaInfoCircle },
|
||||
{ value: 'FaCalendarAlt', label: 'Kalendář', icon: FaCalendarAlt },
|
||||
{ value: 'FaFutbol', label: 'Hráči', icon: FaFutbol },
|
||||
{ value: 'FaUsers', label: 'Týmy', icon: FaUsers },
|
||||
{ value: 'FaTable', label: 'Tabulky', icon: FaTable },
|
||||
{ value: 'FaNewspaper', label: 'Články', icon: FaNewspaper },
|
||||
{ value: 'FaVideo', label: 'Videa', icon: FaVideo },
|
||||
{ value: 'FaCamera', label: 'Galerie', icon: FaCamera },
|
||||
{ value: 'FaHandshake', label: 'Sponzoři', icon: FaHandshake },
|
||||
{ value: 'FaEnvelope', label: 'Kontakt', icon: FaEnvelope },
|
||||
{ value: 'FaSearch', label: 'Hledat', icon: FaSearch },
|
||||
{ value: 'FaBars', label: 'Menu', icon: FaBars },
|
||||
{ value: 'FaLink', label: 'Odkaz', icon: FaLink },
|
||||
{ value: 'FaCog', label: 'Nastavení', icon: FaCog },
|
||||
{ value: 'FaPoll', label: 'Ankety', icon: FaPoll },
|
||||
{ value: 'FaUserShield', label: 'Uživatelé', icon: FaUserShield },
|
||||
{ value: 'FaFolder', label: 'Soubory', icon: FaFolder },
|
||||
{ value: 'FaBook', label: 'Stránka', icon: FaBook },
|
||||
{ value: 'FaTshirt', label: 'Oblečení', icon: FaTshirt },
|
||||
];
|
||||
|
||||
const ICON_COMPONENTS: Record<string, any> = Object.fromEntries(NAV_ICON_OPTIONS.map(opt => [opt.value, opt.icon]));
|
||||
|
||||
// NavItemCard component for hierarchical display
|
||||
interface NavItemCardProps {
|
||||
item: NavigationItem;
|
||||
@@ -153,6 +199,8 @@ interface NavItemCardProps {
|
||||
borderColor: string;
|
||||
hoverBg: string;
|
||||
level?: number;
|
||||
onChildMoveUp?: (parentId: number, index: number) => void;
|
||||
onChildMoveDown?: (parentId: number, index: number) => void;
|
||||
}
|
||||
|
||||
const NavItemCard: React.FC<NavItemCardProps> = ({
|
||||
@@ -170,6 +218,8 @@ const NavItemCard: React.FC<NavItemCardProps> = ({
|
||||
borderColor,
|
||||
hoverBg,
|
||||
level = 0,
|
||||
onChildMoveUp,
|
||||
onChildMoveDown,
|
||||
}) => {
|
||||
const hasChildren = item.children && item.children.length > 0;
|
||||
const indentPx = level * 32;
|
||||
@@ -299,14 +349,14 @@ const NavItemCard: React.FC<NavItemCardProps> = ({
|
||||
{/* Render children if expanded */}
|
||||
{hasChildren && isExpanded && (
|
||||
<VStack spacing={2} align="stretch" mt={2}>
|
||||
{item.children!.map((child) => (
|
||||
{item.children!.map((child, childIndex) => (
|
||||
<NavItemCard
|
||||
key={child.id}
|
||||
item={child}
|
||||
index={0}
|
||||
total={1}
|
||||
onMoveUp={() => {}}
|
||||
onMoveDown={() => {}}
|
||||
index={childIndex}
|
||||
total={item.children!.length}
|
||||
onMoveUp={() => onChildMoveUp && onChildMoveUp(item.id!, childIndex)}
|
||||
onMoveDown={() => onChildMoveDown && onChildMoveDown(item.id!, childIndex)}
|
||||
onEdit={() => onEdit()}
|
||||
onDelete={() => onDelete()}
|
||||
onAddChild={() => {}}
|
||||
@@ -316,6 +366,8 @@ const NavItemCard: React.FC<NavItemCardProps> = ({
|
||||
borderColor={borderColor}
|
||||
hoverBg={hoverBg}
|
||||
level={level + 1}
|
||||
onChildMoveUp={onChildMoveUp}
|
||||
onChildMoveDown={onChildMoveDown}
|
||||
/>
|
||||
))}
|
||||
</VStack>
|
||||
@@ -352,13 +404,9 @@ const NavigationAdminPage = () => {
|
||||
getAllNavigationItems(),
|
||||
getAllSocialLinks(),
|
||||
]);
|
||||
|
||||
console.log('Načtená navigace:', navData);
|
||||
console.log('Načtené sociální odkazy:', socialData);
|
||||
|
||||
|
||||
// Auto-seed if navigation is empty
|
||||
if (!navData || navData.length === 0) {
|
||||
console.log('Navigace je prázdná, automaticky vytváříme výchozí navigaci...');
|
||||
try {
|
||||
const seedResult = await seedDefaultNavigation();
|
||||
if (seedResult.seeded) {
|
||||
@@ -408,6 +456,43 @@ const NavigationAdminPage = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const moveChildNavItem = async (parentId: number, index: number, direction: 'up' | 'down') => {
|
||||
const moveWithin = async (
|
||||
list: NavigationItem[],
|
||||
setList: React.Dispatch<React.SetStateAction<NavigationItem[]>>
|
||||
): Promise<boolean> => {
|
||||
const parentIdx = list.findIndex((it) => it.id === parentId);
|
||||
if (parentIdx === -1) return false;
|
||||
const parent = list[parentIdx];
|
||||
const children = Array.isArray(parent.children) ? [...parent.children] : [];
|
||||
if (children.length === 0) return true;
|
||||
if (direction === 'up' && index === 0) return true;
|
||||
if (direction === 'down' && index === children.length - 1) return true;
|
||||
const targetIndex = direction === 'up' ? index - 1 : index + 1;
|
||||
[children[index], children[targetIndex]] = [children[targetIndex], children[index]];
|
||||
|
||||
const updatedParent: NavigationItem = { ...parent, children };
|
||||
const updated = [...list];
|
||||
updated[parentIdx] = updatedParent;
|
||||
setList(updated);
|
||||
|
||||
const orders = children.map((c, idx) => ({ id: c.id!, display_order: idx }));
|
||||
try {
|
||||
await reorderNavigationItems(orders);
|
||||
toast({ title: 'Pořadí aktualizováno', status: 'success', duration: 2000 });
|
||||
} catch (err) {
|
||||
toast({ title: 'Chyba při aktualizaci pořadí', status: 'error', duration: 3000 });
|
||||
loadData();
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
const doneFront = await moveWithin(navItems, setNavItems);
|
||||
if (!doneFront) {
|
||||
await moveWithin(adminNavItems, setAdminNavItems);
|
||||
}
|
||||
};
|
||||
|
||||
const moveNavItem = async (index: number, direction: 'up' | 'down') => {
|
||||
if (direction === 'up' && index === 0) return;
|
||||
if (direction === 'down' && index === navItems.length - 1) return;
|
||||
@@ -811,6 +896,8 @@ const NavigationAdminPage = () => {
|
||||
cardBg={cardBg}
|
||||
borderColor={borderColor}
|
||||
hoverBg={hoverBg}
|
||||
onChildMoveUp={(parentId, childIdx) => moveChildNavItem(parentId, childIdx, 'up')}
|
||||
onChildMoveDown={(parentId, childIdx) => moveChildNavItem(parentId, childIdx, 'down')}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
@@ -860,6 +947,8 @@ const NavigationAdminPage = () => {
|
||||
cardBg={cardBg}
|
||||
borderColor={borderColor}
|
||||
hoverBg={hoverBg}
|
||||
onChildMoveUp={(parentId, childIdx) => moveChildNavItem(parentId, childIdx, 'up')}
|
||||
onChildMoveDown={(parentId, childIdx) => moveChildNavItem(parentId, childIdx, 'down')}
|
||||
/>
|
||||
))}
|
||||
|
||||
@@ -1026,6 +1115,25 @@ const NavigationAdminPage = () => {
|
||||
</FormControl>
|
||||
)}
|
||||
|
||||
<FormControl>
|
||||
<FormLabel>Ikona</FormLabel>
|
||||
<Select
|
||||
value={editingNav?.icon || ''}
|
||||
onChange={(e) => setEditingNav({ ...editingNav!, icon: e.target.value || undefined })}
|
||||
>
|
||||
<option value="">Bez ikony</option>
|
||||
{NAV_ICON_OPTIONS.map(opt => (
|
||||
<option key={opt.value} value={opt.value}>{opt.label}</option>
|
||||
))}
|
||||
</Select>
|
||||
{editingNav?.icon && (
|
||||
<HStack mt={2} spacing={2} align="center">
|
||||
<Icon as={ICON_COMPONENTS[editingNav.icon]} boxSize={5} />
|
||||
<Text fontSize="sm">{editingNav.icon}</Text>
|
||||
</HStack>
|
||||
)}
|
||||
</FormControl>
|
||||
|
||||
{editingNav?.parent_id && (
|
||||
<Alert status="warning" fontSize="sm">
|
||||
<AlertIcon />
|
||||
@@ -1043,14 +1151,7 @@ const NavigationAdminPage = () => {
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
<FormControl>
|
||||
<FormLabel>CSS třída (volitelné)</FormLabel>
|
||||
<Input
|
||||
value={editingNav?.icon || ''}
|
||||
onChange={(e) => setEditingNav({ ...editingNav!, icon: e.target.value })}
|
||||
placeholder="custom-class"
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
|
||||
{editingNav?.type === 'external' && (
|
||||
<FormControl>
|
||||
|
||||
@@ -50,6 +50,8 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { format } from 'date-fns';
|
||||
import { cs } from 'date-fns/locale';
|
||||
import AdminLayout from '../../layouts/AdminLayout';
|
||||
import { usePublicSettings } from '../../hooks/usePublicSettings';
|
||||
import { facrApi } from '../../services/facr/facrApi';
|
||||
import {
|
||||
getNewsletterSubscribers,
|
||||
sendNewsletter,
|
||||
@@ -143,6 +145,29 @@ export default function NewsletterAdminPage() {
|
||||
const [sendNowLoading, setSendNowLoading] = useState<boolean>(false);
|
||||
const openDetails = (t: MailType) => { setActiveType(t); setDetailsOpen(true); };
|
||||
const closeDetails = () => { setDetailsOpen(false); setActiveType(null); setDetailsCompetitions(''); };
|
||||
|
||||
// Helpers for competitions multi-select handling
|
||||
const selectedCompCodes = React.useMemo(() => {
|
||||
return new Set((competitions || '').split(',').map((s) => s.trim()).filter(Boolean));
|
||||
}, [competitions]);
|
||||
const toggleComp = (code: string, on: boolean) => {
|
||||
const next = new Set(selectedCompCodes);
|
||||
if (on) next.add(code); else next.delete(code);
|
||||
setCompetitions(Array.from(next).join(', '));
|
||||
};
|
||||
const clearComps = () => setCompetitions('');
|
||||
const selectAllComps = () => setCompetitions(compOptions.map(o => o.code).join(', '));
|
||||
|
||||
const detailsSelectedCompCodes = React.useMemo(() => {
|
||||
return new Set((detailsCompetitions || '').split(',').map((s) => s.trim()).filter(Boolean));
|
||||
}, [detailsCompetitions]);
|
||||
const toggleDetailsComp = (code: string, on: boolean) => {
|
||||
const next = new Set(detailsSelectedCompCodes);
|
||||
if (on) next.add(code); else next.delete(code);
|
||||
setDetailsCompetitions(Array.from(next).join(', '));
|
||||
};
|
||||
const detailsClearComps = () => setDetailsCompetitions('');
|
||||
const detailsSelectAllComps = () => setDetailsCompetitions(compOptions.map(o => o.code).join(', '));
|
||||
const recipientsForType = (t: MailType): string[] => {
|
||||
const key = t === 'weekly' ? 'weekly' : t;
|
||||
return subscribers
|
||||
@@ -212,6 +237,24 @@ export default function NewsletterAdminPage() {
|
||||
const queryClient = useQueryClient();
|
||||
const isMobile = useBreakpointValue({ base: true, md: false });
|
||||
|
||||
// Load club competitions for nicer dropdowns (FACR)
|
||||
const { data: publicSettings } = usePublicSettings();
|
||||
const clubId = publicSettings?.club_id;
|
||||
const clubType = (publicSettings?.club_type as 'football' | 'futsal') || 'football';
|
||||
const { data: clubCompetitions = [] } = useQuery({
|
||||
queryKey: ['facr', 'competitions', clubId, clubType],
|
||||
queryFn: async () => {
|
||||
if (!clubId) return [] as Array<{ code?: string; id?: string; name?: string }>;
|
||||
const comps = await facrApi.getClubCompetitions(clubId, clubType);
|
||||
return comps || [];
|
||||
},
|
||||
enabled: !!clubId,
|
||||
});
|
||||
const compOptions = (clubCompetitions as any[]).map((c) => ({
|
||||
code: String(c?.code || c?.id || ''),
|
||||
name: String(c?.name || c?.code || c?.id || ''),
|
||||
})).filter((o) => o.code);
|
||||
|
||||
// Admin settings (for scheduling)
|
||||
const settingsQuery = useQuery({
|
||||
queryKey: ['admin', 'settings'],
|
||||
@@ -222,10 +265,11 @@ export default function NewsletterAdminPage() {
|
||||
const [enableMatchReminders, setEnableMatchReminders] = useState<boolean>(!!settings?.enable_match_reminders);
|
||||
const [enableResults, setEnableResults] = useState<boolean>(!!settings?.enable_results);
|
||||
const [weeklyDay, setWeeklyDay] = useState<AdminSettings['newsletter_weekly_day']>(settings?.newsletter_weekly_day || 'sun');
|
||||
const [weeklyHour, setWeeklyHour] = useState<number>(typeof settings?.newsletter_weekly_hour === 'number' ? (settings!.newsletter_weekly_hour as number) : 18);
|
||||
const toTimeString = (h?: number) => (String(typeof h === 'number' ? Math.max(0, Math.min(23, h)) : 18).padStart(2, '0')) + ':00';
|
||||
const [weeklyTime, setWeeklyTime] = useState<string>(toTimeString(settings?.newsletter_weekly_hour as number | undefined));
|
||||
const [reminderLead, setReminderLead] = useState<number>(typeof settings?.newsletter_reminder_lead_hours === 'number' ? (settings!.newsletter_reminder_lead_hours as number) : 48);
|
||||
const [quietStart, setQuietStart] = useState<number>(typeof settings?.newsletter_quiet_start === 'number' ? (settings!.newsletter_quiet_start as number) : 22);
|
||||
const [quietEnd, setQuietEnd] = useState<number>(typeof settings?.newsletter_quiet_end === 'number' ? (settings!.newsletter_quiet_end as number) : 7);
|
||||
const [quietStartTime, setQuietStartTime] = useState<string>(toTimeString(settings?.newsletter_quiet_start as number | undefined));
|
||||
const [quietEndTime, setQuietEndTime] = useState<string>(toTimeString(settings?.newsletter_quiet_end as number | undefined));
|
||||
|
||||
// Sync local state when settings load
|
||||
useEffect(() => {
|
||||
@@ -234,22 +278,27 @@ export default function NewsletterAdminPage() {
|
||||
setEnableMatchReminders(!!settings.enable_match_reminders);
|
||||
setEnableResults(!!settings.enable_results);
|
||||
setWeeklyDay(settings.newsletter_weekly_day || 'sun');
|
||||
setWeeklyHour(typeof settings.newsletter_weekly_hour === 'number' ? settings.newsletter_weekly_hour! : 18);
|
||||
setWeeklyTime(toTimeString(typeof settings.newsletter_weekly_hour === 'number' ? settings.newsletter_weekly_hour! : 18));
|
||||
setReminderLead(typeof settings.newsletter_reminder_lead_hours === 'number' ? settings.newsletter_reminder_lead_hours! : 48);
|
||||
setQuietStart(typeof settings.newsletter_quiet_start === 'number' ? settings.newsletter_quiet_start! : 22);
|
||||
setQuietEnd(typeof settings.newsletter_quiet_end === 'number' ? settings.newsletter_quiet_end! : 7);
|
||||
setQuietStartTime(toTimeString(typeof settings.newsletter_quiet_start === 'number' ? settings.newsletter_quiet_start! : 22));
|
||||
setQuietEndTime(toTimeString(typeof settings.newsletter_quiet_end === 'number' ? settings.newsletter_quiet_end! : 7));
|
||||
}, [settings]);
|
||||
|
||||
const parseHour = (t: string) => {
|
||||
const m = /^\s*(\d{1,2})(?::(\d{1,2}))?/.exec(t || '');
|
||||
const h = m ? parseInt(m[1], 10) : 18;
|
||||
return Math.max(0, Math.min(23, isNaN(h) ? 18 : h));
|
||||
};
|
||||
const saveScheduleMutation = useMutation({
|
||||
mutationFn: () => updateAdminSettings({
|
||||
enable_weekly: enableWeekly,
|
||||
enable_match_reminders: enableMatchReminders,
|
||||
enable_results: enableResults,
|
||||
newsletter_weekly_day: weeklyDay,
|
||||
newsletter_weekly_hour: weeklyHour,
|
||||
newsletter_weekly_hour: parseHour(weeklyTime),
|
||||
newsletter_reminder_lead_hours: reminderLead,
|
||||
newsletter_quiet_start: quietStart,
|
||||
newsletter_quiet_end: quietEnd,
|
||||
newsletter_quiet_start: parseHour(quietStartTime),
|
||||
newsletter_quiet_end: parseHour(quietEndTime),
|
||||
}),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['admin', 'settings'] });
|
||||
@@ -621,9 +670,9 @@ export default function NewsletterAdminPage() {
|
||||
<option value="sun">Neděle</option>
|
||||
</Select>
|
||||
</FormControl>
|
||||
<FormControl maxW="160px">
|
||||
<FormLabel>Hodina</FormLabel>
|
||||
<Input type="number" min={0} max={23} value={weeklyHour} onChange={(e)=> setWeeklyHour(Math.max(0, Math.min(23, Number(e.target.value)||0)))} />
|
||||
<FormControl maxW="200px">
|
||||
<FormLabel>Čas odeslání</FormLabel>
|
||||
<Input type="time" step={900} value={weeklyTime} onChange={(e)=> setWeeklyTime(e.target.value)} />
|
||||
</FormControl>
|
||||
</HStack>
|
||||
|
||||
@@ -648,13 +697,13 @@ export default function NewsletterAdminPage() {
|
||||
<Switch isChecked={enableResults} onChange={(e)=> setEnableResults(e.target.checked)} />
|
||||
</HStack>
|
||||
<HStack spacing={3}>
|
||||
<FormControl maxW="160px">
|
||||
<FormControl maxW="200px">
|
||||
<FormLabel>Tiché hodiny od</FormLabel>
|
||||
<Input type="number" min={0} max={23} value={quietStart} onChange={(e)=> setQuietStart(Math.max(0, Math.min(23, Number(e.target.value)||0)))} />
|
||||
<Input type="time" step={900} value={quietStartTime} onChange={(e)=> setQuietStartTime(e.target.value)} />
|
||||
</FormControl>
|
||||
<FormControl maxW="160px">
|
||||
<FormControl maxW="200px">
|
||||
<FormLabel>Tiché hodiny do</FormLabel>
|
||||
<Input type="number" min={0} max={23} value={quietEnd} onChange={(e)=> setQuietEnd(Math.max(0, Math.min(23, Number(e.target.value)||0)))} />
|
||||
<Input type="time" step={900} value={quietEndTime} onChange={(e)=> setQuietEndTime(e.target.value)} />
|
||||
<FormHelperText>E-maily s výsledky se neposílají v tomto intervalu.</FormHelperText>
|
||||
</FormControl>
|
||||
</HStack>
|
||||
@@ -918,8 +967,28 @@ export default function NewsletterAdminPage() {
|
||||
<>
|
||||
<FormControl>
|
||||
<FormLabel>Filtr soutěží (volitelné)</FormLabel>
|
||||
<Input placeholder="NAPŘ. KP, I.A, I.B" value={competitions} onChange={(e)=> setCompetitions(e.target.value)} />
|
||||
<FormHelperText>Čárkou oddělený seznam kódů soutěží.</FormHelperText>
|
||||
{compOptions.length > 0 ? (
|
||||
<VStack align="stretch" spacing={2} maxH="220px" overflowY="auto" borderWidth="1px" borderRadius="md" p={3}>
|
||||
<HStack>
|
||||
<Button size="xs" variant="outline" onClick={selectAllComps}>Vybrat vše</Button>
|
||||
<Button size="xs" variant="ghost" onClick={clearComps}>Zrušit vše</Button>
|
||||
</HStack>
|
||||
{compOptions.map((o) => {
|
||||
const checked = selectedCompCodes.has(o.code.toLowerCase()) || selectedCompCodes.has(o.code);
|
||||
return (
|
||||
<HStack key={o.code} justify="space-between">
|
||||
<Text>{o.name}</Text>
|
||||
<Switch isChecked={checked} onChange={(e)=> toggleComp(o.code, e.target.checked)} />
|
||||
</HStack>
|
||||
);
|
||||
})}
|
||||
</VStack>
|
||||
) : (
|
||||
<>
|
||||
<Input placeholder="NAPŘ. KP, I.A, I.B" value={competitions} onChange={(e)=> setCompetitions(e.target.value)} />
|
||||
<FormHelperText>Čárkou oddělený seznam kódů soutěží.</FormHelperText>
|
||||
</>
|
||||
)}
|
||||
</FormControl>
|
||||
<HStack>
|
||||
<Button variant="outline" onClick={async ()=>{
|
||||
@@ -1009,9 +1078,27 @@ export default function NewsletterAdminPage() {
|
||||
<ModalBody>
|
||||
<VStack align="stretch" spacing={4}>
|
||||
<HStack spacing={4} align="flex-end">
|
||||
<FormControl maxW="360px">
|
||||
<FormControl maxW="420px">
|
||||
<FormLabel>Filtr soutěží (volitelné)</FormLabel>
|
||||
<Input placeholder="NAPŘ. KP, I.A, I.B" value={detailsCompetitions} onChange={(e)=> setDetailsCompetitions(e.target.value)} />
|
||||
{compOptions.length > 0 ? (
|
||||
<VStack align="stretch" spacing={2} maxH="220px" overflowY="auto" borderWidth="1px" borderRadius="md" p={3}>
|
||||
<HStack>
|
||||
<Button size="xs" variant="outline" onClick={detailsSelectAllComps}>Vybrat vše</Button>
|
||||
<Button size="xs" variant="ghost" onClick={detailsClearComps}>Zrušit vše</Button>
|
||||
</HStack>
|
||||
{compOptions.map((o) => {
|
||||
const checked = detailsSelectedCompCodes.has(o.code.toLowerCase()) || detailsSelectedCompCodes.has(o.code);
|
||||
return (
|
||||
<HStack key={o.code} justify="space-between">
|
||||
<Text>{o.name}</Text>
|
||||
<Switch isChecked={checked} onChange={(e)=> toggleDetailsComp(o.code, e.target.checked)} />
|
||||
</HStack>
|
||||
);
|
||||
})}
|
||||
</VStack>
|
||||
) : (
|
||||
<Input placeholder="NAPŘ. KP, I.A, I.B" value={detailsCompetitions} onChange={(e)=> setDetailsCompetitions(e.target.value)} />
|
||||
)}
|
||||
</FormControl>
|
||||
<Button onClick={async()=>{ if(!activeType) return; setDetailsLoading(true); try { await loadPreviewForType(activeType, detailsCompetitions); } finally { setDetailsLoading(false); } }} isLoading={detailsLoading}>Aktualizovat náhled</Button>
|
||||
{activeType && typePreview[activeType]?.subject && (
|
||||
|
||||
@@ -42,7 +42,7 @@ import { Player, getPlayers, createPlayer, updatePlayer, deletePlayer } from '..
|
||||
import { uploadFile } from '../../services/articles';
|
||||
import { translateNationality } from '../../utils/nationality';
|
||||
import ThumbnailPreview from '../../components/common/ThumbnailPreview';
|
||||
import { API_URL } from '../../services/api';
|
||||
import { assetUrl } from '../../utils/url';
|
||||
|
||||
type Editing = Partial<Player> & { id?: number };
|
||||
|
||||
@@ -51,16 +51,7 @@ const PlayersAdminPage: React.FC = () => {
|
||||
const borderColor = useColorModeValue('gray.200', 'gray.700');
|
||||
const inputBg = useColorModeValue('white', 'gray.700');
|
||||
const toast = useToast();
|
||||
const normalizeImageUrl = (url?: string) => {
|
||||
if (!url || url === '') return '/logo192.png';
|
||||
// If it's already absolute, return as-is
|
||||
if (/^https?:\/\//i.test(url)) return url;
|
||||
// If it's an uploads path, prefix with API origin
|
||||
const origin = new URL(API_URL, typeof window !== 'undefined' ? window.location.origin : 'http://localhost:3000').origin;
|
||||
if (url.startsWith('/uploads/')) return `${origin}${url}`;
|
||||
// Fallback: treat as relative to origin
|
||||
return `${origin}${url.startsWith('/') ? '' : '/'}${url}`;
|
||||
};
|
||||
|
||||
|
||||
// Hoisted helper: convert country code to flag emoji
|
||||
function countryCodeToEmoji(cc: string) {
|
||||
@@ -231,7 +222,6 @@ const PlayersAdminPage: React.FC = () => {
|
||||
const { isOpen, onOpen, onClose } = useDisclosure();
|
||||
|
||||
const JERSEY_MIN = 0;
|
||||
const JERSEY_MAX = 99;
|
||||
const HEIGHT_MIN = 0;
|
||||
const HEIGHT_MAX = 250;
|
||||
const WEIGHT_MIN = 0;
|
||||
@@ -315,14 +305,12 @@ const PlayersAdminPage: React.FC = () => {
|
||||
return;
|
||||
}
|
||||
const tooBig = (
|
||||
typeof editing.jersey_number === 'number' && Number.isFinite(editing.jersey_number) && editing.jersey_number > JERSEY_MAX
|
||||
) || (
|
||||
typeof editing.height === 'number' && Number.isFinite(editing.height) && editing.height > HEIGHT_MAX
|
||||
) || (
|
||||
typeof editing.weight === 'number' && Number.isFinite(editing.weight) && editing.weight > WEIGHT_MAX
|
||||
);
|
||||
if (tooBig) {
|
||||
toast({ title: 'Neplatná čísla', description: `Maxima: číslo dresu ${JERSEY_MAX}, výška ${HEIGHT_MAX} cm, váha ${WEIGHT_MAX} kg`, status: 'warning' });
|
||||
toast({ title: 'Neplatná čísla', description: `Maxima: výška ${HEIGHT_MAX} cm, váha ${WEIGHT_MAX} kg`, status: 'warning' });
|
||||
return;
|
||||
}
|
||||
// Require date of birth: all three values must be selected
|
||||
@@ -337,7 +325,7 @@ const PlayersAdminPage: React.FC = () => {
|
||||
};
|
||||
if (editing.date_of_birth) payload.date_of_birth = editing.date_of_birth;
|
||||
if (editing.position) payload.position = editing.position;
|
||||
if (typeof editing.jersey_number === 'number' && Number.isFinite(editing.jersey_number) && editing.jersey_number > 0) {
|
||||
if (typeof editing.jersey_number === 'number' && Number.isFinite(editing.jersey_number) && editing.jersey_number >= 0) {
|
||||
payload.jersey_number = editing.jersey_number;
|
||||
}
|
||||
if (editing.nationality) payload.nationality = editing.nationality;
|
||||
@@ -391,7 +379,7 @@ const PlayersAdminPage: React.FC = () => {
|
||||
<Tr key={p.id}>
|
||||
<Td>
|
||||
<ThumbnailPreview
|
||||
src={normalizeImageUrl(p.image_url)}
|
||||
src={assetUrl(p.image_url) || '/logo192.png'}
|
||||
alt={`${p.first_name} ${p.last_name}`}
|
||||
size="48px"
|
||||
previewSize="300px"
|
||||
@@ -450,7 +438,7 @@ const PlayersAdminPage: React.FC = () => {
|
||||
</Select>
|
||||
</HStack>
|
||||
<Box mt={2} fontSize="sm" color="gray.500">
|
||||
{formatDobPreview(dobParts)}
|
||||
{formatDobPreview(dobParts)}{calculateAgeFromParts(dobParts) != null ? ` — ${calculateAgeFromParts(dobParts)} let` : ''}
|
||||
</Box>
|
||||
</FormControl>
|
||||
|
||||
@@ -466,12 +454,11 @@ const PlayersAdminPage: React.FC = () => {
|
||||
</Select>
|
||||
</FormControl>
|
||||
|
||||
<FormControl isInvalid={typeof editing?.jersey_number === 'number' && (editing?.jersey_number as number) > JERSEY_MAX}>
|
||||
<FormControl>
|
||||
<FormLabel>Číslo dresu</FormLabel>
|
||||
<NumberInput min={JERSEY_MIN} max={JERSEY_MAX} keepWithinRange={false} clampValueOnBlur={false} value={typeof editing?.jersey_number === 'number' ? editing?.jersey_number : ''} onChange={(_, v) => setEditing((p) => ({ ...(p as any), jersey_number: Number.isFinite(v) ? v : undefined }))}>
|
||||
<NumberInput min={JERSEY_MIN} keepWithinRange={false} clampValueOnBlur={false} value={typeof editing?.jersey_number === 'number' ? editing?.jersey_number : ''} onChange={(_, v) => setEditing((p) => ({ ...(p as any), jersey_number: Number.isFinite(v) && v >= 0 ? v : undefined }))}>
|
||||
<NumberInputField inputMode="numeric" />
|
||||
</NumberInput>
|
||||
<FormErrorMessage>Maximální číslo dresu je {JERSEY_MAX}.</FormErrorMessage>
|
||||
</FormControl>
|
||||
|
||||
<FormControl>
|
||||
@@ -547,7 +534,7 @@ const PlayersAdminPage: React.FC = () => {
|
||||
<FormControl>
|
||||
<FormLabel>Fotka</FormLabel>
|
||||
<HStack>
|
||||
<Image src={normalizeImageUrl(editing?.image_url)} alt="photo" boxSize="56px" objectFit="cover" borderRadius="md" fallbackSrc="/dist/img/logo-club-empty.svg" />
|
||||
<Image src={assetUrl(editing?.image_url) || '/logo192.png'} alt="photo" boxSize="56px" objectFit="cover" borderRadius="md" fallbackSrc="/dist/img/logo-club-empty.svg" />
|
||||
<Button as="label" type="button" leftIcon={<FiUpload />}>Nahrát
|
||||
<Input
|
||||
type="file"
|
||||
@@ -614,6 +601,20 @@ const PlayersAdminPage: React.FC = () => {
|
||||
return `${dd}.${mm}.${yyyy}`;
|
||||
}
|
||||
|
||||
function calculateAgeFromParts(parts: { day: string; month: string; year: string }): number | null {
|
||||
if (!parts.day || !parts.month || !parts.year) return null;
|
||||
const y = Number(parts.year);
|
||||
const m = Number(parts.month);
|
||||
const d = Number(parts.day);
|
||||
if (!Number.isFinite(y) || !Number.isFinite(m) || !Number.isFinite(d)) return null;
|
||||
const today = new Date();
|
||||
let age = today.getFullYear() - y;
|
||||
const month = today.getMonth() + 1;
|
||||
const day = today.getDate();
|
||||
if (month < m || (month === m && day < d)) age--;
|
||||
return age;
|
||||
}
|
||||
|
||||
// Update DOB parts and, when complete, compose YYYY-MM-DD. Clamp day to month length.
|
||||
function updateDobPart(part: 'day'|'month'|'year', value: string) {
|
||||
setDobParts((prev) => {
|
||||
|
||||
@@ -54,7 +54,53 @@ import { assetUrl } from '../../utils/url';
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { API_URL } from '../../services/api';
|
||||
|
||||
function normalize(s: string): string {
|
||||
let out = String(s || '');
|
||||
// Normalize diacritics and case
|
||||
out = out
|
||||
.normalize('NFD')
|
||||
.replace(/[\u0300-\u036f]/g, '')
|
||||
.toLowerCase();
|
||||
// Unify various dash characters to a simple hyphen
|
||||
out = out.replace(/[\u2012\u2013\u2014\u2015\u2212]/g, '-');
|
||||
// Remove legal suffixes like ", z.s." / ", z. s." / " z.s." / "o.s." at end
|
||||
out = out.replace(/[,,\s]*(z\.?\s*s\.?|o\.?\s*s\.?)\s*$/g, '');
|
||||
// Remove organization phrases/prefixes anywhere (keep core locality/name)
|
||||
const orgPhrases = [
|
||||
'fotbalovy klub',
|
||||
'sportovni klub',
|
||||
'telovychovna jednota',
|
||||
'skolni sportovni klub',
|
||||
'fotbal',
|
||||
'futsal',
|
||||
];
|
||||
for (const phrase of orgPhrases) {
|
||||
const re = new RegExp('(^|\\b)'+ phrase + '(\\b|$)', 'g');
|
||||
out = out.replace(re, ' ');
|
||||
}
|
||||
// Remove common short prefixes (tokens) like FC, FK, MFK, TJ, SK, SFC, AFK at word boundaries
|
||||
out = out.replace(/\b(1\.)?\s*(sfc|afc|fc|fk|mfk|tj|sk|afk)\b\.?/g, ' ');
|
||||
// Remove punctuation except hyphen
|
||||
out = out.replace(/[\.,!;:()\[\]{}]/g, ' ');
|
||||
// Collapse multiple spaces and trim
|
||||
out = out.replace(/\s+/g, ' ').trim();
|
||||
return out;
|
||||
}
|
||||
|
||||
// Derive FACR team UUID from the logo URL if team_id is missing in the row
|
||||
// Example: https://is1.fotbal.cz/media/kluby/<UUID>/<UUID>_crop.jpg
|
||||
function deriveTeamIdFromLogoUrl(url?: string): string | undefined {
|
||||
try {
|
||||
const u = String(url || '');
|
||||
if (!u) return undefined;
|
||||
const m = u.match(/[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}/);
|
||||
return m ? m[0].toLowerCase() : undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
type TableRow = {
|
||||
rank?: string;
|
||||
team?: string;
|
||||
@@ -82,6 +128,9 @@ const TeamsAdminPage = () => {
|
||||
const mainClubBase: string = useMemo(() => normalize(String(data?.name || '')), [data?.name]);
|
||||
// Backend origin (used to resolve relative URLs like /uploads/...)
|
||||
const backendOrigin = new URL(API_URL, typeof window !== 'undefined' ? window.location.origin : 'http://localhost:3000').origin;
|
||||
const theadBg = useColorModeValue('gray.50', 'gray.700');
|
||||
const rowHoverBg = useColorModeValue('gray.50', 'gray.700');
|
||||
const searchBg = useColorModeValue('white', 'gray.800');
|
||||
|
||||
// Load public/admin overrides map to apply on cache-fed view
|
||||
const { data: overrides = {} } = useQuery({
|
||||
@@ -120,38 +169,6 @@ const TeamsAdminPage = () => {
|
||||
.catch((err) => console.error('Failed to fetch sport logos:', err))
|
||||
.finally(() => setSportLogosLoading(false));
|
||||
}, [competitions]);
|
||||
const normalize = (s: string) => {
|
||||
let out = String(s || '');
|
||||
// Normalize diacritics and case
|
||||
out = out
|
||||
.normalize('NFD')
|
||||
.replace(/[\u0300-\u036f]/g, '')
|
||||
.toLowerCase();
|
||||
// Unify various dash characters to a simple hyphen
|
||||
out = out.replace(/[\u2012\u2013\u2014\u2015\u2212]/g, '-');
|
||||
// Remove legal suffixes like ", z.s." / ", z. s." / " z.s." / "o.s." at end
|
||||
out = out.replace(/[,,\s]*(z\.?\s*s\.?|o\.?\s*s\.?)\s*$/g, '');
|
||||
// Remove organization phrases/prefixes anywhere (keep core locality/name)
|
||||
const orgPhrases = [
|
||||
'fotbalovy klub',
|
||||
'sportovni klub',
|
||||
'telovychovna jednota',
|
||||
'skolni sportovni klub',
|
||||
'fotbal',
|
||||
'futsal',
|
||||
];
|
||||
for (const phrase of orgPhrases) {
|
||||
const re = new RegExp('(^|\\b)'+ phrase + '(\\b|$)', 'g');
|
||||
out = out.replace(re, ' ');
|
||||
}
|
||||
// Remove common short prefixes (tokens) like FC, FK, MFK, TJ, SK, SFC, AFK at word boundaries
|
||||
out = out.replace(/\b(1\.)?\s*(sfc|afc|fc|fk|mfk|tj|sk|afk)\b\.?/g, ' ');
|
||||
// Remove punctuation except hyphen
|
||||
out = out.replace(/[\.,!;:()\[\]{}]/g, ' ');
|
||||
// Collapse multiple spaces and trim
|
||||
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> = {};
|
||||
@@ -161,18 +178,6 @@ const TeamsAdminPage = () => {
|
||||
return idx;
|
||||
}, [byName]);
|
||||
|
||||
// Derive FACR team UUID from the logo URL if team_id is missing in the row
|
||||
// Example: https://is1.fotbal.cz/media/kluby/<UUID>/<UUID>_crop.jpg
|
||||
const deriveTeamIdFromLogoUrl = (url?: string): string | undefined => {
|
||||
try {
|
||||
const u = String(url || '');
|
||||
if (!u) return undefined;
|
||||
const m = u.match(/[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}/);
|
||||
return m ? m[0].toLowerCase() : undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
const getLogo = (teamName?: string, teamId?: string, original?: string) => {
|
||||
if (!teamName) return assetUrl('/dist/img/logo-club-empty.svg') as string;
|
||||
// Priority 0: Admin override by team ID
|
||||
@@ -561,7 +566,7 @@ const TeamsAdminPage = () => {
|
||||
}}
|
||||
>
|
||||
<Table size="sm" variant="simple">
|
||||
<Thead bg={useColorModeValue('gray.50', 'gray.700')}>
|
||||
<Thead bg={theadBg}>
|
||||
<Tr>
|
||||
<Th w="40px" fontSize="xs" py={2}>#</Th>
|
||||
<Th fontSize="xs" py={2}>Tým</Th>
|
||||
@@ -576,7 +581,7 @@ const TeamsAdminPage = () => {
|
||||
</Thead>
|
||||
<Tbody>
|
||||
{rowsFiltered.map((r, idx) => (
|
||||
<Tr key={`${comp.id}-${idx}`} _hover={{ bg: useColorModeValue('gray.50', 'gray.700') }}>
|
||||
<Tr key={`${comp.id}-${idx}`} _hover={{ bg: rowHoverBg }}>
|
||||
<Td py={1.5} fontSize="xs">{r.rank}</Td>
|
||||
<Td py={1.5}>
|
||||
<HStack spacing={2} align="center">
|
||||
@@ -687,7 +692,7 @@ const TeamsAdminPage = () => {
|
||||
{searchResults.length > 0 && (
|
||||
<Box
|
||||
mt={4}
|
||||
bg={useColorModeValue('white', 'gray.800')}
|
||||
bg={searchBg}
|
||||
borderWidth="1px"
|
||||
borderRadius="lg"
|
||||
overflowX="auto"
|
||||
|
||||
Reference in New Issue
Block a user