mirror of
https://github.com/Dvorinka/MyClubServer.git
synced 2026-06-03 18:22:57 +00:00
526 lines
21 KiB
TypeScript
526 lines
21 KiB
TypeScript
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; |