This commit is contained in:
Tomas Dvorak
2025-10-24 14:52:46 +02:00
parent 70ea0c3c91
commit 8a7c292e54
41 changed files with 912 additions and 404 deletions
+9 -2
View File
@@ -287,11 +287,18 @@ const App: React.FC = () => {
}
if (isAuthenticated) {
const role = user?.role;
const role = String(user?.role || '').toLowerCase();
if (role === 'admin') {
return <Navigate to="/admin" replace />;
}
if (role === 'editor') {
return <Navigate to="/admin" replace />;
}
if (role === 'fan') {
return <Navigate to="/semiadmin" replace />;
}
return <Navigate to="/admin" replace />;
// Default: regular users to frontpage
return <Navigate to="/" replace />;
}
// If setup is required, redirect to setup wizard unless already on setup
+49 -5
View File
@@ -79,10 +79,11 @@ const normalizeSocialUrl = (network: 'facebook' | 'instagram' | 'youtube', raw?:
};
// Mobile menu component
const MobileMenu = ({ isOpen, onClose, isAdmin, menuBg, dividerColor, settings, categories, galleryHref, galleryLabel, hasTables, hasActivities, hasPlayers, hasArticles, hasVideos, hasGallery, dynamicNavItems, navLoading }: {
const MobileMenu = ({ isOpen, onClose, isAdmin, isAuthenticated, menuBg, dividerColor, settings, categories, galleryHref, galleryLabel, hasTables, hasActivities, hasPlayers, hasArticles, hasVideos, hasGallery, dynamicNavItems, navLoading }: {
isOpen: boolean;
onClose: () => void;
isAdmin: boolean;
isAuthenticated: boolean;
menuBg: string;
dividerColor: string;
settings?: any;
@@ -232,6 +233,18 @@ const MobileMenu = ({ isOpen, onClose, isAdmin, menuBg, dividerColor, settings,
</Button>
</>
)}
{!isAuthenticated && (
<>
<Divider my={2} borderColor={dividerColor} />
<Button as={RouterLink} to="/login" colorScheme="blue" justifyContent="flex-start">
Přihlásit se
</Button>
<Button as={RouterLink} to="/register" variant="outline" justifyContent="flex-start">
Registrovat se
</Button>
</>
)}
</VStack>
</DrawerBody>
</DrawerContent>
@@ -662,7 +675,7 @@ const Navbar: React.FC<{ fullWidth?: boolean }> = ({ fullWidth = false }) => {
boxShadow={scrolled ? 'sm' : 'none'}
transition="box-shadow 0.2s ease, background-color 0.2s ease, backdrop-filter 0.2s ease"
>
<MobileMenu isOpen={isOpen} onClose={onClose} isAdmin={isAdmin} menuBg={menuBg} dividerColor={dividerColor} settings={settings} categories={navCategories} galleryHref={galleryHref} galleryLabel={galleryLabel} hasTables={hasTables} hasActivities={hasActivities} hasPlayers={hasPlayers} hasArticles={hasArticles} hasVideos={hasVideos} hasGallery={hasGallery} dynamicNavItems={dynamicNavItems} navLoading={navLoading} />
<MobileMenu isOpen={isOpen} onClose={onClose} isAdmin={isAdmin} isAuthenticated={isAuthenticated} menuBg={menuBg} dividerColor={dividerColor} settings={settings} categories={navCategories} galleryHref={galleryHref} galleryLabel={galleryLabel} hasTables={hasTables} hasActivities={hasActivities} hasPlayers={hasPlayers} hasArticles={hasArticles} hasVideos={hasVideos} hasGallery={hasGallery} dynamicNavItems={dynamicNavItems} navLoading={navLoading} />
<Container maxW={containerMaxW}>
<Flex h={16} alignItems="center" justifyContent="space-between">
<HStack spacing={4} alignItems="center">
@@ -774,6 +787,33 @@ const Navbar: React.FC<{ fullWidth?: boolean }> = ({ fullWidth = false }) => {
icon={colorMode === 'light' ? <MoonIcon /> : <SunIcon />}
/>
{/* Auth buttons (desktop) */}
{!isAuthenticated && (
<>
<Button
as={RouterLink}
to="/register"
size="sm"
variant="outline"
display={{ base: 'none', md: 'inline-flex' }}
ml={2}
mr={2}
>
Registrovat se
</Button>
<Button
as={RouterLink}
to="/login"
size="sm"
colorScheme="blue"
display={{ base: 'none', md: 'inline-flex' }}
mr={2}
>
Přihlásit se
</Button>
</>
)}
{isAuthenticated && (
<Menu>
<MenuButton
@@ -845,6 +885,10 @@ const Navbar: React.FC<{ fullWidth?: boolean }> = ({ fullWidth = false }) => {
// HoverMenu component for desktop dropdown nav
const HoverMenu = ({ label, items, isActive }: { label: string; items: { label: string; to: string }[]; isActive?: boolean }) => {
const { isOpen, onOpen, onClose } = useDisclosure();
const menuColorActive = useColorModeValue('brand.primary', 'brand.accent');
const menuColorInactive = useColorModeValue('gray.700', 'gray.200');
const menuBgActive = useColorModeValue('blackAlpha.50', 'whiteAlpha.100');
const menuHoverBg = useColorModeValue('blackAlpha.100', 'whiteAlpha.200');
return (
<Box onMouseEnter={onOpen} onMouseLeave={onClose}>
<Menu isOpen={isOpen} placement="bottom-start" gutter={4}>
@@ -855,9 +899,9 @@ const HoverMenu = ({ label, items, isActive }: { label: string; items: { label:
size="sm"
px={3}
fontWeight={isActive ? '700' : '600'}
color={useColorModeValue(isActive ? 'brand.primary' : 'gray.700', isActive ? 'brand.accent' : 'gray.200')}
bg={isActive ? useColorModeValue('blackAlpha.50', 'whiteAlpha.100') : 'transparent'}
_hover={{ bg: useColorModeValue('blackAlpha.100', 'whiteAlpha.200'), transform: 'translateY(-1px)' }}
color={isActive ? menuColorActive : menuColorInactive}
bg={isActive ? menuBgActive : 'transparent'}
_hover={{ bg: menuHoverBg, transform: 'translateY(-1px)' }}
transition="all 0.2s"
>
{label}
@@ -53,6 +53,7 @@ const PollLinker: React.FC<PollLinkerProps> = ({ articleId, eventId, onPollsChan
title: '',
description: '',
type: 'single',
style: 'auto',
status: 'active',
allow_multiple: false,
max_choices: 1,
@@ -199,6 +200,7 @@ const PollLinker: React.FC<PollLinkerProps> = ({ articleId, eventId, onPollsChan
title: '',
description: '',
type: 'single',
style: 'auto',
status: 'active',
allow_multiple: false,
max_choices: 1,
@@ -447,6 +449,29 @@ const PollLinker: React.FC<PollLinkerProps> = ({ articleId, eventId, onPollsChan
</Select>
</FormControl>
<FormControl>
<FormLabel fontSize="sm">Styl</FormLabel>
<Select
size="sm"
value={(newPollData as any).style || 'auto'}
onChange={(e) => setNewPollData(prev => ({ ...prev, style: e.target.value as any }))}
>
<option value="auto">Automaticky</option>
{newPollData.type === 'rating' ? (
<>
<option value="rating-stars">Hvězdičky</option>
<option value="rating-scale">Číselná stupnice</option>
</>
) : (
<>
<option value="choices-list">Seznam</option>
<option value="choices-chips">Štítky</option>
<option value="choices-cards">Karty</option>
</>
)}
</Select>
</FormControl>
<FormControl>
<FormLabel fontSize="sm">Možnosti (min. 2)</FormLabel>
<VStack spacing={2} align="stretch">
@@ -478,6 +503,42 @@ const PollLinker: React.FC<PollLinkerProps> = ({ articleId, eventId, onPollsChan
>
Přidat možnost
</Button>
<HStack>
<Button size="xs" onClick={() => setNewPollData(prev => ({
...prev,
title: 'Hodnocení zápasu',
description: 'Ohodnoťte zápas (1 = nejhorší, 5 = nejlepší)',
type: 'rating',
style: 'rating-stars',
allow_multiple: false,
max_choices: 1,
options: Array.from({ length: 5 }).map((_, i) => ({ text: String(i+1), display_order: i+1 }))
}))}> 5</Button>
<Button size="xs" onClick={() => setNewPollData(prev => ({
...prev,
title: 'Hodnocení (110)',
description: 'Ohodnoťte (1 = nejhorší, 10 = nejlepší)',
type: 'rating',
style: 'rating-scale',
allow_multiple: false,
max_choices: 1,
options: Array.from({ length: 10 }).map((_, i) => ({ text: String(i+1), display_order: i+1 }))
}))}>110</Button>
<Button size="xs" onClick={() => setNewPollData(prev => ({
...prev,
title: 'Docházka',
description: 'Dej vědět, zda dorazíš.',
type: 'single',
style: 'choices-chips',
allow_multiple: false,
max_choices: 1,
options: [
{ text: 'Ano', display_order: 0 },
{ text: 'Ne', display_order: 1 },
{ text: 'Možná', display_order: 2 },
]
}))}>Docházka</Button>
</HStack>
</VStack>
</FormControl>
@@ -126,6 +126,7 @@ const CustomRichEditor: React.FC<CustomRichEditorProps> = ({
const [isPreviewOpen, setIsPreviewOpen] = useState(false);
const [imageWidth, setImageWidth] = useState<number>(0);
const [manualWidth, setManualWidth] = useState<string>('');
const [widthPercent, setWidthPercent] = useState<number>(0);
// Define toolbar configurations
const toolbarConfigs = {
@@ -499,12 +500,17 @@ const CustomRichEditor: React.FC<CustomRichEditorProps> = ({
img.style.width = `${newWidth}px`;
img.style.maxWidth = '100%';
img.style.height = 'auto';
try { img.setAttribute('width', String(Math.round(newWidth))); } catch {}
setImageWidth(newWidth);
setManualWidth(newWidth.toString());
try {
const editorWidth = editor.root.clientWidth || newWidth || 1;
setWidthPercent(Math.max(1, Math.min(100, Math.round((newWidth / editorWidth) * 100))));
} catch {}
updateHandlePositions();
};
const onMouseUp = () => {
const onMouseUp: (ev: MouseEvent) => void = () => {
isResizing = false;
document.removeEventListener('mousemove', onMouseMove);
document.removeEventListener('mouseup', onMouseUp);
@@ -557,6 +563,10 @@ const CustomRichEditor: React.FC<CustomRichEditorProps> = ({
const currentWidth = img.offsetWidth || img.width;
setImageWidth(currentWidth);
setManualWidth(currentWidth.toString());
try {
const editorWidth = editor.root.clientWidth || currentWidth || 1;
setWidthPercent(Math.max(1, Math.min(100, Math.round((currentWidth / editorWidth) * 100))));
} catch {}
// Load saved filters
const filtersData = img.getAttribute('data-filters');
@@ -665,10 +675,59 @@ const CustomRichEditor: React.FC<CustomRichEditorProps> = ({
const handleMouseDown = (e: MouseEvent) => {
const target = e.target as HTMLElement;
if (target.tagName === 'IMG' && selectedImage === target) {
// Only enable dragging if clicking directly on the image (not on resize handle)
// Allow edge-drag fallback resize if overlay handle doesn't catch it
const rect = target.getBoundingClientRect();
const isNearEdge = (e.clientX > rect.right - 20 || e.clientY > rect.bottom - 20);
if (isNearEdge) return; // Let resize handle take over
const nearLeft = e.clientX < rect.left + 16;
const nearRight = e.clientX > rect.right - 16;
const nearTop = e.clientY < rect.top + 16;
const nearBottom = e.clientY > rect.bottom - 16;
if (nearLeft || nearRight || nearTop || nearBottom) {
e.preventDefault();
e.stopPropagation();
isResizing = true;
startX = e.clientX;
startY = e.clientY;
startWidth = (target as HTMLImageElement).offsetWidth;
const startHeight = (target as HTMLImageElement).offsetHeight;
const aspectRatio = startWidth / Math.max(1, startHeight);
const edge = nearRight ? 'right' : nearLeft ? 'left' : nearBottom ? 'bottom' : 'top';
const onMouseMove: (ev: MouseEvent) => void = (ev: MouseEvent) => {
if (!isResizing) return;
const deltaX = ev.clientX - startX;
const deltaY = ev.clientY - startY;
let newWidth = startWidth;
if (edge === 'right') newWidth = startWidth + deltaX;
else if (edge === 'left') newWidth = startWidth - deltaX;
else if (edge === 'bottom') newWidth = startWidth + (deltaY * aspectRatio);
else if (edge === 'top') newWidth = startWidth - (deltaY * aspectRatio);
const maxW = editor.root.clientWidth - 40;
newWidth = Math.max(50, Math.min(newWidth, maxW));
const imgEl = target as HTMLImageElement;
imgEl.style.width = `${newWidth}px`;
imgEl.style.maxWidth = '100%';
imgEl.style.height = 'auto';
try { imgEl.setAttribute('width', String(Math.round(newWidth))); } catch {}
setImageWidth(newWidth);
setManualWidth(String(Math.round(newWidth)));
try {
const editorWidth = editor.root.clientWidth || newWidth || 1;
setWidthPercent(Math.max(1, Math.min(100, Math.round((newWidth / editorWidth) * 100))));
} catch {}
handleScroll();
};
const onMouseUp = () => {
isResizing = false;
document.removeEventListener('mousemove', onMouseMove);
document.removeEventListener('mouseup', onMouseUp);
onChangeRef.current(editor.root.innerHTML);
};
document.addEventListener('mousemove', onMouseMove);
document.addEventListener('mouseup', onMouseUp);
return;
}
e.preventDefault();
e.stopPropagation();
@@ -690,7 +749,7 @@ const CustomRichEditor: React.FC<CustomRichEditorProps> = ({
// Already set in selectImage, but ensure it's off
target.setAttribute('draggable', 'false');
const onMouseMove = (e: MouseEvent) => {
const onMouseMove: (e: MouseEvent) => void = (e: MouseEvent) => {
if (!isDragging || !selectedImage) return;
const deltaX = e.clientX - startX;
@@ -718,7 +777,7 @@ const CustomRichEditor: React.FC<CustomRichEditorProps> = ({
}
};
const onMouseUp = () => {
const onMouseUp: (e: MouseEvent) => void = () => {
isDragging = false;
document.removeEventListener('mousemove', onMouseMove);
document.removeEventListener('mouseup', onMouseUp);
@@ -943,30 +1002,74 @@ const CustomRichEditor: React.FC<CustomRichEditorProps> = ({
}
}, [selectedImageElement, toast]);
const applyWidthPx = useCallback((px: number, opts?: { silent?: boolean }) => {
if (!selectedImageElement) return;
const editor = quillRef.current?.getEditor();
const maxWidth = editor ? editor.root.clientWidth - 40 : 1200;
const finalWidth = Math.min(Math.max(50, Math.round(px)), maxWidth);
selectedImageElement.style.width = `${finalWidth}px`;
selectedImageElement.style.height = 'auto';
selectedImageElement.style.maxWidth = '100%';
selectedImageElement.setAttribute('width', String(finalWidth));
setImageWidth(finalWidth);
setManualWidth(finalWidth.toString());
if (editor) {
onChangeRef.current(editor.root.innerHTML);
try { editor.root.dispatchEvent(new Event('scroll')); } catch {}
}
if (!opts?.silent) {
toast({ title: 'Šířka nastavena', description: `${finalWidth}px`, status: 'success', duration: 1500 });
}
}, [selectedImageElement, toast]);
const resetWidth = useCallback(() => {
if (!selectedImageElement) return;
const editor = quillRef.current?.getEditor();
selectedImageElement.style.width = '';
selectedImageElement.style.height = '';
selectedImageElement.style.maxWidth = '100%';
selectedImageElement.removeAttribute('width');
const currentWidth = selectedImageElement.offsetWidth || selectedImageElement.width || 0;
setImageWidth(currentWidth);
setManualWidth('');
if (editor) {
onChangeRef.current(editor.root.innerHTML);
try { editor.root.dispatchEvent(new Event('scroll')); } catch {}
}
toast({ title: 'Šířka resetována', status: 'info', duration: 1200 });
}, [selectedImageElement, toast]);
const applyPercent = useCallback((percent: number, opts?: { silent?: boolean }) => {
const clamped = Math.max(5, Math.min(100, Math.round(percent)));
setWidthPercent(clamped);
const editor = quillRef.current?.getEditor();
if (editor && selectedImageElement) {
const px = (editor.root.clientWidth * clamped) / 100;
applyWidthPx(px, opts);
}
}, [applyWidthPx, selectedImageElement]);
// Set manual width
const applyManualWidth = useCallback(() => {
if (selectedImageElement && manualWidth) {
const width = parseInt(manualWidth);
if (!isNaN(width) && width > 0) {
const raw = manualWidth.trim();
if (raw.endsWith('%')) {
const percent = parseFloat(raw.slice(0, -1));
const editor = quillRef.current?.getEditor();
const maxWidth = editor ? editor.root.clientWidth - 40 : 1200;
const finalWidth = Math.min(Math.max(50, width), maxWidth);
selectedImageElement.style.width = `${finalWidth}px`;
selectedImageElement.style.height = 'auto';
selectedImageElement.style.maxWidth = '100%';
setImageWidth(finalWidth);
setManualWidth(finalWidth.toString());
if (editor) {
onChangeRef.current(editor.root.innerHTML);
// Force overlay reposition
try { editor.root.dispatchEvent(new Event('scroll')); } catch {}
if (editor && !isNaN(percent) && percent > 0) {
const px = (editor.root.clientWidth * percent) / 100;
applyWidthPx(px);
return;
}
toast({ title: 'Šířka nastavena', description: `${finalWidth}px`, status: 'success', duration: 1500 });
}
const width = parseInt(raw, 10);
if (!isNaN(width) && width > 0) {
applyWidthPx(width);
} else {
toast({ title: 'Neplatná šířka', description: 'Zadejte kladné číslo', status: 'warning', duration: 1500 });
toast({ title: 'Neplatná šířka', description: 'Zadejte kladné číslo nebo procenta (např. 50%)', status: 'warning', duration: 1500 });
}
}
}, [selectedImageElement, manualWidth, toast]);
}, [selectedImageElement, manualWidth, toast, applyWidthPx]);
// Delete selected image
const deleteSelectedImage = useCallback(() => {
@@ -1329,7 +1432,8 @@ const CustomRichEditor: React.FC<CustomRichEditorProps> = ({
</HStack>
</VStack>
{/* Width Control */}
{/* Width Control */
}
<VStack align="stretch" spacing={2}>
<Text fontSize="xs" fontWeight="semibold" color="gray.600">Šířka obrázku</Text>
<HStack spacing={2}>
@@ -1351,7 +1455,28 @@ const CustomRichEditor: React.FC<CustomRichEditorProps> = ({
Nastavit
</Button>
</HStack>
<Text fontSize="xs" color="gray.500">Aktuální: {imageWidth}px</Text>
<Text fontSize="xs" color="gray.500">Aktuální: {imageWidth}px ({widthPercent || 0}%)</Text>
<HStack spacing={2}>
<Button size="xs" variant="outline" onClick={() => applyPercent(25, { silent: true })}>25%</Button>
<Button size="xs" variant="outline" onClick={() => applyPercent(50, { silent: true })}>50%</Button>
<Button size="xs" variant="outline" onClick={() => applyPercent(75, { silent: true })}>75%</Button>
<Button size="xs" variant="outline" onClick={() => applyPercent(100, { silent: true })}>100%</Button>
<Button size="xs" colorScheme="gray" variant="ghost" onClick={resetWidth}>Reset</Button>
</HStack>
<FormControl>
<HStack justify="space-between">
<FormLabel fontSize="xs" mb={0}>Šířka (%)</FormLabel>
<Text fontSize="xs" color="gray.500">{widthPercent || 0}%</Text>
</HStack>
<input
type="range"
min="5"
max="100"
value={widthPercent || 0}
onChange={(e) => applyPercent(Number(e.target.value), { silent: true })}
style={{ width: '100%' }}
/>
</FormControl>
</VStack>
{/* Transform Buttons */}
+176 -111
View File
@@ -66,6 +66,12 @@ const PollCard: React.FC<PollCardProps> = ({
? Math.max(...ratingOptionsSorted.map(o => (o.display_order || 0))) || ratingOptionsSorted.length
: 5;
const [ratingValue, setRatingValue] = useState<number | null>(null);
const selectedStyle = (poll as any).style || 'auto';
const resolvedStyle = (() => {
if (selectedStyle !== 'auto') return selectedStyle;
if (isRating) return maxRating <= 5 ? 'rating-stars' : 'rating-scale';
return 'choices-list';
})();
const selectOptionForRating = (value: number) => {
setRatingValue(value);
@@ -328,132 +334,191 @@ const PollCard: React.FC<PollCardProps> = ({
{isActive && (
<>
{isRating ? (
{resolvedStyle === 'rating-stars' && (
<VStack align="stretch" spacing={2}>
{maxRating <= 5 ? (
<HStack>
{Array.from({ length: maxRating }).map((_, i) => (
<StarIcon
key={i}
boxSize={6}
cursor="pointer"
color={i < (ratingValue || 0) ? 'yellow.400' : 'gray.300'}
onClick={() => selectOptionForRating(i + 1)}
/>
))}
<Text ml={2}>{ratingValue ? `${ratingValue}/${maxRating}` : 'Vyberte hodnocení'}</Text>
</HStack>
) : (
<HStack flexWrap="wrap" spacing={2}>
{Array.from({ length: maxRating }).map((_, i) => (
<Button
key={i}
size="sm"
variant={ratingValue === i + 1 ? 'solid' : 'outline'}
colorScheme="blue"
onClick={() => selectOptionForRating(i + 1)}
>
{i + 1}
</Button>
))}
</HStack>
)}
<HStack>
{Array.from({ length: maxRating }).map((_, i) => (
<StarIcon
key={i}
boxSize={6}
cursor="pointer"
color={i < (ratingValue || 0) ? 'yellow.400' : 'gray.300'}
onClick={() => selectOptionForRating(i + 1)}
/>
))}
<Text ml={2}>{ratingValue ? `${ratingValue}/${maxRating}` : 'Vyberte hodnocení'}</Text>
</HStack>
</VStack>
) : poll.allow_multiple ? (
<CheckboxGroup
value={selectedOptions.map(String)}
onChange={handleMultipleChoice}
>
<VStack spacing={2} align="stretch">
<Text fontSize="sm" color="gray.500">
Vyberte {poll.max_choices} možností
</Text>
{poll.options.map((option) => (
)}
{resolvedStyle === 'rating-scale' && (
<HStack flexWrap="wrap" spacing={2}>
{Array.from({ length: maxRating }).map((_, i) => (
<Button
key={i}
size="sm"
variant={ratingValue === i + 1 ? 'solid' : 'outline'}
colorScheme="blue"
onClick={() => selectOptionForRating(i + 1)}
>
{i + 1}
</Button>
))}
</HStack>
)}
{resolvedStyle === 'choices-chips' && (
<VStack align="stretch" spacing={2}>
{poll.allow_multiple && (
<Text fontSize="sm" color="gray.500">Vyberte {poll.max_choices} možností</Text>
)}
<HStack flexWrap="wrap" spacing={2}>
{poll.options.map((option) => {
const isSelected = selectedOptions.includes(option.id);
return (
<Button
key={option.id}
size="sm"
borderRadius="full"
variant={isSelected ? 'solid' : 'outline'}
colorScheme="blue"
onClick={() => handleOptionClick(option.id)}
>
{option.text}
</Button>
);
})}
</HStack>
</VStack>
)}
{resolvedStyle === 'choices-cards' && (
<VStack spacing={3} align="stretch">
{poll.options.map((option) => {
const isSelected = selectedOptions.includes(option.id);
return (
<Box
key={option.id}
p={3}
borderWidth="1px"
borderWidth={isSelected ? '2px' : '1px'}
borderColor={isSelected ? 'blue.400' : borderColor}
borderRadius="md"
_hover={{ bg: hoverBg }}
cursor="pointer"
role="button"
tabIndex={0}
onClick={() => handleOptionClick(option.id)}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
handleOptionClick(option.id);
}
}}
>
<Checkbox value={String(option.id)} onClick={(e) => e.stopPropagation()}>
<VStack align="start" spacing={1}>
<Text>{option.text}</Text>
<HStack align="start" spacing={3}>
{option.image_url && (
<Image src={option.image_url} alt={option.text} boxSize="48px" objectFit="cover" borderRadius="md" />
)}
<VStack align="start" spacing={1} flex={1}>
<Text fontWeight="medium">{option.text}</Text>
{option.description && (
<Text fontSize="xs" color="gray.500">
{option.description}
</Text>
<Text fontSize="xs" color="gray.500">{option.description}</Text>
)}
</VStack>
</Checkbox>
</HStack>
</Box>
))}
</VStack>
</CheckboxGroup>
) : (
<RadioGroup
value={selectedOptions[0]?.toString() || ''}
onChange={handleSingleChoice}
>
<VStack spacing={2} align="stretch">
{poll.options.map((option) => (
<Box
key={option.id}
p={3}
borderWidth="1px"
borderRadius="md"
_hover={{ bg: hoverBg }}
cursor="pointer"
role="button"
tabIndex={0}
onClick={() => handleOptionClick(option.id)}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
handleOptionClick(option.id);
}
}}
>
<Radio value={String(option.id)} onClick={(e) => e.stopPropagation()}>
<VStack align="start" spacing={1}>
<Text>{option.text}</Text>
{option.description && (
<Text fontSize="xs" color="gray.500">
{option.description}
</Text>
)}
{option.player && (
<HStack spacing={2}>
{option.player.image_url && (
<Image
src={option.player.image_url}
alt={`${option.player.first_name} ${option.player.last_name}`}
boxSize="24px"
borderRadius="full"
/>
);
})}
</VStack>
)}
{resolvedStyle === 'choices-list' && (
<>
{poll.allow_multiple ? (
<CheckboxGroup
value={selectedOptions.map(String)}
onChange={handleMultipleChoice}
>
<VStack spacing={2} align="stretch">
<Text fontSize="sm" color="gray.500">
Vyberte {poll.max_choices} možností
</Text>
{poll.options.map((option) => (
<Box
key={option.id}
p={3}
borderWidth="1px"
borderRadius="md"
_hover={{ bg: hoverBg }}
cursor="pointer"
role="button"
tabIndex={0}
onClick={() => handleOptionClick(option.id)}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
handleOptionClick(option.id);
}
}}
>
<Checkbox value={String(option.id)} onClick={(e) => e.stopPropagation()}>
<VStack align="start" spacing={1}>
<Text>{option.text}</Text>
{option.description && (
<Text fontSize="xs" color="gray.500">
{option.description}
</Text>
)}
<Text fontSize="xs" color="gray.500">
#{option.player.jersey_number} {option.player.first_name}{' '}
{option.player.last_name}
</Text>
</HStack>
)}
</VStack>
</Radio>
</Box>
))}
</VStack>
</RadioGroup>
</VStack>
</Checkbox>
</Box>
))}
</VStack>
</CheckboxGroup>
) : (
<RadioGroup
value={selectedOptions[0]?.toString() || ''}
onChange={handleSingleChoice}
>
<VStack spacing={2} align="stretch">
{poll.options.map((option) => (
<Box
key={option.id}
p={3}
borderWidth="1px"
borderRadius="md"
_hover={{ bg: hoverBg }}
cursor="pointer"
role="button"
tabIndex={0}
onClick={() => handleOptionClick(option.id)}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
handleOptionClick(option.id);
}
}}
>
<Radio value={String(option.id)} onClick={(e) => e.stopPropagation()}>
<VStack align="start" spacing={1}>
<Text>{option.text}</Text>
{option.description && (
<Text fontSize="xs" color="gray.500">
{option.description}
</Text>
)}
{option.player && (
<HStack spacing={2}>
{option.player.image_url && (
<Image
src={option.player.image_url}
alt={`${option.player.first_name} ${option.player.last_name}`}
boxSize="24px"
borderRadius="full"
/>
)}
<Text fontSize="xs" color="gray.500">
#{option.player.jersey_number} {option.player.first_name}{' '}
{option.player.last_name}
</Text>
</HStack>
)}
</VStack>
</Radio>
</Box>
))}
</VStack>
</RadioGroup>
)}
</>
)}
{isAuthenticated ? (
@@ -4,86 +4,121 @@ import { ScoreboardState } from '@/services/scoreboard';
export const ScoreboardPreview: React.FC<{ state: ScoreboardState }> = ({ state }) => {
const theme = state.theme || 'pill';
const isFlipped = !!state.sidesFlipped;
const left = {
short: (isFlipped ? state.awayShort : state.homeShort) || deriveShortLocal(isFlipped ? state.awayName : state.homeName),
logo: isFlipped ? state.awayLogo : state.homeLogo,
color: (isFlipped ? state.secondaryColor : state.primaryColor) || '#1e3a8a',
score: isFlipped ? state.awayScore : state.homeScore,
fouls: Math.max(0, Math.min(5, isFlipped ? (state.awayFouls || 0) : (state.homeFouls || 0))),
name: isFlipped ? state.awayName : state.homeName,
};
const right = {
short: (!isFlipped ? state.awayShort : state.homeShort) || deriveShortLocal(!isFlipped ? state.awayName : state.homeName),
logo: !isFlipped ? state.awayLogo : state.homeLogo,
color: (!isFlipped ? state.secondaryColor : state.primaryColor) || '#2563eb',
score: !isFlipped ? state.awayScore : state.homeScore,
fouls: Math.max(0, Math.min(5, !isFlipped ? (state.awayFouls || 0) : (state.homeFouls || 0))),
name: !isFlipped ? state.awayName : state.homeName,
};
const timer = state.timer || '00:00';
switch (theme) {
case 'pill':
return (
<HStack spacing={2} px={1.5} py={1} borderRadius="full" bg="white" borderWidth="1px" borderColor="gray.200" boxShadow="sm" width="max-content">
<SegmentTeam colorA={state.primaryColor} left>
{state.homeLogo ? <Image src={state.homeLogo} alt="home" boxSize="16px" objectFit="contain" /> : null}
<Text textTransform="uppercase" fontSize="sm" lineHeight={1}>{state.homeShort || deriveShortLocal(state.homeName)}</Text>
</SegmentTeam>
<SegmentScore>{state.homeScore} {state.awayScore}</SegmentScore>
<SegmentTeam colorA={state.secondaryColor} right>
<Text textTransform="uppercase" fontSize="sm" lineHeight={1}>{state.awayShort || deriveShortLocal(state.awayName)}</Text>
{state.awayLogo ? <Image src={state.awayLogo} alt="away" boxSize="16px" objectFit="contain" /> : null}
</SegmentTeam>
</HStack>
<Box>
<HStack spacing={2} px={1.5} py={1} borderRadius="full" bg="white" borderWidth="1px" borderColor="gray.200" boxShadow="sm" width="max-content">
<SegmentScore>{timer}</SegmentScore>
<SegmentTeam colorA={left.color} left>
{left.logo ? <Image src={left.logo} alt="home" boxSize="16px" objectFit="contain" /> : null}
<Text textTransform="uppercase" fontSize="sm" lineHeight={1}>{left.short}</Text>
</SegmentTeam>
<SegmentScore>{left.score} {right.score}</SegmentScore>
<SegmentTeam colorA={right.color} right>
<Text textTransform="uppercase" fontSize="sm" lineHeight={1}>{right.short}</Text>
{right.logo ? <Image src={right.logo} alt="away" boxSize="16px" objectFit="contain" /> : null}
</SegmentTeam>
</HStack>
<FoulsBar leftCount={left.fouls} rightCount={right.fouls} leftColor={left.color} rightColor={right.color} />
</Box>
);
case 'classic':
case 'var1':
return (
<HStack spacing={3} bgGradient="linear(to-b, #c8d4dc, #a8b8c4)" px={5} py={3} borderRadius="lg" boxShadow="md" width="max-content">
<Box bg="white" color="black" fontWeight="bold" px={3} py={1} borderRadius="md" fontSize="lg">{formatTimer(state.halfLength)}</Box>
<Box bg={state.primaryColor || '#34495e'} color="white" px={4} py={2} borderRadius="md" fontWeight="bold">{state.homeShort || deriveShortLocal(state.homeName)}</Box>
<Text fontWeight="bold" color="black">{state.homeScore}-{state.awayScore}</Text>
<Box bg={state.secondaryColor || '#2c3e50'} color="white" px={4} py={2} borderRadius="md" fontWeight="bold">{state.awayShort || deriveShortLocal(state.awayName)}</Box>
</HStack>
<Box>
<HStack spacing={3} bgGradient="linear(to-b, #c8d4dc, #a8b8c4)" px={5} py={3} borderRadius="lg" boxShadow="md" width="max-content">
<Box bg="white" color="black" fontWeight="bold" px={3} py={1} borderRadius="md" fontSize="lg">{timer}</Box>
<Box bg={left.color} color="white" px={4} py={2} borderRadius="md" fontWeight="bold">{left.short}</Box>
<Text fontWeight="bold" color="black">{left.score}-{right.score}</Text>
<Box bg={right.color} color="white" px={4} py={2} borderRadius="md" fontWeight="bold">{right.short}</Box>
</HStack>
<FoulsBar leftCount={left.fouls} rightCount={right.fouls} leftColor={left.color} rightColor={right.color} />
</Box>
);
case 'var2':
return (
<HStack spacing={0} borderRadius="md" overflow="hidden" boxShadow="md" width="max-content">
<Box bgGradient="linear(135deg, #4a5568, #2d3748)" color="white" px={3} py={2} fontWeight="bold">{formatTimer(state.halfLength)}</Box>
<Box bgGradient="linear(135deg, #2c5282, #2a4365)" color="white" px={4} py={2} fontWeight="bold">{state.homeShort || deriveShortLocal(state.homeName)}</Box>
<Box bgGradient="linear(135deg, #2c5282, #2a4365)" color="white" px={3} py={2} fontWeight="bold">{state.homeScore}-{state.awayScore}</Box>
<Box bgGradient="linear(135deg, #2c5282, #2a4365)" color="white" px={4} py={2} fontWeight="bold">{state.awayShort || deriveShortLocal(state.awayName)}</Box>
</HStack>
<Box>
<HStack spacing={0} borderRadius="md" overflow="hidden" boxShadow="md" width="max-content">
<Box bgGradient="linear(135deg, #4a5568, #2d3748)" color="white" px={3} py={2} fontWeight="bold">{timer}</Box>
<Box bgGradient="linear(135deg, #2c5282, #2a4365)" color="white" px={4} py={2} fontWeight="bold">{left.short}</Box>
<Box bgGradient="linear(135deg, #2c5282, #2a4365)" color="white" px={3} py={2} fontWeight="bold">{left.score}-{right.score}</Box>
<Box bgGradient="linear(135deg, #2c5282, #2a4365)" color="white" px={4} py={2} fontWeight="bold">{right.short}</Box>
</HStack>
<FoulsBar leftCount={left.fouls} rightCount={right.fouls} leftColor={left.color} rightColor={right.color} />
</Box>
);
case 'var3':
return (
<Box textAlign="center" fontFamily="Poppins, Arial, sans-serif">
<HStack spacing={0} justify="center">
<Box w="102px" h="38px" bg="#F6F6F6" lineHeight="41px" position="relative">
<Box position="absolute" left="-8px" top={0} w="6px" h="38px" bg={state.primaryColor || '#ea2212'} />
<Text>{state.homeShort || deriveShortLocal(state.homeName)}</Text>
<Box position="absolute" left="-8px" top={0} w="6px" h="38px" bg={left.color} />
<Text>{left.short}</Text>
</Box>
<Box w="102px" h="38px" bg="#F6F6F6" lineHeight="41px" zIndex={2} boxShadow="0 3px 10px rgba(0,0,0,0.7)">
<Text fontWeight="bold">{state.homeScore}-{state.awayScore}</Text>
<Text fontWeight="bold">{left.score}-{right.score}</Text>
</Box>
<Box w="102px" h="38px" bg="#F6F6F6" lineHeight="41px" position="relative">
<Box position="absolute" right="-8px" top={0} w="6px" h="38px" bg={state.secondaryColor || '#ea2212'} />
<Text>{state.awayShort || deriveShortLocal(state.awayName)}</Text>
<Box position="absolute" right="-8px" top={0} w="6px" h="38px" bg={right.color} />
<Text>{right.short}</Text>
</Box>
</HStack>
<Box mt={2} w="306px" mx="auto" bg="#F6F6F6">
<Text>{formatTimer(state.halfLength)}</Text>
<Text>{timer}</Text>
</Box>
<FoulsBar leftCount={left.fouls} rightCount={right.fouls} leftColor={left.color} rightColor={right.color} />
</Box>
);
case 'var4':
return (
<Box w="340px" borderWidth="1px" borderRadius="xl" boxShadow="xl" p={4} bg="white" color="gray.900">
<HStack>
<Text fontWeight="bold">{state.homeName}</Text>
<Text ml="auto" fontWeight="extrabold">{state.homeScore}</Text>
</HStack>
<Box textAlign="center" fontWeight="extrabold" py={1}>VS</Box>
<HStack>
<Text fontWeight="bold">{state.awayName}</Text>
<Text ml="auto" fontWeight="extrabold">{state.awayScore}</Text>
</HStack>
<HStack justify="flex-end" fontSize="sm" opacity={0.8} pt={2}>
<Text>{formatTimer(state.halfLength)}</Text>
</HStack>
<Box>
<Box w="340px" borderWidth="1px" borderRadius="xl" boxShadow="xl" p={4} bg="white" color="gray.900">
<HStack>
<Text fontWeight="bold">{left.name}</Text>
<Text ml="auto" fontWeight="extrabold">{left.score}</Text>
</HStack>
<Box textAlign="center" fontWeight="extrabold" py={1}>VS</Box>
<HStack>
<Text fontWeight="bold">{right.name}</Text>
<Text ml="auto" fontWeight="extrabold">{right.score}</Text>
</HStack>
<HStack justify="flex-end" fontSize="sm" opacity={0.8} pt={2}>
<Text>{timer}</Text>
</HStack>
</Box>
<FoulsBar leftCount={left.fouls} rightCount={right.fouls} leftColor={left.color} rightColor={right.color} />
</Box>
);
default:
return (
<HStack spacing={3} bg="gray.900" color="white" px={4} py={3} borderRadius="lg" boxShadow="lg" width="max-content">
<Text fontWeight="bold">{state.homeName}</Text>
<Text fontWeight="black">{state.homeScore} : {state.awayScore}</Text>
<Text fontWeight="bold">{state.awayName}</Text>
</HStack>
<Box>
<HStack spacing={3} bg="gray.900" color="white" px={4} py={3} borderRadius="lg" boxShadow="lg" width="max-content">
<Text fontWeight="bold">{left.name}</Text>
<Text fontWeight="black">{left.score} : {right.score}</Text>
<Text fontWeight="bold">{right.name}</Text>
</HStack>
<FoulsBar leftCount={left.fouls} rightCount={right.fouls} leftColor={left.color} rightColor={right.color} />
</Box>
);
}
};
@@ -116,8 +151,23 @@ const SegmentScore: React.FC<{ children: React.ReactNode }> = ({ children }) =>
);
};
const FoulsBar: React.FC<{ leftCount: number; rightCount: number; leftColor: string; rightColor: string }> = ({ leftCount, rightCount, leftColor, rightColor }) => {
const Dot: React.FC<{ active: boolean; color: string }> = ({ active, color }) => (
<Box w="8px" h="8px" borderRadius="full" bg={active ? color : 'gray.200'} borderWidth={active ? 0 : 1} borderColor="gray.300" />
);
return (
<HStack spacing={6} justify="center" mt={2} width="100%">
<HStack spacing={1}>
{Array.from({ length: 5 }).map((_, i) => <Dot key={i} active={i < leftCount} color={leftColor} />)}
</HStack>
<HStack spacing={1}>
{Array.from({ length: 5 }).map((_, i) => <Dot key={i} active={i < rightCount} color={rightColor} />)}
</HStack>
</HStack>
);
};
function formatTimer(halfLength: number): string {
// Simple static mm:ss display using half length as baseline; real timer would come from backend
const min = Math.max(0, Math.min(halfLength, 99));
return `${String(min).padStart(2, '0')}:00`;
}
+12 -2
View File
@@ -76,8 +76,18 @@ const AuthPage: React.FC = () => {
duration: 3000,
isClosable: true,
});
// Redirect to original destination or dashboard
navigate(from, { replace: true });
// Role-based redirect after login
const role = String(user?.role || '').toLowerCase();
if (role === 'admin') {
navigate('/admin', { replace: true });
} else if (role === 'editor') {
navigate('/admin', { replace: true });
} else if (role === 'user') {
navigate('/', { replace: true });
} else {
// Fallback for unknown roles (e.g., fan): go to frontpage
navigate('/', { replace: true });
}
} catch (error: any) {
toast({
title: 'Přihlášení selhalo',
+16 -11
View File
@@ -1580,10 +1580,19 @@ const HomePage: React.FC = () => {
(matchingStanding.rows && matchingStanding.rows.length > 0)
);
const showNews = isVisible('news', true);
const showTable = isVisible('table', true) && hasStandingsForCurrentTab;
const variant = showNews && showTable ? undefined : 'standard';
if (!showNews && !showTable) return null;
return (
<>
{isVisible('news', true) && (
<section data-element="news" className="news-list" style={{ marginTop: 32, ...getStyles('news') }}>
<section
className="standings"
data-variant={variant}
style={{ marginTop: 32 }}
>
{showNews && (
<section data-element="news" className="news-list" style={{ ...getStyles('news') }}>
<div className="section-head" style={{ marginTop: 0 }}>
<h3>Další aktuality</h3>
</div>
@@ -1610,12 +1619,8 @@ const HomePage: React.FC = () => {
</section>
)}
{isVisible('table', true) && hasStandingsForCurrentTab && (
<section
data-element="table"
className="standings"
style={{ marginTop: 32, ...getStyles('table') }}
>
{showTable && (
<div data-element="table" style={{ ...getStyles('table') }}>
<div className="table-card">
<div className="section-head" style={{ marginTop: 0, marginBottom: 12 }}>
<h3>Tabulky</h3>
@@ -1699,9 +1704,9 @@ const HomePage: React.FC = () => {
</table>
</div>
</div>
</section>
</div>
)}
</>
</section>
);
})()}
+1 -1
View File
@@ -11,7 +11,7 @@ const OverlayScoreboardPage: React.FC = () => {
const { data, isLoading } = useQuery<ScoreboardState>({
queryKey: ['public-scoreboard'],
queryFn: getPublicScoreboard,
refetchInterval: 5000,
refetchInterval: 1000,
staleTime: 3000,
});
+29 -1
View File
@@ -92,6 +92,7 @@ const PollsAdminPage: React.FC = () => {
title: '',
description: '',
type: 'single',
style: 'auto',
status: 'draft',
allow_multiple: false,
max_choices: 1,
@@ -191,6 +192,7 @@ const PollsAdminPage: React.FC = () => {
title: '',
description: '',
type: 'single',
style: 'auto',
status: 'draft',
allow_multiple: false,
max_choices: 1,
@@ -221,6 +223,7 @@ const PollsAdminPage: React.FC = () => {
title: 'Hodnocení zápasu',
description: 'Ohodnoťte zápas (1 = nejhorší, 5 = nejlepší)',
type: 'rating',
style: 'rating-stars',
status: 'active',
allow_multiple: false,
max_choices: 1,
@@ -239,6 +242,7 @@ const PollsAdminPage: React.FC = () => {
title: 'Hodnocení zápasu (110)',
description: 'Ohodnoťte zápas (1 = nejhorší, 10 = nejlepší)',
type: 'rating',
style: 'rating-scale',
status: 'active',
allow_multiple: false,
max_choices: 1,
@@ -253,6 +257,7 @@ const PollsAdminPage: React.FC = () => {
title: 'Dorazíš na schůzku?',
description: 'Dej nám vědět, zda dorazíš.',
type: 'single',
style: 'choices-chips',
status: 'active',
allow_multiple: false,
max_choices: 1,
@@ -276,6 +281,7 @@ const PollsAdminPage: React.FC = () => {
title: poll.title,
description: poll.description,
type: poll.type,
style: (poll as any).style || 'auto',
status: poll.status,
start_date: poll.start_date,
end_date: poll.end_date,
@@ -566,7 +572,7 @@ const PollsAdminPage: React.FC = () => {
/>
</FormControl>
<SimpleGrid columns={2} spacing={4} w="full">
<SimpleGrid columns={3} spacing={4} w="full">
<FormControl>
<FormLabel>Typ</FormLabel>
<Select
@@ -595,6 +601,28 @@ const PollsAdminPage: React.FC = () => {
<option value="archived">Archivovaná</option>
</Select>
</FormControl>
<FormControl>
<FormLabel>Styl</FormLabel>
<Select
value={(formData as any).style || 'auto'}
onChange={(e) => setFormData({ ...formData, style: e.target.value as any })}
>
<option value="auto">Automaticky</option>
{formData.type === 'rating' ? (
<>
<option value="rating-stars">Hvězdičky</option>
<option value="rating-scale">Číselná stupnice</option>
</>
) : (
<>
<option value="choices-list">Seznam</option>
<option value="choices-chips">Štítky</option>
<option value="choices-cards">Karty</option>
</>
)}
</Select>
</FormControl>
</SimpleGrid>
<SimpleGrid columns={2} spacing={4} w="full">
@@ -39,6 +39,8 @@ import {
startTimer,
pauseTimer,
resetTimer,
swapSides,
startSecondHalf,
} from '@/services/scoreboard';
import { useFacrApi } from '@/hooks/useFacrApi';
import { SearchResult } from '@/services/facr/types';
@@ -434,6 +436,18 @@ const ScoreboardAdminPage: React.FC = () => {
<NumberInputField />
</NumberInput>
</FormControl>
<FormControl>
<FormLabel>Fauly domácích</FormLabel>
<NumberInput value={state.homeFouls || 0} min={0} max={5} onChange={async (_, n) => setPartial({ homeFouls: Math.max(0, Math.min(5, Number.isFinite(n) ? n : 0)) })}>
<NumberInputField />
</NumberInput>
</FormControl>
<FormControl>
<FormLabel>Fauly hostů</FormLabel>
<NumberInput value={state.awayFouls || 0} min={0} max={5} onChange={async (_, n) => setPartial({ awayFouls: Math.max(0, Math.min(5, Number.isFinite(n) ? n : 0)) })}>
<NumberInputField />
</NumberInput>
</FormControl>
<FormControl>
<FormLabel>Délka poločasu (min)</FormLabel>
<NumberInput value={state.halfLength} min={1} max={60} onChange={async (_, n) => setPartial({ halfLength: Number.isFinite(n) ? n : 45 })}>
@@ -448,6 +462,17 @@ const ScoreboardAdminPage: React.FC = () => {
))}
</Select>
</FormControl>
<FormControl display="flex" alignItems="center">
<FormLabel mb={0}>Přehodit strany (vizuálně)</FormLabel>
<Switch isChecked={!!state.sidesFlipped} onChange={async (e) => setPartial({ sidesFlipped: e.target.checked })} />
</FormControl>
<FormControl>
<FormLabel>Poločas</FormLabel>
<Select value={String(state.half || 1)} onChange={async (e) => setPartial({ half: parseInt(e.target.value, 10) || 1 })}>
<option value="1">1</option>
<option value="2">2</option>
</Select>
</FormControl>
</SimpleGrid>
</Box>
@@ -462,6 +487,18 @@ const ScoreboardAdminPage: React.FC = () => {
<FormLabel>Barva hostů</FormLabel>
<Input type="color" value={state.secondaryColor || '#2563eb'} onChange={async (e) => setPartial({ secondaryColor: e.target.value })} />
</FormControl>
<FormControl>
<FormLabel>QR interval (minuty)</FormLabel>
<NumberInput value={state.qrEvery || 5} min={1} max={120} onChange={async (_, n) => setPartial({ qrEvery: Math.max(1, Number.isFinite(n) ? n : 5) })}>
<NumberInputField />
</NumberInput>
</FormControl>
<FormControl>
<FormLabel>QR délka zobrazení (sekundy)</FormLabel>
<NumberInput value={state.qrDuration || 60} min={5} max={600} onChange={async (_, n) => setPartial({ qrDuration: Math.max(5, Number.isFinite(n) ? n : 60) })}>
<NumberInputField />
</NumberInput>
</FormControl>
</SimpleGrid>
<Divider my={4} />
@@ -528,6 +565,16 @@ const ScoreboardAdminPage: React.FC = () => {
const s = await getScoreboardState();
setState(s);
}}>Reset</Button>
<Button onClick={async () => {
await swapSides();
const s = await getScoreboardState();
setState(s);
}}>Přehodit strany</Button>
<Button colorScheme="purple" onClick={async () => {
await startSecondHalf();
const s = await getScoreboardState();
setState(s);
}}>Začít 2. poločas</Button>
</HStack>
</HStack>
<HStack mt={3} spacing={3} align="center">
+5
View File
@@ -1,10 +1,13 @@
import api from './api';
export type PollStyle = 'auto' | 'rating-stars' | 'rating-scale' | 'choices-list' | 'choices-chips' | 'choices-cards';
export interface Poll {
id: number;
title: string;
description: string;
type: 'single' | 'multiple' | 'rating';
style: PollStyle;
status: 'draft' | 'active' | 'closed' | 'archived';
start_date?: string;
end_date?: string;
@@ -101,6 +104,7 @@ export interface CreatePollRequest {
title: string;
description?: string;
type?: 'single' | 'multiple' | 'rating';
style?: PollStyle;
status?: 'draft' | 'active' | 'closed' | 'archived';
start_date?: string;
end_date?: string;
@@ -129,6 +133,7 @@ export interface UpdatePollRequest {
title?: string;
description?: string;
type?: 'single' | 'multiple' | 'rating';
style?: PollStyle;
status?: 'draft' | 'active' | 'closed' | 'archived';
start_date?: string;
end_date?: string;
+30
View File
@@ -14,12 +14,18 @@ export type ScoreboardState = {
secondaryColor?: string; // away color
homeScore: number;
awayScore: number;
homeFouls?: number;
awayFouls?: number;
halfLength: number; // minutes
theme: ScoreboardTheme;
externalMatchId?: string;
active?: boolean;
timer?: string; // MM:SS
running?: boolean;
sidesFlipped?: boolean;
half?: number;
qrEvery?: number;
qrDuration?: number;
};
const DEFAULT_STATE: ScoreboardState = {
@@ -33,12 +39,18 @@ const DEFAULT_STATE: ScoreboardState = {
secondaryColor: '#2563eb',
homeScore: 0,
awayScore: 0,
homeFouls: 0,
awayFouls: 0,
halfLength: 45,
theme: 'pill',
externalMatchId: '',
active: false,
timer: '00:00',
running: false,
sidesFlipped: false,
half: 1,
qrEvery: 5,
qrDuration: 60,
};
const STORAGE_KEY = 'scoreboard_state_v1';
@@ -121,6 +133,12 @@ export async function pauseTimer(): Promise<void> {
export async function resetTimer(): Promise<void> {
await api.post('/admin/scoreboard/timer/reset');
}
export async function swapSides(): Promise<void> {
await api.post('/admin/scoreboard/swap-sides');
}
export async function startSecondHalf(): Promise<void> {
await api.post('/admin/scoreboard/second-half');
}
// Utilities
export function deriveShort(name?: string): string {
@@ -178,12 +196,18 @@ function normalizeFromApi(d: any): Partial<ScoreboardState> {
secondaryColor: d.secondaryColor || d.secondary_color || d.SecondaryColor || undefined,
homeScore: typeof d.homeScore === 'number' ? d.homeScore : (typeof d.home_score === 'number' ? d.home_score : 0),
awayScore: typeof d.awayScore === 'number' ? d.awayScore : (typeof d.away_score === 'number' ? d.away_score : 0),
homeFouls: typeof d.homeFouls === 'number' ? d.homeFouls : (typeof d.home_fouls === 'number' ? d.home_fouls : 0),
awayFouls: typeof d.awayFouls === 'number' ? d.awayFouls : (typeof d.away_fouls === 'number' ? d.away_fouls : 0),
halfLength: typeof d.halfLength === 'number' ? d.halfLength : (typeof d.half_length === 'number' ? d.half_length : 45),
theme: (d.theme || 'pill') as any,
externalMatchId: d.externalMatchId || d.external_match_id || d.ExternalMatchID || '',
active: typeof d.active === 'boolean' ? d.active : undefined,
timer: d.timer || d.Timer || '00:00',
running: typeof d.running === 'boolean' ? d.running : undefined,
sidesFlipped: typeof d.sidesFlipped === 'boolean' ? d.sidesFlipped : (typeof d.sides_flipped === 'boolean' ? d.sides_flipped : undefined),
half: typeof d.half === 'number' ? d.half : undefined,
qrEvery: typeof d.qrEvery === 'number' ? d.qrEvery : (typeof d.qr_show_every_minutes === 'number' ? d.qr_show_every_minutes : undefined),
qrDuration: typeof d.qrDuration === 'number' ? d.qrDuration : (typeof d.qr_show_duration_seconds === 'number' ? d.qr_show_duration_seconds : undefined),
};
}
@@ -199,10 +223,16 @@ function toApiPayload(p: Partial<ScoreboardState>) {
if (p.secondaryColor !== undefined) out.secondaryColor = p.secondaryColor;
if (p.homeScore !== undefined) out.homeScore = p.homeScore;
if (p.awayScore !== undefined) out.awayScore = p.awayScore;
if (p.homeFouls !== undefined) out.homeFouls = p.homeFouls;
if (p.awayFouls !== undefined) out.awayFouls = p.awayFouls;
if (p.halfLength !== undefined) out.halfLength = p.halfLength;
if (p.theme !== undefined) out.theme = p.theme;
if (p.externalMatchId !== undefined) out.externalMatchId = p.externalMatchId;
if (p.active !== undefined) out.active = p.active;
if (p.timer !== undefined) out.timer = p.timer;
if (p.sidesFlipped !== undefined) out.sidesFlipped = p.sidesFlipped;
if (p.half !== undefined) out.half = p.half;
if (p.qrEvery !== undefined) out.qrEvery = p.qrEvery;
if (p.qrDuration !== undefined) out.qrDuration = p.qrDuration;
return out;
}