mirror of
https://github.com/Dvorinka/MyClubServer.git
synced 2026-06-03 18:22:57 +00:00
dev day #70
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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í (1–10)',
|
||||
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 }))
|
||||
}))}>1–10</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 */}
|
||||
|
||||
@@ -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 až {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 až {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 až {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`;
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
})()}
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ const OverlayScoreboardPage: React.FC = () => {
|
||||
const { data, isLoading } = useQuery<ScoreboardState>({
|
||||
queryKey: ['public-scoreboard'],
|
||||
queryFn: getPublicScoreboard,
|
||||
refetchInterval: 5000,
|
||||
refetchInterval: 1000,
|
||||
staleTime: 3000,
|
||||
});
|
||||
|
||||
|
||||
@@ -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 (1–10)',
|
||||
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">
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user