This commit is contained in:
Tomáš Dvořák
2025-10-16 13:32:05 +02:00
commit 12cba639b9
663 changed files with 168914 additions and 0 deletions
+912
View File
@@ -0,0 +1,912 @@
import { useEffect, useState, useMemo, startTransition } from 'react';
import { Box, Button, FormControl, FormLabel, Input, VStack, Heading, useToast, SimpleGrid, Divider, Text, useColorModeValue, InputGroup, InputRightElement, List, ListItem, Spinner, HStack, Image, Checkbox, Tooltip, Alert, AlertIcon, Select, FormHelperText, Badge, Link } from '@chakra-ui/react';
import { InfoOutlineIcon } from '@chakra-ui/icons';
import './styles/MagazineHome.css';
import './styles/ProHome.css';
import { useNavigate } from 'react-router-dom';
import { getSetupStatus, initializeSetup, SetupInitializePayload, validateSMTP } from '../services/setup';
import { updateSeoSettings } from '../services/seo';
import { API_URL } from '../services/api';
import { assetUrl } from '../utils/url';
import { useFacrApi } from '../hooks/useFacrApi';
import { SearchResult } from '../services/facr/types';
import { extractPalette, pickTextColor, generateJwtSecret, contrastRatio, isContrastAccessible, generateThemeCandidates, ThemeCandidate, adjustForContrast } from '../utils/colors';
import { clearToken, setHasAdmin } from '../utils/auth';
import ContactMap from '../components/home/ContactMap';
import { FONT_PAIRINGS, loadGoogleFont, getFontStyleColor } from '../config/fonts';
import MapLinkImporter from '../components/admin/MapLinkImporter';
import MapStyleSelector from '../components/admin/MapStyleSelector';
import { MapCoordinates } from '../utils/mapUrlParser';
const SetupPage: React.FC = () => {
const [loading, setLoading] = useState(true);
const [submitting, setSubmitting] = useState(false);
const [requiresSetup, setRequiresSetup] = useState(false);
// form state
const [adminEmail, setAdminEmail] = useState('');
const [adminPassword, setAdminPassword] = useState('');
const [showAdminPassword, setShowAdminPassword] = useState(false);
const [firstName, setFirstName] = useState('');
const [lastName, setLastName] = useState('');
const [jwtSecret, setJwtSecret] = useState('');
const [clubId, setClubId] = useState('');
const [clubType, setClubType] = useState('football');
const [clubName, setClubName] = useState('');
const [clubLogoUrl, setClubLogoUrl] = useState('');
const [uploadingLogo, setUploadingLogo] = useState(false);
const [clubUrl, setClubUrl] = useState('');
const [clubQuery, setClubQuery] = useState('');
const { searchClubs, searchResults, searchLoading } = useFacrApi();
const resolveLogoUrl = (u?: string | null) => {
if (!u) return undefined;
// If it's a backend-relative path or dist asset, use assetUrl helper
if (u.startsWith('/uploads') || u.startsWith('/dist') || u.startsWith('/api/')) return assetUrl(u);
// If it's an absolute remote URL, route through backend proxy to avoid CORS/hotlinking issues
if (/^https?:\/\//i.test(u)) {
const base = (API_URL || '').replace(/\/$/, '');
return `${base}/proxy/image?url=${encodeURIComponent(u)}`;
}
return u;
};
// Theme colors
// Default theme colors before any logo/preset is selected
const [primaryColor, setPrimaryColor] = useState('#2d74da');
const [secondaryColor, setSecondaryColor] = useState('#f6f8fb');
const [accentColor, setAccentColor] = useState('#ffb703');
const [backgroundColor, setBackgroundColor] = useState('#ffffff');
const [textColor, setTextColor] = useState('#111827');
// Site style
const [frontpageStyle, setFrontpageStyle] = useState<'unified' | 'magazine'>('unified');
// Theme presets generated from logo
const [themePresets, setThemePresets] = useState<ThemeCandidate[]>([]);
const [selectedPreset, setSelectedPreset] = useState<number | null>(null);
// Typography/Font
const [selectedFont, setSelectedFont] = useState<string>('inter-inter');
// GPS Location
const [mapUrl, setMapUrl] = useState('');
const [gpsLat, setGpsLat] = useState<number | ''>('');
const [gpsLng, setGpsLng] = useState<number | ''>('');
const [mapStyle, setMapStyle] = useState<string>('positron');
// Contact Details
const [contactStreet, setContactStreet] = useState('');
const [contactCity, setContactCity] = useState('');
const [contactPostalCode, setContactPostalCode] = useState('');
const [contactCountry, setContactCountry] = useState('Česká republika');
const [contactPhone, setContactPhone] = useState('');
const [contactEmail, setContactEmail] = useState('');
// SMTP
const [smtpHost, setSmtpHost] = useState('');
const [smtpPort, setSmtpPort] = useState<number | ''>('');
const [smtpUser, setSmtpUser] = useState('');
const [smtpPass, setSmtpPass] = useState('');
const [showSmtpPass, setShowSmtpPass] = useState(false);
// Sender display name only; actual email is derived from smtpUser
const [smtpFromName, setSmtpFromName] = useState('');
const [smtpTLS, setSmtpTLS] = useState(true);
const [testingSMTP, setTestingSMTP] = useState(false);
// Social & gallery
const [facebookUrl, setFacebookUrl] = useState('');
const [instagramUrl, setInstagramUrl] = useState('');
const [youtubeUrl, setYoutubeUrl] = useState('');
const [galleryUrl, setGalleryUrl] = useState('');
const [galleryLabel, setGalleryLabel] = useState('Fotogalerie');
const toast = useToast();
const navigate = useNavigate();
const bg = useColorModeValue('white', 'gray.800');
const borderCol = useColorModeValue('gray.200', 'gray.600');
// Password helpers
const sanitizePassword = (val: string) => {
// remove all whitespace characters (spaces, tabs, newlines, non-breaking spaces)
return (val || '').replace(/\s+/g, '');
};
const isPasswordValid = (val: string) => {
// 8-128 chars, no whitespace
return /^\S{8,128}$/.test(val);
};
const generateStrongPassword = () => {
const chars = 'ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz23456789!@#$%^&*()-_=+[]{};:,.?';
let out = '';
const len = 16;
for (let i = 0; i < len; i++) {
out += chars.charAt(Math.floor(Math.random() * chars.length));
}
return out;
};
useEffect(() => {
let mounted = true;
// Ensure no stale auth/admin flags can interfere with setup
try {
clearToken();
setHasAdmin(false);
} catch {}
(async () => {
try {
const s = await getSetupStatus();
if (!mounted) return;
setRequiresSetup(!!s.requires_setup);
} catch (e) {
setRequiresSetup(false);
} finally {
if (mounted) setLoading(false);
}
})();
return () => { mounted = false; };
}, []);
// Auto-generate JWT secret when setup is required
useEffect(() => {
if (requiresSetup && !jwtSecret) {
setJwtSecret(generateJwtSecret());
}
}, [requiresSetup, jwtSecret]);
// Debounced search for clubs
useEffect(() => {
const q = clubQuery.trim();
if (!q) return;
const t = setTimeout(() => {
searchClubs(q).catch(() => {});
}, 300);
return () => clearTimeout(t);
}, [clubQuery, searchClubs]);
// Load selected font for preview
useEffect(() => {
const pairing = FONT_PAIRINGS.find((f) => f.id === selectedFont);
if (pairing) {
loadGoogleFont(pairing.googleFontsUrl);
}
}, [selectedFont]);
const handleSelectClub = (item: SearchResult) => {
setClubId(item.club_id || '');
setClubType(item.club_type || 'football');
setClubName(item.name || '');
setClubLogoUrl(item.logo_url || '');
setClubUrl(item.url || '');
setClubQuery(item.name || '');
// Auto-fill sender display name from club name if empty
if (!smtpFromName && item.name) {
setSmtpFromName(item.name);
}
// Update the document title immediately for instant feedback
try {
if (typeof document !== 'undefined' && item.name) {
document.title = item.name;
}
} catch {}
// Try to extract colors
if (item.logo_url) {
extractPalette(item.logo_url, 5)
.then((colors) => {
if (!colors || colors.length === 0) return;
const presets = generateThemeCandidates(colors);
setThemePresets(presets);
// Apply first preset by default
const p0 = presets[0];
if (p0) {
setPrimaryColor(p0.primary);
setSecondaryColor(p0.secondary);
setAccentColor(p0.accent);
setBackgroundColor(p0.background);
setTextColor(p0.text);
setSelectedPreset(0);
}
})
.catch(() => {});
}
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setSubmitting(true);
try {
// Validate and sanitize password
const cleanPassword = sanitizePassword(adminPassword);
if (!isPasswordValid(cleanPassword)) {
toast({ title: 'Neplatné heslo', description: 'Heslo musí mít 8128 znaků a nesmí obsahovat mezery.', status: 'error' });
setSubmitting(false);
return;
}
// Compose SMTP From as "Name <username>" if both provided
const composedFrom = smtpFromName && smtpUser
? `${smtpFromName} <${smtpUser}>`
: (smtpUser || undefined);
const payload: SetupInitializePayload = {
admin_email: adminEmail,
admin_password: cleanPassword,
first_name: firstName || undefined,
last_name: lastName || undefined,
jwt_secret: jwtSecret || undefined,
club_id: clubId || undefined,
club_type: clubType || undefined,
club_name: clubName || undefined,
club_logo_url: clubLogoUrl || undefined,
club_url: clubUrl || undefined,
frontpage_style: frontpageStyle || undefined,
primary_color: primaryColor || undefined,
secondary_color: secondaryColor || undefined,
accent_color: accentColor || undefined,
background_color: backgroundColor || undefined,
text_color: textColor || undefined,
// typography (optional)
font_heading: FONT_PAIRINGS.find(f => f.id === selectedFont)?.heading || undefined,
font_body: FONT_PAIRINGS.find(f => f.id === selectedFont)?.body || undefined,
// social & gallery (optional)
facebook_url: facebookUrl || undefined,
instagram_url: instagramUrl || undefined,
youtube_url: youtubeUrl || undefined,
gallery_url: galleryUrl || undefined,
gallery_label: galleryLabel || undefined,
// GPS location (optional)
location_latitude: typeof gpsLat === 'number' ? gpsLat : undefined,
location_longitude: typeof gpsLng === 'number' ? gpsLng : undefined,
map_style: mapStyle || undefined,
// Contact details (optional)
contact_address: contactStreet || undefined,
contact_city: contactCity || undefined,
contact_zip: contactPostalCode || undefined,
contact_country: contactCountry || undefined,
contact_phone: contactPhone || undefined,
contact_email: contactEmail || undefined,
smtp: (smtpHost || smtpPort || smtpUser || smtpPass || smtpFromName) ? {
host: smtpHost || undefined,
port: typeof smtpPort === 'number' ? smtpPort : undefined,
username: smtpUser || undefined,
password: smtpPass || undefined,
from: composedFrom,
use_tls: smtpTLS,
} : null,
};
await initializeSetup(payload);
// Set sensible default SEO based on setup data
try {
const origin = (typeof window !== 'undefined' ? window.location.origin : '').replace(/\/$/, '');
await updateSeoSettings({
site_title: clubName || 'Fotbal Club',
site_description: clubName ? `${clubName} oficiální klubový web: aktuality, zápasy, tabulky, hráči.` : 'Oficiální klubový web: aktuality, zápasy, tabulky, hráči.',
default_og_image_url: clubLogoUrl || undefined,
canonical_base_url: origin || undefined,
enable_indexing: true,
});
} catch {}
toast({ title: 'Nastavení dokončeno', status: 'success', duration: 3000, isClosable: true });
navigate('/login', { replace: true });
// Force full reload to ensure app picks up fresh server state and env
setTimeout(() => {
try { window.location.reload(); } catch {}
}, 800);
} catch (error: any) {
toast({ title: 'Nastavení selhalo', description: error?.response?.data?.error || 'Zkontrolujte prosím své údaje', status: 'error' });
} finally {
setSubmitting(false);
}
};
const onLogoFile = async (f?: File | null) => {
if (!f) return;
setUploadingLogo(true);
try {
// Use a dedicated form so we can pass preserve_quality to backend
const fd = new FormData();
fd.append('file', f);
fd.append('preserve_quality', 'true');
// Upload should go to the API root (usually /api/v1/upload). Use configured API_URL
const uploadUrl = `${(API_URL || '').replace(/\/$/, '')}/upload`;
const res = await fetch(uploadUrl, { method: 'POST', body: fd });
if (!res.ok) throw new Error('Upload failed');
const data = await res.json();
let url = data?.url || '';
try {
const parsed = new URL(url, window.location.origin);
url = parsed.pathname + parsed.search + parsed.hash;
} catch {}
setClubLogoUrl(url);
// Try to extract colors from uploaded logo
try { const colors = await extractPalette(url, 5); const presets = generateThemeCandidates(colors); setThemePresets(presets); if (presets[0]) { setPrimaryColor(presets[0].primary); setSecondaryColor(presets[0].secondary); setAccentColor(presets[0].accent); setBackgroundColor(presets[0].background); setTextColor(presets[0].text); setSelectedPreset(0); } } catch {}
} catch (e) {
toast({ title: 'Nahrání loga selhalo', status: 'error' });
} finally {
setUploadingLogo(false);
}
};
// Derived: contrast checks (memoized for perf)
const bgTextContrast = useMemo(() => {
if (!backgroundColor || !textColor) return null;
return contrastRatio(backgroundColor, textColor);
}, [backgroundColor, textColor]);
const primaryTextContrast = useMemo(() => {
if (!primaryColor || !textColor) return null;
return contrastRatio(primaryColor, textColor);
}, [primaryColor, textColor]);
const isBgTextAccessible = useMemo(() => {
if (bgTextContrast == null) return true;
return isContrastAccessible(backgroundColor, textColor, 'AA');
}, [bgTextContrast, backgroundColor, textColor]);
const autofixTextForBg = () => {
if (!backgroundColor) return;
const fixed = pickTextColor(backgroundColor);
setTextColor(fixed);
};
// Re-generate presets manually when user clicks button (from current logo URL)
const regenerateFromLogo = async () => {
const url = clubLogoUrl?.trim();
if (!url) return;
try {
const colors = await extractPalette(url, 5);
const presets = generateThemeCandidates(colors);
setThemePresets(presets);
if (presets[0]) {
setPrimaryColor(presets[0].primary);
setSecondaryColor(presets[0].secondary);
setAccentColor(presets[0].accent);
setBackgroundColor(presets[0].background);
setTextColor(presets[0].text);
setSelectedPreset(0);
}
} catch {}
};
const applyPreset = (idx: number) => {
const p = themePresets[idx];
if (!p) return;
setPrimaryColor(p.primary);
setSecondaryColor(p.secondary);
setAccentColor(p.accent);
setBackgroundColor(p.background);
setTextColor(p.text);
setSelectedPreset(idx);
};
if (loading) return <Box p={8}>Načítání</Box>;
if (!requiresSetup) {
navigate('/login', { replace: true });
return null;
}
return (
<Box minH="100vh" bg="gray.50" display="flex" alignItems="center" justifyContent="center" px={8} py={8}>
<Box as="form" onSubmit={handleSubmit} w="100%" maxW="3xl" p={8} bg={bg} borderRadius="xl" boxShadow="lg" borderWidth="1px" borderColor={borderCol}>
<VStack spacing={3} mb={6} align="stretch">
<Heading size="xl">🚀 Vítejte v nastavení vašeho webu!</Heading>
<Text fontSize="md" color="gray.600">
Nastavte základní informace o vašem klubu. Můžete vše vyplnit nyní, nebo některé údaje doplnit později v administraci.
</Text>
<Alert status="info" borderRadius="md">
<AlertIcon />
<Box>
<Text fontSize="sm" fontWeight="medium">💡 Tip: Vyhledejte váš klub v databázi FAČR</Text>
<Text fontSize="sm">Logo, barvy a základní údaje se doplní automaticky.</Text>
</Box>
</Alert>
</VStack>
<SimpleGrid columns={[1, 1, 2]} spacing={6}>
<Box>
<Heading as="h3" size="md" mb={4}>🔐 Administrátorský účet</Heading>
<VStack align="stretch" spacing={4}>
<FormControl isRequired>
<FormLabel>Email administrátora</FormLabel>
<Input type="email" value={adminEmail} onChange={(e) => setAdminEmail(e.target.value)} placeholder="admin@example.com" />
</FormControl>
<FormControl isRequired>
<FormLabel>Heslo administrátora</FormLabel>
<InputGroup>
<Input
type={showAdminPassword ? 'text' : 'password'}
value={adminPassword}
onChange={(e) => setAdminPassword(sanitizePassword(e.target.value))}
placeholder="Minimálně 8 znaků, bez mezer"
minLength={8}
/>
<InputRightElement width="4.5rem">
<Button h="1.75rem" size="sm" onClick={() => setShowAdminPassword((s) => !s)}>{showAdminPassword ? 'Skrýt' : 'Zobrazit'}</Button>
</InputRightElement>
</InputGroup>
<FormHelperText>Bez mezer. 8128 znaků. Použijte písmena, číslice a speciální znaky.</FormHelperText>
<HStack mt={2} spacing={2}>
<Button size="sm" variant="outline" onClick={() => setAdminPassword(generateStrongPassword())}>Vygenerovat silné heslo</Button>
<Button size="sm" variant="ghost" onClick={() => setAdminPassword('')}>Vymazat</Button>
</HStack>
</FormControl>
<FormControl>
<FormLabel>Jméno</FormLabel>
<Input value={firstName} onChange={(e) => setFirstName(e.target.value)} />
</FormControl>
<FormControl>
<FormLabel>Příjmení</FormLabel>
<Input value={lastName} onChange={(e) => setLastName(e.target.value)} />
</FormControl>
</VStack>
</Box>
<Box>
<Heading as="h3" size="md" mb={4}> Informace o klubu</Heading>
<VStack align="stretch" spacing={4}>
<FormControl>
<FormLabel>Hledat klub (FAČR)</FormLabel>
<InputGroup>
<Input value={clubQuery} onChange={(e) => setClubQuery(e.target.value)} placeholder="Hledejte podle názvu klubu" />
<InputRightElement>
{searchLoading ? <Spinner size="sm" /> : null}
</InputRightElement>
</InputGroup>
{clubQuery && searchResults?.length > 0 && (
<Box mt={2} borderWidth="1px" borderRadius="md" maxH="240px" overflowY="auto">
<List spacing={0}>
{searchResults.slice(0, 8).map((r) => (
<ListItem
key={`${r.club_type}-${r.club_id}`}
px={3} py={2} _hover={{ bg: 'gray.50', cursor: 'pointer' }}
onClick={() => handleSelectClub(r)}
>
<HStack spacing={3}>
{r.logo_url ? <Image src={resolveLogoUrl(r.logo_url)} alt={r.name} boxSize="24px" objectFit="contain" /> : null}
<Box>
<Text fontWeight="medium">{r.name}</Text>
<Text fontSize="sm" color="gray.500">{r.club_type}</Text>
</Box>
</HStack>
</ListItem>
))}
</List>
</Box>
)}
</FormControl>
<FormControl>
<FormLabel>Typ klubu</FormLabel>
<Input value={clubType} onChange={(e) => setClubType(e.target.value)} placeholder="football nebo futsal" />
</FormControl>
<FormControl>
<FormLabel>ID klubu</FormLabel>
<Input value={clubId} onChange={(e) => setClubId(e.target.value)} placeholder="FAČR ID klubu" />
</FormControl>
<FormControl>
<FormLabel>Název klubu</FormLabel>
<Input value={clubName} onChange={(e) => setClubName(e.target.value)} />
</FormControl>
<FormControl>
<FormLabel>URL loga klubu</FormLabel>
<Input value={clubLogoUrl} onChange={(e) => setClubLogoUrl(e.target.value)} />
<HStack mt={2} spacing={3}>
<Button as="label" size="sm" leftIcon={<InfoOutlineIcon />}>
Nahrát logo
<Input
type="file"
display="none"
accept="image/*,image/svg+xml,application/pdf"
onChange={(e) => {
const f = e.target.files?.[0] || null;
// Process selected file
onLogoFile(f);
// Important: reset input value so selecting the same file again fires onChange
// This fixes the bug where uploading the same logo twice did nothing
try { (e.target as HTMLInputElement).value = ''; } catch {}
}}
/>
</Button>
{uploadingLogo ? <Text fontSize="sm">Nahrávám</Text> : null}
</HStack>
</FormControl>
<FormControl>
<FormLabel>URL klubu</FormLabel>
<Input value={clubUrl} onChange={(e) => setClubUrl(e.target.value)} />
</FormControl>
<Box>
<Text mb={2}>Náhled loga</Text>
<Image src={resolveLogoUrl(clubLogoUrl) || (assetUrl('/dist/img/logo-club-empty.svg') as string)} alt="Logo preview" maxH="80px" objectFit="contain" />
</Box>
</VStack>
</Box>
</SimpleGrid>
{/* removed overall style preview per request */}
<Divider my={6} />
<Heading as="h3" size="md" mb={2}>📱 Sociální sítě a fotogalerie</Heading>
<Text fontSize="sm" mb={3} color="gray.600">Zadejte odkazy na profily klubu a volitelně na fotogalerii. Lze později upravit v administraci.</Text>
<SimpleGrid columns={[1, 1, 2]} spacing={6} mb={2}>
<FormControl>
<FormLabel>Facebook URL</FormLabel>
<Input placeholder="https://www.facebook.com/vas.klub" value={facebookUrl} onChange={(e) => setFacebookUrl(e.target.value)} />
</FormControl>
<FormControl>
<FormLabel>Instagram URL</FormLabel>
<Input placeholder="https://www.instagram.com/vas.klub" value={instagramUrl} onChange={(e) => setInstagramUrl(e.target.value)} />
</FormControl>
<FormControl>
<FormLabel>YouTube URL</FormLabel>
<Input placeholder="https://www.youtube.com/@vas_klub" value={youtubeUrl} onChange={(e) => setYoutubeUrl(e.target.value)} />
</FormControl>
<FormControl>
<FormLabel>URL fotogalerie</FormLabel>
<Input placeholder="https://photos.example.com/club" value={galleryUrl} onChange={(e) => setGalleryUrl(e.target.value)} />
<FormHelperText>Můžete použít libovolný web (SmugMug, Flickr, Google Photos, Zonerama...).</FormHelperText>
</FormControl>
<FormControl>
<FormLabel>Popisek odkazu fotogalerie</FormLabel>
<Input placeholder="Fotogalerie" value={galleryLabel} onChange={(e) => setGalleryLabel(e.target.value)} />
</FormControl>
</SimpleGrid>
<Heading as="h3" size="md" mb={2}>🎨 Barvy a vzhled webu</Heading>
<Text fontSize="sm" mb={3} color="gray.600">Automaticky z loga (lze upravit). Vyberte jednu z předloh nebo barvy ručně dolaďte.</Text>
{/* Preset selector */}
{themePresets.length > 0 && (
<Box mb={4}>
<Text fontWeight="semibold" mb={2}>Předlohy z loga</Text>
<SimpleGrid columns={{ base: 1, md: 5 }} spacing={3}>
{themePresets.map((p, i) => (
<Box key={p.name + i} borderWidth={selectedPreset === i ? '2px' : '1px'} borderColor={selectedPreset === i ? 'blue.400' : 'gray.200'} borderRadius="md" p={3} cursor="pointer" onClick={() => applyPreset(i)}>
<Box mb={2} display="flex" gap="6px" alignItems="center">
<Box flex="1" h="48px" borderRadius="6px" bg={p.primary} display="flex" alignItems="center" justifyContent="center" color={p.text}>{/* primary */}</Box>
<Box w="48px" h="48px" borderRadius="6px" bg={p.secondary} />
<Box w="48px" h="48px" borderRadius="6px" bg={p.accent} />
</Box>
<Text fontSize="sm" fontWeight="semibold">{p.name}</Text>
<Text fontSize="xs" color="gray.500">Primární / Sekundární / Akcent</Text>
</Box>
))}
</SimpleGrid>
<Button mt={3} variant="ghost" onClick={regenerateFromLogo}>Znovu z loga</Button>
</Box>
)}
<SimpleGrid columns={[1, 1, 3]} spacing={6}>
<FormControl>
<FormLabel>Styl webu</FormLabel>
<Select value={frontpageStyle} onChange={(e) => setFrontpageStyle((e.target.value as any) || 'unified')}>
<option value="unified">Aktuální (Unified)</option>
<option value="magazine">Nový (Magazine)</option>
<option value="pro">Pro (Hero fullscreen)</option>
<option value="edge">Edge (Fullwidth minimal)</option>
</Select>
<FormHelperText>Zvolte výchozí vzhled. Lze později změnit v administraci.</FormHelperText>
</FormControl>
<FormControl>
<FormLabel>Primární
<Tooltip label="Hlavní barva značky (tlačítka, odkazy, zvýraznění)." hasArrow><InfoOutlineIcon ml={2} /></Tooltip>
</FormLabel>
<Input type="color" value={primaryColor} onChange={(e) => startTransition(() => setPrimaryColor(e.target.value))} />
<Text fontSize="xs" color="gray.500" mt={1}>Používá se na hlavních prvcích.</Text>
</FormControl>
<FormControl>
<FormLabel>Sekundární
<Tooltip label="Doplňková barva pro méně důležité prvky a plochy." hasArrow><InfoOutlineIcon ml={2} /></Tooltip>
</FormLabel>
<Input type="color" value={secondaryColor} onChange={(e) => startTransition(() => setSecondaryColor(e.target.value))} />
<Text fontSize="xs" color="gray.500" mt={1}>Podpůrné zvýraznění.</Text>
</FormControl>
<FormControl>
<FormLabel>Akcent
<Tooltip label="Kontrastní barva pro odznaky, štítky a malé akce." hasArrow><InfoOutlineIcon ml={2} /></Tooltip>
</FormLabel>
<Input type="color" value={accentColor} onChange={(e) => startTransition(() => setAccentColor(e.target.value))} />
<Text fontSize="xs" color="gray.500" mt={1}>Menší prvky a upozornění.</Text>
</FormControl>
<FormControl>
<FormLabel>Pozadí</FormLabel>
<Input type="color" value={backgroundColor} onChange={(e) => startTransition(() => setBackgroundColor(e.target.value))} />
<Text fontSize="xs" color="gray.500" mt={1}>Barva ploch stránky.</Text>
</FormControl>
<FormControl>
<FormLabel>Text</FormLabel>
<Input type="color" value={textColor} onChange={(e) => startTransition(() => setTextColor(e.target.value))} />
<Text fontSize="xs" color="gray.500" mt={1}>Základní barva textu.</Text>
</FormControl>
</SimpleGrid>
{/* Contrast warnings */}
{!isBgTextAccessible && (
<Alert status="warning" mt={4} borderRadius="md">
<AlertIcon />
Slabý kontrast textu vůči pozadí (poměr {bgTextContrast?.toFixed(2)}). Pro čitelnost upravte barvy nebo
<Button variant="link" colorScheme="blue" ml={2} onClick={autofixTextForBg}>automaticky opravit barvu textu</Button>.
</Alert>
)}
{primaryTextContrast !== null && primaryTextContrast < 4.5 && (
<Alert status="warning" mt={3} borderRadius="md">
<AlertIcon />
Text na primární barvě nízký kontrast (poměr {primaryTextContrast.toFixed(2)}). Zvažte jinou barvu textu nebo primární barvy.
</Alert>
)}
<Divider my={6} />
<Heading as="h3" size="md" mb={2}> Písmo a typografie</Heading>
<Text fontSize="sm" mb={3} color="gray.600">Vyberte vzhled písma pro váš web. Můžete kdykoliv změnit v administraci.</Text>
<Box mb={4}>
<SimpleGrid columns={{ base: 1, md: 3 }} spacing={3}>
{FONT_PAIRINGS.map((font) => (
<Box
key={font.id}
borderWidth={selectedFont === font.id ? '2px' : '1px'}
borderColor={selectedFont === font.id ? 'blue.400' : 'gray.200'}
borderRadius="md"
p={3}
cursor="pointer"
onClick={() => setSelectedFont(font.id)}
>
<VStack align="stretch" spacing={2}>
<HStack justify="space-between">
<Text fontSize="sm" fontWeight="medium">{font.name}</Text>
<Badge colorScheme={getFontStyleColor(font.style)}>{font.style}</Badge>
</HStack>
<Text fontFamily={font.cssHeading} fontSize="lg" fontWeight="bold">Nadpis</Text>
<Text fontFamily={font.cssBody} fontSize="sm">Text běžného odstavce</Text>
</VStack>
</Box>
))}
</SimpleGrid>
<Text fontSize="xs" color="gray.500" mt={2}>Zobrazeno {FONT_PAIRINGS.length} dostupných stylů písma.</Text>
</Box>
<Divider my={6} />
<Heading as="h3" size="md" mb={2}>📍 GPS poloha a mapa</Heading>
<Text fontSize="sm" mb={4} color="gray.600">Nastavte polohu vašeho stadionu. Můžete vložit odkaz z mapy, nebo zadat souřadnice ručně.</Text>
<Box mb={4}>
<MapLinkImporter
currentLatitude={typeof gpsLat === 'number' ? gpsLat : undefined}
currentLongitude={typeof gpsLng === 'number' ? gpsLng : undefined}
currentZoom={15}
mapStyle={mapStyle}
onMapStyleChange={setMapStyle}
clubPrimaryColor={primaryColor}
clubSecondaryColor={accentColor}
clubName={clubName || 'Váš klub'}
onImport={(coords: MapCoordinates) => {
setGpsLat(coords.latitude);
setGpsLng(coords.longitude);
// Auto-fill address fields if available from geocoding
if (coords.street) setContactStreet(coords.street);
if (coords.city) setContactCity(coords.city);
if (coords.zip) setContactPostalCode(coords.zip);
if (coords.country) setContactCountry(coords.country);
toast({
title: 'Poloha importována',
description: coords.city ? `GPS souřadnice a adresa (${coords.city}) načteny` : 'GPS souřadnice načteny',
status: 'success',
duration: 3000,
});
}}
/>
</Box>
<Divider my={6} />
<Heading as="h3" size="md" mb={2}>🎨 Styl mapy</Heading>
<Text fontSize="sm" mb={4} color="gray.600">Vyberte vzhled mapy, který nejlépe pasuje k barvám vašeho klubu.</Text>
<Box mb={4}>
<MapStyleSelector
value={mapStyle}
onChange={setMapStyle}
clubPrimaryColor={primaryColor}
clubSecondaryColor={accentColor}
showPreview={true}
/>
</Box>
<Divider my={6} />
<Heading as="h3" size="md" mb={2}>📧 Kontaktní údaje</Heading>
<Text fontSize="sm" mb={3} color="gray.600">Tyto údaje se automaticky vyplní při importu z mapy. Můžete je upravit nebo doplnit ručně.</Text>
<SimpleGrid columns={[1, 1, 2]} spacing={4} mb={4}>
<FormControl>
<FormLabel>Adresa (ulice a číslo)</FormLabel>
<Input placeholder="Hlavní 123" value={contactStreet} onChange={(e) => setContactStreet(e.target.value)} />
</FormControl>
<FormControl>
<FormLabel>Město</FormLabel>
<Input placeholder="Krnov" value={contactCity} onChange={(e) => setContactCity(e.target.value)} />
</FormControl>
<FormControl>
<FormLabel>PSČ</FormLabel>
<Input placeholder="794 01" value={contactPostalCode} onChange={(e) => setContactPostalCode(e.target.value)} />
</FormControl>
<FormControl>
<FormLabel>Země</FormLabel>
<Input placeholder="Česká republika" value={contactCountry} onChange={(e) => setContactCountry(e.target.value)} />
</FormControl>
<FormControl>
<FormLabel>Telefon</FormLabel>
<Input type="tel" placeholder="+420 123 456 789" value={contactPhone} onChange={(e) => setContactPhone(e.target.value)} />
<FormHelperText>Hlavní kontaktní telefon klubu</FormHelperText>
</FormControl>
<FormControl>
<FormLabel>E-mail</FormLabel>
<Input type="email" placeholder="kontakt@klub.cz" value={contactEmail} onChange={(e) => setContactEmail(e.target.value)} />
<FormHelperText>Hlavní kontaktní e-mail klubu</FormHelperText>
</FormControl>
</SimpleGrid>
<Divider my={6} />
<Heading as="h3" size="md" mb={4}>🔒 Zabezpečení a SMTP</Heading>
<SimpleGrid columns={[1, 1, 2]} spacing={6}>
<FormControl>
<FormLabel>JWT tajemství</FormLabel>
<Input value={jwtSecret} onChange={(e) => setJwtSecret(e.target.value)} placeholder="Ponechte prázdné pro stávající hodnotu" />
<Button mt={2} size="sm" onClick={() => setJwtSecret(generateJwtSecret())}>Vygenerovat bezpečné tajemství</Button>
</FormControl>
<Box>
<FormControl mb={3}>
<FormLabel>SMTP hostitel</FormLabel>
<Input value={smtpHost} onChange={(e) => setSmtpHost(e.target.value)} placeholder="smtp.example.com" />
</FormControl>
<FormControl mb={3}>
<FormLabel>SMTP port</FormLabel>
<Input type="number" value={smtpPort} onChange={(e) => setSmtpPort(e.target.value ? Number(e.target.value) : '')} placeholder="587" />
</FormControl>
<FormControl mb={3}>
<FormLabel>SMTP uživatelské jméno</FormLabel>
<Input value={smtpUser} onChange={(e) => setSmtpUser(e.target.value)} />
</FormControl>
<FormControl mb={3}>
<FormLabel>SMTP heslo</FormLabel>
<InputGroup>
<Input type={showSmtpPass ? 'text' : 'password'} value={smtpPass} onChange={(e) => setSmtpPass(e.target.value)} />
<InputRightElement width="4.5rem">
<Button h="1.75rem" size="sm" onClick={() => setShowSmtpPass((s) => !s)}>{showSmtpPass ? 'Skrýt' : 'Zobrazit'}</Button>
</InputRightElement>
</InputGroup>
</FormControl>
<FormControl mb={3}>
<FormLabel>Jméno odesílatele</FormLabel>
<Input value={smtpFromName} onChange={(e) => setSmtpFromName(e.target.value)} placeholder="Název klubu (např. Fotbal Club)" />
<FormHelperText>Jako adresu použijeme váš SMTP username (email). Zde vyplňte pouze zobrazované jméno.</FormHelperText>
</FormControl>
<Checkbox isChecked={smtpTLS} onChange={(e) => setSmtpTLS(e.target.checked)}>Použít TLS</Checkbox>
<HStack mt={3} spacing={3}>
<Button
size="sm"
onClick={async () => {
const host = smtpHost.trim();
const port = typeof smtpPort === 'number' ? smtpPort : 0;
if (!host || !port) {
toast({ title: 'Zadejte SMTP host a port', status: 'warning' });
return;
}
setTestingSMTP(true);
try {
// Compose From as "Name <username>" for validation, if possible
const composedFrom = smtpFromName && smtpUser ? `${smtpFromName} <${smtpUser}>` : (smtpFromName || undefined);
const res = await validateSMTP({
host,
port,
username: smtpUser || undefined,
password: smtpPass || undefined,
from: composedFrom,
use_tls: smtpTLS,
});
if (res.ok) {
toast({ title: 'SMTP ověřeno', description: 'Připojení a případná autentizace proběhly v pořádku.', status: 'success' });
} else {
toast({ title: 'SMTP selhalo', description: res.error || 'Zkontrolujte prosím údaje (host, port, šifrování, jméno/heslo).', status: 'error', duration: 7000 });
}
} catch (e: any) {
const msg = e?.response?.data?.error || e?.message || 'Neznámá chyba';
toast({ title: 'SMTP selhalo', description: msg, status: 'error', duration: 7000 });
} finally {
setTestingSMTP(false);
}
}}
isLoading={testingSMTP}
>Otestovat SMTP</Button>
<Tooltip label="Pro port 465 použijte implicitní SSL (ponechte zaškrtnuté). Pro 587 obvykle STARTTLS (také zaškrtnout).">
<InfoOutlineIcon />
</Tooltip>
</HStack>
<Alert status="info" mt={4} borderRadius="md">
<AlertIcon />
<Box>
<Text fontWeight="medium">Před dokončením nastavení otestujte SMTP připojení.</Text>
<Text fontSize="sm" mt={1}>Nejčastější chyba: nesprávné heslo. Ujistěte se, že heslo je zkopírováno přesně bez mezer.</Text>
</Box>
</Alert>
</Box>
</SimpleGrid>
<Button type="submit" colorScheme="blue" mt={8} isLoading={submitting} loadingText="Ukládám…">Dokončit nastavení</Button>
</Box>
</Box>
);
};
// Lightweight preview of overall site style during setup
const SetupStylePreview: React.FC<{ styleKey: 'unified' | 'magazine' | 'pro' | 'edge' }> = ({ styleKey }) => {
// Try to render a screenshot if present in /dist/img
const [imgError, setImgError] = useState(false);
const fileMap: Record<string, string> = {
unified: 'style-unified.png',
magazine: 'style-magazine.png',
pro: 'style-pro.png',
edge: 'style-edge.png',
};
const imgSrc = assetUrl(`/dist/img/${fileMap[styleKey] || fileMap.unified}`);
if (!imgError && imgSrc) {
return (
<Box borderWidth="1px" borderRadius="md" overflow="hidden">
<Image src={imgSrc} alt={`Náhled stylu: ${styleKey}`} onError={() => setImgError(true)} width="100%" objectFit="cover" />
</Box>
);
}
if (styleKey === 'magazine') {
return (
<Box className="magazine" borderRadius="md" overflow="hidden" borderWidth="1px" borderColor="gray.200">
<Box className="mag-bars" />
<Box p={3}>
<HStack justify="space-between">
<Text fontWeight="bold">Magazínová hlavička</Text>
<Text fontSize="sm" color="gray.500">2 barvy klubu</Text>
</HStack>
<SimpleGrid columns={{ base: 1, md: 3 }} spacing={2} mt={3}>
<Box h="100px" bg="gray.100" borderRadius="md" />
<Box h="48px" bg="gray.100" borderRadius="md" />
<Box h="48px" bg="gray.100" borderRadius="md" />
</SimpleGrid>
</Box>
</Box>
);
}
if (styleKey === 'pro') {
return (
<Box className="pro" borderRadius="md" overflow="hidden" borderWidth="1px" borderColor="gray.200">
<Box className="pro-hero" height="120px" position="relative">
<Box position="absolute" inset={0} bg="gray.300" />
<Box position="absolute" inset={0} display="flex" alignItems="center" justifyContent="center">
<Text fontWeight="bold" color="white">Fullscreen Hero (náhled)</Text>
</Box>
</Box>
<Box p={3}>
<SimpleGrid columns={{ base: 1, md: 3 }} spacing={3}>
<Box borderWidth="1px" borderRadius="md" p={3}>Zápasy</Box>
<Box borderWidth="1px" borderRadius="md" p={3}>Aktuality</Box>
<Box borderWidth="1px" borderRadius="md" p={3}>Vstupenka</Box>
</SimpleGrid>
</Box>
</Box>
);
}
if (styleKey === 'edge') {
return (
<Box borderRadius="md" overflow="hidden" borderWidth="1px" borderColor="gray.200" p={3}>
<Text fontWeight="bold" mb={2}>Edge: Fullwidth minimal</Text>
<Box h="80px" bg="gray.200" borderRadius="md" mb={2} />
<SimpleGrid columns={{ base: 1, md: 2 }} spacing={2}>
<Box h="48px" bg="gray.100" borderRadius="md" />
<Box h="48px" bg="gray.100" borderRadius="md" />
</SimpleGrid>
</Box>
);
}
return (
<Box borderWidth="1px" borderRadius="md" p={3}>
<Text fontSize="sm" color="gray.600">Stávající styl (Unified): Grid hero, sekce Nejbližší zápas, tři sloupce a sponzoři.</Text>
</Box>
);
};
export default SetupPage;