This commit is contained in:
Tomas Dvorak
2025-11-21 08:44:44 +01:00
parent c941313fd5
commit f5b6f83974
108 changed files with 8642 additions and 5871 deletions
+198 -1
View File
@@ -179,6 +179,7 @@ const AdminSidebar = ({
const { data: upcomingEvents } = useQuery({ queryKey: ['admin-sidebar-upcoming-events'], queryFn: getUpcomingEvents });
const upcomingCount = Array.isArray(upcomingEvents) ? upcomingEvents.length : 0;
const scrollRef = useRef<HTMLDivElement | null>(null);
const seedFixRef = useRef<{ about: boolean }>({ about: false });
const location = useLocation();
const STORAGE_KEY = 'admin-sidebar-scroll';
@@ -202,6 +203,23 @@ const AdminSidebar = ({
const hasSweepstakes = useMemo(() => hasItemDeep(it => (it.page_type === 'sweepstakes') || (it.url === '/admin/sweepstakes')), [hasItemDeep]);
const hasCompetitionAliases = useMemo(() => hasItemDeep(it => (it.page_type === 'competition_aliases') || (it.url === '/admin/aliasy-soutezi')), [hasItemDeep]);
const hasClothing = useMemo(() => hasItemDeep(it => (it.page_type === 'clothing') || (it.url === '/admin/obleceni')), [hasItemDeep]);
const hasAbout = useMemo(() => hasItemDeep(it => (it.page_type === 'about') || (it.url === '/admin/o-klubu')), [hasItemDeep]);
const hasVideos = useMemo(() => hasItemDeep(it => (it.page_type === 'videos') || (it.url === '/admin/videa')), [hasItemDeep]);
const hasGallery = useMemo(() => hasItemDeep(it => (it.page_type === 'gallery') || (it.url === '/admin/galerie')), [hasItemDeep]);
const hasScoreboard = useMemo(() => hasItemDeep(it => (it.page_type === 'scoreboard') || (it.url === '/admin/scoreboard')), [hasItemDeep]);
const hasScoreboardRemote = useMemo(() => hasItemDeep(it => (it.page_type === 'scoreboard_remote') || (it.url === '/admin/scoreboard/remote')), [hasItemDeep]);
const hasSponsors = useMemo(() => hasItemDeep(it => (it.page_type === 'sponsors') || (it.url === '/admin/sponzori')), [hasItemDeep]);
const hasBanners = useMemo(() => hasItemDeep(it => (it.page_type === 'banners') || (it.url === '/admin/bannery')), [hasItemDeep]);
const hasMessages = useMemo(() => hasItemDeep(it => (it.page_type === 'messages') || (it.url === '/admin/zpravy')), [hasItemDeep]);
const hasContacts = useMemo(() => hasItemDeep(it => (it.page_type === 'contacts') || (it.url === '/admin/kontakty')), [hasItemDeep]);
const hasNewsletter = useMemo(() => hasItemDeep(it => (it.page_type === 'newsletter') || (it.url === '/admin/newsletter')), [hasItemDeep]);
const hasPolls = useMemo(() => hasItemDeep(it => (it.page_type === 'polls') || (it.url === '/admin/ankety')), [hasItemDeep]);
const hasFiles = useMemo(() => hasItemDeep(it => (it.page_type === 'files') || (it.url === '/admin/soubory')), [hasItemDeep]);
const hasNavigation = useMemo(() => hasItemDeep(it => (it.page_type === 'navigation') || (it.url === '/admin/navigace')), [hasItemDeep]);
const hasUsers = useMemo(() => hasItemDeep(it => (it.page_type === 'users') || (it.url === '/admin/uzivatele')), [hasItemDeep]);
const hasSettingsPage = useMemo(() => hasItemDeep(it => (it.page_type === 'settings') || (it.url === '/admin/nastaveni')), [hasItemDeep]);
const hasAnalytics = useMemo(() => hasItemDeep(it => (it.page_type === 'analytics') || (it.url === '/admin/analytika')), [hasItemDeep]);
const hasPrefetch = useMemo(() => hasItemDeep(it => (it.page_type === 'prefetch') || (it.url === '/admin/prefetch')), [hasItemDeep]);
// Collapsed state for admin categories (dropdown items)
@@ -294,7 +312,31 @@ const AdminSidebar = ({
setNavItems(adminItems);
}
} else {
setNavItems(adminItems);
// If admin navigation exists but specific required items are missing (e.g., 'about'),
// trigger idempotent seed to backfill missing ones and reload once.
const hasAboutItem = adminItems.some(it => {
if (it.page_type === 'about') return true;
if (Array.isArray(it.children)) {
return it.children.some(c => c.page_type === 'about' || c.url === '/admin/o-klubu');
}
return false;
});
if (!hasAboutItem && isAdmin && !seedFixRef.current.about) {
try {
seedFixRef.current.about = true;
await seedDefaultNavigation();
const reloaded = await getAllNavigationItems();
if (active && Array.isArray(reloaded)) {
const reloadedAdmin = reloaded.filter(item => item.requires_admin);
setNavItems(reloadedAdmin);
}
} catch (e) {
console.warn('Seed backfill for about failed:', e);
setNavItems(adminItems);
}
} else {
setNavItems(adminItems);
}
}
}
} catch (error) {
@@ -538,6 +580,161 @@ const AdminSidebar = ({
Oblečení
</NavItem>
)}
{/* Ensure About page (O klubu) and other core admin pages are present (admins only) */}
{isAdmin && !hasAbout && (
<NavItem
icon={FaBook}
to="/admin/o-klubu"
onClick={onClose}
>
O klubu
</NavItem>
)}
{isAdmin && !hasVideos && (
<NavItem
icon={FaVideo}
to="/admin/videa"
onClick={onClose}
>
Videa
</NavItem>
)}
{isAdmin && !hasGallery && (
<NavItem
icon={FaImage}
to="/admin/galerie"
onClick={onClose}
>
Galerie (Zonerama)
</NavItem>
)}
{isAdmin && !hasScoreboard && (
<NavItem
icon={FaTachometerAlt}
to="/admin/scoreboard"
onClick={onClose}
>
Tabule (Scoreboard)
</NavItem>
)}
{isAdmin && !hasScoreboardRemote && (
<NavItem
icon={FaMobileAlt}
to="/admin/scoreboard/remote"
onClick={onClose}
>
Scoreboard Remote
</NavItem>
)}
{isAdmin && !hasSponsors && (
<NavItem
icon={FaHandshake}
to="/admin/sponzori"
onClick={onClose}
>
Sponzoři
</NavItem>
)}
{isAdmin && !hasBanners && (
<NavItem
icon={FaImage}
to="/admin/bannery"
onClick={onClose}
>
Bannery
</NavItem>
)}
{isAdmin && !hasMessages && (
<NavItem
icon={FaEnvelope}
to="/admin/zpravy"
onClick={onClose}
>
Zprávy
</NavItem>
)}
{isAdmin && !hasContacts && (
<NavItem
icon={FaAddressBook}
to="/admin/kontakty"
onClick={onClose}
>
Kontakty
</NavItem>
)}
{isAdmin && !hasNewsletter && (
<NavItem
icon={FaPaperPlane}
to="/admin/newsletter"
onClick={onClose}
>
Zpravodaj
</NavItem>
)}
{isAdmin && !hasPolls && (
<NavItem
icon={FaPoll}
to="/admin/ankety"
onClick={onClose}
>
Ankety
</NavItem>
)}
{isAdmin && !hasAnalytics && (
<NavItem
icon={FaChartBar}
to="/admin/analytika"
onClick={onClose}
>
Analytika
</NavItem>
)}
{isAdmin && !hasNavigation && (
<NavItem
icon={FaBars}
to="/admin/navigace"
onClick={onClose}
>
Navigace
</NavItem>
)}
{isAdmin && !hasUsers && (
<NavItem
icon={FaUsers}
to="/admin/uzivatele"
onClick={onClose}
>
Uživatelé
</NavItem>
)}
{isAdmin && !hasFiles && (
<NavItem
icon={FaFolder}
to="/admin/soubory"
onClick={onClose}
>
Soubory
</NavItem>
)}
{isAdmin && !hasSettingsPage && (
<NavItem
icon={FaPalette}
to="/admin/nastaveni"
onClick={onClose}
>
Nastavení
</NavItem>
)}
{isAdmin && !hasPrefetch && (
<NavItem
icon={FaSyncAlt}
to="/admin/prefetch"
onClick={onClose}
>
Prefetch & Cache
</NavItem>
)}
</>
) : (
// Fallback to hardcoded navigation
@@ -56,6 +56,7 @@ const AlbumPhotoPicker: React.FC<AlbumPhotoPickerProps> = ({
const [loading, setLoading] = useState(false);
const [album, setAlbum] = useState<Album | null>(null);
const [selectedPhotos, setSelectedPhotos] = useState<Set<string>>(new Set());
const [visibleCount, setVisibleCount] = useState<number>(60);
const toast = useToast();
const handleFetchAlbum = async () => {
@@ -117,6 +118,7 @@ const AlbumPhotoPicker: React.FC<AlbumPhotoPickerProps> = ({
photos: mappedPhotos,
});
setSelectedPhotos(new Set());
setVisibleCount(60);
toast({
title: 'Album načteno',
@@ -176,6 +178,7 @@ const AlbumPhotoPicker: React.FC<AlbumPhotoPickerProps> = ({
setAlbumLink('');
setAlbum(null);
setSelectedPhotos(new Set());
setVisibleCount(60);
onClose();
};
@@ -269,7 +272,7 @@ const AlbumPhotoPicker: React.FC<AlbumPhotoPickerProps> = ({
{/* Photos Grid */}
<SimpleGrid columns={{ base: 3, md: 4, lg: 5 }} spacing={3}>
{album.photos.map((photo) => (
{album.photos.slice(0, visibleCount).map((photo) => (
<Box
key={photo.id}
position="relative"
@@ -288,6 +291,8 @@ const AlbumPhotoPicker: React.FC<AlbumPhotoPickerProps> = ({
w="100%"
h="150px"
objectFit="cover"
loading="lazy"
decoding="async"
/>
<Checkbox
position="absolute"
@@ -301,6 +306,11 @@ const AlbumPhotoPicker: React.FC<AlbumPhotoPickerProps> = ({
</Box>
))}
</SimpleGrid>
{album.photos.length > visibleCount && (
<HStack justify="center" pt={2}>
<Button size="sm" onClick={() => setVisibleCount((c) => c + 60)}>Načíst další</Button>
</HStack>
)}
</>
)}
</VStack>
@@ -5,7 +5,7 @@ import { useAuth } from '../../contexts/AuthContext';
import { createShortLink, createPublicShortLink } from '../../services/shortlinks';
import { Article, getArticleMatchLink } from '../../services/articles';
import { API_URL } from '../../services/api';
import { composeInstagramPostFromArticle, composeInstagramPostFromActivity, MatchSnapshot, stripHtml } from '../../services/instagram';
import { composeInstagramPostFromArticle, composeInstagramPostFromActivity, MatchSnapshot, stripHtml, formatDateTime, cleanVenue } from '../../services/instagram';
import { generateInstagramAI } from '../../services/ai';
import { usePublicSettings } from '../../hooks/usePublicSettings';
@@ -159,12 +159,13 @@ const InstagramGeneratorButton: React.FC<Props> = ({
content: stripHtml(article.content),
club_name: clubName,
link: sUrl || fullUrl,
category: (article as any)?.category?.name || (article as any)?.category_name,
match: resolvedMatch ? {
home: resolvedMatch.home,
away: resolvedMatch.away,
competition: resolvedMatch.competition,
date_time: resolvedMatch.date_time,
venue: resolvedMatch.venue,
date_time: resolvedMatch.date_time ? formatDateTime(resolvedMatch.date_time) : undefined,
venue: resolvedMatch.venue ? cleanVenue(resolvedMatch.venue) : undefined,
score: resolvedMatch.score,
} : undefined,
});
@@ -1,5 +1,5 @@
import React from 'react';
import { Box, VStack, HStack, Text, Heading, Textarea, Button, Avatar, IconButton, useColorModeValue, Spinner, Link as ChakraLink, Badge } from '@chakra-ui/react';
import { Box, VStack, HStack, Text, Heading, Textarea, Button, Avatar, IconButton, useColorModeValue, Spinner, Link as ChakraLink, Badge, Tooltip } from '@chakra-ui/react';
import { useInfiniteQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { listComments, createComment, updateComment, deleteComment, CommentItem, reactComment, unreactComment, requestUnban, reportComment } from '../../services/comments';
import { useAuth } from '../../contexts/AuthContext';
@@ -87,7 +87,37 @@ const CommentsSection: React.FC<Props> = ({ targetType, targetId }) => {
const reactMut = useMutation({
mutationFn: (args: { id: number; type: string }) => reactComment(args.id, args.type),
onSuccess: async () => {
onMutate: async ({ id, type }) => {
const qk = ['comments', targetType, targetId] as const;
await queryClient.cancelQueries({ queryKey: qk });
const previous = queryClient.getQueryData<any>(qk);
queryClient.setQueryData(qk, (oldData: any) => {
if (!oldData) return oldData;
const pages = (oldData.pages || []).map((page: any) => {
const items = (page.items || []).map((it: any) => {
if (it.id !== id) return it;
const next = { ...it, reactions: { ...(it.reactions || {}) } };
const prevType = next.my_reaction as string | undefined;
if (prevType && typeof next.reactions[prevType] === 'number') {
next.reactions[prevType] = Math.max(0, (next.reactions[prevType] || 0) - 1);
}
next.reactions[type] = (next.reactions[type] || 0) + 1;
next.my_reaction = type;
return next;
});
return { ...page, items };
});
return { ...oldData, pages };
});
return { previous };
},
onError: (_err, _vars, ctx) => {
const qk = ['comments', targetType, targetId] as const;
if ((ctx as any)?.previous) {
queryClient.setQueryData(qk, (ctx as any).previous);
}
},
onSettled: async () => {
await queryClient.invalidateQueries({ queryKey: ['comments', targetType, targetId] });
try { window.dispatchEvent(new CustomEvent('engagement:refresh')); } catch {}
},
@@ -95,7 +125,36 @@ const CommentsSection: React.FC<Props> = ({ targetType, targetId }) => {
const unreactMut = useMutation({
mutationFn: (id: number) => unreactComment(id),
onSuccess: async () => {
onMutate: async (id: number) => {
const qk = ['comments', targetType, targetId] as const;
await queryClient.cancelQueries({ queryKey: qk });
const previous = queryClient.getQueryData<any>(qk);
queryClient.setQueryData(qk, (oldData: any) => {
if (!oldData) return oldData;
const pages = (oldData.pages || []).map((page: any) => {
const items = (page.items || []).map((it: any) => {
if (it.id !== id) return it;
const next = { ...it, reactions: { ...(it.reactions || {}) } };
const prevType = next.my_reaction as string | undefined;
if (prevType && typeof next.reactions[prevType] === 'number') {
next.reactions[prevType] = Math.max(0, (next.reactions[prevType] || 0) - 1);
}
next.my_reaction = '';
return next;
});
return { ...page, items };
});
return { ...oldData, pages };
});
return { previous };
},
onError: (_err, _vars, ctx) => {
const qk = ['comments', targetType, targetId] as const;
if ((ctx as any)?.previous) {
queryClient.setQueryData(qk, (ctx as any).previous);
}
},
onSettled: async () => {
await queryClient.invalidateQueries({ queryKey: ['comments', targetType, targetId] });
try { window.dispatchEvent(new CustomEvent('engagement:refresh')); } catch {}
},
@@ -136,24 +195,41 @@ const CommentsSection: React.FC<Props> = ({ targetType, targetId }) => {
}, [allItems]);
const ReactionBar: React.FC<{ c: CommentItem }> = ({ c }) => {
const options: { key: string; label: string }[] = [
{ key: 'thumbs_up', label: '👍' },
{ key: 'heart', label: '❤️' },
{ key: 'smile', label: '😀' },
{ key: 'surprised', label: '😮' },
{ key: 'thumbs_down', label: '👎' },
const options: { key: string; label: string; color: string; name: string }[] = [
{ key: 'thumbs_up', label: '👍', color: 'green', name: 'Palec nahoru' },
{ key: 'heart', label: '❤️', color: 'pink', name: 'Srdíčko' },
{ key: 'smile', label: '😀', color: 'yellow', name: 'Úsměv' },
{ key: 'surprised', label: '😮', color: 'purple', name: 'Překvapení' },
{ key: 'thumbs_down', label: '👎', color: 'red', name: 'Palec dolů' },
];
const counts = c.reactions || {};
const active = c.my_reaction;
const isBusy = reactMut.isPending || unreactMut.isPending;
return (
<HStack spacing={2} mt={1}>
{options.map((o) => (
<Button key={o.key} size="xs" variant={active === o.key ? 'solid' : 'outline'} onClick={() => {
if (!isAuthenticated) return;
if (active === o.key) unreactMut.mutate(c.id); else reactMut.mutate({ id: c.id, type: o.key });
}}>
<HStack spacing={1}><Text as="span">{o.label}</Text><Text as="span" fontSize="xs">{counts[o.key] || 0}</Text></HStack>
</Button>
<Tooltip key={o.key} label={o.name} placement="top" hasArrow>
<Button
size="xs"
colorScheme={o.color}
variant={active === o.key ? 'solid' : 'outline'}
isDisabled={!isAuthenticated || isBusy}
aria-pressed={active === o.key}
onClick={() => {
if (!isAuthenticated) return;
if (active === o.key) {
unreactMut.mutate(c.id);
} else {
reactMut.mutate({ id: c.id, type: o.key });
}
}}
>
<HStack spacing={1}>
<Text as="span">{o.label}</Text>
<Text as="span" fontSize="xs">{counts[o.key] || 0}</Text>
</HStack>
</Button>
</Tooltip>
))}
</HStack>
);
@@ -25,7 +25,7 @@ import {
import ReactQuill from 'react-quill';
import ReactCrop, { Crop } from 'react-image-crop';
import DOMPurify from 'dompurify';
import 'react-quill/dist/quill.snow.css';
import 'quill/dist/quill.snow.css';
import 'react-image-crop/dist/ReactCrop.css';
import '../../styles/custom-editor.css';
import {
@@ -74,7 +74,6 @@ const CustomRichEditor: React.FC<CustomRichEditorProps> = ({
}) => {
const toast = useToast();
const quillRef = useRef<ReactQuill | null>(null);
const containerRef = useRef<HTMLDivElement | null>(null);
const toolbarRef = useRef<HTMLDivElement | null>(null);
const onChangeRef = useRef(onChange);
const selectedImageIdRef = useRef<string | null>(null);
@@ -99,7 +98,7 @@ const CustomRichEditor: React.FC<CustomRichEditorProps> = ({
const [cropFile, setCropFile] = useState<File | null>(null);
const [crop, setCrop] = useState<Crop>({ unit: '%', width: 80, height: 80, x: 10, y: 10 });
const [cropQuality, setCropQuality] = useState<number>(85);
const [cropMaxWidth, setCropMaxWidth] = useState<number>(1920);
const [cropMaxWidth, setCropMaxWidth] = useState<number>(1600);
const [cropProcessing, setCropProcessing] = useState(false);
const imgRef = useRef<HTMLImageElement | null>(null);
@@ -137,24 +136,6 @@ const CustomRichEditor: React.FC<CustomRichEditorProps> = ({
const [imageWidth, setImageWidth] = useState<number>(0);
const [manualWidth, setManualWidth] = useState<string>('');
const [widthPercent, setWidthPercent] = useState<number>(0);
const [isListStyleOpen, setIsListStyleOpen] = useState(false);
// Helper: wait for Quill editor/root to exist in DOM before manipulating toolbar or attaching listeners
const withEditor = useCallback((fn: (ed: any) => void) => {
let attempts = 0;
const tryRun = () => {
const ed = quillRef.current?.getEditor();
if (ed && ed.root && typeof document !== 'undefined' && document.contains(ed.root)) {
try { fn(ed); } catch {}
return;
}
if (attempts < 40) {
attempts++;
setTimeout(tryRun, 25);
}
};
tryRun();
}, []);
// Define toolbar configurations
const toolbarConfigs = {
@@ -162,7 +143,7 @@ const CustomRichEditor: React.FC<CustomRichEditorProps> = ({
[{ header: [1, 2, 3, false] }],
['bold', 'italic', 'underline', 'strike'],
[{ color: [] }, { background: [] }],
[{ list: 'ordered' }, { list: 'bullet' }, 'liststyle'],
[{ list: 'ordered' }, { list: 'bullet' }],
[{ align: [] }],
['link', 'image'],
['blockquote'],
@@ -171,8 +152,7 @@ const CustomRichEditor: React.FC<CustomRichEditorProps> = ({
basic: [
[{ header: [1, 2, 3, false] }],
['bold', 'italic', 'underline'],
[{ color: [] }, { background: [] }],
[{ list: 'ordered' }, { list: 'bullet' }, 'liststyle'],
[{ list: 'ordered' }, { list: 'bullet' }],
[{ align: [] }],
['link', 'image'],
['clean'],
@@ -254,92 +234,18 @@ const CustomRichEditor: React.FC<CustomRichEditorProps> = ({
setIsLinkOpen(true);
}, []);
// Apply bullet style (disc | circle | square) to the current list
const applyBulletStyle = useCallback((style: 'disc' | 'circle' | 'square') => {
const quill = quillRef.current?.getEditor();
if (!quill) return;
const range = quill.getSelection();
if (!range) return;
const [line] = quill.getLine(range.index);
const node = (line as any)?.domNode as HTMLElement | null;
if (!node) return;
// find nearest UL
let el: HTMLElement | null = node;
while (el && el.tagName !== 'UL' && el !== quill.root) {
el = el.parentElement;
}
if (el && el.tagName === 'UL') {
(el as HTMLElement).style.listStyleType = style;
onChangeRef.current(cleanEditorHTML(quill.root.innerHTML));
}
}, [onChangeRef]);
// Toggle bullet style through toolbar handler
const toggleListStyle = useCallback(() => {
const quill = quillRef.current?.getEditor();
if (!quill) return;
const range = quill.getSelection();
if (!range) return;
const [line] = quill.getLine(range.index);
let el: HTMLElement | null = (line as any)?.domNode as HTMLElement | null;
while (el && el.tagName !== 'UL' && el !== quill.root) {
el = el.parentElement;
}
if (el && el.tagName === 'UL') {
const current = (el.style.listStyleType || '').toLowerCase();
const next: 'disc' | 'circle' | 'square' = current === 'disc' ? 'circle' : current === 'circle' ? 'square' : 'disc';
applyBulletStyle(next);
} else {
quill.format('list', 'bullet');
setTimeout(() => {
try {
const [ln] = quill.getLine(range.index);
let n: HTMLElement | null = (ln as any)?.domNode as HTMLElement | null;
while (n && n.tagName !== 'UL' && n !== quill.root) n = n.parentElement;
if (n && n.tagName === 'UL') {
(n as HTMLElement).style.listStyleType = 'disc';
onChangeRef.current(cleanEditorHTML(quill.root.innerHTML));
}
} catch {}
}, 0);
}
}, [applyBulletStyle]);
const quillModules = useMemo(() => ({
toolbar: {
container: toolbarConfig,
handlers: {
image: onImageUpload ? handleImageUpload : undefined,
link: handleLinkToolbar,
liststyle: toggleListStyle,
list: (value: any) => {
const quill = quillRef.current?.getEditor();
if (!quill) return;
quill.format('list', value);
if (value === 'bullet') {
setTimeout(() => setIsListStyleOpen(true), 0);
}
},
},
},
clipboard: {
matchVisual: false,
},
}), [toolbarConfig, onImageUpload, handleImageUpload, handleLinkToolbar, toggleListStyle]);
useEffect(() => {
if (!isMounted) return;
let active = true;
withEditor((ed) => {
if (!active) return;
try {
const toolbarEl = ed.root.parentElement?.previousElementSibling as HTMLElement | null;
const btn = toolbarEl?.querySelector('.ql-liststyle') as HTMLButtonElement | null;
if (btn) btn.setAttribute('title', 'Styl odrážek');
} catch {}
});
return () => { active = false; };
}, [isMounted, toolbarConfig, withEditor]);
}), [toolbarConfig, onImageUpload, handleImageUpload, handleLinkToolbar]);
const quillFormats = useMemo(
() => [
@@ -363,100 +269,50 @@ const CustomRichEditor: React.FC<CustomRichEditorProps> = ({
// Localize Quill toolbar tooltips/labels to Czech
useEffect(() => {
if (!isMounted) return;
let active = true;
withEditor((editor) => {
if (!active) return;
const container = editor.root?.parentElement; // .ql-container
const toolbarEl = container?.previousElementSibling as HTMLElement | null; // .ql-toolbar
if (!toolbarEl) return;
const editor = quillRef.current?.getEditor();
if (!editor) return;
const container = editor.root?.parentElement; // .ql-container
const toolbarEl = container?.previousElementSibling as HTMLElement | null; // .ql-toolbar
if (!toolbarEl) return;
const setTitle = (selector: string, title: string) => {
toolbarEl.querySelectorAll(selector).forEach((el) => {
(el as HTMLElement).setAttribute('title', title);
(el as HTMLElement).setAttribute('aria-label', title);
});
};
const setTitle = (selector: string, title: string) => {
toolbarEl.querySelectorAll(selector).forEach((el) => {
(el as HTMLElement).setAttribute('title', title);
(el as HTMLElement).setAttribute('aria-label', title);
});
};
// Basic formatting
setTitle('button.ql-bold', 'Tučné');
setTitle('button.ql-italic', 'Kurzíva');
setTitle('button.ql-underline', 'Podtržení');
setTitle('button.ql-strike', 'Přeškrtnutí');
setTitle('button.ql-link', 'Vložit odkaz');
setTitle('button.ql-image', 'Vložit obrázek');
setTitle('button.ql-blockquote', 'Citace');
setTitle('button.ql-clean', 'Vyčistit formátování');
// Basic formatting
setTitle('button.ql-bold', 'Tučné');
setTitle('button.ql-italic', 'Kurzíva');
setTitle('button.ql-underline', 'Podtržení');
setTitle('button.ql-strike', 'Přeškrtnutí');
setTitle('button.ql-link', 'Vložit odkaz');
setTitle('button.ql-image', 'Vložit obrázek');
setTitle('button.ql-blockquote', 'Citace');
setTitle('button.ql-clean', 'Vyčistit formátování');
// Lists
setTitle('button.ql-list[value="ordered"]', 'Číslovaný seznam');
setTitle('button.ql-list[value="bullet"]', 'Odrážkový seznam');
// Lists
setTitle('button.ql-list[value="ordered"]', 'Číslovaný seznam');
setTitle('button.ql-list[value="bullet"]', 'Odrážkový seznam');
// Alignment
setTitle('button.ql-align', 'Zarovnání');
setTitle('button.ql-align[value=""]', 'Zarovnat vlevo');
setTitle('button.ql-align[value="center"]', 'Zarovnat na střed');
setTitle('button.ql-align[value="right"]', 'Zarovnat vpravo');
setTitle('button.ql-align[value="justify"]', 'Do bloku');
// Alignment
setTitle('button.ql-align', 'Zarovnání');
setTitle('button.ql-align[value=""]', 'Zarovnat vlevo');
setTitle('button.ql-align[value="center"]', 'Zarovnat na střed');
setTitle('button.ql-align[value="right"]', 'Zarovnat vpravo');
setTitle('button.ql-align[value="justify"]', 'Do bloku');
// Colors and background
setTitle('.ql-color .ql-picker-label', 'Barva textu');
setTitle('.ql-background .ql-picker-label', 'Barva pozadí');
// Inject reset option inside color/background pickers
try {
const injectReset = (
pickerSelector: string,
format: 'color' | 'background',
label: string
) => {
const picker = toolbarEl.querySelector(pickerSelector) as HTMLElement | null; // .ql-color or .ql-background
const options = picker?.querySelector('.ql-picker-options') as HTMLElement | null;
if (!options) return;
if (options.querySelector(`button.ql-picker-item[data-reset="${format}"]`)) return;
const btn = document.createElement('button');
btn.setAttribute('type', 'button');
btn.className = 'ql-picker-item';
btn.setAttribute('data-reset', format);
btn.setAttribute('title', label);
btn.setAttribute('aria-label', label);
btn.style.width = '16px';
btn.style.height = '16px';
btn.style.border = '1px solid #e2e8f0';
btn.style.borderRadius = '2px';
btn.style.position = 'relative';
btn.style.background = '#ffffff';
const slash = document.createElement('span');
slash.style.position = 'absolute';
slash.style.left = '2px';
slash.style.right = '2px';
slash.style.top = '7px';
slash.style.height = '2px';
slash.style.background = '#e53e3e';
slash.style.transform = 'rotate(-45deg)';
btn.appendChild(slash);
btn.addEventListener('click', (e) => {
e.preventDefault();
const q = quillRef.current?.getEditor();
if (!q) return;
q.format(format, false);
try { picker?.classList.remove('ql-expanded'); } catch {}
});
options.insertBefore(btn, options.firstChild);
};
injectReset('.ql-color', 'color', 'Zrušit barvu');
injectReset('.ql-background', 'background', 'Zrušit pozadí');
} catch {}
// Colors and background
setTitle('.ql-color .ql-picker-label', 'Barva textu');
setTitle('.ql-background .ql-picker-label', 'Barva pozadí');
// Headers
setTitle('.ql-header .ql-picker-label', 'Nadpis');
setTitle('.ql-header .ql-picker-item[data-value="1"]', 'Nadpis 1');
setTitle('.ql-header .ql-picker-item[data-value="2"]', 'Nadpis 2');
setTitle('.ql-header .ql-picker-item[data-value="3"]', 'Nadpis 3');
setTitle('button.ql-liststyle', 'Styl odrážek');
});
return () => { active = false; };
}, [isMounted, toolbar, withEditor]);
// (Removed) Previously injected custom bullet-style group; now using a single toolbar button 'liststyle'.
// Headers
setTitle('.ql-header .ql-picker-label', 'Nadpis');
setTitle('.ql-header .ql-picker-item[data-value="1"]', 'Nadpis 1');
setTitle('.ql-header .ql-picker-item[data-value="2"]', 'Nadpis 2');
setTitle('.ql-header .ql-picker-item[data-value="3"]', 'Nadpis 3');
}, [isMounted, toolbar]);
// Get cropped blob
const getCroppedBlob = (image: HTMLImageElement, cropPixels: { x: number; y: number; width: number; height: number }): Promise<Blob> => {
@@ -592,13 +448,8 @@ const CustomRichEditor: React.FC<CustomRichEditorProps> = ({
try { targetImg.setAttribute('width', String(px)); } catch {}
}
} catch {}
try {
if (document.contains(quill.root)) {
quill.setSelection(index + 1, 0, 'api');
} else {
setTimeout(() => { try { if (document.contains(quill.root)) quill.setSelection(index + 1, 0, 'api'); } catch {} }, 0);
}
} catch {}
// Move cursor after the image
quill.setSelection(index + 1, 0, 'api');
// Persist content so default width is saved
onChangeRef.current(cleanEditorHTML(quill.root.innerHTML));
toast({ title: 'Obrázek vložen', status: 'success', duration: 2000 });
@@ -627,7 +478,7 @@ const CustomRichEditor: React.FC<CustomRichEditorProps> = ({
setCropFile(null);
setCrop({ unit: '%', width: 80, height: 80, x: 10, y: 10 });
setCropQuality(85);
setCropMaxWidth(1920);
setCropMaxWidth(1600);
}
};
@@ -635,6 +486,7 @@ const CustomRichEditor: React.FC<CustomRichEditorProps> = ({
useEffect(() => {
const editor = quillRef.current?.getEditor();
if (!editor || readOnly) return;
const enableDragReposition = true;
let selectedImage: HTMLImageElement | null = null;
let resizeHandle: HTMLDivElement | null = null;
@@ -658,7 +510,7 @@ const CustomRichEditor: React.FC<CustomRichEditorProps> = ({
`;
// Position relative to Quill container (parent of .ql-editor)
const editorContainer = editor.root?.parentElement as HTMLElement | null;
const editorContainer = editor.root.parentElement as HTMLElement | null;
if (!editorContainer) return null;
const sizeLabel = document.createElement('div');
sizeLabel.style.cssText = `
@@ -678,18 +530,16 @@ const CustomRichEditor: React.FC<CustomRichEditorProps> = ({
try {
const edW = editor.root.clientWidth || w || 1;
const pct = Math.max(1, Math.min(100, Math.round((w / edW) * 100)));
sizeLabel.textContent = `${Math.round(w)} px (${pct}%)`;
const idAttr = img.getAttribute('data-img-id') || '';
sizeLabel.textContent = `${Math.round(w)} px (${pct}%)${idAttr ? `${idAttr}` : ''}`;
} catch {
sizeLabel.textContent = `${Math.round(w)} px`;
const idAttr = img.getAttribute('data-img-id') || '';
sizeLabel.textContent = `${Math.round(w)} px${idAttr ? `${idAttr}` : ''}`;
}
};
// Create edge handles (right, bottom, left, top)
// Only corner handles (edge dragging disabled)
const handles = [
{ position: 'right', cursor: 'ew-resize', width: '12px', height: '60%' },
{ position: 'bottom', cursor: 'ns-resize', width: '60%', height: '12px' },
{ position: 'left', cursor: 'ew-resize', width: '12px', height: '60%' },
{ position: 'top', cursor: 'ns-resize', width: '60%', height: '12px' },
{ position: 'bottom-right', cursor: 'nwse-resize', width: '20px', height: '20px', isCorner: true },
{ position: 'bottom-left', cursor: 'nesw-resize', width: '20px', height: '20px', isCorner: true },
{ position: 'top-right', cursor: 'nesw-resize', width: '20px', height: '20px', isCorner: true },
@@ -791,27 +641,6 @@ const CustomRichEditor: React.FC<CustomRichEditorProps> = ({
startWidth = img.offsetWidth;
const startHeight = img.offsetHeight;
const aspectRatio = startWidth / startHeight;
let lastWidth = startWidth;
// Reduce selection/paint costs during resize
try { (editor.root as HTMLElement).style.userSelect = 'none'; } catch {}
let frame = 0;
let pendingWidth: number | null = null;
const flush = () => {
frame = 0;
if (pendingWidth == null) return;
const newWidth = pendingWidth;
pendingWidth = null;
img.style.width = `${newWidth}px`;
img.style.maxWidth = '100%';
img.style.height = 'auto';
try { img.setAttribute('width', String(Math.round(newWidth))); } catch {}
updateHandlePositions();
updateSizeLabel(newWidth);
};
const schedule = () => {
if (frame) return;
frame = requestAnimationFrame(flush);
};
const onPointerMove = (ev: PointerEvent) => {
if (!isResizing) return;
const deltaX = ev.clientX - startX;
@@ -825,23 +654,23 @@ const CustomRichEditor: React.FC<CustomRichEditorProps> = ({
newWidth = startWidth + (deltaY * aspectRatio);
}
newWidth = Math.max(50, Math.min(newWidth, editor.root.clientWidth - 40));
lastWidth = newWidth;
pendingWidth = newWidth;
schedule();
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();
updateSizeLabel(newWidth);
};
const onPointerUp = () => {
isResizing = false;
document.removeEventListener('pointermove', onPointerMove);
document.removeEventListener('pointerup', onPointerUp);
if (frame) cancelAnimationFrame(frame);
if (pendingWidth != null) flush();
try { (editor.root as HTMLElement).style.userSelect = ''; } catch {}
setImageWidth(lastWidth);
setManualWidth(String(Math.round(lastWidth)));
try {
const editorWidth = editor.root.clientWidth || lastWidth || 1;
setWidthPercent(Math.max(1, Math.min(100, Math.round((lastWidth / editorWidth) * 100))));
} catch {}
onChangeRef.current(cleanEditorHTML(editor.root.innerHTML));
const id = selectedImageIdRef.current;
setTimeout(() => { if (id) { try { selectImageByIdRef.current?.(id); } catch {} } }, 30);
@@ -890,7 +719,7 @@ const CustomRichEditor: React.FC<CustomRichEditorProps> = ({
img.style.boxShadow = '0 4px 12px rgba(49, 130, 206, 0.3)';
// Prevent default drag behavior to avoid duplication
img.setAttribute('draggable', 'false');
img.setAttribute('draggable', enableDragReposition ? 'true' : 'false');
createResizeHandle(img);
@@ -976,20 +805,22 @@ const CustomRichEditor: React.FC<CustomRichEditorProps> = ({
const handleImageClick = (e: Event) => {
const target = e.target as HTMLElement;
if (target.tagName === 'IMG') {
// Support images wrapped in anchors or other elements (e.g., Zonerama links)
const imgEl = target.tagName === 'IMG' ? (target as HTMLImageElement) : (target.closest('img') as HTMLImageElement | null);
if (imgEl) {
e.preventDefault();
e.stopPropagation();
e.stopImmediatePropagation();
// In read-only mode, show preview instead of selecting
if (readOnly) {
const imgSrc = (target as HTMLImageElement).src;
const imgSrc = imgEl.src;
setPreviewImage(imgSrc);
setIsPreviewOpen(true);
return;
}
selectImage(target as HTMLImageElement);
selectImage(imgEl);
return; // Important: return early to prevent further processing
}
@@ -1013,13 +844,16 @@ const CustomRichEditor: React.FC<CustomRichEditorProps> = ({
const handleMouseDown = (e: MouseEvent) => {
const target = e.target as HTMLElement;
if (target.tagName === 'IMG' && selectedImage === target) {
if (enableDragReposition) {
return;
}
// Allow edge-drag fallback resize if overlay handle doesn't catch it
const rect = target.getBoundingClientRect();
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) {
if (false) {
e.preventDefault();
e.stopPropagation();
isResizing = true;
@@ -1029,22 +863,7 @@ const CustomRichEditor: React.FC<CustomRichEditorProps> = ({
const startHeight = (target as HTMLImageElement).offsetHeight;
const aspectRatio = startWidth / Math.max(1, startHeight);
const edge = nearRight ? 'right' : nearLeft ? 'left' : nearBottom ? 'bottom' : 'top';
let lastWidth = startWidth;
try { (editor.root as HTMLElement).style.userSelect = 'none'; } catch {}
let raf = 0;
let queued: number | null = null;
const flush = () => {
raf = 0;
if (queued == null) return;
const newWidth = queued; queued = null;
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 {}
handleScroll();
};
const schedule = () => { if (!raf) raf = requestAnimationFrame(flush); };
const onMouseMove: (ev: MouseEvent) => void = (ev: MouseEvent) => {
if (!isResizing) return;
const deltaX = ev.clientX - startX;
@@ -1054,32 +873,34 @@ const CustomRichEditor: React.FC<CustomRichEditorProps> = ({
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;
const maxW = (editor?.root?.clientWidth ?? (startWidth || 1200)) - 40;
newWidth = Math.max(50, Math.min(newWidth, maxW));
lastWidth = newWidth;
queued = newWidth;
schedule();
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);
if (raf) cancelAnimationFrame(raf);
if (queued != null) flush();
try { (editor.root as HTMLElement).style.userSelect = ''; } catch {}
setImageWidth(lastWidth);
setManualWidth(String(Math.round(lastWidth)));
try {
const editorWidth = editor.root.clientWidth || lastWidth || 1;
setWidthPercent(Math.max(1, Math.min(100, Math.round((lastWidth / editorWidth) * 100))));
} catch {}
onChangeRef.current(cleanEditorHTML(editor.root.innerHTML));
if (editor) { onChangeRef.current(cleanEditorHTML(editor.root.innerHTML)); }
};
document.addEventListener('mousemove', onMouseMove);
document.addEventListener('mouseup', onMouseUp);
return;
}
e.preventDefault();
e.stopPropagation();
isDragging = true;
@@ -1176,20 +997,69 @@ const CustomRichEditor: React.FC<CustomRichEditorProps> = ({
});
};
// Prevent default drag behavior on images
// Drag & drop repositioning for images inside editor
let draggedImage: HTMLImageElement | null = null;
const handleDragStart = (e: DragEvent) => {
const target = e.target as HTMLElement;
if (target.tagName === 'IMG') {
e.preventDefault();
e.stopPropagation();
return false;
if (target && target.tagName === 'IMG') {
draggedImage = target as HTMLImageElement;
try {
e.dataTransfer?.setData('text/plain', (target as HTMLImageElement).src || '');
e.dataTransfer!.effectAllowed = 'move';
} catch {}
}
};
const handleDragOver = (e: DragEvent) => {
if (draggedImage) {
e.preventDefault();
e.dataTransfer!.dropEffect = 'move';
}
};
const handleDrop = (e: DragEvent) => {
if (!draggedImage) return;
e.preventDefault();
e.stopPropagation();
const q = quillRef.current?.getEditor();
if (!q) return;
// Place caret to drop coordinates
try {
const sel = window.getSelection();
const anyDoc: any = document as any;
const docWithCaretRange = document as Document & {
caretRangeFromPoint?: (x: number, y: number) => Range;
};
const range = docWithCaretRange.caretRangeFromPoint
? docWithCaretRange.caretRangeFromPoint.call(document, e.clientX, e.clientY)
: typeof anyDoc.caretPositionFromPoint === 'function'
? (() => { const pos = anyDoc.caretPositionFromPoint(e.clientX, e.clientY); const r = document.createRange(); r.setStart(pos.offsetNode, pos.offset); r.setEnd(pos.offsetNode, pos.offset); return r; })()
: null;
if (range && sel) {
sel.removeAllRanges();
sel.addRange(range);
}
} catch {}
const dropRange = q.getSelection(true) || { index: q.getLength(), length: 0 };
const src = draggedImage.src;
// Remove original image node
try { draggedImage.remove(); } catch {}
// Insert at new location
q.insertEmbed(dropRange.index, 'image', src, 'user');
q.setSelection(dropRange.index + 1, 0, 'user');
onChangeRef.current(cleanEditorHTML(q.root.innerHTML));
// Reposition overlay if same image was selected
const id = selectedImageIdRef.current;
if (id) { setTimeout(() => { try { selectImageByIdRef.current?.(id); } catch {} }, 30); }
draggedImage = null;
};
editor.root.addEventListener('click', handleImageClick);
editor.root.addEventListener('mousedown', handleMouseDown);
editor.root.addEventListener('scroll', handleScroll);
editor.root.addEventListener('dragstart', handleDragStart);
const root = editor.root as HTMLElement;
root.addEventListener('click', handleImageClick);
root.addEventListener('scroll', handleScroll);
if (enableDragReposition) {
root.addEventListener('dragstart', handleDragStart);
root.addEventListener('dragover', handleDragOver);
root.addEventListener('drop', handleDrop);
}
document.addEventListener('keydown', handleKeyDown);
// Also reposition on window resize and any document scroll (capture phase)
window.addEventListener('resize', handleScroll);
@@ -1197,9 +1067,13 @@ const CustomRichEditor: React.FC<CustomRichEditorProps> = ({
return () => {
editor.root.removeEventListener('click', handleImageClick);
editor.root.removeEventListener('mousedown', handleMouseDown);
editor.root.removeEventListener('scroll', handleScroll);
editor.root.removeEventListener('dragstart', handleDragStart);
const root = editor.root as HTMLElement;
root.removeEventListener('scroll', handleScroll);
if (enableDragReposition) {
root.removeEventListener('dragstart', handleDragStart);
root.removeEventListener('dragover', handleDragOver);
root.removeEventListener('drop', handleDrop);
}
document.removeEventListener('keydown', handleKeyDown);
window.removeEventListener('resize', handleScroll);
document.removeEventListener('scroll', handleScroll, true);
@@ -1209,6 +1083,65 @@ const CustomRichEditor: React.FC<CustomRichEditorProps> = ({
};
}, [readOnly, toast, isMounted]);
// Auto-resize very large images (e.g., pasted/Zonerama) to a comfortable width for editing
useEffect(() => {
const editor = quillRef.current?.getEditor();
if (!editor || readOnly) return;
const root = editor.root as HTMLElement;
const COMFORTABLE_MAX = 1600; // px
const processImg = async (img: HTMLImageElement) => {
try {
if (!img || img.getAttribute('data-auto-resized') === '1') return;
const doResize = async () => {
// Skip if we've already processed or if image is already small
const natW = img.naturalWidth || 0;
if (natW > COMFORTABLE_MAX) {
try {
toast({ title: 'Optimalizace velkého obrázku…', status: 'info', duration: 1500 });
} catch {}
try {
const res = await quickEditImage({ image_url: img.src, width: COMFORTABLE_MAX, quality: 85 });
if (res?.url) {
const newUrl = assetUrl(res.url) || res.url;
img.src = newUrl;
img.setAttribute('data-auto-resized', '1');
img.style.maxWidth = '100%';
img.style.height = 'auto';
const q = quillRef.current?.getEditor();
if (q) {
onChangeRef.current(cleanEditorHTML(q.root.innerHTML));
// If this image is selected, reselect to reposition overlay
const id = selectedImageIdRef.current;
if (id) setTimeout(() => { try { selectImageByIdRef.current?.(id); } catch {} }, 30);
}
}
} catch (e) {
console.error('Auto-resize failed', e);
}
}
};
if (img.complete) doResize();
else img.addEventListener('load', () => doResize(), { once: true });
} catch {}
};
// Initial scan
root.querySelectorAll('img').forEach((n) => processImg(n as HTMLImageElement));
// Observe changes
const mo = new MutationObserver((mutations) => {
mutations.forEach((m) => {
m.addedNodes.forEach((node) => {
if (node instanceof HTMLImageElement) processImg(node);
else if (node instanceof HTMLElement) node.querySelectorAll?.('img').forEach((el) => processImg(el as HTMLImageElement));
});
});
});
mo.observe(root, { childList: true, subtree: true });
return () => mo.disconnect();
}, [readOnly, isMounted, toast]);
// Apply filters to selected image
const applyFiltersToImage = useCallback((img: HTMLImageElement, filters: ImageFilters) => {
const filterString = `
@@ -1229,6 +1162,7 @@ const CustomRichEditor: React.FC<CustomRichEditorProps> = ({
img.style.filter = filterString;
img.style.transform = transform;
img.style.transformOrigin = "center center";
img.setAttribute('data-filters', JSON.stringify(filters));
}, []);
@@ -1265,6 +1199,12 @@ const CustomRichEditor: React.FC<CustomRichEditorProps> = ({
const editor = quillRef.current?.getEditor();
if (editor) {
onChangeRef.current(cleanEditorHTML(editor.root.innerHTML));
try { editor.root.dispatchEvent(new Event('scroll')); } catch {}
}
// Keep selection active and overlay positioned after DOM update
const id = selectedImageIdRef.current;
if (id) {
setTimeout(() => { try { selectImageByIdRef.current?.(id); } catch {} }, 30);
}
}
return newFilters;
@@ -1387,6 +1327,7 @@ const CustomRichEditor: React.FC<CustomRichEditorProps> = ({
setManualWidth(finalWidth.toString());
if (editor) {
onChangeRef.current(cleanEditorHTML(editor.root.innerHTML));
try { editor.root.dispatchEvent(new Event('scroll')); } catch {}
}
// Keep selection active for subsequent operations (e.g., 50% → 75%)
reselectAfterContentUpdate();
@@ -1458,11 +1399,140 @@ const CustomRichEditor: React.FC<CustomRichEditorProps> = ({
}
}, [selectedImageElement, toast]);
// Defer heavy sanitization to submit time to prevent selection glitches; keep minimal cleanup only
// Sanitize HTML on change and keep author-selected colors intact
const handleChange = (content: string) => {
onChangeRef.current(content);
// First sanitize
let cleaned = DOMPurify.sanitize(content, {
USE_PROFILES: { html: true },
ADD_TAGS: ['iframe'],
ADD_ATTR: ['target', 'rel', 'allow', 'allowfullscreen', 'style', 'data-filters', 'data-img-id', 'data-bullets', 'data-list'],
});
onChangeRef.current(cleanEditorHTML(cleaned));
};
// Apply bullet style (disc | circle | square) to the current list
const applyBulletStyle = useCallback((style: 'disc' | 'circle' | 'square') => {
const quill = quillRef.current?.getEditor();
if (!quill) return;
const range = quill.getSelection();
if (!range) return;
const [line] = quill.getLine(range.index);
const node = (line as any)?.domNode as HTMLElement | null;
if (!node) return;
// find nearest UL and set custom data attribute for CSS-based bullet override (Quill v2)
let el: HTMLElement | null = node;
while (el && el.tagName !== 'UL' && el !== quill.root) {
el = el.parentElement;
}
if (el && el.tagName === 'UL') {
(el as HTMLElement).setAttribute('data-bullets', style);
onChangeRef.current(cleanEditorHTML(quill.root.innerHTML));
}
}, [onChangeRef]);
// Enhance toolbar: add bullet-style popover and color reset buttons
useEffect(() => {
if (!isMounted) return;
const editor = quillRef.current?.getEditor();
if (!editor) return;
const container = editor.root?.parentElement; // .ql-container
const toolbarEl = container?.previousElementSibling as HTMLElement | null; // .ql-toolbar
if (!toolbarEl) return;
// Add reset buttons next to color/background pickers
const addResetButton = (selector: string, className: string, formatName: 'color' | 'background') => {
const picker = toolbarEl.querySelector(selector) as HTMLElement | null;
if (picker && !toolbarEl.querySelector(`button.${className}`)) {
const btn = document.createElement('button');
btn.type = 'button';
btn.className = className;
btn.setAttribute('title', formatName === 'color' ? 'Reset barvy textu' : 'Reset barvy pozadí');
btn.addEventListener('click', (e) => {
e.preventDefault();
e.stopPropagation();
const q = quillRef.current?.getEditor();
if (!q) return;
q.format(formatName, false, 'user');
onChangeRef.current(cleanEditorHTML(q.root.innerHTML));
});
(picker.parentElement as HTMLElement)?.insertBefore(btn, picker.nextSibling);
}
};
addResetButton('.ql-color .ql-picker', 'ql-colorreset', 'color');
addResetButton('.ql-background .ql-picker', 'ql-bgreset', 'background');
// Create bullet styles popover and attach to bullet list button
const bulletBtn = toolbarEl.querySelector('button.ql-list[value="bullet"]') as HTMLButtonElement | null;
if (!bulletBtn) return;
let popover = toolbarEl.querySelector('.bullet-style-popover') as HTMLDivElement | null;
if (!popover) {
popover = document.createElement('div');
popover.className = 'bullet-style-popover';
popover.style.cssText = 'position:absolute;display:none;background:#fff;border:1px solid rgba(0,0,0,0.15);border-radius:8px;box-shadow:0 8px 24px rgba(0,0,0,0.15);padding:6px;gap:6px;z-index:1000;';
const mk = (label: string, st: 'disc'|'circle'|'square') => {
const b = document.createElement('button');
b.type = 'button';
b.className = 'ql-bulletstyle';
b.textContent = label;
b.style.cssText = 'min-width:32px;height:28px;padding:0 8px;border-radius:6px;border:1px solid #e2e8f0;background:#fff;cursor:pointer;';
b.addEventListener('click', (e) => {
e.preventDefault();
e.stopPropagation();
const q = quillRef.current?.getEditor();
if (!q) return;
const range = q.getSelection(true);
if (range) {
q.format('list', 'bullet', 'user');
applyBulletStyle(st);
}
if (popover) popover.style.display = 'none';
});
b.addEventListener('mouseenter', () => { b.style.background = '#f7fafc'; });
b.addEventListener('mouseleave', () => { b.style.background = '#fff'; });
return b;
};
popover.appendChild(mk('•', 'disc'));
popover.appendChild(mk('○', 'circle'));
popover.appendChild(mk('▪', 'square'));
toolbarEl.appendChild(popover);
}
let hideTimer: number | null = null;
const show = () => {
if (!popover) return;
const rect = bulletBtn.getBoundingClientRect();
const tRect = toolbarEl.getBoundingClientRect();
popover.style.left = `${rect.left - tRect.left}px`;
popover.style.top = `${rect.bottom - tRect.top + 6}px`;
popover.style.display = 'flex';
};
const toggle = (e: MouseEvent) => {
e.preventDefault();
e.stopPropagation();
if (!popover) return;
if (popover.style.display === 'flex') {
popover.style.display = 'none';
} else {
show();
}
};
const scheduleHide = () => {
if (hideTimer) window.clearTimeout(hideTimer);
hideTimer = window.setTimeout(() => { if (popover) popover.style.display = 'none'; }, 200);
};
const cancelHide = () => { if (hideTimer) { window.clearTimeout(hideTimer); hideTimer = null; } };
bulletBtn.addEventListener('mouseenter', show);
bulletBtn.addEventListener('click', toggle);
bulletBtn.addEventListener('mouseleave', scheduleHide);
popover.addEventListener('mouseenter', cancelHide);
popover.addEventListener('mouseleave', scheduleHide);
return () => {
bulletBtn.removeEventListener('mouseenter', show);
bulletBtn.removeEventListener('click', toggle);
bulletBtn.removeEventListener('mouseleave', scheduleHide);
popover && popover.removeEventListener('mouseenter', cancelHide);
popover && popover.removeEventListener('mouseleave', scheduleHide);
};
}, [isMounted, applyBulletStyle]);
const insertOrUpdateLink = useCallback(() => {
const quill = quillRef.current?.getEditor();
@@ -1479,22 +1549,10 @@ const CustomRichEditor: React.FC<CustomRichEditorProps> = ({
// Replace selected text with provided text and link
quill.deleteText(range.index, range.length, 'user');
quill.insertText(range.index, text || url, 'link', url, 'user');
try {
if (document.contains(quill.root)) {
quill.setSelection(range.index + (text || url).length, 0, 'user');
} else {
setTimeout(() => { try { if (document.contains(quill.root)) quill.setSelection(range.index + (text || url).length, 0, 'user'); } catch {} }, 0);
}
} catch {}
quill.setSelection(range.index + (text || url).length, 0, 'user');
} else {
quill.insertText(range.index, text || url, 'link', url, 'user');
try {
if (document.contains(quill.root)) {
quill.setSelection(range.index + (text || url).length, 0, 'user');
} else {
setTimeout(() => { try { if (document.contains(quill.root)) quill.setSelection(range.index + (text || url).length, 0, 'user'); } catch {} }, 0);
}
} catch {}
quill.setSelection(range.index + (text || url).length, 0, 'user');
}
onChangeRef.current(cleanEditorHTML(quill.root.innerHTML));
setIsLinkOpen(false);
@@ -1522,7 +1580,6 @@ const CustomRichEditor: React.FC<CustomRichEditorProps> = ({
</Text>
</HStack>
)}
<Box display="none" />
</VStack>
)}
@@ -1533,7 +1590,6 @@ const CustomRichEditor: React.FC<CustomRichEditorProps> = ({
borderRadius="md"
overflow="visible"
bg={bgColor}
ref={containerRef}
sx={{
'.ql-toolbar': {
borderBottom: '1px solid',
@@ -1587,11 +1643,6 @@ const CustomRichEditor: React.FC<CustomRichEditorProps> = ({
boxShadow: '0 4px 12px rgba(0,0,0,0.15)',
padding: '8px',
},
'& .ql-liststyle::before': {
content: '"•◦▪"',
fontSize: '14px',
fontWeight: 'bold',
},
},
'.ql-container': {
fontSize: '16px',
@@ -1675,12 +1726,10 @@ const CustomRichEditor: React.FC<CustomRichEditorProps> = ({
height: 'auto',
display: 'block',
margin: '12px 0',
transition: 'box-shadow 0.15s ease, opacity 0.15s ease, transform 0.15s ease',
transition: 'all 0.2s ease',
borderRadius: '4px',
userSelect: 'none',
pointerEvents: 'auto',
WebkitUserDrag: 'none',
userDrag: 'none',
'&:hover': {
opacity: 0.95,
transform: 'scale(1.01)',
@@ -1704,18 +1753,6 @@ const CustomRichEditor: React.FC<CustomRichEditorProps> = ({
ref={quillRef}
modules={quillModules}
formats={quillFormats}
onBlur={(_prev, _source, editor) => {
try {
const ed = quillRef.current?.getEditor();
const html = editor?.getHTML ? editor.getHTML() : (ed?.root?.innerHTML || value);
const cleaned = cleanEditorHTML(html);
if (cleaned !== value) {
setTimeout(() => {
try { onChangeRef.current(cleaned); } catch {}
}, 0);
}
} catch {}
}}
/>
)}
</Box>
@@ -2111,47 +2148,11 @@ const CustomRichEditor: React.FC<CustomRichEditorProps> = ({
</ModalBody>
<ModalFooter>
<Button variant="ghost" mr={3} onClick={() => setIsLinkOpen(false)}>Zrušit</Button>
<Button
variant="outline"
colorScheme="red"
mr={3}
onClick={() => {
const quill = quillRef.current?.getEditor();
if (!quill) return;
const r = quill.getSelection() || linkRangeRef.current || { index: quill.getLength(), length: 0 };
quill.format('link', false);
onChangeRef.current(cleanEditorHTML(quill.root.innerHTML));
setIsLinkOpen(false);
setLinkText('');
setLinkUrl('');
}}
>
Odstranit odkaz
</Button>
<Button colorScheme="blue" onClick={insertOrUpdateLink}>Vložit</Button>
</ModalFooter>
</ModalContent>
</Modal>
{/* Bullet Style Modal */}
<Modal isOpen={isListStyleOpen} onClose={() => setIsListStyleOpen(false)} isCentered>
<ModalOverlay />
<ModalContent>
<ModalHeader>Styl odrážek</ModalHeader>
<ModalCloseButton />
<ModalBody>
<VStack align="stretch" spacing={2}>
<Button onClick={() => { applyBulletStyle('disc'); setIsListStyleOpen(false); }}> Plné tečky</Button>
<Button onClick={() => { applyBulletStyle('circle'); setIsListStyleOpen(false); }}> Kroužky</Button>
<Button onClick={() => { applyBulletStyle('square'); setIsListStyleOpen(false); }}> Čtverečky</Button>
</VStack>
</ModalBody>
<ModalFooter>
<Button variant="ghost" onClick={() => setIsListStyleOpen(false)}>Zavřít</Button>
</ModalFooter>
</ModalContent>
</Modal>
{/* Crop Modal */}
{/* Image Preview Modal */}
<Modal isOpen={isPreviewOpen} onClose={() => setIsPreviewOpen(false)} size="6xl" isCentered>
+63 -18
View File
@@ -3,13 +3,12 @@ import {
Button,
HStack,
Icon,
Link as ChakraLink,
Text,
VStack,
useColorModeValue,
} from '@chakra-ui/react';
import {
FiExternalLink,
FiDownload,
FiFile,
FiFileText,
FiImage,
@@ -24,6 +23,7 @@ export interface FilePreviewProps {
mimeType?: string;
size?: number;
showInline?: boolean;
buttonOnly?: boolean;
}
const FilePreview: React.FC<FilePreviewProps> = ({
@@ -31,10 +31,20 @@ const FilePreview: React.FC<FilePreviewProps> = ({
name,
mimeType = '',
size,
buttonOnly = false,
}) => {
const fullUrl = assetUrl(url) || url;
const fileName = name || url.split('/').pop() || 'file';
const shortenName = (n: string, max = 34) => {
const base = String(n || '').trim();
if (base.length <= max) return base;
const dot = base.lastIndexOf('.');
const ext = dot > 0 ? base.slice(dot) : '';
const keep = Math.max(10, max - (ext.length + 3));
return `${base.slice(0, keep)}${ext}`;
};
const displayName = shortenName(fileName);
const mime = mimeType.toLowerCase();
const borderColor = useColorModeValue('gray.200', 'gray.700');
@@ -72,7 +82,51 @@ const FilePreview: React.FC<FilePreviewProps> = ({
const sizeMB = sizeKB && sizeKB > 1024 ? (sizeKB / 1024).toFixed(1) : undefined;
const sizeStr = sizeMB ? `${sizeMB} MB` : sizeKB ? `${sizeKB} kB` : '';
// Simplified preview: only provide an "Open in new window" action
// Action button handler
const handleDownload = async () => {
const fallback = () => {
try {
const a = document.createElement('a');
a.href = fullUrl;
a.setAttribute('download', fileName);
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
} catch {}
};
try {
const res = await fetch(fullUrl);
if (!res.ok) return fallback();
const blob = await res.blob();
const urlObj = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = urlObj;
a.setAttribute('download', fileName);
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
setTimeout(() => URL.revokeObjectURL(urlObj), 2000);
} catch {
fallback();
}
};
// Button-only compact variant (for tight sidebars like Přílohy)
if (buttonOnly) {
return (
<Button
size="xs"
leftIcon={<FiDownload />}
colorScheme="blue"
variant="outline"
onClick={handleDownload}
>
Stáhnout
</Button>
);
}
// Default: row with icon, truncated name and action
return (
<HStack
justify="space-between"
@@ -81,30 +135,21 @@ const FilePreview: React.FC<FilePreviewProps> = ({
borderColor={borderColor}
borderRadius="md"
bg={cardBg}
flexWrap="wrap"
w="100%"
>
<HStack flex={1} minW={0}>
<Icon as={fileInfo.icon} color={fileInfo.color} flexShrink={0} />
<VStack align="start" spacing={0} flex={1} minW={0}>
<Text
fontWeight="medium"
isTruncated
maxW="100%"
>
{fileName}
<Text fontWeight="medium" isTruncated maxW="100%">
{displayName}
</Text>
{sizeStr && <Text fontSize="xs" color={mutedText}>{sizeStr}</Text>}
</VStack>
</HStack>
<HStack spacing={2} flexShrink={0}>
<Button
as={ChakraLink}
href={fullUrl}
isExternal
size="sm"
leftIcon={<FiExternalLink />}
colorScheme="blue"
>
Otevřít v novém okně
<Button size="sm" leftIcon={<FiDownload />} colorScheme="blue" onClick={handleDownload}>
Stáhnout
</Button>
</HStack>
</HStack>
+17 -90
View File
@@ -10,12 +10,10 @@ import {
HStack,
Button,
Text,
useToast,
IconButton,
VStack,
Link,
} from '@chakra-ui/react';
import { Download, ExternalLink } from 'lucide-react';
import { API_URL } from '../../services/api';
import { ExternalLink } from 'lucide-react';
interface PhotoModalProps {
isOpen: boolean;
@@ -32,47 +30,7 @@ const PhotoModal: React.FC<PhotoModalProps> = ({
pageUrl,
albumTitle,
}) => {
const toast = useToast();
const getProxyUrl = (url: string) => {
return `${API_URL}/gallery/proxy-image?url=${encodeURIComponent(url)}`;
};
const handleDownload = async () => {
try {
const response = await fetch(getProxyUrl(photoUrl));
if (!response.ok) {
throw new Error('Failed to fetch image');
}
const blob = await response.blob();
const url = window.URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = `fotka-${Date.now()}.jpg`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
window.URL.revokeObjectURL(url);
toast({
title: 'Stahování zahájeno',
description: 'Fotka se stahuje',
status: 'success',
duration: 2000,
isClosable: true,
});
} catch (error) {
console.error('Failed to download image:', error);
toast({
title: 'Chyba',
description: 'Nepodařilo se stáhnout obrázek',
status: 'error',
duration: 2000,
isClosable: true,
});
}
};
return (
<Modal isOpen={isOpen} onClose={onClose} size="6xl" isCentered>
@@ -107,6 +65,7 @@ const PhotoModal: React.FC<PhotoModalProps> = ({
objectFit="contain"
loading="lazy"
/>
</Box>
{/* Controls */}
@@ -125,53 +84,21 @@ const PhotoModal: React.FC<PhotoModalProps> = ({
</Text>
)}
<HStack spacing={2} justify="space-between" flexWrap="wrap">
<HStack spacing={2}>
<Button
leftIcon={<Download size={18} />}
onClick={handleDownload}
colorScheme="green"
size="sm"
>
Stáhnout
</Button>
<Button
as="a"
href={pageUrl}
target="_blank"
rel="noopener noreferrer"
leftIcon={<ExternalLink size={18} />}
colorScheme="purple"
size="sm"
>
Zobrazit originál
</Button>
</HStack>
<HStack spacing={2} justify="flex-start" flexWrap="wrap">
<Button
as="a"
href={pageUrl}
target="_blank"
rel="noopener noreferrer"
leftIcon={<ExternalLink size={18} />}
colorScheme="purple"
size="sm"
>
Zobrazit originál
</Button>
</HStack>
{/* Zonerama Copyright */}
<Box
pt={2}
borderTopWidth="1px"
borderColor="gray.200"
>
<HStack spacing={2} fontSize="xs" color="gray.500">
<Text>
© Fotografie z{' '}
<Text
as="a"
href="https://zonerama.com"
target="_blank"
rel="noopener noreferrer"
color="blue.500"
fontWeight="600"
_hover={{ textDecoration: 'underline' }}
>
Zonerama
</Text>
</Text>
</HStack>
</Box>
{/* Attribution moved into image overlay */}
</VStack>
</Box>
</VStack>
@@ -167,7 +167,7 @@ const GallerySection: React.FC<{ zoneramaUrl?: string | null }> = ({ zoneramaUrl
</Button>
</HStack>
{/* Zonerama Attribution */}
{/* Zonerama Attribution (single source of truth) */}
<Box
bg={infoBg}
borderWidth="1px"
@@ -177,7 +177,7 @@ const GallerySection: React.FC<{ zoneramaUrl?: string | null }> = ({ zoneramaUrl
py={2}
>
<Text fontSize="xs" color={infoText}>
📸 Všechny fotografie jsou z platformy{' '}
© Fotografie z{' '}
<Text
as="a"
href={zoneramaUrl || profileUrl || 'https://zonerama.com'}
@@ -74,26 +74,6 @@ const PhotosSection: React.FC<{ zoneramaUrl?: string | null }> = ({ zoneramaUrl
</Box>
)}
{/* Zonerama Attribution */}
{albums.length > 0 && (
<Box bg="blue.50" borderWidth="1px" borderColor="blue.200" color="blue.800" p={2} borderRadius="md" mb={3} fontSize="xs">
<Text>
📸 Fotografie z{' '}
<Text
as="a"
href={zoneramaUrl || 'https://zonerama.com'}
target="_blank"
rel="noopener noreferrer"
fontWeight="600"
color="blue.600"
_hover={{ textDecoration: 'underline' }}
>
Zonerama
</Text>
</Text>
</Box>
)}
<Grid templateColumns={{ base: '1fr', md: 'repeat(2, 1fr)', lg: 'repeat(3, 1fr)' }} gap={4}>
{albums.map((album) => {
const coverPhoto = album.photos && album.photos.length > 0 ? album.photos[0] : null;
@@ -191,6 +191,26 @@ const VideosSection: React.FC<Props> = ({ videos, variant }) => {
decoding="async"
referrerPolicy="origin-when-cross-origin"
style={{ objectFit: 'cover' }}
data-fallback-idx={0 as any}
onError={(e: any) => {
try {
const el = e.currentTarget as HTMLImageElement & { dataset: { fallbackIdx?: string } };
const idx = Number(el.dataset.fallbackIdx || '0');
const id = it.videoId || '';
const chain = id
? [
`https://i.ytimg.com/vi/${id}/mqdefault.jpg`,
`https://i.ytimg.com/vi/${id}/sddefault.jpg`,
`https://i.ytimg.com/vi/${id}/hqdefault.jpg`,
'/dist/img/logo-club-empty.svg',
]
: ['/dist/img/logo-club-empty.svg'];
if (idx < chain.length) {
el.src = chain[idx];
el.dataset.fallbackIdx = String(idx + 1);
}
} catch {}
}}
/>
) : (
<Box bg={placeholderBg} display="flex" alignItems="center" justifyContent="center">
+12 -1
View File
@@ -19,6 +19,8 @@ interface EmbeddedPollProps {
title?: string;
showTitle?: boolean;
maxPolls?: number;
// When true, render without outer background/padding so parent wrapper controls layout
unstyled?: boolean;
}
/**
@@ -32,6 +34,7 @@ const EmbeddedPoll: React.FC<EmbeddedPollProps> = ({
title = 'Hlasování',
showTitle = true,
maxPolls,
unstyled = false,
}) => {
const bgSection = useColorModeValue('gray.50', 'gray.900');
@@ -100,8 +103,13 @@ const EmbeddedPoll: React.FC<EmbeddedPollProps> = ({
return null;
}
// Wrapper styling: allow transparent/compact when unstyled
const wrapperProps = unstyled
? { bg: 'transparent', py: 0, px: 0, borderRadius: 'none' as any, my: 0 }
: { bg: bgSection, py: 8, px: 4, borderRadius: 'xl' as any, my: 8 };
return (
<Box bg={bgSection} py={8} px={4} borderRadius="xl" my={8}>
<Box {...wrapperProps}>
<VStack spacing={6} maxW="6xl" mx="auto">
{showTitle && (
<Heading size="md" textAlign="center">
@@ -139,6 +147,7 @@ const EmbeddedPoll: React.FC<EmbeddedPollProps> = ({
hasVoted={pollResponse.has_voted}
isActive={pollResponse.is_active}
canShowResults={pollResponse.can_show_results}
flat={unstyled}
/>
</Box>
);
@@ -153,6 +162,7 @@ const EmbeddedPoll: React.FC<EmbeddedPollProps> = ({
hasVoted={pollResponse.has_voted}
isActive={pollResponse.is_active}
canShowResults={pollResponse.can_show_results}
flat={unstyled}
/>
</Box>
))}
@@ -168,6 +178,7 @@ const EmbeddedPoll: React.FC<EmbeddedPollProps> = ({
hasVoted={pollResponse.has_voted}
isActive={pollResponse.is_active}
canShowResults={pollResponse.can_show_results}
flat={unstyled}
/>
</Box>
))}
+13 -10
View File
@@ -40,6 +40,8 @@ interface PollCardProps {
isActive: boolean;
canShowResults: boolean;
onVoteSuccess?: () => void;
// When true, render transparent card without own bg/border/shadow
flat?: boolean;
}
const PollCard: React.FC<PollCardProps> = ({
@@ -48,6 +50,7 @@ const PollCard: React.FC<PollCardProps> = ({
isActive,
canShowResults: initialCanShowResults,
onVoteSuccess,
flat = false,
}) => {
const toast = useToast();
const queryClient = useQueryClient();
@@ -254,12 +257,12 @@ const PollCard: React.FC<PollCardProps> = ({
return (
<Box
bg={bgCard}
borderWidth="1px"
borderColor={borderColor}
bg={flat ? 'transparent' : bgCard}
borderWidth={flat ? '0' : '1px'}
borderColor={flat ? 'transparent' : borderColor}
borderRadius="xl"
p={6}
boxShadow="md"
p={flat ? 0 : 6}
boxShadow={flat ? 'none' : 'md'}
>
<VStack spacing={4} align="stretch">
{poll.image_url && (
@@ -319,12 +322,12 @@ const PollCard: React.FC<PollCardProps> = ({
// Show voting form
return (
<Box
bg={bgCard}
borderWidth="1px"
borderColor={borderColor}
bg={flat ? 'transparent' : bgCard}
borderWidth={flat ? '0' : '1px'}
borderColor={flat ? 'transparent' : borderColor}
borderRadius="xl"
p={6}
boxShadow="md"
p={flat ? 0 : 6}
boxShadow={flat ? 'none' : 'md'}
>
<VStack spacing={4} align="stretch">
{poll.image_url && (
@@ -53,13 +53,10 @@ const styleBlock = `
.scoreboard { display: flex; justify-content: space-between; align-items: center; background: rgba(0,0,0,0.75); color: #ffffff; padding: 18px 28px; font-size: 32px; font-weight: 700; border-radius: 14px; width: min(90vw, 900px); margin: 24px auto; gap: 20px; box-shadow: 0 8px 24px rgba(0,0,0,0.35); backdrop-filter: blur(6px); border: 1px solid rgba(255,255,255,0.15); }
.scoreboard.pill { background: var(--pill-bg, #f8fafc); color: var(--pill-text, #0f172a); border: 1px solid #e5e7eb; box-shadow: 0 10px 30px rgba(2,6,23,0.18); border-radius: 999px; padding: 4px 6px; width: max-content; margin: 0 auto; gap: 6px; backdrop-filter: none; font-size: 15px; transform: scale(var(--pill-scale, 1.7)); transform-origin: center; will-change: transform; }
.scoreboard.pill .seg { display: flex; align-items: center; justify-content: center; height: 36px; }
.scoreboard.pill .seg.timer { font-variant-numeric: tabular-nums; font-weight: 800; background: linear-gradient(180deg, #eef2f7 0%, #e2e8f0 100%); padding: 0 8px; border-radius: 999px; font-size: 15px; color: #0f172a; }
.scoreboard.pill .seg.team { color: #ffffff; padding: 0 10px; border-radius: 10px; font-weight: 800; letter-spacing: 0.5px; min-width: 46px; text-transform: uppercase; position: relative; overflow: visible; }
.scoreboard.pill .seg.team.home { background: linear-gradient(90deg, var(--home-dark), var(--home-light)); }
.scoreboard.pill .seg.team.away { background: linear-gradient(90deg, var(--away-dark), var(--away-light)); }
.scoreboard.pill .seg.team.home::before, .scoreboard.pill .seg.team.away::after { position: absolute; top: 0; width: 12px; height: 100%; background: inherit; content: ''; }
.scoreboard.pill .seg.team.home::before { left: -6px; border-top-left-radius: 999px; border-bottom-left-radius: 999px; }
.scoreboard.pill .seg.team.away::after { right: -6px; border-top-right-radius: 999px; border-bottom-right-radius: 999px; }
.scoreboard.pill .seg.timer { font-variant-numeric: tabular-nums; font-weight: 800; background: linear-gradient(180deg, #eef2f7 0%, #e2e8f0 100%); padding: 0 12px 0 8px; border-radius: 999px; font-size: 15px; color: #0f172a; }
.scoreboard.pill .seg.team { padding: 0 10px; border-radius: 10px; font-weight: 800; letter-spacing: 0.5px; min-width: 46px; text-transform: uppercase; position: relative; overflow: visible; }
.scoreboard.pill .seg.team.home { background: linear-gradient(90deg, var(--home-dark), var(--home-light)); color: var(--home-text, #ffffff); }
.scoreboard.pill .seg.team.away { background: linear-gradient(90deg, var(--away-dark), var(--away-light)); color: var(--away-text, #ffffff); }
.scoreboard.pill .seg.score { background: linear-gradient(180deg, #ffffff 0%, #f3f4f6 100%); border: 1px solid #e5e7eb; border-radius: 10px; padding: 0 10px; font-weight: 800; color: #0f172a; min-width: 58px; box-shadow: inset 0 1px 0 rgba(255,255,255,0.9); font-size: 15px; }
.scoreboard.pill .divider { width: 2px; height: 14px; background: rgba(15,23,42,0.35); border-radius: 1px; align-self: center; }
.scoreboard.pill .team .logo { width: 24px; height: 24px; object-fit: contain; margin-right: 6px; filter: drop-shadow(0 1px 1px rgba(0,0,0,0.2)); }
@@ -104,6 +101,10 @@ const MyClubOverlay: React.FC<{ state: ScoreboardState }> = ({ state }) => {
'--away-dark': right.color,
// @ts-ignore
'--away-light': shade(right.color, 20),
// @ts-ignore
'--home-text': (state as any).homeTextColor || '#ffffff',
// @ts-ignore
'--away-text': (state as any).awayTextColor || '#ffffff',
} as any;
if (theme !== 'pill') {
@@ -113,7 +114,6 @@ const MyClubOverlay: React.FC<{ state: ScoreboardState }> = ({ state }) => {
<div className="pill-wrapper" style={cssVars as any}>
<div className="scoreboard pill">
<div className="seg timer"><span>{timer}</span></div>
<span className="divider" aria-hidden="true"></span>
<div className="seg team home"><img className="logo" alt="" src={left.logo || ''} />
<span>{left.short}</span>
</div>
@@ -147,7 +147,6 @@ const MyClubOverlay: React.FC<{ state: ScoreboardState }> = ({ state }) => {
<div className="pill-wrapper" style={cssVars as any}>
<div className="scoreboard pill">
<div className="seg timer"><span>{timer}</span></div>
<span className="divider" aria-hidden="true"></span>
<div className="seg team home"><img className="logo" alt="" src={left.logo || ''} />
<span>{left.short}</span>
</div>
@@ -89,6 +89,66 @@ export const MatchesWidget: React.FC<{
staleTime: 5 * 60 * 1000,
});
const byId: Record<string, { name?: string; logo_url?: string }> = (overrides as any)?.by_id || {};
const byNameMap: Record<string, string> = (overrides as any)?.by_name || {} as Record<string, string>;
const normName = (s?: string) => String(s || '')
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
.replace(/[\u2012\u2013\u2014\u2015\u2212]/g, '-')
.replace(/\bn\.?\b/g, ' nad ')
.replace(/\bp\.?\b/g, ' pod ')
.replace(/[\,\s]*(z\.?\s*s\.?|o\.?\s*s\.?)\s*$/g, '')
.replace(/[\.,!;:()\[\]{}]/g, ' ')
.replace(/\s+/g, ' ')
.trim()
.toLowerCase();
const aliasNameIndex = React.useMemo(() => {
const urlToName: Record<string, string> = {};
for (const v of Object.values(byId || {})) {
const nm = String((v as any)?.name || '').trim();
const lg = String((v as any)?.logo_url || '').trim();
if (nm && lg) urlToName[lg] = nm;
}
const idx: Record<string, string> = {};
for (const [alias, url] of Object.entries(byNameMap || {})) {
const canon = urlToName[String(url)] || '';
const key = normName(alias);
if (canon && key) idx[key] = canon;
}
return idx;
}, [byId, byNameMap]);
const nameIndex = React.useMemo(() => {
const idx: Record<string, { id: string; name: string; logo_url: string }> = {};
try {
for (const [id, v] of Object.entries(byId || {})) {
const nm = String((v as any)?.name || '').trim();
const lg = String((v as any)?.logo_url || '').trim();
if (!nm) continue;
const key = normName(nm);
if (!key) continue;
idx[key] = { id, name: nm, logo_url: lg };
}
} catch {}
return idx;
}, [byId]);
const getOverrideName = (teamName?: string, teamId?: string) => {
const tid = teamId ? String(teamId) : '';
if (tid && byId?.[tid]?.name && String(byId[tid].name).trim()) {
return String(byId[tid].name).trim();
}
try {
const n = normName(teamName);
if (aliasNameIndex[n]) return aliasNameIndex[n];
let hit: any = nameIndex[n];
if (!hit) {
for (const [k, v] of Object.entries(nameIndex)) {
if (!k) continue;
if (n.endsWith(k) || k.endsWith(n)) { hit = v; break; }
}
}
if (hit && (hit as any).name) return String((hit as any).name);
} catch {}
return String(teamName || '');
};
const getLogo = (teamName?: string, original?: string) => {
const byName = (overrides as any)?.by_name || {} as Record<string, string>;
const norm = (s: string) => String(s || '')
@@ -153,8 +213,8 @@ export const MatchesWidget: React.FC<{
id: m.match_id,
date_time: m.date_time || m.date,
competitionName: m.competitionName,
home: (m.home_id && byId?.[m.home_id]?.name && String(byId[m.home_id].name).trim()) ? String(byId[m.home_id].name) : (m.home || m.home_team),
away: (m.away_id && byId?.[m.away_id]?.name && String(byId[m.away_id].name).trim()) ? String(byId[m.away_id].name) : (m.away || m.away_team),
home: getOverrideName(m.home || m.home_team, m.home_id),
away: getOverrideName(m.away || m.away_team, m.away_id),
score: m.score,
venue: m.venue,
home_logo_url: (m.home_id && byId?.[m.home_id]?.logo_url) ? String(byId[m.home_id].logo_url) : getLogo(m.home || m.home_team, m.home_logo_url),
+36 -11
View File
@@ -60,8 +60,11 @@ export function useAutoSave<T extends Record<string, any>>({
const [draftAge, setDraftAge] = useState<number | null>(null);
const saveTimerRef = useRef<NodeJS.Timeout>();
const lastDataRef = useRef<string>('');
const lastLocalDataRef = useRef<string>('');
const lastBackendDataRef = useRef<string>('');
const isSavingRef = useRef(false);
const lastDataObjRef = useRef<T | null>(null);
const localSaveTimerRef = useRef<NodeJS.Timeout>();
// Check for existing draft on mount
useEffect(() => {
@@ -153,18 +156,26 @@ export function useAutoSave<T extends Record<string, any>>({
// Main auto-save effect
useEffect(() => {
if (!enabled) return;
const dataString = JSON.stringify(data);
// Skip if data hasn't changed
if (dataString === lastDataRef.current) {
if (lastDataObjRef.current === data) {
return;
}
lastDataRef.current = dataString;
// Save to localStorage immediately
saveToLocalStorage(data);
lastDataObjRef.current = data;
if (localSaveTimerRef.current) {
clearTimeout(localSaveTimerRef.current);
}
localSaveTimerRef.current = setTimeout(() => {
try {
const dataString = JSON.stringify(data);
if (dataString !== lastLocalDataRef.current) {
lastLocalDataRef.current = dataString;
saveToLocalStorage(data);
}
} catch (err) {
console.error('Local draft serialize error:', err);
}
}, 300);
// Debounce backend save
if (saveTimerRef.current) {
@@ -172,13 +183,24 @@ export function useAutoSave<T extends Record<string, any>>({
}
saveTimerRef.current = setTimeout(() => {
saveToBackend(data);
try {
const dataString = JSON.stringify(data);
if (dataString !== lastBackendDataRef.current) {
lastBackendDataRef.current = dataString;
saveToBackend(data);
}
} catch (err) {
console.error('Backend draft serialize error:', err);
}
}, debounceMs);
return () => {
if (saveTimerRef.current) {
clearTimeout(saveTimerRef.current);
}
if (localSaveTimerRef.current) {
clearTimeout(localSaveTimerRef.current);
}
};
}, [data, enabled, debounceMs, saveToLocalStorage, saveToBackend]);
@@ -211,6 +233,9 @@ export function useAutoSave<T extends Record<string, any>>({
if (saveTimerRef.current) {
clearTimeout(saveTimerRef.current);
}
if (localSaveTimerRef.current) {
clearTimeout(localSaveTimerRef.current);
}
};
}, []);
+1 -1
View File
@@ -6,7 +6,7 @@ import './styles/admin-enhancements.css';
import './styles/home-style-pack.css';
import './styles/sparta-styles.css';
// Quill editor styles (MUST be imported globally) - CRITICAL for rich text editor
import 'react-quill/dist/quill.snow.css';
import 'quill/dist/quill.snow.css';
import 'react-image-crop/dist/ReactCrop.css';
// Custom editor styles AFTER quill base styles to ensure proper override
import './styles/custom-editor.css';
File diff suppressed because it is too large Load Diff
+64 -3
View File
@@ -118,8 +118,12 @@ const BlogPage: React.FC = () => {
const matchId = searchParams.get('match_id') || '';
const qParam = searchParams.get('q') || '';
const [qInput, setQInput] = React.useState<string>(qParam);
const [matchInput, setMatchInput] = React.useState<string>('');
const borderColor = useColorModeValue('gray.200', 'gray.700');
const textColor = useColorModeValue('gray.500', 'gray.400');
const suggestBg = useColorModeValue('white','gray.800');
const suggestBorder = useColorModeValue('gray.200','gray.700');
const suggestHoverBg = useColorModeValue('gray.50','gray.700');
React.useEffect(() => {
(async () => {
@@ -151,6 +155,24 @@ const BlogPage: React.FC = () => {
React.useEffect(() => {
setQInput(qParam);
}, [qParam]);
// Match suggestions: only show matches that already have a blog (articles with match_snapshot)
const matchSuggestQ = useQuery<Paginated<Article>>(
['blog-match-suggest', { q: matchInput }],
() => getArticles({ page: 1, page_size: 50, published: true, q: matchInput }),
{ enabled: matchInput.trim().length >= 2 }
);
const matchSuggestions = React.useMemo(() => {
const items = matchSuggestQ.data?.data || [];
const uniq = new Map<string, any>();
items.forEach((a: any) => {
const ms = (a as any)?.match_snapshot;
const id = String(ms?.external_match_id || '') || '';
if (!id) return;
if (!uniq.has(id)) uniq.set(id, { id, title: a.title, date: ms?.date_time || ms?.date, home: ms?.home, away: ms?.away, comp: ms?.competition || ms?.competitionName });
});
return Array.from(uniq.values()).slice(0, 10);
}, [matchSuggestQ.data]);
const featuredQ = useQuery<Paginated<Article>>(
['articles-featured', { page_size: 3 }],
() => getFeaturedArticles({ page_size: 3 }),
@@ -266,9 +288,9 @@ const BlogPage: React.FC = () => {
{/* Header like blog.html */}
<Box bg="transparent" color="inherit" py={{ base: 8, md: 10 }} mb={4} borderBottom="1px" borderColor={borderColor}>
<Container maxW="7xl">
<HStack justify="space-between" align="center" spacing={4}>
<HStack justify="space-between" align="center" spacing={4} wrap="wrap">
<Heading as="h1" size={{ base: 'xl', md: '2xl' }}>Blog</Heading>
<HStack spacing={3} w={{ base: '56%', md: '520px' }}>
<HStack spacing={3} w={{ base: '100%', md: '620px' }}>
<Box flex="1">
<InputGroup>
<InputLeftElement pointerEvents="none">
@@ -301,7 +323,7 @@ const BlogPage: React.FC = () => {
</Box>
{!!categories.length && (
<Select
maxW={{ base: '44%', md: '240px' }}
maxW={{ base: '48%', md: '220px' }}
placeholder="Všechny kategorie"
value={categoryId}
onChange={(e) => {
@@ -320,6 +342,45 @@ const BlogPage: React.FC = () => {
))}
</Select>
)}
<Box flex={{ base: '1', md: '0 0 220px' }} position="relative">
<InputGroup>
<Input
placeholder="Hledat zápas…"
value={matchInput}
onChange={(e) => setMatchInput(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
const s = matchInput.trim();
if (/^\d+$/.test(s)) {
const next: Record<string, string> = {};
next.match_id = s;
if (categoryId) next.category_id = String(categoryId);
if (month) next.month = month;
if (qParam) next.q = qParam;
setSearchParams(next);
}
}
}}
/>
</InputGroup>
{(matchInput.trim().length >= 2 && matchSuggestions.length > 0) && (
<Box position="absolute" top="100%" left={0} right={0} bg={suggestBg} borderWidth="1px" borderColor={suggestBorder} borderRadius="md" mt={1} zIndex={10} maxH="260px" overflowY="auto" boxShadow="lg">
{matchSuggestions.map((m: any) => (
<Box key={m.id} px={3} py={2} _hover={{ bg: suggestHoverBg }} cursor="pointer" onClick={() => {
const next: Record<string, string> = { match_id: String(m.id) };
if (categoryId) next.category_id = String(categoryId);
if (month) next.month = month;
if (qParam) next.q = qParam;
setSearchParams(next);
setMatchInput('');
}}>
<Text fontSize="sm" fontWeight="600" noOfLines={1}>{m.home} vs {m.away}</Text>
<Text fontSize="xs" color={textColor} noOfLines={1}>{m.date || ''}{m.comp ? `${m.comp}` : ''}</Text>
</Box>
))}
</Box>
)}
</Box>
</HStack>
</HStack>
</Container>
+61 -4
View File
@@ -232,6 +232,63 @@ const CalendarPage: React.FC = () => {
const byId: Record<string, { name?: string; logo_url?: string }> = (overrides?.by_id || {}) as any;
const byNameNormalized: Record<string, string> = Object.keys(byName || {}).reduce((acc: Record<string, string>, k: string) => { acc[normalize(k)] = byName[k]; return acc; }, {});
const byNameStrippedPairs: Array<{ keyNorm: string; url: string }> = Object.keys(byName || {}).map((k: string) => ({ keyNorm: stripPrefixes(k), url: byName[k] }));
const normName = (s?: string) => String(s || '')
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
.replace(/[\u2012\u2013\u2014\u2015\u2212]/g, '-')
.replace(/\bn\.?\b/g, ' nad ')
.replace(/\bp\.?\b/g, ' pod ')
.replace(/[\,\s]*(z\.?\s*s\.?|o\.?\s*s\.?)\s*$/g, '')
.replace(/[\.,!;:()\[\]{}]/g, ' ')
.replace(/\s+/g, ' ')
.trim()
.toLowerCase();
const aliasNameIndex: Record<string, string> = (() => {
const urlToName: Record<string, string> = {};
for (const v of Object.values(byId || {})) {
const nm = String((v as any)?.name || '').trim();
const lg = String((v as any)?.logo_url || '').trim();
if (nm && lg) urlToName[lg] = nm;
}
const idx: Record<string, string> = {};
for (const [alias, url] of Object.entries(byName || {})) {
const canon = urlToName[String(url)] || '';
const key = normName(alias);
if (canon && key) idx[key] = canon;
}
return idx;
})();
const nameIndex: Record<string, { id: string; name: string; logo_url: string }> = (() => {
const idx: Record<string, { id: string; name: string; logo_url: string }> = {};
for (const [id, v] of Object.entries(byId || {})) {
const nm = String((v as any)?.name || '').trim();
const lg = String((v as any)?.logo_url || '').trim();
if (!nm) continue;
const key = normName(nm);
if (!key) continue;
idx[key] = { id, name: nm, logo_url: lg } as any;
}
return idx;
})();
const getOverrideName = (teamName?: string, teamId?: string) => {
const tid = teamId ? String(teamId) : '';
if (tid && byId?.[tid]?.name && String(byId[tid].name).trim()) {
return String(byId[tid].name).trim();
}
try {
const n = normName(teamName);
if (aliasNameIndex[n]) return aliasNameIndex[n];
let hit: any = nameIndex[n];
if (!hit) {
for (const [k, v] of Object.entries(nameIndex)) {
if (!k) continue;
if (n.endsWith(k) || k.endsWith(n)) { hit = v; break; }
}
}
if (hit && (hit as any).name) return String((hit as any).name);
} catch {}
return String(teamName || '');
};
const getOverrideLogo = (teamName?: string, original?: string, teamId?: string) => {
// Prefer admin override by ID
if (teamId && byId?.[teamId]?.logo_url) {
@@ -270,8 +327,8 @@ const CalendarPage: React.FC = () => {
const isoDate = (day && month && year) ? `${year}-${month.padStart(2,'0')}-${day.padStart(2,'0')}` : new Date().toISOString().slice(0,10);
const time = (t || '00:00').slice(0,5);
const score = (m.score || m.result || (typeof m.goals_home === 'number' && typeof m.goals_away === 'number' ? `${m.goals_home}:${m.goals_away}` : '') || '').toString();
const homeName = (byId?.[m.home_id]?.name && String(byId[m.home_id].name).trim()) ? String(byId[m.home_id].name) : m.home;
const awayName = (byId?.[m.away_id]?.name && String(byId[m.away_id].name).trim()) ? String(byId[m.away_id].name) : m.away;
const homeName = getOverrideName(m.home, m.home_id);
const awayName = getOverrideName(m.away, m.away_id);
return {
id: m.match_id || `${cIdx}-${idx}`,
date: isoDate,
@@ -321,8 +378,8 @@ const CalendarPage: React.FC = () => {
id: m.match_id || `${cIdx}-${idx}`,
date: isoDate,
time,
home: (byId?.[m.home_id]?.name && String(byId[m.home_id].name).trim()) ? String(byId[m.home_id].name) : m.home,
away: (byId?.[m.away_id]?.name && String(byId[m.away_id].name).trim()) ? String(byId[m.away_id].name) : m.away,
home: getOverrideName(m.home, m.home_id),
away: getOverrideName(m.away, m.away_id),
home_id: m.home_id,
away_id: m.away_id,
venue: m.venue,
-24
View File
@@ -145,30 +145,6 @@ const GalleryPage: React.FC = () => {
<Heading size="2xl" color={textPrimary}>
Fotogalerie
</Heading>
{/* Zonerama Attribution */}
<Box
bg={infoBg}
borderWidth="1px"
borderColor={infoBorder}
borderRadius="md"
p={4}
>
<Text fontSize="sm" color={infoText}>
📸 Všechny fotografie jsou z platformy{' '}
<Text
as="a"
href={zoneramaProfileUrl}
target="_blank"
rel="noopener noreferrer"
fontWeight="600"
color="blue.600"
_hover={{ textDecoration: 'underline' }}
>
Zonerama
</Text>
</Text>
</Box>
</VStack>
{/* Loading State */}
+70 -57
View File
@@ -1548,65 +1548,78 @@ const HomePage: React.FC = () => {
{/* Next match: categories (competitions) with left/right navigation - synced with matchesTab */}
{isVisible('matches', true) ? (
facrCompetitions.length > 0 ? (
upcomingCompIndices.length > 0 ? (
(() => {
const effectiveIndex = Math.max(0, Math.min(matchesTab, facrCompetitions.length - 1));
const comp = facrCompetitions[effectiveIndex];
const items = Array.isArray(comp?.matches) ? comp.matches : [];
const upcoming = items
.map((m: any) => ({ m, t: new Date(`${m.date}T${(m.time || '00:00')}:00`).getTime() }))
.filter((x: any) => !isNaN(x.t) && x.t > Date.now())
.sort((a: any, b: any) => a.t - b.t)[0]?.m;
const show = upcoming || null;
const link = (show && (show.facr_link || show.report_url)) || comp?.matches_link || nextMatchLink;
// Compute prev/next among competitions that actually have upcoming matches
const pos = upcomingCompIndices.indexOf(effectiveIndex);
const prevIdx = upcomingCompIndices[(Math.max(0, pos) - 1 + upcomingCompIndices.length) % upcomingCompIndices.length];
const nextIdx = upcomingCompIndices[(Math.max(0, pos) + 1) % upcomingCompIndices.length];
const handleNextMatchClick = () => {
if (show) {
setSelectedMatch({
...show,
competition: comp?.name,
});
setIsMatchModalOpen(true);
} else if (link) {
window.open(link, '_blank', 'noopener,noreferrer');
}
};
(() => {
// Only render when the currently selected competition has an upcoming match
if (upcomingCompIndices.length === 0) return null;
const effectiveIndex = Math.max(0, Math.min(matchesTab, facrCompetitions.length - 1));
const comp = facrCompetitions[effectiveIndex];
const items = Array.isArray(comp?.matches) ? comp.matches : [];
const upcoming = items
.map((m: any) => ({ m, t: new Date(`${m.date}T${(m.time || '00:00')}:00`).getTime() }))
.filter((x: any) => !isNaN(x.t) && x.t > Date.now())
.sort((a: any, b: any) => a.t - b.t)[0]?.m;
if (!upcoming) return null;
const show = upcoming;
const link = (show && (show.facr_link || show.report_url)) || comp?.matches_link || nextMatchLink;
// Compute prev/next among competitions that actually have upcoming matches
const pos = upcomingCompIndices.indexOf(effectiveIndex);
const prevIdx = upcomingCompIndices[(Math.max(0, pos) - 1 + upcomingCompIndices.length) % upcomingCompIndices.length];
const nextIdx = upcomingCompIndices[(Math.max(0, pos) + 1) % upcomingCompIndices.length];
const handleNextMatchClick = () => {
if (show) {
setSelectedMatch({
...show,
competition: comp?.name,
});
setIsMatchModalOpen(true);
} else if (link) {
window.open(link, '_blank', 'noopener,noreferrer');
}
};
return (
<NextMatch
data={show}
competitionName={comp?.name}
countdown={countdown}
onPrev={() => { setNextCompIdx(prevIdx); setMatchesTab(prevIdx); }}
onNext={() => { setNextCompIdx(nextIdx); setMatchesTab(nextIdx); }}
onOpen={handleNextMatchClick}
elementProps={{
'data-element': 'matches' as any,
'data-variant': getVariant('matches', 'compact') as any,
'aria-live': 'polite' as any,
style: { ...getStyles('matches') },
}}
/>
);
})()
) : null
return (
<NextMatch
data={show}
competitionName={comp?.name}
countdown={countdown}
onPrev={() => { setNextCompIdx(prevIdx); setMatchesTab(prevIdx); }}
onNext={() => { setNextCompIdx(nextIdx); setMatchesTab(nextIdx); }}
onOpen={handleNextMatchClick}
elementProps={{
'data-element': 'matches' as any,
'data-variant': getVariant('matches', 'compact') as any,
'aria-live': 'polite' as any,
style: { ...getStyles('matches') },
}}
/>
);
})()
) : (
<div className="card">
<NextMatch
key={`matches-${refreshKey}-${getVariant('matches', 'compact')}`}
data={{
home: matches[0]?.homeTeam || clubName,
home_logo_url: matches[0]?.homeLogoURL || clubLogo,
away: matches[0]?.awayTeam || 'Soupeř',
away_logo_url: matches[0]?.awayLogoURL,
}}
countdown={countdown}
elementProps={{ 'data-element': 'matches', 'data-variant': getVariant('matches', 'compact'), 'aria-live': 'polite', style: { position: 'relative', ...getStyles('matches') } }}
/>
</div>
(() => {
// Fallback without FACR: show only if there is an upcoming match in the fallback list
if (!matches || matches.length === 0) return null;
const future = matches
.map((m: any) => ({ m, t: new Date(`${m.date}T${(m.time || '00:00')}:00`).getTime() }))
.filter((x: any) => !isNaN(x.t) && x.t > Date.now())
.sort((a: any, b: any) => a.t - b.t);
const next = future[0]?.m;
if (!next) return null;
return (
<div className="card">
<NextMatch
key={`matches-${refreshKey}-${getVariant('matches', 'compact')}`}
data={{
home: next?.homeTeam || clubName,
home_logo_url: next?.homeLogoURL || clubLogo,
away: next?.awayTeam || 'Soupeř',
away_logo_url: next?.awayLogoURL,
}}
countdown={countdown}
elementProps={{ 'data-element': 'matches', 'data-variant': getVariant('matches', 'compact'), 'aria-live': 'polite', style: { position: 'relative', ...getStyles('matches') } }}
/>
</div>
);
})()
)
) : null}
+59 -2
View File
@@ -296,6 +296,63 @@ const MatchesPage: React.FC = () => {
return acc;
}, {});
const byNameStrippedPairs: Array<{ keyNorm: string; url: string }> = Object.keys(byName || {}).map((k: string) => ({ keyNorm: stripPrefixes(k), url: byName[k] }));
const normName = (s?: string) => String(s || '')
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
.replace(/[\u2012\u2013\u2014\u2015\u2212]/g, '-')
.replace(/\bn\.?\b/g, ' nad ')
.replace(/\bp\.?\b/g, ' pod ')
.replace(/[\,\s]*(z\.?\s*s\.?|o\.?\s*s\.?)\s*$/g, '')
.replace(/[\.,!;:()\[\]{}]/g, ' ')
.replace(/\s+/g, ' ')
.trim()
.toLowerCase();
const aliasNameIndex: Record<string, string> = (() => {
const urlToName: Record<string, string> = {};
for (const v of Object.values(byId || {})) {
const nm = String((v as any)?.name || '').trim();
const lg = String((v as any)?.logo_url || '').trim();
if (nm && lg) urlToName[lg] = nm;
}
const idx: Record<string, string> = {};
for (const [alias, url] of Object.entries(byName || {})) {
const canon = urlToName[String(url)] || '';
const key = normName(alias);
if (canon && key) idx[key] = canon;
}
return idx;
})();
const nameIndex: Record<string, { id: string; name: string; logo_url: string }> = (() => {
const idx: Record<string, { id: string; name: string; logo_url: string }> = {};
for (const [id, v] of Object.entries(byId || {})) {
const nm = String((v as any)?.name || '').trim();
const lg = String((v as any)?.logo_url || '').trim();
if (!nm) continue;
const key = normName(nm);
if (!key) continue;
idx[key] = { id, name: nm, logo_url: lg } as any;
}
return idx;
})();
const getOverrideName = (teamName?: string, teamId?: string) => {
const tid = teamId ? String(teamId) : '';
if (tid && byId?.[tid]?.name && String(byId[tid].name).trim()) {
return String(byId[tid].name).trim();
}
try {
const n = normName(teamName);
if (aliasNameIndex[n]) return aliasNameIndex[n];
let hit: any = nameIndex[n];
if (!hit) {
for (const [k, v] of Object.entries(nameIndex)) {
if (!k) continue;
if (n.endsWith(k) || k.endsWith(n)) { hit = v; break; }
}
}
if (hit && (hit as any).name) return String((hit as any).name);
} catch {}
return String(teamName || '');
};
const getFallbackLogo = (teamName?: string, original?: string) => {
if (teamName) {
@@ -370,8 +427,8 @@ const MatchesPage: React.FC = () => {
const [day, month, year] = (d || '').split('.');
const isoDate = (day && month && year) ? `${year}-${month.padStart(2,'0')}-${day.padStart(2,'0')}` : new Date().toISOString().slice(0,10);
const time = (t || '18:00').slice(0,5);
const homeName = (byId?.[m.home_id]?.name && String(byId[m.home_id].name).trim()) ? String(byId[m.home_id].name) : m.home;
const awayName = (byId?.[m.away_id]?.name && String(byId[m.away_id].name).trim()) ? String(byId[m.away_id].name) : m.away;
const homeName = getOverrideName(m.home, m.home_id);
const awayName = getOverrideName(m.away, m.away_id);
// Check if match is in the future - if so, ignore score
const matchTime = new Date(`${isoDate}T${time}:00`).getTime();
+46
View File
@@ -5,6 +5,7 @@ import './styles/MagazineHome.css';
import './styles/ProHome.css';
import { useNavigate } from 'react-router-dom';
import { getSetupStatus, initializeSetup, SetupInitializePayload, validateSMTP } from '../services/setup';
import { getRembgStatus, startRembgBatch } from '../services/rembg';
import { updateSeoSettings } from '../services/seo';
import { API_URL } from '../services/api';
import { assetUrl } from '../utils/url';
@@ -123,6 +124,9 @@ const SetupPage: React.FC = () => {
const [isDomainHost, setIsDomainHost] = useState(false);
const [showAdvancedApi, setShowAdvancedApi] = useState(false);
const [apiUrlTouched, setApiUrlTouched] = useState(false);
const [processingLogos, setProcessingLogos] = useState(false);
const [rembgTotal, setRembgTotal] = useState(0);
const [rembgDone, setRembgDone] = useState(0);
const toast = useToast();
const navigate = useNavigate();
@@ -378,6 +382,38 @@ const SetupPage: React.FC = () => {
});
} catch {}
toast({ title: 'Nastavení dokončeno', status: 'success', duration: 3000, isClosable: true });
// Start background removal only if backend allows it; otherwise skip waiting UI
let allowRembg = false;
try {
const resp = await startRembgBatch().catch(() => null as any);
allowRembg = !!resp && (resp.started || resp.status?.running || (resp.status?.total || 0) > 0);
} catch {}
if (allowRembg) {
setProcessingLogos(true);
try {
// Wait for batch to actually start or totals to appear (prefetch must finish first)
const deadline = Date.now() + 120000; // 2 minutes max
let started = false;
while (Date.now() < deadline) {
const s0 = await getRembgStatus();
setRembgTotal(s0?.total || 0);
setRembgDone(s0?.done || 0);
if (s0?.running || (s0?.total || 0) > 0 || (s0?.done || 0) > 0) { started = true; break; }
await new Promise((r) => setTimeout(r, 1000));
}
if (started) {
// Poll progress until finished
for (;;) {
const s = await getRembgStatus();
setRembgTotal(s?.total || 0);
setRembgDone(s?.done || 0);
if (!s?.running) break;
await new Promise((r) => setTimeout(r, 1200));
}
}
} catch {}
setProcessingLogos(false);
}
try {
const fb = (frontendBaseUrl || '').trim().replace(/\/$/, '');
let ab = (apiBaseUrl || '').trim();
@@ -982,6 +1018,16 @@ const SetupPage: React.FC = () => {
<Button type="submit" colorScheme="blue" mt={8} isLoading={submitting} loadingText="Ukládám…">Dokončit nastavení</Button>
</Box>
{processingLogos && (
<Box position="fixed" top={0} left={0} right={0} bottom={0} bg="rgba(0,0,0,0.6)" zIndex={9999} display="flex" alignItems="center" justifyContent="center">
<VStack spacing={3} bg={bg} p={8} borderRadius="xl" boxShadow="xl">
<Spinner size="xl" />
<Heading size="md">Připravuji klubová loga</Heading>
<Text>Odstraňuji pozadí: {rembgDone}/{rembgTotal}</Text>
<Text fontSize="sm" color="gray.500">Prosím vyčkejte, dokončuji přípravu webu</Text>
</VStack>
</Box>
)}
</Box>
);
};
+20
View File
@@ -185,6 +185,26 @@ const VideosPage: React.FC = () => {
decoding="async"
referrerPolicy="origin-when-cross-origin"
style={{ objectFit: 'cover' }}
data-fallback-idx={0 as any}
onError={(e: any) => {
try {
const el = e.currentTarget as HTMLImageElement & { dataset: { fallbackIdx?: string } };
const idx = Number(el.dataset.fallbackIdx || '0');
const id = item.videoId || '';
const chain = id
? [
`https://i.ytimg.com/vi/${id}/mqdefault.jpg`,
`https://i.ytimg.com/vi/${id}/sddefault.jpg`,
`https://i.ytimg.com/vi/${id}/hqdefault.jpg`,
'/dist/img/logo-club-empty.svg',
]
: ['/dist/img/logo-club-empty.svg'];
if (idx < chain.length) {
el.src = chain[idx];
el.dataset.fallbackIdx = String(idx + 1);
}
} catch {}
}}
/>
) : (
<Box bg={placeholderBg} display="flex" alignItems="center" justifyContent="center">
@@ -262,7 +262,7 @@ const AdminActivitiesPage: React.FC = () => {
if (localDraft) {
setEditing(localDraft);
} else {
setEditing({ title: '', description: '', type: 'other', is_public: false } as any);
setEditing({ title: '', description: '', type: 'other', is_public: true } as any);
}
setLocationLat(undefined);
setLocationLng(undefined);
@@ -504,7 +504,7 @@ const AdminActivitiesPage: React.FC = () => {
end_time: (endISO as any) || null,
location: (editing.location || '').trim(),
type: (editing.type || 'other') as any,
is_public: !!editing.is_public,
is_public: true,
image_url: imageUrl || undefined,
file_url: (editing as any).file_url || undefined,
category_name: (editing as any)?.category_name || undefined,
@@ -538,7 +538,6 @@ const AdminActivitiesPage: React.FC = () => {
<Th>Začátek</Th>
<Th>Konec</Th>
<Th>Místo</Th>
<Th>Veřejná</Th>
<Th w="140px">Akce</Th>
</Tr>
</Thead>
@@ -594,7 +593,7 @@ const AdminActivitiesPage: React.FC = () => {
</Tr>
)}
{!isLoading && events.map(ev => (
<Tr key={ev.id} opacity={ev.is_public ? 1 : 0.6}>
<Tr key={ev.id}>
<Td>
{(ev as any).image_url ? (
<ThumbnailPreview
@@ -623,7 +622,6 @@ const AdminActivitiesPage: React.FC = () => {
<Td>{new Date(ev.start_time).toLocaleString()}</Td>
<Td>{ev.end_time ? new Date(ev.end_time).toLocaleString() : '-'}</Td>
<Td>{ev.location || '-'}</Td>
<Td>{ev.is_public ? 'Ano' : 'Ne'}</Td>
<Td>
<HStack>
<IconButton aria-label="Upravit" size="sm" icon={<FiEdit2 />} onClick={() => openEdit(ev)} />
@@ -708,7 +706,16 @@ const AdminActivitiesPage: React.FC = () => {
<Switch isChecked={aiOverwrite} onChange={(e)=> setAiOverwrite(e.target.checked)} />
</FormControl>
<Tooltip label="AI doplní titul a popis podle zadaných informací." hasArrow>
<Button onClick={generateWithAI} isLoading={aiLoading} leftIcon={<FiPlus />} bg="brand.primary" color="text.onPrimary" _hover={{ filter: 'brightness(0.95)' }}>
<Button
size="sm"
onClick={generateWithAI}
isLoading={aiLoading}
leftIcon={<FiPlus size={14} />}
bg="brand.primary"
color="text.onPrimary"
_hover={{ filter: 'brightness(0.95)' }}
whiteSpace="nowrap"
>
AI text
</Button>
</Tooltip>
@@ -1063,10 +1070,7 @@ const AdminActivitiesPage: React.FC = () => {
))}
</Select>
</FormControl>
<FormControl display="flex" alignItems="center" mt={3}>
<FormLabel mb="0">Veřejná</FormLabel>
<Switch isChecked={!!editing?.is_public} onChange={(e) => setEditing(prev => ({ ...(prev || {}), is_public: e.target.checked }))} />
</FormControl>
{/* ... (rest of the code remains the same) */}
<HStack mt={4} align="flex-start">
+52 -2
View File
@@ -443,7 +443,27 @@ const AdminVideosPage: React.FC = () => {
.map((v) => (
<Box key={v.video_id} borderWidth="1px" borderRadius="md" p={2}>
<VStack align="stretch" spacing={2}>
<Image src={v.thumbnail_url} alt={v.title} borderRadius="md" />
<Image
src={v.thumbnail_url}
alt={v.title}
borderRadius="md"
data-fallback-idx={0 as any}
onError={(e) => {
const el = e.currentTarget as HTMLImageElement & { dataset: { fallbackIdx?: string } };
const idx = Number(el.dataset.fallbackIdx || '0');
const id = v.video_id;
const chain = [
`https://i.ytimg.com/vi/${id}/mqdefault.jpg`,
`https://i.ytimg.com/vi/${id}/sddefault.jpg`,
`https://i.ytimg.com/vi/${id}/hqdefault.jpg`,
'/images/sponsors/placeholder.png',
];
if (idx < chain.length) {
el.src = chain[idx];
el.dataset.fallbackIdx = String(idx + 1);
}
}}
/>
<Box>
<Text fontWeight="semibold" noOfLines={2}>{v.title}</Text>
<HStack spacing={2} color="gray.600" fontSize="sm">
@@ -484,7 +504,37 @@ const AdminVideosPage: React.FC = () => {
{items.map((it, idx) => (
<Box key={`${idx}-${it.url}`} borderWidth="1px" borderRadius="md" p={2}>
<VStack align="stretch" spacing={2}>
<Image src={it.thumbnail_url || getThumbFromUrl(it.url)} alt={it.title || `Video ${idx+1}`} borderRadius="md" />
<Image
src={it.thumbnail_url || getThumbFromUrl(it.url)}
alt={it.title || `Video ${idx+1}`}
borderRadius="md"
data-fallback-idx={0 as any}
onError={(e) => {
const el = e.currentTarget as HTMLImageElement & { dataset: { fallbackIdx?: string } };
const idxFb = Number(el.dataset.fallbackIdx || '0');
// Try to parse video id from URL; fallback to placeholder
let id: string | undefined;
try {
const u = (it.url || '').trim();
if (u.includes('youtu.be/')) {
id = u.split('youtu.be/')[1]?.split(/[?&#]/)[0];
} else if (u.includes('youtube.com')) {
const url = new URL(u);
id = url.searchParams.get('v') || undefined;
}
} catch {}
const chain = id ? [
`https://i.ytimg.com/vi/${id}/mqdefault.jpg`,
`https://i.ytimg.com/vi/${id}/sddefault.jpg`,
`https://i.ytimg.com/vi/${id}/hqdefault.jpg`,
'/images/sponsors/placeholder.png',
] : ['/images/sponsors/placeholder.png'];
if (idxFb < chain.length) {
el.src = chain[idxFb];
el.dataset.fallbackIdx = String(idxFb + 1);
}
}}
/>
<Box>
<Text fontWeight="semibold" noOfLines={2}>{it.title || `Video ${idx+1}`}</Text>
<HStack spacing={2} color="gray.600" fontSize="sm">
+225 -128
View File
@@ -6,7 +6,7 @@ import {
Textarea, Icon, useBreakpointValue, InputGroup, InputLeftElement,
ButtonGroup, Spinner, Heading, Td, Th, Thead, Tr, Tbody, Table, Switch,
Select, Badge, Tabs, TabList, TabPanels, Tab, TabPanel, Accordion, AccordionItem,
AccordionButton, AccordionPanel, AccordionIcon, AspectRatio, Link, Alert, AlertIcon
AccordionButton, AccordionPanel, AccordionIcon, AspectRatio, Link, Alert, AlertIcon, Checkbox
} from '@chakra-ui/react';
import { FiEdit2, FiTrash2, FiPlus, FiSearch, FiUpload, FiExternalLink, FiVideo, FiX, FiRefreshCcw, FiLink } from 'react-icons/fi';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
@@ -22,6 +22,8 @@ import { getPublicSettings } from '../../services/settings';
import { getZoneramaManifestWithFallbacks, getZoneramaAlbum, putZoneramaPick, saveAlbumToCache } from '../../services/zonerama';
import { facrApi } from '../../services/facr/facrApi';
import { API_URL } from '../../services/api';
import { triggerPrefetch } from '../../services/admin/prefetch';
import { saveArticleReliable } from '../../services/articleSave';
import AlbumPhotoPicker from '../../components/admin/AlbumPhotoPicker';
import PollLinker from '../../components/admin/PollLinker';
import ThumbnailPreview from '../../components/common/ThumbnailPreview';
@@ -278,109 +280,27 @@ const ArticlesAdminPage = () => {
const [youtubeSearch, setYoutubeSearch] = useState<string>('');
const [youtubeManualInput, setYoutubeManualInput] = useState<string>('');
const { isOpen: isYouTubeModalOpen, onOpen: onYouTubeModalOpen, onClose: onYouTubeModalClose } = useDisclosure();
const { isOpen: isExistingAlbumsOpen, onOpen: onExistingAlbumsOpen, onClose: onExistingAlbumsClose } = useDisclosure();
const [zVisibleCount, setZVisibleCount] = useState<number>(60);
const [existingSelectedAlbum, setExistingSelectedAlbum] = useState<{ id: string; date: string; title?: string; photos: Array<{ id: string; image_1500: string; page_url: string }> } | null>(null);
const [existingSelectedPhotos, setExistingSelectedPhotos] = useState<Set<string>>(new Set());
const [existingVisibleCount, setExistingVisibleCount] = useState<number>(60);
// Auto-save hook - saves draft automatically
const { saveStatus, lastSaved, forceSave, clearDraft } = useAutoSave({
data: editing || {},
storageKey: draftKey,
onSave: async (data) => {
// If article has ID, update it as draft
if (data.id) {
try {
// Build safe minimal payload the backend expects
const attachmentsNorm = (() => {
const a: any = (data as any)?.attachments;
if (!Array.isArray(a) || a.length === 0) return undefined;
return a.map((it: any) => {
const name = it?.name || (String(it?.url || '').split('/').pop() || 'soubor');
const url = it?.url || '';
const mime_type = it?.mime_type || it?.type;
const size = typeof it?.size === 'number' ? it.size : undefined;
return { name, url, mime_type, size };
});
})();
const galleryIdsNorm = (() => {
const g: any = (data as any)?.gallery_photo_ids;
if (Array.isArray(g)) return g.map(String);
return undefined;
})();
const isPublished = !!(data as any)?.published;
const payload: UpdateArticlePayload = {
title: (data as any)?.title || '',
...(((typeof (data as any)?.content === 'string') && ((String((data as any)?.content || '').trim().length > 0) || !isPublished)) ? { content: (data as any)?.content || '' } : {}),
image_url: (data as any)?.image_url || '',
...(typeof (data as any)?.category_id === 'number' ? { category_id: (data as any).category_id } : {}),
category_name: (data as any)?.category_name || undefined,
slug: (data as any)?.slug || undefined,
seo_title: (data as any)?.seo_title || undefined,
seo_description: (data as any)?.seo_description || undefined,
og_image_url: (data as any)?.og_image_url || undefined,
featured: !!(data as any)?.featured,
// Gallery fields
gallery_album_id: (data as any)?.gallery_album_id || undefined,
gallery_album_url: (data as any)?.gallery_album_url || undefined,
...(galleryIdsNorm ? { gallery_photo_ids: galleryIdsNorm } : {}),
// YouTube fields
youtube_video_id: (data as any)?.youtube_video_id || undefined,
youtube_video_title: (data as any)?.youtube_video_title || undefined,
youtube_video_url: (data as any)?.youtube_video_url || undefined,
youtube_video_thumbnail: (data as any)?.youtube_video_thumbnail || undefined,
// Attachments
...(attachmentsNorm ? { attachments: attachmentsNorm } : {}),
} as UpdateArticlePayload;
return await updateArticle(data.id, payload);
} catch (e: any) {
const status = e?.response?.status;
if (status === 404 && data.title?.trim()) {
const payload: CreateArticlePayload = {
title: data.title || 'Koncept článku',
content: data.content || '',
image_url: data.image_url || '',
category_name: data.category_name,
published: false,
slug: data.slug || '',
seo_title: data.seo_title || '',
seo_description: data.seo_description || '',
og_image_url: data.og_image_url || '',
featured: data.featured || false,
};
const created = await createArticle(payload);
if (created?.id) {
setEditing(prev => ({ ...prev, id: created.id } as any));
setDraftKey(`draft-article-${created.id}`);
try { localStorage.removeItem('draft-article-new'); } catch {}
}
return created;
}
throw e;
}
}
// If no ID, create as draft
if (data.title?.trim()) {
const payload: CreateArticlePayload = {
title: data.title || 'Koncept článku',
content: data.content || '',
image_url: data.image_url || '',
category_name: data.category_name,
published: false,
slug: data.slug || '',
seo_title: data.seo_title || '',
seo_description: data.seo_description || '',
og_image_url: data.og_image_url || '',
featured: data.featured || false,
};
const created = await createArticle(payload);
if (created?.id) {
setEditing(prev => ({ ...prev, id: created.id } as any));
setDraftKey(`draft-article-${created.id}`);
// Use centralized reliable saver (normalizes payload, retries, triggers cache refresh when published)
if ((data as any)?.id || (data as any)?.title?.trim()) {
const saved: any = await saveArticleReliable(data as any);
if (saved?.id && !(data as any)?.id) {
setEditing(prev => ({ ...(prev as any), id: saved.id } as any));
setDraftKey(`draft-article-${saved.id}`);
try { localStorage.removeItem('draft-article-new'); } catch {}
}
return created;
return saved;
}
// Don't save if no title
return {};
},
debounceMs: 2000,
@@ -467,6 +387,12 @@ const ArticlesAdminPage = () => {
}
}, [isGalleryPickerOpen, cachedAlbums.length, galleryLoading, fetchCachedGallery]);
React.useEffect(() => {
if (isExistingAlbumsOpen && cachedAlbums.length === 0 && !galleryLoading) {
fetchCachedGallery();
}
}, [isExistingAlbumsOpen, cachedAlbums.length, galleryLoading, fetchCachedGallery]);
const filteredYoutubeVideos = useMemo(() => {
const q = youtubeSearch.trim().toLowerCase();
if (!q) return youtubeVideos;
@@ -545,16 +471,19 @@ const ArticlesAdminPage = () => {
// Handle album photo selection for blog content
const handleAlbumPhotosSelected = useCallback(async (photos: Array<{ id: string; page_url: string; image_1500: string }>, albumInfo: any) => {
try {
// Save album to cache (admins only)
if (isAdmin) {
// Save album to cache (admins only) with a sufficiently high photo limit to fetch the full album
if (isAdmin && albumInfo?.url) {
toast({ title: 'Ukládám album...', status: 'info', duration: 2000 });
await saveAlbumToCache(albumInfo.url, photos.length);
const limit = Math.max(500, Number(albumInfo?.photos_count || 0) || photos.length || 100);
await saveAlbumToCache(albumInfo.url, limit);
}
// Store album info with article and append images to content
setEditing((prev) => {
const currentContent = (prev as any)?.content || '';
const photosHTML = photos.map(p => `<img src="${p.image_1500}" alt="Gallery photo" />`).join('\n');
const photosHTML = photos
.map(p => `<img src="${p.image_1500}" alt="Gallery photo" data-page-url="${p.page_url}" data-img-id="${p.id}" />`)
.join('\n');
return {
...(prev as any),
gallery_album_id: albumInfo.id,
@@ -733,13 +662,7 @@ const ArticlesAdminPage = () => {
const settings = await getPublicSettings();
const clubId = (settings as any)?.club_id || '';
const clubType = ((settings as any)?.club_type || 'football') as 'football' | 'futsal';
let comps: Array<{ code?: string; name: string }> = [];
if (clubId) {
try {
const club = await facrApi.getClub(String(clubId), clubType);
comps = (club?.competitions || []).map((c: any) => ({ code: c.code, name: c.name || c.code }));
} catch {}
}
// Aliases
let amap: Record<string, string> = {};
try {
@@ -747,6 +670,27 @@ const ArticlesAdminPage = () => {
list.forEach((a) => { if (a.code && a.alias) amap[a.code] = a.alias; });
setAliasesList(list as any);
} catch {}
// Try cached prefetch JSON first
let comps: Array<{ code?: string; name: string }> = [];
try {
const origin = new URL(API_URL, typeof window !== 'undefined' ? window.location.origin : 'http://localhost:3000').origin;
const res = await fetch(`${origin}/cache/prefetch/facr_club_info.json`, { cache: 'no-cache' });
if (res.ok) {
const json = await res.json();
const arr = Array.isArray((json as any)?.competitions) ? (json as any).competitions : [];
comps = arr.map((c: any) => ({ code: c.code || c.id, name: c.name || c.code || c.id }));
}
} catch {}
// Fallback to live FACR API if cache is empty/unavailable
if (comps.length === 0 && clubId) {
try {
const club = await facrApi.getClub(String(clubId), clubType);
comps = (club?.competitions || []).map((c: any) => ({ code: c.code, name: c.name || c.code }));
} catch {}
}
// Apply aliases to names for display
const withAliases = comps.map((c) => ({ code: c.code, name: (c.code && amap[c.code]) ? amap[c.code] : c.name }));
setAliasesMap(amap);
@@ -759,7 +703,16 @@ const ArticlesAdminPage = () => {
mutationFn: () => {
const parsed = parseInt(String(aiMinWordsInput || '').trim(), 10);
const effective = Number.isFinite(parsed) && !isNaN(parsed) && parsed > 0 ? parsed : aiMinWords;
return generateBlogAI({ prompt: aiPrompt, audience: aiAudience, min_words: effective });
const base = String(aiPrompt || '').trim();
const htmlGuidelines = [
'Piš česky a strukturovaně pro blog fotbalového klubu.',
'Používej bohaté HTML prvky: rozděl článek do 24 sekcí s <h2>/<h3>, krátké odstavce <p>, alespoň jeden seznam <ul><li>, zvýraznění <strong>/<em>.',
'Pokud se to hodí, vlož krátký citát pomocí <blockquote>…</blockquote> (max. 1×).',
'Nevkládej <html>, <head> ani <body>. Vrať jen validní HTML části obsahu.',
'Zachovej fakta zadaná uživatelem, vyhýbej se hyperbolem a marketingovým frázím.',
].join(' ');
const finalPrompt = `${base}\n\n${htmlGuidelines}`.trim();
return generateBlogAI({ prompt: finalPrompt, audience: aiAudience, min_words: effective });
},
onSuccess: (res) => {
console.log('AI blog response:', res);
@@ -891,6 +844,7 @@ const ArticlesAdminPage = () => {
// Clear temporary storage
setTempMatchLink('');
setMatchIdInput('');
try { if (created?.published) { await triggerPrefetch(); } } catch {}
// Invalidate queries to refresh the list
qc.invalidateQueries({ queryKey: ['admin-articles'] });
@@ -916,9 +870,10 @@ const ArticlesAdminPage = () => {
mutationFn: ({ id, payload }: { id: number | string; payload: UpdateArticlePayload }) =>
// Forward the payload as-is so new fields (youtube, gallery) are persisted
updateArticle(id, payload),
onSuccess: (_, variables) => {
onSuccess: async (saved: any, variables) => {
const articleId = variables.id;
console.log('Article updated successfully in mutation callback:', articleId);
try { if (saved?.published) { await triggerPrefetch(); } } catch {}
// Invalidate queries to refresh the list
qc.invalidateQueries({ queryKey: ['admin-articles'] });
@@ -1110,11 +1065,6 @@ const ArticlesAdminPage = () => {
const onSubmit = async (options: { keepOpen?: boolean } = {}) => {
if (!editing) return;
// Require category selection by name (kategorie je povinná)
if (!String((editing as any)?.category_name || '').trim()) {
toast({ title: 'Vyberte kategorii', description: 'Nejprve vyberte kategorii článku (soutěž).', status: 'warning' });
return;
}
// Check if content contains raw AI JSON (invalid state)
const contentText = String(editing.content || '').trim();
@@ -1618,10 +1568,10 @@ const ArticlesAdminPage = () => {
/>
<FormHelperText>Automaticky generováno z názvu článku</FormHelperText>
</FormControl>
<FormControl isRequired>
<FormControl>
<FormLabel fontWeight="bold">Kategorie (soutěž)</FormLabel>
<Select
placeholder="Vyberte kategorii článku"
placeholder="Vyberte kategorii (volitelné)"
value={(editing as any)?.category_name || ''}
onChange={(e) => setEditing((prev) => ({ ...(prev as any), category_name: e.target.value }))}
size="lg"
@@ -1632,10 +1582,7 @@ const ArticlesAdminPage = () => {
</option>
))}
</Select>
<FormHelperText>Kategorie určuje, ve které sekci se článek zobrazí</FormHelperText>
{!(editing as any)?.category_name && (
<Text color="orange.500" fontSize="sm" mt={1}> Kategorie je povinná</Text>
)}
<FormHelperText>Kategorie určuje, ve které sekci se článek zobrazí (volitelné)</FormHelperText>
</FormControl>
{/* Featured toggle - prominent display */}
@@ -1831,6 +1778,14 @@ const ArticlesAdminPage = () => {
>
Vložit fotografie z alba
</Button>
<Button
size="sm"
variant="outline"
leftIcon={<FiSearch />}
onClick={onExistingAlbumsOpen}
>
Vybrat z alba
</Button>
</HStack>
{activeTabIndex === 2 && (
<RichTextEditor
@@ -1893,15 +1848,22 @@ const ArticlesAdminPage = () => {
<Text fontSize="sm" color="gray.500" mt={2}>Zadejte odkaz na Zonerama album a klikněte na "Načíst album"</Text>
)}
{zAlbumPhotos.length > 0 && (
<SimpleGrid columns={{ base: 3, md: 6 }} spacing={2} mt={2}>
{zAlbumPhotos.map((p) => (
<Box key={p.id} borderWidth="1px" borderRadius="md" overflow="hidden" _hover={{ boxShadow: 'md' }} cursor="pointer"
onClick={() => pickZoneramaImage({ id: p.id, album_id: '', album_url: zAlbumLink, page_url: p.page_url, image_url: p.image_1500 || '', title: p.title })}
>
<Image src={p.image_1500 || ''} alt={p.id} w="100%" h="100px" objectFit="cover" />
</Box>
))}
</SimpleGrid>
<>
<SimpleGrid columns={{ base: 3, md: 6 }} spacing={2} mt={2}>
{zAlbumPhotos.slice(0, zVisibleCount).map((p) => (
<Box key={p.id} borderWidth="1px" borderRadius="md" overflow="hidden" _hover={{ boxShadow: 'md' }} cursor="pointer"
onClick={() => pickZoneramaImage({ id: p.id, album_id: '', album_url: zAlbumLink, page_url: p.page_url, image_url: p.image_1500 || '', title: p.title })}
>
<Image src={p.image_1500 || ''} alt={p.id} w="100%" h="100px" objectFit="cover" loading="lazy" decoding="async" />
</Box>
))}
</SimpleGrid>
{zAlbumPhotos.length > zVisibleCount && (
<HStack justify="center" mt={2}>
<Button size="sm" onClick={() => setZVisibleCount((c) => c + 60)}>Načíst další</Button>
</HStack>
)}
</>
)}
</VStack>
</Box>
@@ -2246,6 +2208,139 @@ const ArticlesAdminPage = () => {
onPhotosSelected={handleAlbumPhotosSelected}
/>
<Modal isOpen={isExistingAlbumsOpen} onClose={() => { setExistingSelectedAlbum(null); setExistingSelectedPhotos(new Set()); setExistingVisibleCount(60); onExistingAlbumsClose(); }} size="6xl" scrollBehavior="inside">
<ModalOverlay />
<ModalContent maxH="90vh">
<ModalHeader>Vybrat z existujících alb</ModalHeader>
<ModalCloseButton />
<ModalBody overflowY="auto">
<VStack align="stretch" spacing={4}>
{!existingSelectedAlbum && (
<>
{galleryLoading && (
<HStack spacing={2} justify="center" py={8}>
<Spinner size="lg" color="purple.500" />
<Text color="gray.600">Načítám alba z galerie...</Text>
</HStack>
)}
{!galleryLoading && cachedAlbums.length > 0 && (
<VStack align="stretch" spacing={6}>
{cachedAlbums.map((album) => (
<Box key={album.id} borderWidth="1px" borderRadius="md" p={4} bg={albumCardBg}>
<HStack justify="space-between" mb={3}>
<VStack align="start" spacing={0}>
<Text fontWeight="bold" fontSize="lg">{album.title || 'Album bez názvu'}</Text>
<Text fontSize="sm" color="gray.500">{album.date} {album.photos.length} fotografií</Text>
</VStack>
<Button size="sm" colorScheme="purple" onClick={() => { setExistingSelectedAlbum(album); setExistingSelectedPhotos(new Set()); setExistingVisibleCount(60); }}>
Otevřít album
</Button>
</HStack>
<SimpleGrid columns={{ base: 6, md: 10 }} spacing={2}>
{album.photos.slice(0, 20).map((photo) => (
<AspectRatio key={photo.id} ratio={1}>
<Image src={photo.image_1500} alt={photo.id} objectFit="cover" loading="lazy" decoding="async" />
</AspectRatio>
))}
</SimpleGrid>
</Box>
))}
</VStack>
)}
{!galleryLoading && cachedAlbums.length === 0 && (
<VStack py={8} spacing={3}>
<Icon as={FiSearch} boxSize={12} color="gray.400" />
<Text color="gray.600" textAlign="center">Žádná alba nebyla nalezena v cache.</Text>
<Button size="sm" onClick={fetchCachedGallery} leftIcon={<FiRefreshCcw />}>Obnovit seznam</Button>
</VStack>
)}
</>
)}
{existingSelectedAlbum && (
<VStack align="stretch" spacing={3}>
<HStack justify="space-between">
<Button size="sm" variant="ghost" onClick={() => { setExistingSelectedAlbum(null); setExistingSelectedPhotos(new Set()); setExistingVisibleCount(60); }}> Zpět na seznam alb</Button>
<Checkbox
isChecked={existingSelectedPhotos.size === existingSelectedAlbum.photos.length}
isIndeterminate={existingSelectedPhotos.size > 0 && existingSelectedPhotos.size < existingSelectedAlbum.photos.length}
onChange={() => {
if (!existingSelectedAlbum) return;
if (existingSelectedPhotos.size === existingSelectedAlbum.photos.length) {
setExistingSelectedPhotos(new Set());
} else {
setExistingSelectedPhotos(new Set(existingSelectedAlbum.photos.map(p => p.id)));
}
}}
>
Vybrat vše ({existingSelectedPhotos.size}/{existingSelectedAlbum.photos.length})
</Checkbox>
</HStack>
<SimpleGrid columns={{ base: 3, md: 4, lg: 5 }} spacing={3}>
{existingSelectedAlbum.photos.slice(0, existingVisibleCount).map((photo) => {
const checked = existingSelectedPhotos.has(photo.id);
return (
<Box
key={photo.id}
position="relative"
cursor="pointer"
onClick={() => {
const next = new Set(existingSelectedPhotos);
if (next.has(photo.id)) next.delete(photo.id); else next.add(photo.id);
setExistingSelectedPhotos(next);
}}
borderRadius="md"
overflow="hidden"
borderWidth="2px"
borderColor={checked ? 'purple.500' : 'transparent'}
transition="all 0.2s"
_hover={{ transform: 'scale(1.05)' }}
>
<Image src={photo.image_1500} alt={photo.id} w="100%" h="150px" objectFit="cover" loading="lazy" decoding="async" />
<Checkbox position="absolute" top={2} right={2} isChecked={checked} pointerEvents="none" bg="white" borderRadius="sm" />
</Box>
);
})}
</SimpleGrid>
{existingSelectedAlbum.photos.length > existingVisibleCount && (
<HStack justify="center" pt={2}>
<Button size="sm" onClick={() => setExistingVisibleCount(c => c + 60)}>Načíst další</Button>
</HStack>
)}
</VStack>
)}
</VStack>
</ModalBody>
<ModalFooter>
<HStack spacing={3}>
<Button variant="ghost" onClick={() => { setExistingSelectedAlbum(null); setExistingSelectedPhotos(new Set()); onExistingAlbumsClose(); }}>Zrušit</Button>
<Button
colorScheme="purple"
onClick={() => {
if (!existingSelectedAlbum || existingSelectedPhotos.size === 0) return;
const photos = existingSelectedAlbum.photos.filter(p => existingSelectedPhotos.has(p.id));
handleAlbumPhotosSelected(photos as any, {
id: existingSelectedAlbum.id,
title: existingSelectedAlbum.title || '',
url: '', // already cached; skip saveAlbumToCache
date: existingSelectedAlbum.date,
photos_count: existingSelectedAlbum.photos.length,
photos: existingSelectedAlbum.photos,
});
setExistingSelectedAlbum(null);
setExistingSelectedPhotos(new Set());
onExistingAlbumsClose();
}}
isDisabled={!existingSelectedAlbum || existingSelectedPhotos.size === 0}
>
Vložit vybrané ({existingSelectedPhotos.size || 0})
</Button>
</HStack>
</ModalFooter>
</ModalContent>
</Modal>
{/* YouTube Video Picker Modal */}
<Modal isOpen={isYouTubeModalOpen} onClose={onYouTubeModalClose} size="6xl">
<ModalOverlay />
@@ -2402,6 +2497,8 @@ const ArticlesAdminPage = () => {
src={photo.image_1500}
alt={photo.id}
objectFit="cover"
loading="lazy"
decoding="async"
/>
</AspectRatio>
</Box>
@@ -55,6 +55,15 @@ const BANNER_PRESETS: BannerPreset[] = [
aspectRatio: 8.09,
position: 'article'
},
{
value: 'article_sidebar',
label: 'Banner v článku (sidebar)',
description: 'Banner v pravém sloupci detailu článku',
width: 300,
height: 250,
aspectRatio: 1.2,
position: 'article'
},
{
value: 'homepage_under_table',
label: 'Pod tabulkou (Homepage)',
@@ -2,7 +2,7 @@ import React, { useEffect, useMemo, useRef, useState } from 'react';
import { Box, Button, Center, HStack, Heading, Image, SimpleGrid, Text, useColorModeValue, useToast, VStack, Badge } from '@chakra-ui/react';
import AdminLayout from '@/layouts/AdminLayout';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { getAdminScoreboard, updateAdminScoreboard, ScoreboardState, startTimer, pauseTimer, resetTimer, swapSides, startSecondHalf } from '@/services/scoreboard';
import { getAdminScoreboard, updateAdminScoreboard, ScoreboardState, startTimer, pauseTimer, resetTimer, startSecondHalf } from '@/services/scoreboard';
const MobileScoreboardControlPage: React.FC = () => {
const toast = useToast();
@@ -59,54 +59,57 @@ const MobileScoreboardControlPage: React.FC = () => {
<Box p={3}>
<Heading size="md" mb={3}>Mobilní ovládání tabule</Heading>
<VStack align="stretch" spacing={3}>
<Box borderWidth="1px" borderColor={borderCol} bg={cardBg} borderRadius="lg" p={3}>
<SimpleGrid columns={3} spacing={2} alignItems="center">
<VStack spacing={2}>
{state.homeLogo ? <Image src={state.homeLogo} alt="DOM" boxSize="64px" objectFit="contain" /> : null}
<Text fontWeight="bold" textAlign="center">{state.homeShort || 'DOM'}</Text>
<HStack>
<Button size="lg" onClick={() => setPartial({ homeScore: Math.max(0, (state.homeScore || 0) - 1) })}></Button>
<Button size="lg" colorScheme="green" onClick={() => setPartial({ homeScore: (state.homeScore || 0) + 1 })}>+</Button>
</HStack>
<HStack>
<Button size="sm" onClick={() => setPartial({ homeFouls: Math.max(0, Math.min(5, (state.homeFouls || 0) - 1)) })}> Faul</Button>
<Text fontWeight="semibold">{Math.max(0, Math.min(5, state.homeFouls || 0))}</Text>
<Button size="sm" colorScheme="orange" onClick={() => setPartial({ homeFouls: Math.max(0, Math.min(5, (state.homeFouls || 0) + 1)) })}>+ Faul</Button>
</HStack>
</VStack>
<VStack spacing={2}>
<Text fontSize="5xl" fontWeight="black">{state.homeScore} : {state.awayScore}</Text>
<HStack>
<Button onClick={() => (state.running ? handlePauseTimer() : handleStartTimer())}>{state.running ? 'Stop' : 'Start'}</Button>
<Button variant="outline" onClick={handleResetTimer}>Reset</Button>
</HStack>
<Text fontSize="2xl" fontFamily="mono">{mmss}</Text>
<HStack>
<Badge colorScheme="purple">Poločas: {state.half || 1}</Badge>
</HStack>
<HStack>
<Button size="sm" variant="outline" onClick={async ()=>{ try { await swapSides(); await qc.invalidateQueries({ queryKey: ['admin-scoreboard-mobile'] }); toast({ title: 'Strany prohozeny', status: 'success' }); } catch { toast({ title: 'Prohození selhalo', status: 'error' }); } }}>Prohodit strany</Button>
<Button size="sm" colorScheme="purple" onClick={async ()=>{ try { await startSecondHalf(); await qc.invalidateQueries({ queryKey: ['admin-scoreboard-mobile'] }); toast({ title: 'Začal 2. poločas', status: 'success' }); } catch { toast({ title: 'Akce selhala', status: 'error' }); } }}>Začít 2. poločas</Button>
</HStack>
</VStack>
<VStack spacing={2}>
{state.awayLogo ? <Image src={state.awayLogo} alt="HOS" boxSize="64px" objectFit="contain" /> : null}
<Text fontWeight="bold" textAlign="center">{state.awayShort || 'HOS'}</Text>
<HStack>
<Button size="lg" onClick={() => setPartial({ awayScore: Math.max(0, (state.awayScore || 0) - 1) })}></Button>
<Button size="lg" colorScheme="green" onClick={() => setPartial({ awayScore: (state.awayScore || 0) + 1 })}>+</Button>
</HStack>
<HStack>
<Button size="sm" onClick={() => setPartial({ awayFouls: Math.max(0, Math.min(5, (state.awayFouls || 0) - 1)) })}> Faul</Button>
<Text fontWeight="semibold">{Math.max(0, Math.min(5, state.awayFouls || 0))}</Text>
<Button size="sm" colorScheme="orange" onClick={() => setPartial({ awayFouls: Math.max(0, Math.min(5, (state.awayFouls || 0) + 1)) })}>+ Faul</Button>
</HStack>
</VStack>
</SimpleGrid>
<Box borderWidth="1px" borderColor={borderCol} bg={cardBg} borderRadius="lg" p={{ base: 3, md: 4 }}>
<VStack spacing={3} align="stretch">
<HStack justify="space-between" align="center" flexWrap="wrap">
<Text fontSize={{ base: '4xl', md: '5xl' }} fontWeight="black" lineHeight="1">{state.homeScore} : {state.awayScore}</Text>
<Text fontSize={{ base: '3xl', md: '4xl' }} fontFamily="mono" fontWeight="semibold">{mmss}</Text>
</HStack>
<HStack spacing={2} wrap="wrap">
<Button size="lg" colorScheme={state.running ? 'red' : 'green'} onClick={() => (state.running ? handlePauseTimer() : handleStartTimer())}>
{state.running ? 'Stop' : 'Start'}
</Button>
<Button size="lg" variant="outline" onClick={handleResetTimer}>Reset</Button>
<Button size="lg" colorScheme="purple" onClick={async ()=>{ try { await startSecondHalf(); await qc.invalidateQueries({ queryKey: ['admin-scoreboard-mobile'] }); toast({ title: 'Začal 2. poločas', status: 'success' }); } catch { toast({ title: 'Akce selhala', status: 'error' }); } }}>
Začít 2. poločas
</Button>
<Badge ml="auto" colorScheme="purple" fontSize={{ base: 'sm', md: 'md' }}>Poločas: {state.half || 1}</Badge>
</HStack>
</VStack>
</Box>
{/* Removed 'Vybraný zápas' section for remote managed on main Tabule page */}
<SimpleGrid columns={{ base: 1, sm: 2 }} spacing={3} alignItems="stretch">
<VStack spacing={3} borderWidth="1px" borderColor={borderCol} bg={cardBg} borderRadius="lg" p={{ base: 3, md: 4 }} align="stretch">
{state.homeLogo ? <Image src={state.homeLogo} alt="DOM" boxSize={{ base: '56px', md: '64px' }} objectFit="contain" alignSelf="center" /> : null}
<Text fontWeight="bold" textAlign="center">{state.homeShort || 'DOM'}</Text>
<HStack justify="center">
<Button size="lg" onClick={() => setPartial({ homeScore: Math.max(0, (state.homeScore || 0) - 1) })}></Button>
<Button size="lg" colorScheme="green" onClick={() => setPartial({ homeScore: (state.homeScore || 0) + 1 })}>+</Button>
</HStack>
<HStack justify="center">
<Button size="sm" onClick={() => setPartial({ homeFouls: Math.max(0, Math.min(5, (state.homeFouls || 0) - 1)) })}> Faul</Button>
<Text fontWeight="semibold">{Math.max(0, Math.min(5, state.homeFouls || 0))}</Text>
<Button size="sm" colorScheme="orange" onClick={() => setPartial({ homeFouls: Math.max(0, Math.min(5, (state.homeFouls || 0) + 1)) })}>+ Faul</Button>
</HStack>
</VStack>
<VStack spacing={3} borderWidth="1px" borderColor={borderCol} bg={cardBg} borderRadius="lg" p={{ base: 3, md: 4 }} align="stretch">
{state.awayLogo ? <Image src={state.awayLogo} alt="HOS" boxSize={{ base: '56px', md: '64px' }} objectFit="contain" alignSelf="center" /> : null}
<Text fontWeight="bold" textAlign="center">{state.awayShort || 'HOS'}</Text>
<HStack justify="center">
<Button size="lg" onClick={() => setPartial({ awayScore: Math.max(0, (state.awayScore || 0) - 1) })}></Button>
<Button size="lg" colorScheme="green" onClick={() => setPartial({ awayScore: (state.awayScore || 0) + 1 })}>+</Button>
</HStack>
<HStack justify="center">
<Button size="sm" onClick={() => setPartial({ awayFouls: Math.max(0, Math.min(5, (state.awayFouls || 0) - 1)) })}> Faul</Button>
<Text fontWeight="semibold">{Math.max(0, Math.min(5, state.awayFouls || 0))}</Text>
<Button size="sm" colorScheme="orange" onClick={() => setPartial({ awayFouls: Math.max(0, Math.min(5, (state.awayFouls || 0) + 1)) })}>+ Faul</Button>
</HStack>
</VStack>
</SimpleGrid>
</VStack>
{/* Removed 'Vybraný zápas' section for remote managed on main Tabule page */}
</Box>
</AdminLayout>
);
+167 -40
View File
@@ -18,6 +18,14 @@ import {
Text,
Switch,
Badge,
Modal,
ModalOverlay,
ModalContent,
ModalHeader,
ModalCloseButton,
ModalBody,
ModalFooter,
useDisclosure,
Tabs,
TabList,
TabPanels,
@@ -49,12 +57,13 @@ import {
prefillSponsorsFromPage,
getQr,
uploadQr,
deleteQr,
} from '@/services/scoreboard';
import { useFacrApi } from '@/hooks/useFacrApi';
import { SearchResult } from '@/services/facr/types';
import { API_URL } from '@/services/api';
import { useQuery } from '@tanstack/react-query';
import { AdminMatch, fetchAdminMatches } from '@/services/adminMatches';
import { AdminMatch, fetchAdminMatches, fetchTeamLogoOverrides } from '@/services/adminMatches';
import { getFacrClubInfoCache } from '@/services/facr/cache';
import { createSponsor } from '@/services/sponsors';
@@ -85,6 +94,8 @@ const ScoreboardAdminPage: React.FC = () => {
const [sUploadBusy, setSUploadBusy] = useState(false);
const [qrUrl, setQrUrl] = useState<string>('');
const [qrBusy, setQrBusy] = useState(false);
const { isOpen: isSponsorModalOpen, onOpen: openSponsorModal, onClose: closeSponsorModal } = useDisclosure();
const [uploadedSponsorUrls, setUploadedSponsorUrls] = useState<string[]>([]);
// Club search inline (home/away target)
const [clubQuery, setClubQuery] = useState('');
@@ -126,6 +137,101 @@ const ScoreboardAdminPage: React.FC = () => {
staleTime: 60_000,
});
// Load team overrides (names + logos)
const { data: teamOverrides = {} } = useQuery<any>({
queryKey: ['team-logo-overrides-admin'],
queryFn: fetchTeamLogoOverrides,
staleTime: 5 * 60 * 1000,
});
const byId: Record<string, { name?: string; logo_url?: string }> = (teamOverrides as any)?.by_id || {};
const byNameMap: Record<string, string> = (teamOverrides as any)?.by_name || {} as Record<string, string>;
const normName = (s?: string) => String(s || '')
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
.replace(/[\u2012\u2013\u2014\u2015\u2212]/g, '-')
.replace(/\bn\.?\b/g, ' nad ')
.replace(/\bp\.?\b/g, ' pod ')
.replace(/[\,\s]*(z\.?\s*s\.?|o\.?\s*s\.?)\s*$/g, '')
.replace(/[\.,!;:()\[\]{}]/g, ' ')
.replace(/\s+/g, ' ')
.trim()
.toLowerCase();
const aliasNameIndex = React.useMemo(() => {
const urlToName: Record<string, string> = {};
for (const v of Object.values(byId || {})) {
const nm = String((v as any)?.name || '').trim();
const lg = String((v as any)?.logo_url || '').trim();
if (nm && lg) urlToName[lg] = nm;
}
const idx: Record<string, string> = {};
for (const [alias, url] of Object.entries(byNameMap || {})) {
const canon = urlToName[String(url)] || '';
const key = normName(alias);
if (canon && key) idx[key] = canon;
}
return idx;
}, [byId, byNameMap]);
const nameIndex = React.useMemo(() => {
const idx: Record<string, { id: string; name: string; logo_url: string }> = {};
try {
for (const [id, v] of Object.entries(byId || {})) {
const nm = String((v as any)?.name || '').trim();
const lg = String((v as any)?.logo_url || '').trim();
if (!nm) continue;
const key = normName(nm);
if (!key) continue;
idx[key] = { id, name: nm, logo_url: lg } as any;
}
} catch {}
return idx;
}, [byId]);
const getOverrideName = (teamName?: string, teamId?: string) => {
const tid = teamId ? String(teamId) : '';
if (tid && byId?.[tid]?.name && String(byId[tid].name).trim()) {
return String(byId[tid].name).trim();
}
try {
const n = normName(teamName);
if (aliasNameIndex[n]) return aliasNameIndex[n];
let hit: any = nameIndex[n];
if (!hit) {
for (const [k, v] of Object.entries(nameIndex)) {
if (!k) continue;
if (n.endsWith(k) || k.endsWith(n)) { hit = v; break; }
}
}
if (hit && (hit as any).name) return String((hit as any).name);
} catch {}
return String(teamName || '');
};
const getLogo = (teamName?: string, original?: string) => {
const byName = (teamOverrides as any)?.by_name || {} as Record<string, string>;
const norm = (s: string) => String(s || '')
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
.replace(/\s+/g, ' ')
.trim()
.toLowerCase();
const stripPrefixes = (s: string) => {
let x = norm(s);
x = x.replace(/\b(mestsky|m\.?f\.?k\.?|mfk|tj|sk|sokol|fotbalovy|fotbalový|fotbalovy\s+klub|fotbalovy\s+klub)\b/g, '');
return x.replace(/\s+/g, ' ').trim();
};
const byNameNorm: Record<string, string> = Object.keys(byName || {}).reduce((acc: Record<string, string>, k) => { acc[norm(k)] = byName[k]; return acc; }, {});
const strippedPairs = Object.keys(byName || {}).map((k) => ({ key: stripPrefixes(k), url: byName[k] }));
const pick = (name?: string, orig?: string) => {
if (!name) return orig;
const exact = byName[name];
let candidate = exact || byNameNorm[norm(name)];
if (!candidate) {
const s = stripPrefixes(name);
for (const { key, url } of strippedPairs) { if (key && (s.endsWith(key) || key.endsWith(s))) { candidate = url; break; } }
}
return candidate || orig;
};
return pick(teamName, original);
};
// Load competitions/matches from cached FACR blob
const { data: facrCache } = useQuery<any>({
queryKey: ['facr-club-info-cache'],
@@ -229,10 +335,17 @@ const ScoreboardAdminPage: React.FC = () => {
const applyMatch = async (m: AdminMatch) => {
if (!state) return;
// Populate names, logos and short codes
const homeName = String(m.home || m.home_team || '').trim();
const awayName = String(m.away || m.away_team || '').trim();
const homeLogo = resolveLogoUrl(m.home_logo_url || '') || '';
const awayLogo = resolveLogoUrl(m.away_logo_url || '') || '';
const rawHomeName = String(m.home || (m as any).home_team || '').trim();
const rawAwayName = String(m.away || (m as any).away_team || '').trim();
const homeTeamId = String((m as any).home_id || (m as any).homeTeamId || (m as any).home_team_id || '');
const awayTeamId = String((m as any).away_id || (m as any).awayTeamId || (m as any).away_team_id || '');
const homeName = getOverrideName(rawHomeName, homeTeamId) || rawHomeName;
const awayName = getOverrideName(rawAwayName, awayTeamId) || rawAwayName;
// Prefer ID-based logo override, then name-based, then original logo URL
const homeLogoOverride = (homeTeamId && byId?.[homeTeamId]?.logo_url) ? String(byId[homeTeamId].logo_url) : getLogo(rawHomeName, m.home_logo_url || (m as any).homeLogoURL || '');
const awayLogoOverride = (awayTeamId && byId?.[awayTeamId]?.logo_url) ? String(byId[awayTeamId].logo_url) : getLogo(rawAwayName, m.away_logo_url || (m as any).awayLogoURL || '');
const homeLogo = resolveLogoUrl(homeLogoOverride || '') || '';
const awayLogo = resolveLogoUrl(awayLogoOverride || '') || '';
const updates: Partial<ScoreboardState> = {
homeName,
awayName,
@@ -444,30 +557,6 @@ const ScoreboardAdminPage: React.FC = () => {
}}
/>
</FormControl>
<FormControl>
<FormLabel>Skóre domácích</FormLabel>
<NumberInput value={state.homeScore} min={0} onChange={async (_, n) => setPartial({ homeScore: Number.isFinite(n) ? n : 0 })}>
<NumberInputField />
</NumberInput>
</FormControl>
<FormControl>
<FormLabel>Skóre hostů</FormLabel>
<NumberInput value={state.awayScore} min={0} onChange={async (_, n) => setPartial({ awayScore: Number.isFinite(n) ? n : 0 })}>
<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 })}>
@@ -503,6 +592,14 @@ 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>Barva textu domácích</FormLabel>
<Input type="color" value={state.homeTextColor || '#ffffff'} onChange={async (e) => setPartial({ homeTextColor: e.target.value })} />
</FormControl>
<FormControl>
<FormLabel>Barva textu hostů</FormLabel>
<Input type="color" value={state.awayTextColor || '#ffffff'} onChange={async (e) => setPartial({ awayTextColor: 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) })}>
@@ -578,17 +675,8 @@ const ScoreboardAdminPage: React.FC = () => {
try {
const urls = (res?.files || []).filter(Boolean) as string[];
if (urls.length > 0) {
const want = window.confirm('Chcete přidat nahraná loga i jako nové sponzory na web?');
if (want) {
for (const u of urls) {
const fname = (u.split('/').pop() || '').replace(/\.[a-z0-9]+$/i, '');
const name = window.prompt('Název sponzora pro logo '+fname, fname) || '';
if (!name.trim()) continue;
const website = window.prompt('Web sponzora (volitelné, včetně https://)', '') || '';
try { await createSponsor({ name: name.trim(), logo_url: u, website_url: website.trim() || undefined, is_active: true }); } catch {}
}
toast({ title: 'Sponzoři přidáni', status: 'success' });
}
setUploadedSponsorUrls(urls);
openSponsorModal();
}
} catch {}
} catch (err: any) {
@@ -623,6 +711,42 @@ const ScoreboardAdminPage: React.FC = () => {
</SimpleGrid>
</Box>
{/* Modal: Add uploaded logos as sponsors */}
<Modal isOpen={isSponsorModalOpen} onClose={closeSponsorModal} size="lg">
<ModalOverlay />
<ModalContent>
<ModalHeader>Přidat loga jako sponzory?</ModalHeader>
<ModalCloseButton />
<ModalBody>
<Text mb={3}>Chcete přidat nahraná loga i jako nové sponzory na web?</Text>
<SimpleGrid columns={{ base: 2, md: 4 }} spacing={3}>
{uploadedSponsorUrls.map((u)=> (
<Image key={u} src={u} alt="logo" boxSize="64px" objectFit="contain" borderWidth="1px" borderRadius="md" />
))}
</SimpleGrid>
</ModalBody>
<ModalFooter>
<Button mr={3} onClick={closeSponsorModal}>Ne</Button>
<Button colorScheme="blue" onClick={async ()=>{
try {
for (const u of uploadedSponsorUrls) {
const fname = (u.split('/').pop() || '').replace(/\.[a-z0-9]+$/i, '');
const name = window.prompt('Název sponzora pro logo '+fname, fname) || '';
if (!name.trim()) continue;
const website = window.prompt('Web sponzora (volitelné, včetně https://)', '') || '';
try { await createSponsor({ name: name.trim(), logo_url: u, website_url: website.trim() || undefined, is_active: true }); } catch {}
}
setSponsors(await listSponsorsAdmin());
toast({ title: 'Sponzoři přidáni', status: 'success' });
} finally {
closeSponsorModal();
setUploadedSponsorUrls([]);
}
}}>Ano, přidat</Button>
</ModalFooter>
</ModalContent>
</Modal>
<Box borderWidth="1px" borderRadius="lg" p={4} bg={cardBg} mb={6}>
<Heading size="md" mb={3}>QR kód</Heading>
<HStack spacing={4} align="flex-start" flexWrap="wrap">
@@ -652,6 +776,9 @@ const ScoreboardAdminPage: React.FC = () => {
}} />
</Button>
<Button variant="ghost" onClick={async ()=>{ try { setQrUrl(await getQr()); toast({ title: 'Obnoveno', status: 'info' }); } catch {} }}>Obnovit</Button>
<Button variant="outline" colorScheme="red" isDisabled={!qrUrl} onClick={async ()=>{
try { await deleteQr(); setQrUrl(''); toast({ title: 'QR smazán', status: 'info' }); } catch { toast({ title: 'Smazání selhalo', status: 'error' }); }
}}>Smazat QR</Button>
</HStack>
</Box>
@@ -55,7 +55,10 @@ const SettingsAdminPage: React.FC = () => {
getAdminSettings()
.then((data) => {
const s = data || {};
setSettings(s);
const normalized: any = { ...s };
if (!normalized.storage_warn_threshold || normalized.storage_warn_threshold <= 0) normalized.storage_warn_threshold = 80;
if (!normalized.storage_critical_threshold || normalized.storage_critical_threshold <= 0) normalized.storage_critical_threshold = 95;
setSettings(normalized);
})
.catch(() => {
toast({ title: 'Chyba', description: 'Nepodařilo se načíst nastavení', status: 'error' });
@@ -208,8 +211,8 @@ const SettingsAdminPage: React.FC = () => {
api_base_url: (settings as any).api_base_url,
// homepage matches display
finished_match_display_days: (settings as any).finished_match_display_days as any,
storage_warn_threshold: (settings as any).storage_warn_threshold as any,
storage_critical_threshold: (settings as any).storage_critical_threshold as any,
storage_warn_threshold: (((settings as any).storage_warn_threshold ?? 0) > 0 ? (settings as any).storage_warn_threshold : 80) as any,
storage_critical_threshold: (((settings as any).storage_critical_threshold ?? 0) > 0 ? (settings as any).storage_critical_threshold : 95) as any,
// error-review integration (domain managed via .env; only tokens are saved)
error_review_admin_token: (settings as any).error_review_admin_token,
error_review_ingest_token: (settings as any).error_review_ingest_token,
@@ -302,7 +305,7 @@ const SettingsAdminPage: React.FC = () => {
type="number"
min={0}
max={100}
value={(settings as any).storage_warn_threshold ?? 80}
value={((settings as any).storage_warn_threshold ?? 0) > 0 ? (settings as any).storage_warn_threshold : 80}
onChange={handleNumChange('storage_warn_threshold' as any)}
/>
</FormControl>
@@ -312,7 +315,7 @@ const SettingsAdminPage: React.FC = () => {
type="number"
min={0}
max={100}
value={(settings as any).storage_critical_threshold ?? 95}
value={((settings as any).storage_critical_threshold ?? 0) > 0 ? (settings as any).storage_critical_threshold : 95}
onChange={handleNumChange('storage_critical_threshold' as any)}
/>
</FormControl>
@@ -55,13 +55,17 @@ const ShortlinksAdminPage: React.FC = () => {
if (!t) { toast({ title: 'Zadejte cílovou URL', status: 'warning' }); return; }
try {
setCreating(true);
const res = await createShortLink({ target_url: t, title: title.trim() || undefined, code: code.trim() || undefined, active: true });
// sanitize code early for UX
const rawCode = code.trim();
const safeCode = rawCode ? rawCode.replace(/[^a-zA-Z0-9_-]/g, '').slice(0, 16) : undefined;
const res = await createShortLink({ target_url: t, title: title.trim() || undefined, code: safeCode, active: true });
await navigator.clipboard.writeText(res.short_url);
toast({ title: 'Odkaz vytvořen', description: `Zkopírováno: ${res.short_url}`, status: 'success' });
setTargetUrl(''); setTitle(''); setCode('');
qc.invalidateQueries({ queryKey: ['admin-shortlinks'] });
} catch (e: any) {
toast({ title: 'Vytvoření selhalo', description: e?.message || 'Zkuste to znovu', status: 'error' });
const msg = e?.response?.data?.error || e?.response?.data?.details || e?.message || 'Zkuste to znovu';
toast({ title: 'Vytvoření selhalo', description: String(msg), status: 'error' });
} finally {
setCreating(false);
}
@@ -93,7 +97,7 @@ const ShortlinksAdminPage: React.FC = () => {
<HStack spacing={2} flexWrap="wrap">
<Input placeholder="https://…" value={targetUrl} onChange={(e)=>setTargetUrl(e.target.value)} flex={3} />
<Input placeholder="Titulek (volitelný)" value={title} onChange={(e)=>setTitle(e.target.value)} flex={2} />
<Input placeholder="Vlastní kód (volitelné)" value={code} onChange={(e)=>setCode(e.target.value)} flex={1} />
<Input placeholder="Vlastní kód (volitelné)" value={code} onChange={(e)=>setCode(e.target.value)} flex={1} maxLength={16} pattern="[A-Za-z0-9_-]+" title="Povoleno: písmena, čísla, -, _ (max 16 znaků)" />
<Button onClick={handleCreate} isLoading={creating} colorScheme="blue">Vytvořit</Button>
</HStack>
</Box>
@@ -266,9 +266,9 @@ const SweepstakesAdminPage: React.FC = () => {
return (
<AdminLayout>
<Container maxW="7xl" py={8}>
<HStack justify="space-between" mb={4}>
<HStack justify="space-between" mb={4} flexWrap="wrap">
<Heading size="lg">Soutěže</Heading>
<HStack>
<HStack flexWrap="wrap">
<Select value={status} onChange={(e)=>setStatus(e.target.value)} size="sm" maxW="220px">
<option value="">Všechny</option>
<option value="draft">Koncepty</option>
@@ -277,7 +277,7 @@ const SweepstakesAdminPage: React.FC = () => {
<option value="finalized">Dokončené</option>
<option value="archived">Archiv</option>
</Select>
<Button colorScheme="blue" onClick={openCreate}>Nová soutěž</Button>
<Button colorScheme="blue" onClick={openCreate} minW="max-content">Nová soutěž</Button>
</HStack>
</HStack>
@@ -332,7 +332,7 @@ const SweepstakesAdminPage: React.FC = () => {
)}
{/* Create/Edit Modal with tabs */}
<Modal isOpen={isOpen} onClose={onClose} size="3xl">
<Modal isOpen={isOpen} onClose={onClose} size="3xl" scrollBehavior="inside" isCentered>
<ModalOverlay />
<ModalContent>
<ModalHeader>{editing ? 'Upravit soutěž' : 'Nová soutěž'}</ModalHeader>
@@ -373,7 +373,7 @@ const SweepstakesAdminPage: React.FC = () => {
<FormControl>
<FormLabel>Pravidla</FormLabel>
<VStack align="start" spacing={2}>
<HStack>
<HStack flexWrap="wrap" spacing={2}>
<Button as="label" leftIcon={<FiUpload />} variant="outline">
Nahrát PDF/obrázek
<Input ref={rulesInputRef} type="file" display="none" accept="image/*,application/pdf" onChange={(e)=>onUploadRules(e.target.files?.[0])} />
+6 -4
View File
@@ -1,4 +1,5 @@
import api from './api';
const AI_TIMEOUT = Number(process.env.REACT_APP_AI_TIMEOUT_MS || '') || 90000;
export interface AIGenerateBlogReq {
prompt: string;
@@ -13,7 +14,7 @@ export interface AIGenerateBlogResp {
}
export async function generateBlogAI(payload: AIGenerateBlogReq): Promise<AIGenerateBlogResp> {
const { data } = await api.post<AIGenerateBlogResp>('/ai/blog/generate', payload);
const { data } = await api.post<AIGenerateBlogResp>('/ai/blog/generate', payload, { timeout: AI_TIMEOUT });
// Handle potential JSON string response from AI (defensive parsing)
let parsedData = data;
@@ -47,13 +48,14 @@ export interface AIGenerateInstagramReq {
hashtags?: string[];
audience?: string;
tone?: string;
category?: string;
match?: AIGenerateInstagramMatch | null;
}
export interface AIGenerateInstagramResp { text: string }
export async function generateInstagramAI(payload: AIGenerateInstagramReq): Promise<AIGenerateInstagramResp> {
const { data } = await api.post<AIGenerateInstagramResp>('/ai/instagram/generate', payload);
const { data } = await api.post<AIGenerateInstagramResp>('/ai/instagram/generate', payload, { timeout: AI_TIMEOUT });
let parsed: any = data;
if (typeof parsed === 'string') {
try { parsed = JSON.parse(parsed); } catch { parsed = { text: '' }; }
@@ -77,7 +79,7 @@ export interface AIGenerateCSSResp {
}
export async function generateCSSAI(payload: AIGenerateCSSReq): Promise<AIGenerateCSSResp> {
const { data } = await api.post<AIGenerateCSSResp>('/ai/css/generate', payload);
const { data } = await api.post<AIGenerateCSSResp>('/ai/css/generate', payload, { timeout: AI_TIMEOUT });
let parsed = data as any;
if (typeof parsed === 'string') {
try { parsed = JSON.parse(parsed); } catch { parsed = { css: '' }; }
@@ -101,7 +103,7 @@ export interface AIGenerateAboutResp {
}
export async function generateAboutAI(payload: AIGenerateAboutReq): Promise<AIGenerateAboutResp> {
const { data } = await api.post<AIGenerateAboutResp>('/ai/about/generate', payload);
const { data } = await api.post<AIGenerateAboutResp>('/ai/about/generate', payload, { timeout: AI_TIMEOUT });
// Handle potential JSON string response from AI (defensive parsing)
let parsedData = data;
+114
View File
@@ -0,0 +1,114 @@
import { triggerPrefetch } from './admin/prefetch';
import { Article, CreateArticlePayload, UpdateArticlePayload, createArticle, updateArticle } from './articles';
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
function normStr(v: any): string {
return String(v ?? '').trim();
}
function normalizeAttachments(aRaw: any): Array<{ name: string; url: string; mime_type?: string; size?: number }> | undefined {
try {
const arr = Array.isArray(aRaw) ? aRaw : (typeof aRaw === 'string' ? JSON.parse(aRaw) : []);
if (!Array.isArray(arr) || arr.length === 0) return undefined;
return arr.map((it: any) => {
if (typeof it === 'string') {
const name = it.split('/').pop() || 'soubor';
return { name, url: it };
}
const name = it?.name || (String(it?.url || '').split('/').pop() || 'soubor');
const url = String(it?.url || '');
const mime_type = it?.mime_type || it?.type;
const size = typeof it?.size === 'number' ? it.size : undefined;
return { name, url, mime_type, size };
});
} catch {
return undefined;
}
}
function normalizeGalleryIds(raw: any): string[] | undefined {
if (Array.isArray(raw)) return raw.map(String);
if (typeof raw === 'string') {
try {
const parsed = JSON.parse(raw);
if (Array.isArray(parsed)) return parsed.map(String);
} catch {}
return raw.split(',').map((s) => s.trim()).filter(Boolean);
}
return undefined;
}
function buildUpdatePayload(editing: Partial<Article>): UpdateArticlePayload {
const attachments = normalizeAttachments((editing as any)?.attachments);
const galleryIds = normalizeGalleryIds((editing as any)?.gallery_photo_ids);
return {
title: normStr((editing as any)?.title),
content: typeof (editing as any)?.content === 'string' ? (editing as any).content : '',
image_url: normStr((editing as any)?.image_url),
...(typeof (editing as any)?.category_id === 'number' ? { category_id: (editing as any).category_id } : {}),
category_name: normStr((editing as any)?.category_name),
slug: normStr((editing as any)?.slug),
seo_title: normStr((editing as any)?.seo_title),
seo_description: normStr((editing as any)?.seo_description),
og_image_url: normStr((editing as any)?.og_image_url),
featured: !!(editing as any)?.featured,
// Gallery
gallery_album_id: normStr((editing as any)?.gallery_album_id),
gallery_album_url: normStr((editing as any)?.gallery_album_url),
...(galleryIds ? { gallery_photo_ids: galleryIds } : {}),
// YouTube
youtube_video_id: normStr((editing as any)?.youtube_video_id),
youtube_video_title: normStr((editing as any)?.youtube_video_title),
youtube_video_url: normStr((editing as any)?.youtube_video_url),
youtube_video_thumbnail: normStr((editing as any)?.youtube_video_thumbnail),
// Attachments
...(attachments ? { attachments } : {}),
} as UpdateArticlePayload;
}
function buildCreatePayload(editing: Partial<Article>): CreateArticlePayload {
const u = buildUpdatePayload(editing);
return u as unknown as CreateArticlePayload;
}
export async function saveArticleReliable(editing: Partial<Article>): Promise<Article> {
const id = (editing as any)?.id;
const isUpdate = !!id;
const payloadU = buildUpdatePayload(editing);
const payloadC = buildCreatePayload(editing);
const maxAttempts = 3;
let lastErr: any;
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
try {
let saved: Article;
if (isUpdate) {
try {
saved = await updateArticle(id as any, payloadU);
} catch (e: any) {
if (e?.response?.status === 404) {
saved = await createArticle(payloadC);
} else {
throw e;
}
}
} else {
saved = await createArticle(payloadC);
}
if (saved?.published) {
try { await triggerPrefetch(); } catch {}
}
return saved;
} catch (e) {
lastErr = e;
if (attempt < maxAttempts) {
await sleep(attempt * 400);
continue;
}
throw e;
}
}
throw lastErr;
}
+3 -2
View File
@@ -199,7 +199,7 @@ export async function deleteArticle(id: number | string) {
export async function getArticleBySlug(slug: string) {
try {
const res = await api.get<Article>(`/articles/slug/${encodeURIComponent(slug)}`);
return res.data;
return normalizeArticle(res.data);
} catch (e) {
// Fallback: attempt list query through normalized helper and return first match
const list = await getArticles({ slug });
@@ -239,7 +239,8 @@ export async function uploadFile(file: File) {
export async function trackArticleView(id: number | string) {
try {
await api.post(`/articles/${id}/track-view`);
// Send an explicit empty JSON body to satisfy backend Content-Type validation
await api.post(`/articles/${id}/track-view`, {});
} catch (e) {
console.debug('Failed to track article view:', e);
}
+37 -10
View File
@@ -29,7 +29,8 @@ export function composeInstagramPostFromArticle(params: {
}): string {
const { article, trackingUrl, clubName, hashtags = [], match } = params;
const title = article.title?.trim() || '';
const plain = stripHtml(article.content).slice(0, 280);
const catName = (article as any)?.category?.name || (article as any)?.category_name || '';
const snippet = stripHtml(article.content).slice(0, 160);
const defaultTags = hashtags.length ? hashtags : [
`#${normalizeTag(clubName || 'FKKrnov')}`,
'#fotbal',
@@ -43,15 +44,15 @@ export function composeInstagramPostFromArticle(params: {
const date = match.date_time ? formatDateTime(match.date_time) : '';
const score = match.score && /\d/.test(match.score) ? match.score : '';
const header = `💙💛 ${clubName || 'Náš klub'}: ${title} 💛💙`;
const header = `💙💛 ${(catName || clubName || 'Náš klub')}: ${title} 💛💙`;
const lines = [
header,
'',
score ? `Výsledek: ${home} ${score} ${away}` : `${home} vs ${away}`,
comp || date ? `${comp}${comp && date ? ' • ' : ''}${date}` : '',
match.venue ? `Místo: ${match.venue}` : '',
match.venue ? `Místo: ${cleanVenue(String(match.venue))}` : '',
'',
plain ? `${plain}${plain.length === 280 ? '…' : ''}` : '',
snippet ? `${snippet}${snippet.length === 160 ? '…' : ''}` : '',
'',
'📸 Celý článek najdeš tady 👇',
`🔗 ${trackingUrl}`,
@@ -63,11 +64,11 @@ export function composeInstagramPostFromArticle(params: {
}
// Informative/general article
const header = `💙💛 ${clubName || 'Náš klub'}: ${title} 💛💙`;
const header = `💙💛 ${(catName || clubName || 'Náš klub')}: ${title} 💛💙`;
const lines = [
header,
'',
plain,
snippet,
'',
'📸 Celý článek najdeš tady 👇',
`🔗 ${trackingUrl}`,
@@ -112,12 +113,38 @@ export function composeInstagramPostFromActivity(params: {
return lines.join('\n');
}
function formatDateTime(dt: string): string {
export function formatDateTime(dt: string): string {
const s = String(dt || '').trim();
// Handle FAČR format: dd.mm.yyyy or dd.mm.yyyy HH:MM
const m = s.match(/^(\d{1,2})\.(\d{1,2})\.(\d{4})(?:\s+(\d{1,2}):(\d{2}))?$/);
if (m) {
const dd = parseInt(m[1], 10);
const MM = parseInt(m[2], 10);
const yyyy = parseInt(m[3], 10);
const hh = m[4] ? parseInt(m[4], 10) : 0;
const min = m[5] ? parseInt(m[5], 10) : 0;
const d = new Date(yyyy, MM - 1, dd, hh, min);
const dateStr = d.toLocaleDateString('cs-CZ');
const timeStr = (m[4] ? d.toLocaleTimeString('cs-CZ', { hour: '2-digit', minute: '2-digit' }) : '');
return timeStr ? `${dateStr} ${timeStr}` : dateStr;
}
// ISO-like or other parseable formats
try {
const d = new Date(dt);
return `${d.toLocaleDateString('cs-CZ')} ${d.toLocaleTimeString('cs-CZ', { hour: '2-digit', minute: '2-digit' })}`;
const d = new Date(s);
if (!isNaN(d.getTime())) {
return `${d.toLocaleDateString('cs-CZ')} ${d.toLocaleTimeString('cs-CZ', { hour: '2-digit', minute: '2-digit' })}`;
}
} catch {}
return s;
}
export function cleanVenue(v: string): string {
try {
const base = String(v || '').trim();
// Prefer locality before first " - " (e.g., "Kobeřice - tráva" -> "Kobeřice")
return base.split(' - ')[0].trim();
} catch {
return dt;
return v;
}
}
+19
View File
@@ -0,0 +1,19 @@
import api from './api';
export type RembgStatus = {
running: boolean;
total: number;
done: number;
started_at?: string;
finished_at?: string | null;
};
export const getRembgStatus = async (): Promise<RembgStatus> => {
const res = await api.get('/rembg/status', { timeout: 20000 });
return res.data;
};
export const startRembgBatch = async (): Promise<{ started: boolean; status: RembgStatus }> => {
const res = await api.post('/rembg/start', null, { timeout: 20000 });
return res.data;
};
+27 -2
View File
@@ -12,6 +12,8 @@ export type ScoreboardState = {
awayShort?: string;
primaryColor?: string; // home color
secondaryColor?: string; // away color
homeTextColor?: string; // text color for home label/short
awayTextColor?: string; // text color for away label/short
homeScore: number;
awayScore: number;
homeFouls?: number;
@@ -286,15 +288,32 @@ export async function derivePrimaryFromLogo(logoUrl?: string): Promise<string |
// Helpers to map API payloads
function normalizeFromApi(d: any): Partial<ScoreboardState> {
if (!d) return {};
const absolutize = (u?: string) => {
try {
if (!u) return '';
const s = String(u);
if (s.startsWith('/uploads/') || s.startsWith('/dist/')) {
const base = new URL(API_URL || '', typeof window !== 'undefined' ? window.location.origin : undefined);
return `${base.protocol}//${base.host}${s}`;
}
return s;
} catch {
return u || '';
}
};
const rawHome = d.homeLogo || d.home_logo || d.home_logo_url || d.HomeLogoURL || '';
const rawAway = d.awayLogo || d.away_logo || d.away_logo_url || d.AwayLogoURL || '';
return {
homeName: d.homeName || d.home_name || d.HomeName || '',
awayName: d.awayName || d.away_name || d.AwayName || '',
homeLogo: d.homeLogo || d.home_logo || d.home_logo_url || d.HomeLogoURL || '',
awayLogo: d.awayLogo || d.away_logo || d.away_logo_url || d.AwayLogoURL || '',
homeLogo: absolutize(rawHome),
awayLogo: absolutize(rawAway),
homeShort: d.homeShort || d.home_short || d.HomeShort || '',
awayShort: d.awayShort || d.away_short || d.AwayShort || '',
primaryColor: d.primaryColor || d.primary_color || d.PrimaryColor || undefined,
secondaryColor: d.secondaryColor || d.secondary_color || d.SecondaryColor || undefined,
homeTextColor: d.homeTextColor || d.home_text_color || d.HomeTextColor || undefined,
awayTextColor: d.awayTextColor || d.away_text_color || d.AwayTextColor || 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),
@@ -322,6 +341,8 @@ function toApiPayload(p: Partial<ScoreboardState>) {
if (p.awayShort !== undefined) out.awayShort = p.awayShort;
if (p.primaryColor !== undefined) out.primaryColor = p.primaryColor;
if (p.secondaryColor !== undefined) out.secondaryColor = p.secondaryColor;
if (p.homeTextColor !== undefined) out.homeTextColor = p.homeTextColor;
if (p.awayTextColor !== undefined) out.awayTextColor = p.awayTextColor;
if (p.homeScore !== undefined) out.homeScore = p.homeScore;
if (p.awayScore !== undefined) out.awayScore = p.awayScore;
if (p.homeFouls !== undefined) out.homeFouls = p.homeFouls;
@@ -337,3 +358,7 @@ function toApiPayload(p: Partial<ScoreboardState>) {
if (p.qrDuration !== undefined) out.qrDuration = p.qrDuration;
return out;
}
export async function deleteQr(): Promise<void> {
await api.delete('/admin/scoreboard/qr');
}
+34 -13
View File
@@ -18,32 +18,53 @@ export interface ShortLinkResponse {
}
export async function createShortLink(payload: CreateShortLinkPayload): Promise<ShortLinkResponse> {
const normalized: CreateShortLinkPayload = { ...payload };
if (normalized.target_url && !/^https?:\/\//i.test(normalized.target_url)) {
normalized.target_url = `https://${normalized.target_url}`;
}
if (typeof normalized.code === 'string') {
const s = normalized.code.trim();
const filtered = s.replace(/[^a-zA-Z0-9_-]/g, '').slice(0, 16);
normalized.code = filtered || undefined;
}
// Prefer admin endpoint in admin contexts to avoid 400/403 on public routes
try {
// Prefer editor-accessible endpoint
const res = await api.post<ShortLinkResponse>('/shortlinks', payload);
return res.data;
} catch (e: any) {
// Fallback to admin endpoint (for admin-only contexts)
const res2 = await api.post<ShortLinkResponse>('/admin/shortlinks', payload);
return res2.data;
const resAdmin = await api.post<ShortLinkResponse>('/admin/shortlinks', normalized);
return resAdmin.data;
} catch (_) {
// Fallback to public/editor route if admin path is not available
try {
const resPublic = await api.post<ShortLinkResponse>('/shortlinks', normalized);
return resPublic.data;
} catch (e2: any) {
// Last resort: public-create endpoint (strict allowed-host policy)
const resPub = await api.post<ShortLinkResponse>('/shortlinks/public', {
target_url: normalized.target_url!,
title: normalized.title,
} as any);
return resPub.data;
}
}
}
// Public shortlink creation for visitors (no auth; backend validates allowed host)
export async function createPublicShortLink(payload: { target_url: string; title?: string }): Promise<ShortLinkResponse> {
const res = await api.post<ShortLinkResponse>('/shortlinks/public', payload);
const body = { ...payload };
if (body.target_url && !/^https?:\/\//i.test(body.target_url)) {
body.target_url = `https://${body.target_url}`;
}
const res = await api.post<ShortLinkResponse>('/shortlinks/public', body);
return res.data;
}
export async function listShortLinks(): Promise<{ items: any[] }> {
// Prefer editor-accessible endpoint
// Prefer admin endpoint first in admin context
try {
const resAdmin = await api.get<{ items: any[] }>('/admin/shortlinks');
return resAdmin.data;
} catch (_) {
const res = await api.get<{ items: any[] }>('/shortlinks');
return res.data;
} catch (e) {
// Fallback to admin endpoint (admins only)
const res2 = await api.get<{ items: any[] }>('/admin/shortlinks');
return res2.data;
}
}
+37 -4
View File
@@ -171,12 +171,31 @@
align-items: center;
justify-content: center;
font-weight: 700;
width: 32px;
height: 32px;
border: 1px solid #e2e8f0;
border-radius: 4px;
color: #e53e3e;
position: relative;
}
.ql-toolbar.ql-snow button.ql-colorreset::before,
.ql-toolbar.ql-snow button.ql-bgreset::before {
content: "×";
font-size: 16px;
line-height: 1;
content: "";
position: absolute;
width: 18px;
height: 18px;
background: #fff;
border: 1px solid #e2e8f0;
border-radius: 2px;
}
.ql-toolbar.ql-snow button.ql-colorreset::after,
.ql-toolbar.ql-snow button.ql-bgreset::after {
content: "";
position: absolute;
width: 20px;
height: 2px;
background: #e53e3e;
transform: rotate(45deg);
}
/* Center icons and enlarge align icon */
@@ -265,6 +284,20 @@
margin: 0.25em 0;
}
/* Quill v2 renders bullets via li[data-list=bullet] > .ql-ui::before. Allow switching marker type via parent UL[data-bullets] */
.ql-editor ul[data-bullets="disc"] li[data-list="bullet"] > .ql-ui::before { content: '\2022'; }
.ql-editor ul[data-bullets="circle"] li[data-list="bullet"] > .ql-ui::before { content: '\25E6'; }
.ql-editor ul[data-bullets="square"] li[data-list="bullet"] > .ql-ui::before { content: '\25AA'; }
/* Ensure our custom marker is visible and not overridden */
.ql-editor li[data-list] > .ql-ui::before {
display: inline-block;
width: 1.2em;
margin-left: -1.5em;
margin-right: 0.3em;
text-align: right;
}
.ql-editor blockquote {
border-left: 4px solid #3182ce;
padding-left: 16px;
@@ -425,7 +458,7 @@
.ql-editor {
background-color: white !important;
color: #2d3748 !important;
/* do not force color here; allow inline styles from the editor to apply */
}
/* Responsive Adjustments */