mirror of
https://github.com/Dvorinka/MyClubServer.git
synced 2026-06-04 02:32:57 +00:00
dev day #99
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
))}
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 */}
|
||||
|
||||
@@ -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}
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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 2–4 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>
|
||||
);
|
||||
|
||||
@@ -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])} />
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 */
|
||||
|
||||
Reference in New Issue
Block a user