This commit is contained in:
Tomáš Dvořák
2025-10-16 13:32:05 +02:00
commit 12cba639b9
663 changed files with 168914 additions and 0 deletions
@@ -0,0 +1,517 @@
import React, { useState, useRef } 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 { Sponsor, getSponsors, createSponsor, updateSponsor, deleteSponsor } from '../../services/sponsors';
import { uploadFile } from '../../services/articles';
import { assetUrl } from '../../utils/url';
// Banner placement presets with dimensions and descriptions
type BannerPreset = {
value: string;
label: string;
description: string;
width: number;
height: number;
aspectRatio: number;
position: 'top' | 'middle' | 'sidebar' | 'footer' | 'article';
};
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)',
description: 'Banner ve středu stránky mezi obsahem',
width: 970,
height: 250,
aspectRatio: 3.88,
position: 'middle'
},
{
value: 'homepage_sidebar',
label: 'Postranní banner (Homepage - sidebar)',
description: 'Menší banner v pravém postranním panelu',
width: 300,
height: 250,
aspectRatio: 1.2,
position: 'sidebar'
},
{
value: 'homepage_footer',
label: 'Spodní banner (Homepage - zápatí)',
description: 'Banner v dolní části stránky před zápatím',
width: 1200,
height: 200,
aspectRatio: 6,
position: 'footer'
},
{
value: 'article_inline',
label: 'Banner v článcích',
description: 'Banner zobrazený v textu článků',
width: 728,
height: 90,
aspectRatio: 8.09,
position: 'article'
}
];
const BannersAdminPage: React.FC = () => {
const cardBg = useColorModeValue('white', 'gray.800');
const borderColor = useColorModeValue('gray.200', 'gray.700');
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 [imageResolution, setImageResolution] = useState<{ width: number; height: number } | null>(null);
const [recommendedPlacements, setRecommendedPlacements] = useState<BannerPreset[]>([]);
const [uploadingImage, setUploadingImage] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
const { isOpen, onOpen, onClose } = useDisclosure();
// Get preset by value
const getPreset = (placement?: string): BannerPreset | undefined => {
return BANNER_PRESETS.find(p => p.value === placement);
};
// Recommend placements based on image resolution
const recommendPlacement = (imgWidth: number, imgHeight: number): BannerPreset[] => {
const imgAspectRatio = imgWidth / imgHeight;
// Sort presets by how close their aspect ratio is to the image
const scored = BANNER_PRESETS.map(preset => {
const ratioDiff = Math.abs(preset.aspectRatio - imgAspectRatio);
const widthDiff = Math.abs(preset.width - imgWidth) / preset.width;
const heightDiff = Math.abs(preset.height - imgHeight) / preset.height;
// Lower score is better
const score = ratioDiff * 2 + widthDiff + heightDiff;
return { preset, score };
}).sort((a, b) => a.score - b.score);
// Return top 3 recommendations
return scored.slice(0, 3).map(s => s.preset);
};
const openCreate = () => {
const defaultPreset = BANNER_PRESETS[0];
setEditing({
name: '',
is_active: true,
placement: defaultPreset.value,
width: defaultPreset.width,
height: defaultPreset.height
} as any);
setImageResolution(null);
setRecommendedPlacements([]);
onOpen();
};
const openEdit = (s: Sponsor) => {
setEditing({ ...s });
setImageResolution(null);
setRecommendedPlacements([]);
onOpen();
};
const closeModal = () => {
setEditing(null);
setImageResolution(null);
setRecommendedPlacements([]);
if (fileInputRef.current) {
fileInputRef.current.value = '';
}
onClose();
};
const createMut = useMutation({
mutationFn: (payload: any) => createSponsor(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),
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),
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' }),
});
const onSubmit = async () => {
if (!editing) return;
const payload = {
name: editing.name || '',
logo_url: editing.logo_url,
website_url: editing.website_url,
is_active: editing.is_active ?? true,
placement: (editing as any).placement || '',
width: (editing as any).width || undefined,
height: (editing as any).height || undefined,
};
if ((editing as any).id != null) {
await updateMut.mutateAsync({ id: (editing as any).id, payload });
} else {
await createMut.mutateAsync(payload);
}
};
const onUpload = async (file?: File | null) => {
if (!file) return;
setUploadingImage(true);
try {
// First, detect image resolution
const img = new window.Image();
const imageLoadPromise = new Promise<{ width: number; height: number }>((resolve, reject) => {
img.onload = () => resolve({ width: img.width, height: img.height });
img.onerror = reject;
img.src = URL.createObjectURL(file);
});
const resolution = await imageLoadPromise;
setImageResolution(resolution);
// Recommend placements based on resolution
const recommended = recommendPlacement(resolution.width, resolution.height);
setRecommendedPlacements(recommended);
// Upload the file
const res = await uploadFile(file);
// Update editing state with uploaded URL
setEditing((prev) => ({ ...(prev || {}), logo_url: res.url }));
// If no placement selected yet, auto-select the best recommendation
if (!editing?.placement && recommended.length > 0) {
const bestMatch = recommended[0];
setEditing((prev) => ({
...(prev || {}),
placement: bestMatch.value,
width: bestMatch.width,
height: bestMatch.height
}));
toast({
title: 'Obrázek nahrán',
description: `Rozlišení: ${resolution.width}×${resolution.height}. Doporučeno umístění: ${bestMatch.label}`,
status: 'success',
duration: 6000
});
} else {
toast({
title: 'Obrázek nahrán',
description: `Rozlišení: ${resolution.width}×${resolution.height}`,
status: 'success'
});
}
// Clean up object URL
URL.revokeObjectURL(img.src);
} catch (e: any) {
toast({ title: 'Nahrání selhalo', description: e?.message || 'Zkuste to znovu', status: 'error' });
} finally {
setUploadingImage(false);
}
};
const banners = data || [];
return (
<AdminLayout>
<Box>
<HStack justify="space-between" mb={4}>
<Heading size="lg">Bannery a reklamní plochy</Heading>
<Button leftIcon={<FiPlus />} colorScheme="blue" onClick={openCreate}>
Nový banner
</Button>
</HStack>
<Text color="gray.500" mb={6}>
Správa bannerů a reklamních ploch zobrazovaných na webu. Můžete přidávat, upravovat a odebírat bannery.
</Text>
<Box
bg={useColorModeValue('white', 'gray.800')}
borderWidth="1px"
borderRadius="lg"
overflowX="auto"
boxShadow="sm"
mb={6}
>
<Table size="sm">
<Thead>
<Tr>
<Th w="100px">Náhled</Th>
<Th>Název</Th>
<Th>Umístění</Th>
<Th>Rozměry</Th>
<Th w="100px">Aktivní</Th>
<Th w="160px">Akce</Th>
</Tr>
</Thead>
<Tbody>
{isLoading && (
<Tr><Td colSpan={6} textAlign="center"><Spinner size="sm" mr={2} />Načítání</Td></Tr>
)}
{!isLoading && banners.map((b) => {
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" />
</Td>
<Td>
<Text fontWeight="500">{b.name}</Text>
{b.website_url && (
<Text fontSize="xs" color="gray.500" noOfLines={1}>{b.website_url}</Text>
)}
</Td>
<Td>
{preset ? (
<VStack align="start" spacing={0}>
<Text fontSize="sm" fontWeight="500">{preset.label}</Text>
<Badge colorScheme="blue" fontSize="xs">{preset.position}</Badge>
</VStack>
) : (
<Text fontSize="xs" color="gray.500">-</Text>
)}
</Td>
<Td>
{(b as any).width && (b as any).height ? (
<Text fontSize="xs">{(b as any).width} × {(b as any).height}</Text>
) : '-'}
</Td>
<Td>
<Badge colorScheme={b.is_active ? 'green' : 'gray'}>
{b.is_active ? 'Ano' : 'Ne'}
</Badge>
</Td>
<Td>
<HStack>
<IconButton aria-label="Upravit" size="sm" icon={<FiEdit2 />} onClick={() => openEdit(b)} />
<IconButton aria-label="Smazat" size="sm" colorScheme="red" icon={<FiTrash2 />} onClick={() => { if (b.id != null) deleteMut.mutate(b.id); }} />
</HStack>
</Td>
</Tr>
);
})}
</Tbody>
</Table>
</Box>
<Modal isOpen={isOpen} onClose={closeModal} size="lg">
<ModalOverlay />
<ModalContent maxW="90vw" maxH="90vh" overflowY="auto">
<ModalHeader>{(editing as any)?.id ? 'Upravit banner' : 'Nový banner'}</ModalHeader>
<ModalCloseButton />
<ModalBody>
<VStack align="stretch" spacing={4}>
<FormControl isRequired>
<FormLabel>Název</FormLabel>
<Input value={editing?.name || ''} onChange={(e) => setEditing((prev) => ({ ...(prev as any), name: e.target.value }))} />
</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" />
</FormControl>
{/* Image resolution info */}
{imageResolution && (
<Alert status="info" borderRadius="md">
<AlertIcon />
<Box flex="1">
<AlertTitle fontSize="sm">Rozlišení obrázku: {imageResolution.width} × {imageResolution.height} px</AlertTitle>
<AlertDescription fontSize="xs">
Poměr stran: {(imageResolution.width / imageResolution.height).toFixed(2)}:1
</AlertDescription>
</Box>
</Alert>
)}
{/* Recommended placements */}
{recommendedPlacements.length > 0 && (
<Box p={3} bg={useColorModeValue('blue.50', 'blue.900')} borderRadius="md">
<Text fontSize="sm" fontWeight="600" mb={2} color={useColorModeValue('blue.700', 'blue.200')}>
<FiCheckCircle style={{ display: 'inline', marginRight: '6px' }} />
Doporučená umístění na základě rozlišení:
</Text>
<VStack align="stretch" spacing={1}>
{recommendedPlacements.map((preset, idx) => (
<HStack key={preset.value} justify="space-between" fontSize="xs">
<Text>
<Badge colorScheme={idx === 0 ? 'green' : 'blue'} mr={2}>
{idx === 0 ? 'Nejlepší' : `#${idx + 1}`}
</Badge>
{preset.label} ({preset.width}×{preset.height})
</Text>
{editing?.placement !== preset.value && (
<Button
size="xs"
variant="link"
colorScheme="blue"
onClick={() => {
setEditing(prev => ({
...prev,
placement: preset.value,
width: preset.width,
height: preset.height
}));
}}
>
Použít
</Button>
)}
</HStack>
))}
</VStack>
</Box>
)}
<FormControl isRequired>
<FormLabel>Umístění na webu</FormLabel>
<Select
value={(editing as any)?.placement || ''}
onChange={(e) => {
const placement = e.target.value;
const preset = getPreset(placement);
setEditing((prev) => ({
...(prev as any),
placement,
width: preset?.width,
height: preset?.height
}));
}}
>
<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>
))}
</Select>
{editing?.placement && (() => {
const preset = getPreset((editing as any).placement);
return preset ? (
<Text fontSize="xs" color="gray.500" mt={1}>
{preset.description}
</Text>
) : null;
})()}
</FormControl>
{/* Placement dimensions display (read-only) */}
{editing?.placement && (() => {
const preset = getPreset((editing as any).placement);
return preset ? (
<Box p={3} bg={inputBg} borderRadius="md" borderWidth="1px" borderColor={borderColor}>
<HStack justify="space-between" mb={1}>
<Text fontSize="sm" fontWeight="600">Rozměry banneru:</Text>
<Badge colorScheme="blue">{preset.width} × {preset.height} px</Badge>
</HStack>
<Text fontSize="xs" color="gray.500">
Poměr stran: {preset.aspectRatio.toFixed(2)}:1 Pozice: {preset.position}
</Text>
</Box>
) : null;
})()}
<Divider />
<FormControl>
<FormLabel>Obrázek banneru</FormLabel>
<VStack align="stretch" spacing={3}>
{/* Preview */}
{editing?.logo_url && (() => {
const preset = getPreset((editing as any)?.placement);
const previewWidth = preset ? Math.min(preset.width, 600) : 300;
const previewHeight = preset ? (previewWidth / preset.aspectRatio) : 150;
return (
<Box>
<Text fontSize="xs" color="gray.500" mb={2}>Náhled banneru:</Text>
<Box
borderWidth="2px"
borderColor={borderColor}
borderRadius="md"
p={2}
bg={inputBg}
>
<Image
src={assetUrl(editing?.logo_url) || '/logo192.png'}
alt="banner preview"
width={`${previewWidth}px`}
height={`${previewHeight}px`}
objectFit="contain"
mx="auto"
display="block"
/>
</Box>
{preset && (
<Text fontSize="xs" color="gray.500" mt={1} textAlign="center">
Zobrazení v pozici: {preset.label}
</Text>
)}
</Box>
);
})()}
{/* Upload button */}
<HStack>
<Button
as="label"
type="button"
leftIcon={<FiUpload />}
colorScheme="blue"
variant="outline"
isLoading={uploadingImage}
loadingText="Nahrávání..."
>
{editing?.logo_url ? 'Změnit obrázek' : 'Nahrát obrázek'}
<Input
ref={fileInputRef}
type="file"
display="none"
accept="image/*"
onChange={async (e) => {
await onUpload(e.target.files?.[0]);
}}
/>
</Button>
{uploadingImage && <Spinner size="sm" />}
</HStack>
{!editing?.logo_url && (
<Alert status="warning" fontSize="xs">
<AlertIcon boxSize="12px" />
<Text fontSize="xs">Nahrajte obrázek pro automatické doporučení umístění</Text>
</Alert>
)}
</VStack>
</FormControl>
<FormControl display="flex" alignItems="center">
<FormLabel mb="0">Aktivní</FormLabel>
<Switch isChecked={!!editing?.is_active} onChange={(e) => setEditing((prev) => ({ ...(prev as any), is_active: e.target.checked }))} />
</FormControl>
</VStack>
</ModalBody>
<ModalFooter>
<Button variant="ghost" mr={3} onClick={closeModal}>Zrušit</Button>
<Button colorScheme="blue" onClick={onSubmit} isLoading={createMut.isLoading || updateMut.isLoading}>Uložit</Button>
</ModalFooter>
</ModalContent>
</Modal>
</Box>
</AdminLayout>
);
};
export default BannersAdminPage;