Files
MyClub/frontend/src/pages/admin/BannersAdminPage.tsx
T
Tomas Dvorak 16e4533202 dev day #75
2025-10-29 21:20:16 +01:00

526 lines
21 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 { Banner as AdminBanner, getBanners, createBanner, updateBanner, deleteBanner } from '../../services/banners';
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' | 'under_table';
};
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'
},
{
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'
}
];
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<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);
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: AdminBanner) => {
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) => 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 }) => 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) => 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' }),
});
const onSubmit = async () => {
if (!editing) return;
const payload = {
name: editing.name || '',
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,
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 || {}), image_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: AdminBanner) => {
const preset = getPreset((b as any).placement);
return (
<Tr key={b.id}>
<Td>
<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 as any).click_url && (
<Text fontSize="xs" color="gray.500" noOfLines={1}>{(b as any).click_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 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 && (
<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 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;
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 as any)?.image_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 as any)?.image_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 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>
</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;