mirror of
https://github.com/Dvorinka/MyClubServer.git
synced 2026-06-05 03:02:56 +00:00
upload
This commit is contained in:
@@ -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;
|
||||
Reference in New Issue
Block a user