mirror of
https://github.com/Dvorinka/MyClubServer.git
synced 2026-06-04 18:52:56 +00:00
upload
This commit is contained in:
@@ -0,0 +1,313 @@
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
Heading,
|
||||
HStack,
|
||||
IconButton,
|
||||
Image,
|
||||
Input,
|
||||
Link,
|
||||
Modal,
|
||||
ModalBody,
|
||||
ModalCloseButton,
|
||||
ModalContent,
|
||||
ModalFooter,
|
||||
ModalHeader,
|
||||
ModalOverlay,
|
||||
Table,
|
||||
Tbody,
|
||||
Td,
|
||||
Th,
|
||||
Thead,
|
||||
Tr,
|
||||
FormControl,
|
||||
FormLabel,
|
||||
Switch,
|
||||
useDisclosure,
|
||||
useToast,
|
||||
VStack,
|
||||
useColorModeValue,
|
||||
Text,
|
||||
Select,
|
||||
NumberInput,
|
||||
NumberInputField,
|
||||
Badge,
|
||||
} from '@chakra-ui/react';
|
||||
import { useState } from 'react';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { FiEdit2, FiPlus, FiTrash2, FiUpload, FiExternalLink } from 'react-icons/fi';
|
||||
import AdminLayout from '../../layouts/AdminLayout';
|
||||
import { Sponsor, getSponsors, createSponsor, updateSponsor, deleteSponsor } from '../../services/sponsors';
|
||||
import { uploadFile } from '../../services/articles';
|
||||
|
||||
const SponsorsAdminPage: React.FC = () => {
|
||||
const cardBg = useColorModeValue('white', 'gray.800');
|
||||
const borderColor = useColorModeValue('gray.200', 'gray.700');
|
||||
const inputBg = useColorModeValue('white', 'gray.700');
|
||||
const normalizeImageUrl = (url?: string) => {
|
||||
if (!url || url === '') return '/logo192.png';
|
||||
if (/^https?:\/\//i.test(url)) return url;
|
||||
const apiUrl = process.env.REACT_APP_API_URL || 'http://localhost:8080/api/v1';
|
||||
const origin = new URL(apiUrl).origin;
|
||||
if (url.startsWith('/uploads/')) return `${origin}${url}`;
|
||||
return `${origin}${url.startsWith('/') ? '' : '/'}${url}`;
|
||||
};
|
||||
const toast = useToast();
|
||||
const qc = useQueryClient();
|
||||
const { data, isLoading } = useQuery({ queryKey: ['admin-sponsors'], queryFn: getSponsors });
|
||||
|
||||
const [editing, setEditing] = useState<Partial<Sponsor> | null>(null);
|
||||
const { isOpen, onOpen, onClose } = useDisclosure();
|
||||
|
||||
const openCreate = () => { setEditing({ name: '', is_active: true, tier: 'standard', display_order: 0 }); onOpen(); };
|
||||
const openEdit = (s: Sponsor) => { setEditing({ ...s }); onOpen(); };
|
||||
const closeModal = () => { setEditing(null); onClose(); };
|
||||
|
||||
const createMut = useMutation({
|
||||
mutationFn: (payload: any) => createSponsor(payload),
|
||||
onSuccess: (created: any) => {
|
||||
// If backend returned the created sponsor, merge into cache for immediate UI update
|
||||
try {
|
||||
qc.setQueryData(['admin-sponsors'], (old: any) => {
|
||||
const list = Array.isArray(old) ? old : (old?.data || []);
|
||||
const newList = [created, ...list];
|
||||
// if cache shape is { data, total, page }, attempt to preserve
|
||||
if (old && old.data) return { ...old, data: newList };
|
||||
return newList;
|
||||
});
|
||||
} catch (e) {
|
||||
// ignore cache update errors
|
||||
}
|
||||
toast({ title: 'Sponzor vytvořen', status: 'success' });
|
||||
qc.invalidateQueries({ queryKey: ['admin-sponsors'] });
|
||||
closeModal();
|
||||
},
|
||||
onError: (e: any) => toast({ title: 'Vytvoření selhalo', description: e?.response?.data?.chyba || 'Chyba', status: 'error' }),
|
||||
});
|
||||
const updateMut = useMutation({
|
||||
mutationFn: ({ id, payload }: { id: number | string; payload: any }) => updateSponsor(id, payload),
|
||||
onSuccess: (updated: any) => {
|
||||
// Optimistically merge returned sponsor into cache to avoid transient undefined fields
|
||||
try {
|
||||
qc.setQueryData(['admin-sponsors'], (old: any) => {
|
||||
const list = Array.isArray(old) ? old : (old?.data || []);
|
||||
const newList = (list || []).map((it: any) => (it?.id === updated?.id ? { ...it, ...updated } : it));
|
||||
if (old && old.data) return { ...old, data: newList };
|
||||
return newList;
|
||||
});
|
||||
} catch {}
|
||||
toast({ title: 'Sponzor aktualizován', status: 'success' });
|
||||
qc.invalidateQueries({ queryKey: ['admin-sponsors'] });
|
||||
closeModal();
|
||||
},
|
||||
onError: (e: any) => toast({ title: 'Aktualizace selhala', description: e?.response?.data?.chyba || 'Chyba', status: 'error' }),
|
||||
});
|
||||
const deleteMut = useMutation({
|
||||
mutationFn: (id: number) => deleteSponsor(id),
|
||||
onSuccess: () => { toast({ title: 'Sponzor smazán', status: 'success' }); qc.invalidateQueries({ queryKey: ['admin-sponsors'] }); },
|
||||
onError: (e: any) => toast({ title: 'Smazání selhalo', description: e?.response?.data?.chyba || 'Chyba', status: 'error' }),
|
||||
});
|
||||
|
||||
const onSubmit = async () => {
|
||||
if (!editing) return;
|
||||
const payload = {
|
||||
name: editing.name || '',
|
||||
website_url: editing.website_url || '',
|
||||
logo_url: editing.logo_url || '',
|
||||
is_active: editing.is_active ?? true,
|
||||
tier: editing.tier || 'standard',
|
||||
display_order: editing.display_order ?? 0,
|
||||
};
|
||||
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;
|
||||
try {
|
||||
const res = await uploadFile(file);
|
||||
const apiUrl = process.env.REACT_APP_API_URL || 'http://localhost:8080/api/v1';
|
||||
const apiOrigin = new URL(apiUrl).origin;
|
||||
// If backend returned an absolute URL pointing to the dev host (same origin as app), rewrite to API origin
|
||||
let url = res.url || '';
|
||||
try {
|
||||
const parsed = new URL(url, window.location.origin);
|
||||
const appOrigin = window.location.origin;
|
||||
if (parsed.origin === appOrigin) {
|
||||
// replace with API origin while keeping path
|
||||
url = apiOrigin + parsed.pathname + parsed.search + parsed.hash;
|
||||
}
|
||||
} catch (e) {
|
||||
// ignore
|
||||
}
|
||||
setEditing((prev) => ({ ...(prev || {}), logo_url: url }));
|
||||
toast({ title: 'Logo nahráno', status: 'success' });
|
||||
} catch (err) {
|
||||
toast({ title: 'Nahrání loga selhalo', status: 'error' });
|
||||
}
|
||||
};
|
||||
|
||||
const toggleActive = async (s: Sponsor) => {
|
||||
if (s.id == null) return;
|
||||
try {
|
||||
await updateMut.mutateAsync({ id: s.id, payload: { is_active: !s.is_active } });
|
||||
toast({ title: 'Stav sponzora aktualizován', status: 'success' });
|
||||
} catch (err) {
|
||||
toast({ title: 'Aktualizace selhala', status: 'error' });
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<AdminLayout requireAdmin={false}>
|
||||
<Box>
|
||||
<HStack justify="space-between" mb={4}>
|
||||
<Heading size="lg">Správa sponzorů</Heading>
|
||||
<Button leftIcon={<FiPlus />} colorScheme="blue" onClick={openCreate}>
|
||||
Nový sponzor
|
||||
</Button>
|
||||
</HStack>
|
||||
<Text color="gray.500" mb={6}>
|
||||
Správa sponzorů a partnerů klubu. Můžete přidávat, upravovat a odebírat sponzory, kteří se zobrazují na webu.
|
||||
</Text>
|
||||
|
||||
<Box
|
||||
bg={useColorModeValue('white', 'gray.800')}
|
||||
borderWidth="1px"
|
||||
borderRadius="lg"
|
||||
overflowX="auto"
|
||||
boxShadow="sm"
|
||||
mb={6}
|
||||
>
|
||||
<Table size="sm">
|
||||
<Thead>
|
||||
<Tr>
|
||||
<Th w="80px">Logo</Th>
|
||||
<Th>Název</Th>
|
||||
<Th>Úroveň</Th>
|
||||
<Th>Pořadí</Th>
|
||||
<Th>Web</Th>
|
||||
<Th w="120px">Aktivní</Th>
|
||||
<Th w="160px">Akce</Th>
|
||||
</Tr>
|
||||
</Thead>
|
||||
<Tbody>
|
||||
{isLoading && (<Tr><Td colSpan={7}>Načítám...</Td></Tr>)}
|
||||
{!isLoading && (data || []).map((s) => (
|
||||
<Tr key={s.id}>
|
||||
<Td>
|
||||
<Image src={normalizeImageUrl(s.logo_url)} alt={s.name} boxSize="48px" objectFit="contain" />
|
||||
</Td>
|
||||
<Td>{s.name}</Td>
|
||||
<Td>
|
||||
<Badge colorScheme={s.tier === 'general' ? 'green' : 'blue'}>
|
||||
{s.tier === 'general' ? 'Hlavní partner' : 'Partner'}
|
||||
</Badge>
|
||||
</Td>
|
||||
<Td>{s.display_order ?? 0}</Td>
|
||||
<Td>
|
||||
{s.website_url ? (
|
||||
<HStack>
|
||||
<Link href={s.website_url} color="blue.500" isExternal fontSize="sm">{s.website_url}</Link>
|
||||
<FiExternalLink />
|
||||
</HStack>
|
||||
) : '-'}
|
||||
</Td>
|
||||
<Td>
|
||||
<Switch isChecked={!!s.is_active} onChange={() => toggleActive(s)} />
|
||||
</Td>
|
||||
<Td>
|
||||
<HStack spacing={2}>
|
||||
<IconButton
|
||||
aria-label="Upravit"
|
||||
icon={<FiEdit2 />}
|
||||
size="sm"
|
||||
onClick={() => openEdit(s)}
|
||||
/>
|
||||
<IconButton
|
||||
aria-label="Smazat"
|
||||
icon={<FiTrash2 />}
|
||||
size="sm"
|
||||
colorScheme="red"
|
||||
variant="outline"
|
||||
onClick={async () => {
|
||||
if (!window.confirm(`Opravdu chcete smazat sponzora "${s.name}"?`)) return;
|
||||
try {
|
||||
await deleteMut.mutateAsync(s.id as number);
|
||||
toast({ title: 'Sponzor smazán', status: 'success' });
|
||||
} catch (err) {
|
||||
toast({ title: 'Smazání sponzora selhalo', status: 'error' });
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</HStack>
|
||||
</Td>
|
||||
</Tr>
|
||||
))}
|
||||
</Tbody>
|
||||
</Table>
|
||||
</Box>
|
||||
|
||||
<Modal isOpen={isOpen} onClose={closeModal} size="lg">
|
||||
<ModalOverlay />
|
||||
<ModalContent>
|
||||
<ModalHeader>{(editing as any)?.id ? 'Upravit sponzora' : 'Nový sponzor'}</ModalHeader>
|
||||
<ModalCloseButton />
|
||||
<ModalBody>
|
||||
<VStack align="stretch" spacing={4}>
|
||||
<FormControl isRequired>
|
||||
<FormLabel>Název</FormLabel>
|
||||
<Input value={editing?.name || ''} onChange={(e) => setEditing((p) => ({ ...(p as any), name: e.target.value }))} />
|
||||
</FormControl>
|
||||
<FormControl>
|
||||
<FormLabel>Web (URL)</FormLabel>
|
||||
<Input value={editing?.website_url || ''} onChange={(e) => setEditing((p) => ({ ...(p as any), website_url: e.target.value }))} />
|
||||
</FormControl>
|
||||
<FormControl>
|
||||
<FormLabel>Logo</FormLabel>
|
||||
<HStack>
|
||||
<Image src={normalizeImageUrl(editing?.logo_url)} alt="logo" boxSize="56px" objectFit="contain" />
|
||||
<Button as="label" type="button" leftIcon={<FiUpload />}>Upload
|
||||
<Input type="file" display="none" accept="image/*" onChange={async (e) => {
|
||||
const file = e.target.files?.[0];
|
||||
await onUpload(file);
|
||||
// reset input to allow selecting the same file again
|
||||
(e.target as HTMLInputElement).value = '';
|
||||
}} />
|
||||
</Button>
|
||||
</HStack>
|
||||
</FormControl>
|
||||
<FormControl>
|
||||
<FormLabel>Úroveň partnera</FormLabel>
|
||||
<Select value={editing?.tier || 'standard'} onChange={(e) => setEditing((p) => ({ ...(p as any), tier: e.target.value }))}>
|
||||
<option value="general">Hlavní partner</option>
|
||||
<option value="standard">Partner</option>
|
||||
</Select>
|
||||
</FormControl>
|
||||
<FormControl>
|
||||
<FormLabel>Pořadí zobrazení</FormLabel>
|
||||
<NumberInput value={editing?.display_order ?? 0} onChange={(val) => setEditing((p) => ({ ...(p as any), display_order: parseInt(val) || 0 }))} min={0}>
|
||||
<NumberInputField />
|
||||
</NumberInput>
|
||||
<Text fontSize="xs" color="gray.500" mt={1}>Menší číslo = vyšší pozice</Text>
|
||||
</FormControl>
|
||||
<FormControl display="flex" alignItems="center">
|
||||
<FormLabel mb="0">Aktivní</FormLabel>
|
||||
<Switch isChecked={!!editing?.is_active} onChange={(e) => setEditing((p) => ({ ...(p 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 SponsorsAdminPage;
|
||||
Reference in New Issue
Block a user