Files
MyClub/frontend/src/pages/admin/SettingsAdminPage.tsx
T
Tomas Dvorak c941313fd5 dev day #92
2025-11-14 15:53:12 +01:00

766 lines
35 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 { useEffect, useRef, useState } from 'react';
import { FiSave, FiEye, FiCode, FiLayout, FiZap, FiTrash } from 'react-icons/fi';
import {
Box,
Heading,
Text,
SimpleGrid,
FormControl,
FormLabel,
FormHelperText,
Input,
Select,
Button,
useToast,
VStack,
HStack,
Divider,
Image,
Textarea,
Tabs,
TabList,
TabPanels,
Tab,
TabPanel,
Switch,
Badge,
} from '@chakra-ui/react';
import AdminLayout from '../../layouts/AdminLayout';
import { AdminSettings, getAdminSettings, updateAdminSettings } from '../../services/settings';
import { sendNewsletterTestAdvanced } from '../../services/admin/newsletter';
import { SeoSettings, getSeoSettings, updateSeoSettings } from '../../services/seo';
import { uploadFile } from '../../services/articles';
import { assetUrl } from '../../utils/url';
import { triggerPrefetch } from '../../services/admin/prefetch';
import api from '../../services/api';
const SettingsAdminPage: React.FC = () => {
const toast = useToast();
const [loading, setLoading] = useState<boolean>(false);
const [saving, setSaving] = useState<boolean>(false);
const [settings, setSettings] = useState<AdminSettings>({});
const [testEmail, setTestEmail] = useState<string>('');
const [testing, setTesting] = useState<boolean>(false);
const fileInputRef = useRef<HTMLInputElement | null>(null);
const [seo, setSeo] = useState<SeoSettings>({});
// Analytics state
const [umamiWebsiteId, setUmamiWebsiteId] = useState<string>('');
const [umamiDomain, setUmamiDomain] = useState<string>('');
const [umamiName, setUmamiName] = useState<string>('');
const [umamiInitializing, setUmamiInitializing] = useState<boolean>(false);
const [umamiConfig, setUmamiConfig] = useState<any>(null);
useEffect(() => {
setLoading(true);
getAdminSettings()
.then((data) => {
const s = data || {};
setSettings(s);
})
.catch(() => {
toast({ title: 'Chyba', description: 'Nepodařilo se načíst nastavení', status: 'error' });
})
.finally(() => setLoading(false));
// load SEO
getSeoSettings()
.then((data) => setSeo(data || {}))
.catch(() => {})
.finally(() => {});
// load Umami config
fetchUmamiConfig();
}, [toast]);
const fetchUmamiConfig = async () => {
try {
const response = await api.get('/insights/config');
setUmamiConfig(response.data);
if (response.data?.website_id) {
setUmamiWebsiteId(response.data.website_id);
}
// Set default domain from current host
if (!umamiDomain) {
const hostname = window.location.hostname;
setUmamiDomain(hostname);
}
// Set default name from club name if available
if (!umamiName && settings.club_name) {
setUmamiName(settings.club_name);
}
} catch (error) {
console.error('Failed to fetch Umami config:', error);
}
};
// Autofill SEO fields from general settings if missing
useEffect(() => {
setSeo((prev) => {
const next: SeoSettings = { ...prev };
let changed = false;
if (!next.site_title && settings.club_name) { next.site_title = settings.club_name; changed = true; }
if (!next.default_og_image_url && settings.club_logo_url) { next.default_og_image_url = settings.club_logo_url; changed = true; }
return changed ? next : prev;
});
}, [settings.club_name, settings.club_logo_url]);
const handleChange = (key: keyof AdminSettings) => (e: React.ChangeEvent<HTMLInputElement>) => {
setSettings((prev) => ({ ...prev, [key]: e.target.value }));
};
const handleSeoChange = (key: keyof SeoSettings) => (e: React.ChangeEvent<HTMLInputElement>) => {
const v = e.target.type === 'checkbox' ? (e.target as any).checked : e.target.value;
setSeo((prev) => ({ ...prev, [key]: v }));
};
const sendSmtpTest = async () => {
setTesting(true);
try {
const payload: any = { type: 'newsletter' };
if (testEmail && testEmail.trim()) payload.email = testEmail.trim();
await sendNewsletterTestAdvanced(payload);
toast({ title: 'Test odeslán', description: testEmail ? `Zkontrolujte schránku ${testEmail}` : 'Test odeslán na výchozí administrátorský email.', status: 'success', duration: 5000 });
} catch (e: any) {
const errorMsg = e?.response?.data?.error || e?.message || 'Neznámá chyba';
const isAuthError = errorMsg.includes('535') || errorMsg.toLowerCase().includes('authentication failed');
toast({
title: isAuthError ? '⚠️ Chyba autentizace SMTP (535)' : 'Chyba při odeslání testu',
description: isAuthError
? 'Zkontrolujte SMTP heslo. Ujistěte se, že je správně zkopírované bez mezer. Některé poskytovatele vyžadují aplikační heslo místo běžného hesla.'
: errorMsg + ' Zkuste upravit SMTP nastavení.',
status: 'error',
duration: 10000,
isClosable: true,
});
} finally {
setTesting(false);
}
};
// Numeric and boolean helpers for admin settings
const handleNumChange = (key: keyof AdminSettings) => (e: React.ChangeEvent<HTMLInputElement>) => {
const val = e.target.value;
const n = val === '' ? undefined : Number(val);
setSettings((prev) => ({ ...prev, [key]: (Number.isFinite(n as number) ? (n as any) : undefined) }));
};
const handleBoolChange = (key: keyof AdminSettings) => (e: React.ChangeEvent<HTMLInputElement>) => {
const checked = (e.target as any).checked as boolean;
setSettings((prev) => ({ ...prev, [key]: checked as any }));
};
const saveSEO = async () => {
try {
await updateSeoSettings(seo);
toast({ title: 'SEO uloženo', status: 'success' });
} catch (e: any) {
toast({ title: 'Chyba', description: e?.message || 'Uložení SEO nastavení se nezdařilo', status: 'error' });
}
};
const handleSelectChange = (key: keyof AdminSettings) => (e: React.ChangeEvent<HTMLSelectElement>) => {
setSettings((prev) => ({ ...prev, [key]: e.target.value }));
};
const presetStorageThresholds = () => {
setSettings((prev) => ({
...prev,
storage_warn_threshold: 80 as any,
storage_critical_threshold: 95 as any,
}));
};
const handleSave = async () => {
setSaving(true);
try {
const payload: Partial<AdminSettings> = {
club_name: settings.club_name,
club_logo_url: settings.club_logo_url,
// social & gallery links
facebook_url: (settings as any).facebook_url,
instagram_url: (settings as any).instagram_url,
youtube_url: (settings as any).youtube_url,
// generic gallery (preferred)
gallery_url: (settings as any).gallery_url,
// backward compatibility
zonerama_url: (settings as any).zonerama_url,
// SMTP
smtp_host: (settings as any).smtp_host,
smtp_port: (settings as any).smtp_port as any,
smtp_user: (settings as any).smtp_user,
smtp_password: (settings as any).smtp_password,
smtp_from: (settings as any).smtp_from,
smtp_from_name: (settings as any).smtp_from_name,
smtp_encryption: (settings as any).smtp_encryption as any,
...(typeof (settings as any).smtp_auth === 'boolean' ? { smtp_auth: (settings as any).smtp_auth as any } : {}),
smtp_skip_verify: (settings as any).smtp_skip_verify as any,
// videos module
videos_module_enabled: (settings as any).videos_module_enabled as any,
videos_source: (settings as any).videos_source as any,
// contact & map
contact_address: (settings as any).contact_address,
contact_city: (settings as any).contact_city,
contact_zip: (settings as any).contact_zip,
contact_country: (settings as any).contact_country,
contact_phone: (settings as any).contact_phone,
contact_email: (settings as any).contact_email,
location_latitude: (settings as any).location_latitude as any,
location_longitude: (settings as any).location_longitude as any,
map_zoom_level: (settings as any).map_zoom_level as any,
show_map_on_homepage:
(typeof (settings as any).location_latitude === 'number') &&
(typeof (settings as any).location_longitude === 'number'),
map_style: (settings as any).map_style,
frontend_base_url: (settings as any).frontend_base_url,
api_base_url: (settings as any).api_base_url,
// homepage matches display
finished_match_display_days: (settings as any).finished_match_display_days as any,
storage_warn_threshold: (settings as any).storage_warn_threshold as any,
storage_critical_threshold: (settings as any).storage_critical_threshold as any,
// error-review integration (domain managed via .env; only tokens are saved)
error_review_admin_token: (settings as any).error_review_admin_token,
error_review_ingest_token: (settings as any).error_review_ingest_token,
};
const saved = await updateAdminSettings(payload);
setSettings((prev) => ({ ...prev, ...saved }));
toast({ title: 'Uloženo', description: 'Nastavení bylo úspěšně aktualizováno', status: 'success' });
// Try to refresh prefetch caches
try { await triggerPrefetch(); } catch {}
try {
const fb = String(((saved as any).frontend_base_url || (settings as any).frontend_base_url || '')).replace(/\/$/, '');
let ab = String(((saved as any).api_base_url || (settings as any).api_base_url || '')).trim();
if (fb || ab) {
try {
const u = new URL(ab || '', fb || (typeof window !== 'undefined' ? window.location.origin : ''));
if (!/\/api\//.test(u.pathname)) { u.pathname = u.pathname.replace(/\/$/, '') + '/api/v1'; }
ab = u.toString();
} catch {}
try { localStorage.setItem('fc_frontend_base_url', fb); } catch {}
try { localStorage.setItem('fc_api_base_url', ab); } catch {}
try { localStorage.setItem('api_base_url', ab); } catch {}
try { (api as any).defaults.baseURL = ab; } catch {}
setTimeout(() => { try { window.location.reload(); } catch {} }, 600);
}
// Persist ingest token for frontend errorReporter (URL is fixed by default wiring)
try {
const ingestToken = (saved as any).error_review_ingest_token || (settings as any).error_review_ingest_token;
if (ingestToken) localStorage.setItem('fc_error_ingest_token', String(ingestToken));
} catch {}
} catch {}
} catch (e: any) {
toast({ title: 'Chyba', description: e?.message || 'Uložení nastavení se nezdařilo', status: 'error' });
} finally {
setSaving(false);
}
};
const onSelectLogo = () => fileInputRef.current?.click();
const onLogoChosen: React.ChangeEventHandler<HTMLInputElement> = async (e) => {
try {
const file = e.target.files?.[0];
if (!file) return;
// Basic validation: accept PNG/SVG/PDF and limit ~5MB
const okType = ['image/png', 'image/svg+xml', 'application/pdf'].includes(file.type);
if (!okType) {
toast({ title: 'Nepodporovaný formát', description: 'Nahrajte prosím logo ve formátu PNG, SVG nebo PDF.', status: 'warning' });
return;
}
if (file.size > 5 * 1024 * 1024) {
toast({ title: 'Soubor je příliš velký', description: 'Maximálně 5 MB.', status: 'warning' });
return;
}
const res = await uploadFile(file);
setSettings((prev) => ({ ...prev, club_logo_url: res.url }));
toast({ title: 'Logo nahráno', status: 'success' });
} catch (err: any) {
toast({ title: 'Nahrávání selhalo', description: err?.message || 'Logo se nepodařilo nahrát', status: 'error' });
} finally {
if (fileInputRef.current) fileInputRef.current.value = '';
}
};
return (
<AdminLayout>
<Box>
<Heading size="lg" mb={4}>Nastavení webu</Heading>
<Tabs variant="enclosed" colorScheme="brand">
<TabList flexWrap="wrap">
{['Obecné','Sociální sítě','Videa','SMTP','Analytika','SEO'].map((label) => (
<Tab key={label}
_selected={{ bg: 'brand.primary', color: 'text.onPrimary', borderColor: 'brand.primary' }}
_hover={{ bg: 'rgba(0,0,0,0.04)', borderColor: 'brand.500' }}
>{label}</Tab>
))}
</TabList>
<TabPanels>
<TabPanel>
<VStack align="stretch" spacing={4}>
<FormControl>
<FormLabel>Název klubu</FormLabel>
<Input value={settings.club_name || ''} onChange={handleChange('club_name')} />
</FormControl>
<Heading size="sm">Úložiště souborů</Heading>
<SimpleGrid columns={{ base: 1, md: 3 }} spacing={4}>
<FormControl>
<FormLabel>Varování při (%)</FormLabel>
<Input
type="number"
min={0}
max={100}
value={(settings as any).storage_warn_threshold ?? 80}
onChange={handleNumChange('storage_warn_threshold' as any)}
/>
</FormControl>
<FormControl>
<FormLabel>Kritické při (%)</FormLabel>
<Input
type="number"
min={0}
max={100}
value={(settings as any).storage_critical_threshold ?? 95}
onChange={handleNumChange('storage_critical_threshold' as any)}
/>
</FormControl>
<FormControl>
<FormLabel>Přednastavit</FormLabel>
<Button onClick={presetStorageThresholds} variant="outline">80 % / 95 %</Button>
</FormControl>
</SimpleGrid>
<FormControl>
<FormLabel>Logo klubu</FormLabel>
<HStack align="center" spacing={3}>
{settings.club_logo_url && (
<Image src={assetUrl(settings.club_logo_url) || settings.club_logo_url} alt="Logo" boxSize="56px" borderRadius="md" />
)}
<input ref={fileInputRef} type="file" accept="image/png,image/svg+xml,application/pdf" style={{ display: 'none' }} onChange={onLogoChosen} />
<Button onClick={onSelectLogo} variant="outline">Nahrát logo</Button>
</HStack>
</FormControl>
<Divider />
<Heading size="sm">Nastavení URL</Heading>
<FormControl>
<FormLabel>URL webu</FormLabel>
<Input value={(settings as any).frontend_base_url || ''} onChange={handleChange('frontend_base_url' as any)} placeholder="https://www.vasklub.cz" />
</FormControl>
<FormControl>
<FormLabel>API URL</FormLabel>
<Input value={(settings as any).api_base_url || ''} onChange={handleChange('api_base_url' as any)} placeholder="https://api.vasklub.cz/api/v1" />
<FormHelperText>Ujistěte se, že adresa končí na /api/v1</FormHelperText>
</FormControl>
<Heading size="sm">Zobrazení zápasů</Heading>
<FormControl>
<FormLabel>Počet dní zobrazení dokončených zápasů</FormLabel>
<Input
type="number"
min={0}
max={30}
value={(settings as any).finished_match_display_days ?? 2}
onChange={(e) => {
const val = parseInt(e.target.value, 10);
setSettings((prev) => ({ ...prev, finished_match_display_days: isNaN(val) ? 2 : val } as any));
}}
/>
<FormHelperText>
Počet dní, po které se na úvodní stránce zobrazí dokončený zápas s výsledkem místo příštího zápasu (výchozí: 2 dny).
</FormHelperText>
</FormControl>
<HStack>
<Button onClick={handleSave} isLoading={saving} bg="brand.primary" color="text.onPrimary" _hover={{ filter: 'brightness(0.95)' }}>Uložit nastavení</Button>
</HStack>
</VStack>
</TabPanel>
<TabPanel>
<VStack align="stretch" spacing={4}>
<FormControl>
<FormLabel>Facebook URL</FormLabel>
<Input value={(settings as any).facebook_url || ''} onChange={handleChange('facebook_url')} />
</FormControl>
<FormControl>
<FormLabel>Instagram URL</FormLabel>
<Input value={(settings as any).instagram_url || ''} onChange={handleChange('instagram_url')} />
</FormControl>
<FormControl>
<FormLabel>YouTube URL</FormLabel>
<Input value={(settings as any).youtube_url || ''} onChange={handleChange('youtube_url')} />
<FormHelperText>Pro automatické načítání videí vyplňte YouTube kanál.</FormHelperText>
</FormControl>
<FormControl>
<FormLabel>Fotogalerie URL</FormLabel>
<Input value={(settings as any).gallery_url || ''} onChange={handleChange('gallery_url')} />
</FormControl>
<HStack>
<Button onClick={handleSave} isLoading={saving} bg="brand.primary" color="text.onPrimary" _hover={{ filter: 'brightness(0.95)' }}>Uložit nastavení</Button>
</HStack>
</VStack>
</TabPanel>
<TabPanel>
<VStack align="stretch" spacing={4}>
<Box p={4} bg="blue.50" borderRadius="md" borderLeft="4px solid" borderColor="blue.500">
<HStack spacing={2} mb={2}>
<Text fontWeight="bold" fontSize="sm"> Sekce Videa</Text>
{(settings as any).videos_module_enabled && (
<Badge colorScheme="green">Aktivní</Badge>
)}
</HStack>
<Text fontSize="sm" color="gray.700">
Videa spravujte na stránce{' '}
<Button
as="a"
href="/admin/videa"
variant="link"
colorScheme="blue"
size="sm"
fontWeight="bold"
>
Videa
</Button>
. Zde nastavte zobrazení na titulní stránce.
{(settings as any).youtube_url && (
<Text as="span" color="green.700" fontWeight="semibold" ml={1}>
Sekce se automaticky aktivuje při vyplnění YouTube kanálu v tabě Sociální sítě.
</Text>
)}
</Text>
</Box>
<FormControl display="flex" alignItems="center">
<FormLabel mb={0}>Zobrazit sekci Videa na titulní stránce</FormLabel>
<Switch isChecked={!!(settings as any).videos_module_enabled} onChange={handleBoolChange('videos_module_enabled')} colorScheme="green" />
</FormControl>
<FormControl>
<FormLabel>Zdroj videí</FormLabel>
<Select value={(settings as any).videos_source || 'auto'} onChange={handleSelectChange('videos_source')}>
<option value="auto">Automaticky (YouTube kanál)</option>
<option value="manual">Ručně (správa v sekci Videa)</option>
</Select>
<FormHelperText>Pro automatický zdroj vyplňte v tabě Sociální sítě" pole YouTube URL.</FormHelperText>
</FormControl>
<HStack>
<Button onClick={handleSave} isLoading={saving} bg="brand.primary" color="text.onPrimary" _hover={{ filter: 'brightness(0.95)' }}>Uložit nastavení</Button>
</HStack>
</VStack>
</TabPanel>
<TabPanel>
<VStack align="stretch" spacing={4}>
<SimpleGrid columns={{ base: 1, md: 2 }} spacing={4}>
<FormControl>
<FormLabel>SMTP Host</FormLabel>
<Input
value={(settings as any).smtp_host || ''}
onChange={handleChange('smtp_host' as any)}
placeholder="smtp.seznam.cz nebo smtp.gmail.com"
/>
<FormHelperText>
Adresa SMTP serveru. Příklad: smtp.seznam.cz, smtp.gmail.com, smtp.office365.com
</FormHelperText>
</FormControl>
<FormControl>
<FormLabel>SMTP Port</FormLabel>
<Input
type="number"
value={(settings as any).smtp_port ?? ''}
onChange={handleNumChange('smtp_port' as any)}
placeholder="587 pro TLS, 465 pro SSL, 25 bez šifrování"
/>
<FormHelperText>
Nejčastěji 587 (TLS/STARTTLS) nebo 465 (SSL). Port 25 je bez šifrování a často blokovaný.
</FormHelperText>
</FormControl>
<FormControl>
<FormLabel>SMTP Uživatel</FormLabel>
<Input
value={(settings as any).smtp_user || ''}
onChange={handleChange('smtp_user' as any)}
placeholder="většinou celá emailová adresa"
/>
<FormHelperText>
Přihlašovací jméno k SMTP (obvykle emailová adresa). Nechte prázdné, pokud server nevyžaduje přihlášení.
</FormHelperText>
</FormControl>
<FormControl>
<FormLabel>SMTP Heslo</FormLabel>
<Input
type="password"
value={(settings as any).smtp_password || ''}
onChange={handleChange('smtp_password' as any)}
placeholder="heslo nebo aplikační heslo"
/>
<FormHelperText>
Heslo k účtu nebo aplikační heslo (Gmail/Seznam/Office 365 často vyžadují). Vkládejte bez mezer.
</FormHelperText>
</FormControl>
<FormControl>
<FormLabel>From email</FormLabel>
<Input
value={(settings as any).smtp_from || ''}
onChange={handleChange('smtp_from' as any)}
placeholder="noreply@vasklub.cz"
/>
<FormHelperText>
Adresa odesílatele uvedená v emailech. Ideálně existující schránka na vašem SMTP serveru.
</FormHelperText>
</FormControl>
<FormControl>
<FormLabel>From jméno</FormLabel>
<Input
value={(settings as any).smtp_from_name || ''}
onChange={handleChange('smtp_from_name' as any)}
placeholder="FK Váš Klub"
/>
<FormHelperText>
Zobrazované jméno odesílatele (např. FK Váš Klub).
</FormHelperText>
</FormControl>
<FormControl>
<FormLabel>Šifrování</FormLabel>
<Select value={(settings as any).smtp_encryption || 'none'} onChange={handleSelectChange('smtp_encryption' as any)}>
<option value="none">Žádné</option>
<option value="ssl">SSL</option>
<option value="tls">TLS</option>
</Select>
<FormHelperText>
SSL = implicitní šifrování (obvykle port 465). TLS/STARTTLS = šifrování po navázání spojení (obvykle port 587).
</FormHelperText>
</FormControl>
<FormControl>
<FormLabel>Autentizace</FormLabel>
<Select value={(settings as any).smtp_auth || 'plain'} onChange={handleSelectChange('smtp_auth' as any)}>
<option value="plain">PLAIN</option>
<option value="login">LOGIN</option>
<option value="cram-md5">CRAMMD5</option>
</Select>
<FormHelperText>
Mechanismus přihlášení k SMTP. Pokud si nejste jisti, zvolte PLAIN nebo LOGIN. Někteří poskytovatelé vyžadují konkrétní metodu.
</FormHelperText>
</FormControl>
</SimpleGrid>
<FormControl display="flex" alignItems="center">
<FormLabel mb={0}>Přeskočit ověření certifikátu</FormLabel>
<Switch isChecked={!!(settings as any).smtp_skip_verify} onChange={handleBoolChange('smtp_skip_verify' as any)} />
<FormHelperText ml={{ base: 0, md: 4 }}>
Pokročilé: povolte pouze při chybách certifikátu (selfsigned apod.). Nedoporučeno v produkci snižuje bezpečnost.
</FormHelperText>
</FormControl>
<Divider />
<Heading size="sm">Test email</Heading>
<HStack>
<Input placeholder="test@example.com (volitelné)" value={testEmail} onChange={(e)=> setTestEmail(e.target.value)} maxW={{ base: '100%', md: '320px' }} />
<Button onClick={sendSmtpTest} isLoading={testing}>Odeslat test</Button>
</HStack>
<Divider />
<HStack>
<Button onClick={handleSave} isLoading={saving} bg="brand.primary" color="text.onPrimary" _hover={{ filter: 'brightness(0.95)' }}>Uložit nastavení</Button>
</HStack>
</VStack>
</TabPanel>
<TabPanel>
<VStack align="stretch" spacing={4}>
<Heading size="sm">Webová analytika</Heading>
<Text fontSize="sm" color="gray.600">
Nastavení sledování návštěvnosti webu pomocí open-source nástroje pro webovou analytiku.
</Text>
{umamiConfig?.enabled && umamiConfig?.website_id ? (
<Box p={4} borderWidth="1px" borderRadius="md" bg="green.50" borderColor="green.200">
<HStack spacing={2} mb={2}>
<Text fontWeight="bold" color="green.700">✓ Analytika je aktivní</Text>
</HStack>
<Text fontSize="sm" color="gray.700">Website ID: <strong>{umamiConfig.website_id}</strong></Text>
</Box>
) : (
<Box p={4} borderWidth="1px" borderRadius="md" bg="yellow.50" borderColor="yellow.200">
<Text fontWeight="bold" color="yellow.700">⚠ Analytika není nakonfigurována</Text>
<Text fontSize="sm" color="gray.700" mt={1}>Vytvořte webovou stránku pro aktivaci sledování.</Text>
</Box>
)}
<Divider />
<Heading size="sm">Vytvořit novou webovou stránku</Heading>
<FormControl>
<FormLabel>Název webové stránky</FormLabel>
<Input
value={umamiName}
onChange={(e) => setUmamiName(e.target.value)}
placeholder={settings.club_name || 'Fotbal Club'}
/>
<FormHelperText>Název, který se zobrazí v dashboardu analytiky</FormHelperText>
</FormControl>
<FormControl>
<FormLabel>Doména</FormLabel>
<Input
value={umamiDomain}
onChange={(e) => setUmamiDomain(e.target.value)}
placeholder={window.location.hostname}
/>
<FormHelperText>Doména vašeho webu (např. fotbal.example.com)</FormHelperText>
</FormControl>
<HStack>
<Button
onClick={async () => {
if (!umamiName || !umamiDomain) {
toast({
title: 'Chyba',
description: 'Vyplňte název i doménu',
status: 'error',
});
return;
}
setUmamiInitializing(true);
try {
const response = await api.post('/admin/insights/initialize', {
name: umamiName,
domain: umamiDomain,
});
setUmamiWebsiteId(response.data.website_id);
toast({
title: 'Úspěch',
description: `Webová stránka pro analytiku vytvořena! ID: ${response.data.website_id}`,
status: 'success',
duration: 5000,
});
// Refresh config
await fetchUmamiConfig();
} catch (error: any) {
const errorMsg = error?.response?.data?.error || error.message || 'Vytvoření selhalo';
toast({
title: 'Chyba',
description: errorMsg,
status: 'error',
duration: 7000,
});
} finally {
setUmamiInitializing(false);
}
}}
isLoading={umamiInitializing}
bg="brand.primary"
color="text.onPrimary"
_hover={{ filter: 'brightness(0.95)' }}
>
Vytvořit webovou stránku
</Button>
<Button
onClick={fetchUmamiConfig}
variant="outline"
>
Aktualizovat stav
</Button>
</HStack>
<Text fontSize="xs" color="gray.500" mt={2}>
Poznámka: Pro aktivaci analytiky je nutné mít nastavené přihlašovací údaje v .env souboru.
</Text>
</VStack>
</TabPanel>
<TabPanel>
<VStack align="stretch" spacing={4}>
<Heading size="sm">SEO nastavení</Heading>
<Text fontSize="sm" color="gray.600" mb={4}>
Základní SEO nastavení pro lepší viditelnost ve vyhledávačích.
</Text>
<FormControl>
<FormLabel>Titulek webu</FormLabel>
<Input value={seo.site_title || ''} onChange={handleSeoChange('site_title')} />
<FormHelperText>Hlavní název webu, zobrazí se v záložce prohlížeče a ve výsledcích vyhledávání</FormHelperText>
</FormControl>
<FormControl>
<FormLabel>Popis webu</FormLabel>
<Textarea
value={(seo as any).site_description || ''}
onChange={(e) => setSeo((prev) => ({ ...prev, site_description: e.target.value } as any))}
rows={3}
/>
<FormHelperText>Krátký popis webu pro vyhledávače (doporučeno 150-160 znaků)</FormHelperText>
</FormControl>
<FormControl>
<FormLabel>Klíčová slova</FormLabel>
<Input
value={(seo as any).keywords || ''}
onChange={(e) => setSeo((prev) => ({ ...prev, keywords: e.target.value } as any))}
/>
<FormHelperText>Klíčová slova oddělená čárkou (např: fotbal, klub, sport)</FormHelperText>
</FormControl>
<Divider />
<Heading size="sm">Open Graph (sociální sítě)</Heading>
<FormControl>
<FormLabel>Výchozí OG obrázek (URL)</FormLabel>
<Input value={seo.default_og_image_url || ''} onChange={handleSeoChange('default_og_image_url')} />
<FormHelperText>Výchozí obrázek pro sdílení na sociálních sítích (doporučeno 1200x630px)</FormHelperText>
</FormControl>
<FormControl>
<FormLabel>OG Type</FormLabel>
<Select
value={(seo as any).og_type || 'website'}
onChange={(e) => setSeo((prev) => ({ ...prev, og_type: e.target.value } as any))}
>
<option value="website">Website</option>
<option value="article">Article</option>
<option value="profile">Profile</option>
</Select>
<FormHelperText>Typ obsahu pro Open Graph</FormHelperText>
</FormControl>
<Divider />
<Heading size="sm">Twitter Card</Heading>
<FormControl>
<FormLabel>Twitter Card Type</FormLabel>
<Select
value={(seo as any).twitter_card || 'summary_large_image'}
onChange={(e) => setSeo((prev) => ({ ...prev, twitter_card: e.target.value } as any))}
>
<option value="summary">Summary</option>
<option value="summary_large_image">Summary Large Image</option>
</Select>
<FormHelperText>Typ karty pro Twitter</FormHelperText>
</FormControl>
<FormControl>
<FormLabel>Twitter Site</FormLabel>
<Input
value={(seo as any).twitter_site || ''}
onChange={(e) => setSeo((prev) => ({ ...prev, twitter_site: e.target.value } as any))}
placeholder="@nazev_uctu"
/>
<FormHelperText>Twitter účet webu (např. @fotbalklub)</FormHelperText>
</FormControl>
<HStack>
<Button onClick={saveSEO} bg="brand.primary" color="text.onPrimary" _hover={{ filter: 'brightness(0.95)' }}>Uložit SEO</Button>
</HStack>
</VStack>
</TabPanel>
</TabPanels>
</Tabs>
<Divider my={4} />
<HStack>
<Button onClick={handleSave} isLoading={saving} bg="brand.primary" color="text.onPrimary" _hover={{ filter: 'brightness(0.95)' }}>Uložit nastavení</Button>
</HStack>
</Box>
</AdminLayout>
);
};
export default SettingsAdminPage;