dev day #100 - WE ARE FUCKING DONE, hotfixes incoming but we did it in 100 days, lets fucking go guys, anyone reading this...i love you

This commit is contained in:
Tomas Dvorak
2025-11-22 21:30:10 +01:00
parent f5b6f83974
commit aa036b6550
47 changed files with 3607 additions and 2177 deletions
+59 -1
View File
@@ -14,6 +14,7 @@ import ProtectedRoute from './components/ProtectedRoute';
import { getSetupStatus } from './services/setup';
import { useState, useEffect } from 'react';
import { usePublicSettings } from './hooks/usePublicSettings';
import { getEditorAllowedAdminNav } from './services/navigation';
// Create a client
const queryClient = new QueryClient({
@@ -177,6 +178,54 @@ const AdminRoutesWrapper = () => {
return <Outlet />;
};
// Admin index: admins see dashboard; editors redirect to first allowed page
const AdminIndexRoute: React.FC = () => {
const { user } = useAuth();
const role = (user as any)?.role;
const [target, setTarget] = useState<string | null>(null);
const [loading, setLoading] = useState<boolean>(role === 'editor');
useEffect(() => {
let mounted = true;
(async () => {
if (role === 'editor') {
try {
const items: any[] = await getEditorAllowedAdminNav();
let to = '/admin/clanky';
if (Array.isArray(items) && items.length > 0) {
const pickUrl = (it: any): string | null => {
if (it?.url) return it.url;
if (Array.isArray(it?.children) && it.children.length > 0) {
for (const ch of it.children) {
if (ch?.url) return ch.url;
}
}
return null;
};
for (const it of items) {
const u = pickUrl(it);
if (u) { to = u; break; }
}
}
if (mounted) setTarget(to);
} catch (_) {
if (mounted) setTarget('/admin/clanky');
} finally {
if (mounted) setLoading(false);
}
}
})();
return () => { mounted = false; };
}, [role]);
if (role === 'admin') return <AdminDashboardPage />;
if (role === 'editor') {
if (loading) return <PageLoader />;
return <Navigate to={target || '/admin/clanky'} replace />;
}
return <Navigate to="/403" replace />;
};
// Premium-aware route elements (wait for settings before deciding)
const HomeRoute: React.FC = () => {
const { data, isLoading } = usePublicSettings();
@@ -263,6 +312,16 @@ const AppLazy: React.FC = () => {
<Route path="/403" element={<ForbiddenPage />} />
<Route path="/semiadmin" element={<ProtectedRoute><SemiAdminPage /></ProtectedRoute>} />
{/* Admin index: allow both admins and editors; decide inside */}
<Route
path="/admin"
element={
<ProtectedRoute>
<AdminIndexRoute />
</ProtectedRoute>
}
/>
{/* Editor-level content admin routes (accessible to editors and admins) */}
<Route element={<ProtectedRoute requiredRole="editor"><AdminRoutesWrapper /></ProtectedRoute>}>
<Route path="/admin/clanky" element={<ArticlesAdminPage />} />
@@ -272,7 +331,6 @@ const AppLazy: React.FC = () => {
{/* Admin routes */}
<Route element={<ProtectedRoute requiredRole="admin"><AdminRoutesWrapper /></ProtectedRoute>}>
<Route path="/admin" element={<AdminDashboardPage />} />
<Route path="/admin/docs" element={<AdminDocsPage />} />
<Route path="/admin/o-klubu" element={<AboutAdminPage />} />
<Route path="/admin/videa" element={<AdminVideosPage />} />
+32 -2
View File
@@ -365,6 +365,20 @@ const App: React.FC = () => {
return <Outlet />;
};
// Admin index: admins see dashboard; editors are redirected to their first allowed page
const AdminIndexRoute: React.FC = () => {
const { user } = useAuth();
const role = String(user?.role || '').toLowerCase();
if (role === 'admin') {
return <AdminDashboardPage />;
}
if (role === 'editor') {
// Default first allowed page for editors; configurable nav may change links
return <Navigate to="/admin/clanky" replace />;
}
return <Navigate to="/403" replace />;
};
// Premium-aware route elements
const HomeRoute: React.FC = () => {
const { data } = usePublicSettings();
@@ -478,13 +492,22 @@ const App: React.FC = () => {
/>
<Route path="/403" element={<ForbiddenPage />} />
{/* Admin index: allow both admins and editors; decide inside */}
<Route
path="/admin"
element={
<ProtectedRoute>
<AdminIndexRoute />
</ProtectedRoute>
}
/>
{/* Admin area (pages include AdminLayout themselves) */}
<Route element={
<ProtectedRoute requiredRole="admin">
<AdminRoutesWrapper />
</ProtectedRoute>
}>
<Route path="/admin" element={<AdminDashboardPage />} />
<Route path="/admin/docs" element={<AdminDocsPage />} />
{/* moved to editor-accessible routes below */}
<Route path="/admin/o-klubu" element={<AboutAdminPage />} />
@@ -508,7 +531,6 @@ const App: React.FC = () => {
<Route path="/admin/scoreboard" element={<ScoreboardAdminPage />} />
<Route path="/admin/scoreboard/remote" element={<MobileScoreboardControlPage />} />
<Route path="/admin/analytika" element={<AnalyticsAdminPage />} />
<Route path="/admin/shortlinks" element={<ShortlinksAdminPage />} />
<Route path="/admin/soubory" element={<FilesAdminPage />} />
<Route path="/admin/kontakty" element={<ContactsAdminPage />} />
<Route path="/admin/navigace" element={<NavigationAdminPage />} />
@@ -573,6 +595,14 @@ const App: React.FC = () => {
</ProtectedRoute>
}
/>
<Route
path="/admin/shortlinks"
element={
<ProtectedRoute requiredRole="editor">
<ShortlinksAdminPage />
</ProtectedRoute>
}
/>
{/* Not found route */}
<Route path="*" element={<NotFoundRoute />} />
+16 -4
View File
@@ -39,7 +39,7 @@ import { ChevronDownIcon, ChevronRightIcon } from '@chakra-ui/icons';
import { useAuth } from '../../contexts/AuthContext';
import { useQuery } from '@tanstack/react-query';
import { getUpcomingEvents } from '../../services/eventService';
import { getAllNavigationItems, NavigationItem, seedDefaultNavigation } from '../../services/navigation';
import { getAllNavigationItems, NavigationItem, seedDefaultNavigation, getEditorAllowedAdminNav } from '../../services/navigation';
import { usePublicSettings } from '../../hooks/usePublicSettings';
import { assetUrl } from '../../utils/url';
@@ -281,12 +281,24 @@ const AdminSidebar = ({
sessionStorage.setItem(STORAGE_KEY, String(node.scrollTop));
}, []);
// Load dynamic navigation from API (admins only)
// Load dynamic navigation from API
useEffect(() => {
let active = true;
// Editors should not call admin-only navigation endpoint; use fallback
// Editors: load editor-allowed admin navigation
if (!isAdmin) {
setNavLoading(false);
(async () => {
try {
setNavLoading(true);
const editorItems = await getEditorAllowedAdminNav();
if (active) {
setNavItems(editorItems || []);
}
} catch (e) {
if (active) setNavItems([]);
} finally {
if (active) setNavLoading(false);
}
})();
return () => { active = false };
}
(async () => {
@@ -17,7 +17,7 @@ export interface Banner {
interface BannerDisplayProps {
banners: Banner[];
placement: 'homepage_top' | 'homepage_middle' | 'homepage_sidebar' | 'homepage_footer' | 'article_inline' | 'homepage_under_table';
placement: 'homepage_top' | 'homepage_middle' | 'homepage_footer' | 'article_inline' | 'homepage_under_table';
containerStyle?: React.CSSProperties;
}
@@ -37,8 +37,6 @@ const BannerDisplay: React.FC<BannerDisplayProps> = ({ banners, placement, conta
return 'banner-top';
case 'homepage_middle':
return 'banner-middle';
case 'homepage_sidebar':
return 'banner-sidebar';
case 'homepage_footer':
return 'banner-footer';
case 'article_inline':
@@ -88,11 +86,6 @@ const BannerDisplay: React.FC<BannerDisplayProps> = ({ banners, placement, conta
padding: '24px 16px',
borderTop: '1px solid rgba(0, 0, 0, 0.05)',
};
case 'homepage_sidebar':
return {
display: 'block',
margin: '24px 0',
};
case 'homepage_under_table':
return {
...base,
@@ -131,8 +124,8 @@ const BannerDisplay: React.FC<BannerDisplayProps> = ({ banners, placement, conta
width: banner.width ? `${banner.width}px` : 'auto',
height: banner.height ? `${banner.height}px` : 'auto',
objectFit: 'contain',
borderRadius: placement === 'homepage_sidebar' ? '8px' : '4px',
boxShadow: placement === 'homepage_sidebar' ? '0 2px 8px rgba(0,0,0,0.1)' : 'none',
borderRadius: '4px',
boxShadow: 'none',
}}
loading="lazy"
/>
@@ -1,5 +1,6 @@
import React, { useEffect, useState } from 'react';
import { API_URL } from '../../services/api';
import { getZoneramaManifestWithFallbacks } from '../../services/zonerama';
import { Link as RouterLink } from 'react-router-dom';
import {
Box,
@@ -93,6 +94,29 @@ const GallerySection: React.FC<{ zoneramaUrl?: string | null }> = ({ zoneramaUrl
combinedAlbums = [...combinedAlbums, ...validBlogAlbums];
}
// Fallback: synthesize albums from manifest/picks when both sources are empty or invalid
if ((!combinedAlbums || combinedAlbums.length === 0)) {
try {
const items = await getZoneramaManifestWithFallbacks();
if (Array.isArray(items) && items.length > 0) {
const byAlbum: Record<string, typeof items> = {} as any;
items.forEach((it) => {
const aid = String(it.album_id || 'unknown');
(byAlbum[aid] = byAlbum[aid] || []).push(it);
});
const synthesized: Album[] = Object.entries(byAlbum).map(([aid, arr]) => ({
id: aid,
title: 'Album',
url: (arr[0] as any).page_url || '#',
date: '',
photos_count: arr.length,
photos: arr.slice(0, 12).map((p: any) => ({ id: String(p.id || ''), page_url: String(p.page_url || ''), image_1500: String(p.src || p.local || '') })),
}));
combinedAlbums = synthesized;
}
} catch {}
}
// Sort by date (newest first)
combinedAlbums.sort((a, b) => {
+52 -34
View File
@@ -77,16 +77,14 @@ const VideosSection: React.FC<Props> = ({ videos, variant }) => {
useEffect(() => {
let canceled = false;
const run = async () => {
if (source !== 'auto') return;
const payload = await getCachedYouTube();
if (!payload) return;
// Sort by published_date descending (safety; service should already do this)
const vids = (payload.videos || []).slice().sort((a, b) => (Date.parse(b.published_date || '') || 0) - (Date.parse(a.published_date || '') || 0));
if (!canceled) setYt(vids);
};
run();
return () => { canceled = true; };
}, [source]);
}, []);
const extractVideoId = (embedUrl: string): string | undefined => {
if (embedUrl?.includes('/embed/')) {
@@ -96,38 +94,58 @@ const VideosSection: React.FC<Props> = ({ videos, variant }) => {
};
const items: RenderItem[] = useMemo(() => {
if (source === 'auto') {
return (yt || []).slice(0, limit).map(v => ({
key: v.video_id,
title: (titleOverrides?.[v.video_id]?.trim()) || v.title,
embedUrl: toEmbed(v.video_id),
thumbnail: v.thumbnail_url,
date: v.published_date,
videoId: v.video_id,
}));
}
// manual fallback from settings or prop
const manual = (settings?.videos_items || []).map((it, i) => {
const embedUrl = toEmbed(it.url);
return {
key: `${i}-${it.url}`,
title: it.title || `Video ${i+1}`,
embedUrl,
thumbnail: it.thumbnail_url,
date: it.uploaded_at,
videoId: extractVideoId(embedUrl),
};
// Build manual items (preferred from videos_items; fallback to legacy URLs)
const manualItems = (() => {
const manual = (settings?.videos_items || []).map((it, i) => {
const embedUrl = toEmbed(it.url);
return {
key: `${i}-${it.url}`,
title: it.title || `Video ${i + 1}`,
embedUrl,
thumbnail: it.thumbnail_url,
date: it.uploaded_at,
videoId: extractVideoId(embedUrl),
} as RenderItem;
});
const legacy = (videos || settings?.videos || []).map((url, i) => {
const embedUrl = toEmbed(url as any);
return {
key: `${i}-${url}`,
title: `Video ${i + 1}`,
embedUrl,
videoId: extractVideoId(embedUrl),
} as RenderItem;
});
return manual.length ? manual : legacy;
})();
const autoItems = (yt || []).map((v) => ({
key: v.video_id,
title: (titleOverrides?.[v.video_id]?.trim()) || v.title,
embedUrl: toEmbed(v.video_id),
thumbnail: v.thumbnail_url,
date: v.published_date,
videoId: v.video_id,
} as RenderItem));
// Combine manual + auto, de-duplicate by videoId/embedUrl/key, sort by date desc, apply limit
const out: RenderItem[] = [];
const seen = new Set<string>();
const pushUnique = (it: RenderItem) => {
const k = (it.videoId || it.embedUrl || it.key);
if (!k) return;
if (seen.has(k)) return;
seen.add(k);
out.push(it);
};
manualItems.forEach(pushUnique);
autoItems.forEach(pushUnique);
const sorted = out.slice().sort((a, b) => {
const ta = Date.parse(a.date || '') || 0;
const tb = Date.parse(b.date || '') || 0;
return tb - ta;
});
const legacy = (videos || settings?.videos || []).map((url, i) => {
const embedUrl = toEmbed(url as any);
return {
key: `${i}-${url}`,
title: `Video ${i+1}`,
embedUrl,
videoId: extractVideoId(embedUrl),
};
});
return (manual.length ? manual : legacy).slice(0, limit);
return sorted.slice(0, limit);
}, [source, yt, settings?.videos_items, settings?.videos, videos, limit, titleOverrides]);
if (!enabled || items.length === 0) return null;
@@ -12,6 +12,7 @@ export const ScoreboardPreview: React.FC<{ state: ScoreboardState }> = ({ state
score: isFlipped ? state.awayScore : state.homeScore,
fouls: Math.max(0, Math.min(5, isFlipped ? (state.awayFouls || 0) : (state.homeFouls || 0))),
name: isFlipped ? state.awayName : state.homeName,
textColor: (isFlipped ? state.awayTextColor : state.homeTextColor) || '#ffffff',
};
const right = {
short: (!isFlipped ? state.awayShort : state.homeShort) || deriveShortLocal(!isFlipped ? state.awayName : state.homeName),
@@ -20,21 +21,22 @@ export const ScoreboardPreview: React.FC<{ state: ScoreboardState }> = ({ state
score: !isFlipped ? state.awayScore : state.homeScore,
fouls: Math.max(0, Math.min(5, !isFlipped ? (state.awayFouls || 0) : (state.homeFouls || 0))),
name: !isFlipped ? state.awayName : state.homeName,
textColor: (!isFlipped ? state.awayTextColor : state.homeTextColor) || '#ffffff',
};
const timer = state.timer || '00:00';
switch (theme) {
case 'pill':
return (
<Box>
<Box maxW="100%" overflowX="auto">
<HStack spacing={2} px={1.5} py={1} borderRadius="full" bg="white" borderWidth="1px" borderColor="gray.200" boxShadow="sm" width="max-content">
<SegmentScore>{timer}</SegmentScore>
<SegmentTeam colorA={left.color} left>
<SegmentTeam colorA={left.color} textColor={left.textColor} left>
{left.logo ? <Image src={left.logo} alt="home" boxSize="16px" objectFit="contain" /> : null}
<Text textTransform="uppercase" fontSize="sm" lineHeight={1}>{left.short}</Text>
</SegmentTeam>
<SegmentScore>{left.score} {right.score}</SegmentScore>
<SegmentTeam colorA={right.color} right>
<SegmentTeam colorA={right.color} textColor={right.textColor} right>
<Text textTransform="uppercase" fontSize="sm" lineHeight={1}>{right.short}</Text>
{right.logo ? <Image src={right.logo} alt="away" boxSize="16px" objectFit="contain" /> : null}
</SegmentTeam>
@@ -124,14 +126,14 @@ export const ScoreboardPreview: React.FC<{ state: ScoreboardState }> = ({ state
};
// Small presentational helpers for the pill theme
const SegmentTeam: React.FC<{ colorA?: string; left?: boolean; right?: boolean; children: React.ReactNode }> = ({ colorA = '#1e3a8a', left, right, children }) => {
const SegmentTeam: React.FC<{ colorA?: string; textColor?: string; left?: boolean; right?: boolean; children: React.ReactNode }> = ({ colorA = '#1e3a8a', textColor = '#ffffff', left, right, children }) => {
return (
<HStack
px={2}
py={0.5}
borderRadius="full"
bgGradient={`linear(to-r, ${colorA}, ${shadeColor(colorA, 20)})`}
color="white"
color={textColor}
spacing={1.5}
minW="46px"
>
@@ -217,4 +219,4 @@ function shadeColor(hex: string, percent: number) {
}
}
export default ScoreboardPreview;
export default React.memo(ScoreboardPreview);
@@ -2,6 +2,7 @@ import React, { useEffect, useMemo, useRef, useState } from 'react';
import { useAuth } from '../../contexts/AuthContext';
import { getCurrentSweepstake, enterSweepstake, markSweepstakeVisualPlayed, CurrentSweepstakeResponse } from '../../services/sweepstakes';
import { useToast } from '@chakra-ui/react';
import { getImageUrl } from '../../utils/imageUtils';
const fmtDate = (iso?: string | null) => {
if (!iso) return '';
@@ -61,6 +62,8 @@ const SweepstakeWidget: React.FC = () => {
if (loading) return null;
if (!s) return null;
// Hide finalized widget for users who are not winners
if ((data?.state || 'upcoming') === 'finalized' && !iWon) return null;
const onJoin = async () => {
if (!s) return;
@@ -95,12 +98,12 @@ const SweepstakeWidget: React.FC = () => {
<div>
<div className="section-head" style={{ marginTop: 0 }}>
<h3>Soutěž</h3>
{s.rules_url && (<a href={s.rules_url} className="see-all" target="_blank" rel="noreferrer noopener">Pravidla</a>)}
{s.rules_url && (<a href={getImageUrl(s.rules_url) || s.rules_url} className="see-all" target="_blank" rel="noreferrer noopener">Pravidla</a>)}
</div>
<div style={{ display: 'flex', gap: 16, alignItems: 'center', flexWrap: 'wrap' }}>
{s.image_url && (
// eslint-disable-next-line jsx-a11y/alt-text
<img src={s.image_url} style={{ width: 120, height: 120, objectFit: 'cover', borderRadius: 8 }} />
<img src={getImageUrl(s.image_url)} style={{ width: 120, height: 120, objectFit: 'cover', borderRadius: 8 }} />
)}
<div style={{ flex: 1, minWidth: 240 }}>
<div style={{ fontWeight: 700, fontSize: 18, marginBottom: 4 }}>{s.title}</div>
@@ -122,12 +125,12 @@ const SweepstakeWidget: React.FC = () => {
<div>
<div className="section-head" style={{ marginTop: 0 }}>
<h3>Soutěž</h3>
{s.rules_url && (<a href={s.rules_url} className="see-all" target="_blank" rel="noreferrer noopener">Pravidla</a>)}
{s.rules_url && (<a href={getImageUrl(s.rules_url) || s.rules_url} className="see-all" target="_blank" rel="noreferrer noopener">Pravidla</a>)}
</div>
<div style={{ display: 'flex', gap: 16, alignItems: 'center', flexWrap: 'wrap' }}>
{s.image_url && (
// eslint-disable-next-line jsx-a11y/alt-text
<img src={s.image_url} style={{ width: 120, height: 120, objectFit: 'cover', borderRadius: 8 }} />
<img src={getImageUrl(s.image_url)} style={{ width: 120, height: 120, objectFit: 'cover', borderRadius: 8 }} />
)}
<div style={{ flex: 1, minWidth: 240 }}>
<div style={{ fontWeight: 700, fontSize: 18, marginBottom: 4 }}>{s.title}</div>
@@ -142,12 +145,12 @@ const SweepstakeWidget: React.FC = () => {
</div>
{!isLogged ? (
<div style={{ fontWeight: 600 }}>Právě zde probíhá soutěž. <a href="/login">Přihlaste se</a> a zapojte se.</div>
) : data?.has_entered ? (
<span style={{ fontWeight: 600 }}>Jste zapojeni </span>
) : (
) : (data?.can_enter ?? false) ? (
<button className="btn" onClick={onJoin} disabled={joining}>
{joining ? 'Vstupuji…' : 'Vstoupit'}
</button>
) : (
<span style={{ fontWeight: 600 }}> jste registrováni v soutěži </span>
)}
</div>
</div>
@@ -157,7 +160,7 @@ const SweepstakeWidget: React.FC = () => {
<div>
<div className="section-head" style={{ marginTop: 0 }}>
<h3>Výherci soutěže</h3>
{s.rules_url && (<a href={s.rules_url} className="see-all" target="_blank" rel="noreferrer noopener">Pravidla</a>)}
{s.rules_url && (<a href={getImageUrl(s.rules_url)} className="see-all" target="_blank" rel="noreferrer noopener">Pravidla</a>)}
</div>
{winners.length === 0 ? (
<div>Výherci budou vyhlášeni brzy.</div>
@@ -189,7 +192,12 @@ const SweepstakeWidget: React.FC = () => {
))}
</div>
{iWon && (
<div style={{ marginTop: 8, fontWeight: 700 }}>Gratulujeme! Tato stránka rozpoznala, že patříte mezi výherce.</div>
<div style={{ marginTop: 8 }}>
<div style={{ fontWeight: 800 }}>Vyhráli jste! Více informací najdete ve svém e-mailu.</div>
<div style={{ marginTop: 6 }}>
Pokud potřebujete pomoc, <a href={"/kontakt?subject=" + encodeURIComponent("Soutěž výhra: " + (s.title || ''))} className="see-all">kontaktujte nás</a>.
</div>
</div>
)}
</div>
)}
+25 -19
View File
@@ -1248,9 +1248,23 @@ const ArticleDetailPage: React.FC = () => {
<Widget title="Nejbližší aktivity">
<VStack spacing={3} align="stretch">
{items.map((ev: any) => (
<HStack key={ev.id} as={RouterLink} to={`/aktivita/${ev.id}`} _hover={{ textDecoration: 'none' }} align="flex-start" spacing={3}>
<HStack
key={ev.id}
as={RouterLink}
to={`/aktivita/${ev.id}`}
align="flex-start"
spacing={3}
px={3}
py={2}
borderWidth="1px"
borderRadius="md"
borderColor={galleryBorder}
bg={attachmentsBg}
style={{ borderLeftWidth: 4, borderLeftColor: '#3182ce' }}
_hover={{ textDecoration: 'none', bg: miniHoverBg, borderColor: 'blue.300', boxShadow: 'sm', transform: 'translateX(2px)' }}
>
<Box flex={1} minW={0}>
<Text fontWeight="600" noOfLines={2}>{ev.title}</Text>
<Text fontWeight="700" noOfLines={2}>{ev.title}</Text>
<Text fontSize="sm" color={textMuted}>
{(() => { try { const d = new Date(ev.start_time); return d.toLocaleDateString('cs-CZ') + (ev.location ? `${ev.location}` : ''); } catch { return ev.start_time; } })()}
</Text>
@@ -1262,18 +1276,9 @@ const ArticleDetailPage: React.FC = () => {
);
})()}
{/* Polls in sidebar (no duplicate heading, keep wrapper styling) */}
{/* Polls in sidebar (render only when polls exist; internal wrapper handles layout) */}
{(data as any)?.id && (
<Box
bg={cardBg}
p={4}
borderRadius="lg"
boxShadow="sm"
borderWidth="1px"
borderColor={galleryBorder}
>
<EmbeddedPoll articleId={(data as any).id} maxPolls={2} showTitle={false} unstyled />
</Box>
<EmbeddedPoll articleId={(data as any).id} maxPolls={2} showTitle={false} />
)}
{/* Attachments in sidebar */}
@@ -1281,12 +1286,13 @@ const ArticleDetailPage: React.FC = () => {
<Widget title="Přílohy">
<VStack align="stretch" spacing={2}>
{(data as any).attachments.map((f: any, idx: number) => (
<HStack key={idx} justify="space-between" align="center">
<Box flex={1} minW={0} mr={2}>
<Text noOfLines={1}>{f.name || f.url}</Text>
</Box>
<FilePreview url={assetUrl(f.url) || f.url} name={f.name || ''} mimeType={f.mime_type || ''} size={f.size} buttonOnly />
</HStack>
<FilePreview
key={idx}
url={assetUrl(f.url) || f.url}
name={f.name || ''}
mimeType={f.mime_type || ''}
size={f.size}
/>
))}
</VStack>
</Widget>
+40 -39
View File
@@ -102,6 +102,7 @@ const HomePage: React.FC = () => {
// Index for the NEXT MATCH competition carousel
const [nextCompIdx, setNextCompIdx] = useState<number>(0);
const [nextMatchLink, setNextMatchLink] = useState<string | undefined>(undefined);
const [sidebarTop, setSidebarTop] = useState<number>(112);
// Matches slider auto-centering handled internally by MatchesSlider component
// API-driven players and sponsors
@@ -154,6 +155,19 @@ const HomePage: React.FC = () => {
} catch {}
}, []);
useEffect(() => {
const updateTop = () => {
try {
const hdr = (document.querySelector('header[data-element="header"]') as HTMLElement) || (document.querySelector('header') as HTMLElement);
const h = hdr ? Math.round(hdr.getBoundingClientRect().height) : 96;
setSidebarTop(Math.max(64, h + 16));
} catch {}
};
updateTop();
window.addEventListener('resize', updateTop);
return () => window.removeEventListener('resize', updateTop);
}, [refreshKey]);
const heroFallbackArticles = useMemo(() => featured.map((item, index) => ({
id: typeof item.id === 'number' ? item.id : index,
title: item.title,
@@ -281,6 +295,8 @@ const HomePage: React.FC = () => {
facrTablesJSON,
teamLogoOverridesAPI,
teamLogoOverridesFile,
matchesApiJSON,
matchesPastApiJSON,
] = await Promise.all([
fetchJSON('/cache/prefetch/articles.json'),
fetchJSON('/cache/prefetch/matches.json'),
@@ -291,6 +307,8 @@ const HomePage: React.FC = () => {
fetchJSON(`/api/v1/public/team-logo-overrides?t=${Date.now()}`),
// Fallback to cached JSON snapshot written by backend after saves
fetchJSON('/cache/prefetch/team_logo_overrides.json'),
fetchJSON(`/api/v1/matches?t=${Date.now()}`),
fetchJSON(`/api/v1/matches/history?t=${Date.now()}`),
]);
// load aliases (public)
let aliasesList: CompetitionAlias[] = [];
@@ -348,6 +366,19 @@ const HomePage: React.FC = () => {
return chosen;
};
// Build score overrides map from public API
const scoreOverrideMap: Record<string, string> = {};
const addScores = (arr: any[]) => {
if (!Array.isArray(arr)) return;
for (const it of arr) {
const id = String(it?.match_id || it?.id || '').trim();
const sc = String(it?.score || '').trim();
if (id && sc) scoreOverrideMap[id] = sc;
}
};
addScores(matchesApiJSON as any[]);
addScores(matchesPastApiJSON as any[]);
// Matches: map from FACR club info if available, otherwise fallback to matches.json
if (facrClubJSON?.competitions?.length) {
const allMatches = (facrClubJSON.competitions || [])
@@ -359,6 +390,8 @@ const HomePage: 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 mid = String(m.match_id || '').trim();
const score = (mid && scoreOverrideMap[mid]) ? scoreOverrideMap[mid] : m.score;
return {
id: m.match_id || idx + 1,
homeTeam: m.home,
@@ -370,7 +403,7 @@ const HomePage: React.FC = () => {
isHome: facrClubJSON?.name ? (m.home || '').toLowerCase().includes(String(facrClubJSON.name).toLowerCase()) : true,
homeLogoURL: getOverrideLogo(m.home, m.home_logo_url),
awayLogoURL: getOverrideLogo(m.away, m.away_logo_url),
score: m.score,
score,
facr_link: m.facr_link,
report_url: m.report_url,
};
@@ -403,6 +436,8 @@ const HomePage: 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 mid = String(m.match_id || '').trim();
const score = (mid && scoreOverrideMap[mid]) ? scoreOverrideMap[mid] : m.score;
return {
id: m.match_id || idx + 1,
date: isoDate,
@@ -413,7 +448,7 @@ const HomePage: React.FC = () => {
away_id: m.away_id,
home_logo_url: getOverrideLogo(m.home, m.home_logo_url),
away_logo_url: getOverrideLogo(m.away, m.away_logo_url),
score: m.score,
score,
facr_link: m.facr_link,
report_url: m.report_url,
venue: m.venue || '',
@@ -1497,39 +1532,7 @@ const HomePage: React.FC = () => {
{/* Featured articles are now shown in the hero grid above, not here */}
{/* Sidebar banners (homepage_sidebar) - sticky within page container */}
{(banners || []).some(b => b.placement === 'homepage_sidebar') && (
<section
key={`sidebar-${refreshKey}-${getVariant('sidebar', 'right')}`}
data-element="sidebar"
data-variant={getVariant('sidebar', 'right')}
className={`banner banner-sidebar sidebar-${getVariant('sidebar', 'right')}`}
style={{ margin: '24px 0', ...getStyles('sidebar') }}
>
<div style={{ maxWidth: 1200, margin: '0 auto', padding: '0 12px' }}>
<div
style={{
position: 'sticky',
top: 112,
width: 320,
maxWidth: '100%',
marginLeft: getVariant('sidebar', 'right') === 'left' ? 0 : 'auto',
marginRight: getVariant('sidebar', 'right') === 'left' ? 'auto' : 0,
zIndex: 1,
}}
>
{(banners || []).filter(b => b.placement === 'homepage_sidebar').map((b) => (
<div key={b.id} className="card" style={{ display: 'block', marginBottom: 12, padding: 4 }}>
<a href={b.url || '#'} target={b.url ? '_blank' : undefined} rel={b.url ? 'noopener noreferrer' : undefined} style={{ display: 'block' }}>
{/* eslint-disable-next-line jsx-a11y/alt-text */}
<img loading="lazy" decoding="async" src={b.image} alt={b.name} style={{ width: b.width ? `${b.width}px` : '100%', height: b.height ? `${b.height}px` : 'auto', maxWidth: '100%' }} />
</a>
</div>
))}
</div>
</div>
</section>
)}
{getVariant('hero', heroStyle) === 'scroller' && isVisible('hero', true) && (
<section key={`hero-scroller-${refreshKey}`} data-element="hero" data-variant={getVariant('hero', heroStyle)} style={{ position: 'relative', ...getStyles('hero') }}>
<Suspense fallback={<div style={{ minHeight: 240 }} />}>
@@ -1828,7 +1831,7 @@ const HomePage: React.FC = () => {
)}
{/* Gallery */}
{isVisible('gallery', false) && (
{isVisible('gallery', true) && (
<section key={`gallery-${refreshKey}-${getVariant('gallery', 'grid')}`} data-element="gallery" data-variant={getVariant('gallery', 'grid')} aria-labelledby="home-gallery-heading" style={{ marginTop: 32, marginBottom: 32, position: 'relative', contentVisibility: 'auto' as any, containIntrinsicSize: '700px', ...getStyles('gallery') }}>
<div style={{ maxWidth: 1200, margin: '0 auto', padding: '0 12px' }}>
{defer ? (
@@ -1898,9 +1901,7 @@ const HomePage: React.FC = () => {
<div style={{ maxWidth: 1200, margin: '0 auto', padding: '0 12px' }}>
{defer ? (
<Suspense fallback={null}>
<div className="card">
<PollsWidget featuredOnly={true} maxPolls={1} title="Anketa" />
</div>
<PollsWidget featuredOnly={false} maxPolls={1} title="Anketa" />
</Suspense>
) : (
<div className="card skeleton" style={{ height: 320, borderRadius: 12 }} />
+52 -37
View File
@@ -85,10 +85,6 @@ const VideosPage: React.FC = () => {
useEffect(() => {
let canceled = false;
const run = async () => {
if (source !== 'auto') {
setLoading(false);
return;
}
try {
const payload = await getCachedYouTube();
if (!payload) {
@@ -109,42 +105,61 @@ const VideosPage: React.FC = () => {
return () => {
canceled = true;
};
}, [source]);
}, []);
const items: RenderItem[] = useMemo(() => {
if (source === 'auto') {
return (yt || []).map((v) => ({
key: v.video_id,
title: (titleOverrides?.[v.video_id]?.trim()) || v.title,
embedUrl: toEmbed(v.video_id),
thumbnail: v.thumbnail_url,
date: v.published_date,
videoId: v.video_id,
}));
}
// Manual fallback from settings
const manual = (settings?.videos_items || []).map((it, i) => {
const embedUrl = toEmbed(it.url);
return {
key: `${i}-${it.url}`,
title: it.title || `Video ${i + 1}`,
embedUrl,
thumbnail: it.thumbnail_url,
date: it.uploaded_at,
videoId: extractVideoId(embedUrl),
};
// Build manual items (preferred) with legacy fallback
const manualItems = (() => {
const manual = (settings?.videos_items || []).map((it, i) => {
const embedUrl = toEmbed(it.url);
return {
key: `${i}-${it.url}`,
title: it.title || `Video ${i + 1}`,
embedUrl,
thumbnail: it.thumbnail_url,
date: it.uploaded_at,
videoId: extractVideoId(embedUrl),
} as RenderItem;
});
const legacy = ((settings as any)?.videos || []).map((url: string, i: number) => {
const embedUrl = toEmbed(url);
return {
key: `${i}-${url}`,
title: `Video ${i + 1}`,
embedUrl,
videoId: extractVideoId(embedUrl),
} as RenderItem;
});
return manual.length ? manual : legacy;
})();
const autoItems = (yt || []).map((v) => ({
key: v.video_id,
title: (titleOverrides?.[v.video_id]?.trim()) || v.title,
embedUrl: toEmbed(v.video_id),
thumbnail: v.thumbnail_url,
date: v.published_date,
videoId: v.video_id,
} as RenderItem));
const out: RenderItem[] = [];
const seen = new Set<string>();
const pushUnique = (it: RenderItem) => {
const k = it.videoId || it.embedUrl || it.key;
if (!k) return;
if (seen.has(k)) return;
seen.add(k);
out.push(it);
};
manualItems.forEach(pushUnique);
autoItems.forEach(pushUnique);
// Sort by date desc so manual additions integrate among auto
const sorted = out.slice().sort((a, b) => {
const ta = Date.parse(a.date || '') || 0;
const tb = Date.parse(b.date || '') || 0;
return tb - ta;
});
const legacy = ((settings as any)?.videos || []).map((url: string, i: number) => {
const embedUrl = toEmbed(url);
return {
key: `${i}-${url}`,
title: `Video ${i + 1}`,
embedUrl,
videoId: extractVideoId(embedUrl),
};
});
return manual.length ? manual : legacy;
}, [source, yt, settings?.videos_items, settings, titleOverrides]);
return sorted;
}, [yt, settings?.videos_items, (settings as any)?.videos, titleOverrides]);
const openVideo = (item: RenderItem) => {
setSelectedVideo(item);
+251 -281
View File
@@ -1,6 +1,6 @@
import React, { useEffect, useMemo, useState } from 'react';
import AdminLayout from '../../layouts/AdminLayout';
import { Box, Heading, Button, SimpleGrid, FormControl, FormLabel, Input, HStack, VStack, Text, IconButton, useToast, Divider, Alert, AlertIcon, Badge, Tooltip, Checkbox, Image, Spinner, Link, Switch, ButtonGroup } from '@chakra-ui/react';
import { Box, Heading, Button, SimpleGrid, FormControl, FormLabel, Input, HStack, VStack, Text, IconButton, useToast, Divider, Alert, AlertIcon, Badge, Checkbox, Image, Spinner, Link, Switch, useDisclosure, Modal, ModalOverlay, ModalContent, ModalHeader, ModalCloseButton, ModalBody, ModalFooter, Tabs, TabList, TabPanels, Tab, TabPanel } from '@chakra-ui/react';
import { getAdminSettings, updateAdminSettings, AdminSettings } from '../../services/settings';
import { FiPlus, FiTrash2, FiSave } from 'react-icons/fi';
import { triggerPrefetch } from '../../services/admin/prefetch';
@@ -14,15 +14,16 @@ export type AdminVideoItem = {
thumbnail_url?: string;
};
const emptyItem: AdminVideoItem = { url: '' };
//
const AdminVideosPage: React.FC = () => {
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [items, setItems] = useState<AdminVideoItem[]>([]);
const [videosSource, setVideosSource] = useState<'auto'|'manual'>('manual');
const videosSource: 'auto' = 'auto';
const [videosEnabled, setVideosEnabled] = useState<boolean>(true);
const toast = useToast();
const { isOpen: isAddOpen, onOpen: onOpenAdd, onClose: onCloseAdd } = useDisclosure();
// YouTube Scraper API integration state
const [channelInput, setChannelInput] = useState<string>('');
@@ -47,6 +48,7 @@ const AdminVideosPage: React.FC = () => {
const [filter, setFilter] = useState<string>('');
// Title overrides for auto mode (video_id -> title)
const [titleOverrides, setTitleOverrides] = useState<Record<string, string>>({});
const [directUrl, setDirectUrl] = useState<string>('');
// Derived flags
const hasChannel = useMemo(() => (channelInput || '').trim().length > 0, [channelInput]);
@@ -60,8 +62,7 @@ const AdminVideosPage: React.FC = () => {
const vids = Array.isArray((s as any).videos_items) ? (s as any).videos_items as AdminVideoItem[] : [];
const legacy = Array.isArray((s as any).videos) ? ((s as any).videos as string[]).map((url) => ({ url })) : [];
setItems(vids.length ? vids : legacy);
const src = (s as any).videos_source;
if (src === 'auto' || src === 'manual') setVideosSource(src);
// Force automatic source; manual editing is removed in favor of inline add/import
// Default enable if not explicitly set and there are any videos configured
const explicit = (s as any).videos_module_enabled;
const hasAny = (vids.length + legacy.length) > 0;
@@ -80,12 +81,11 @@ const AdminVideosPage: React.FC = () => {
return () => { mounted = false; };
}, []);
// Load cached YouTube videos for preview when auto source is active
// Load cached YouTube videos for preview
useEffect(() => {
let mounted = true;
const run = async () => {
if (loading) return;
if (videosSource !== 'auto') return;
setAutoError('');
setAutoLoading(true);
try {
@@ -101,7 +101,70 @@ const AdminVideosPage: React.FC = () => {
};
run();
return () => { mounted = false; };
}, [loading, videosSource]);
}, [loading]);
type PreviewItem = {
key: string;
title: string;
thumbnail_url?: string;
published_date?: string;
video_id?: string;
source: 'manual'|'auto';
url?: string;
};
// Combined preview for AUTO mode: manual + auto (dedup), filtered by title, ordered by date desc
const combinedAutoPreview = useMemo(() => {
const manual: PreviewItem[] = (items || []).filter(it => (it.url || '').trim().length > 0).map((it, idx) => {
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 {}
return {
key: `m-${idx}-${it.url}`,
title: it.title || `Video ${idx + 1}`,
thumbnail_url: it.thumbnail_url,
published_date: it.uploaded_at,
video_id: id,
source: 'manual',
url: it.url,
} as PreviewItem;
});
const auto: PreviewItem[] = (autoVideos || []).map((v) => ({
key: `a-${v.video_id}`,
title: v.title,
thumbnail_url: v.thumbnail_url,
published_date: v.published_date,
video_id: v.video_id,
source: 'auto',
}));
const out: PreviewItem[] = [];
const seen = new Set<string>();
const pushUnique = (it: PreviewItem) => {
const k = it.video_id || it.url || it.key;
if (!k) return;
if (seen.has(k)) return;
seen.add(k);
out.push(it);
};
manual.forEach(pushUnique);
auto.forEach(pushUnique);
const filtered = out
.filter((it) => (it.title || '').toLowerCase().includes(filter.toLowerCase()))
.slice()
.sort((a, b) => {
const ta = Date.parse(a.published_date || '') || 0;
const tb = Date.parse(b.published_date || '') || 0;
return tb - ta;
});
return { list: filtered, count: filtered.length };
}, [items, autoVideos, filter]);
// Auto-disable videos module if there is neither channel nor manual items configured
useEffect(() => {
@@ -114,7 +177,6 @@ const AdminVideosPage: React.FC = () => {
// Auto-trigger backend prefetch of YouTube cache at most once per ~24h
useEffect(() => {
if (loading) return;
if (videosSource !== 'auto') return;
const channel = (channelInput || '').trim();
if (!channel) return;
const KEY = 'youtube_autoload_last';
@@ -205,45 +267,49 @@ const AdminVideosPage: React.FC = () => {
thumbnail_url: v.thumbnail_url,
}));
// Avoid duplicates by URL
setItems((prev) => {
const urls = new Set(prev.map((p) => p.url));
const merged = [...prev];
const merged = (() => {
const urls = new Set(items.map((p) => p.url));
const out = [...items];
for (const it of newItems) {
if (!urls.has(it.url)) {
merged.push(it);
out.push(it);
urls.add(it.url);
}
}
return merged;
});
// If currently in auto mode, switch to manual so the preview reflects newly added items
if (videosSource !== 'manual') {
setVideosSource('manual');
try {
await updateAdminSettings({ videos_source: 'manual' });
toast({ status: 'info', title: 'Přepnuto na ruční správu', description: 'Nově přidaná videa se budou používat. Nezapomeňte uložit seznam.', duration: 3500 });
} catch {
// ignore
}
return out;
})();
try {
await updateAdminSettings({ videos_items: merged, videos_module_enabled: videosEnabled });
setItems(merged);
toast({ status: 'success', title: 'Videa přidána', description: `${selected.length} videí bylo přidáno do seznamu.` });
} catch (e) {
toast({ status: 'error', title: 'Chyba', description: 'Nepodařilo se uložit přidaná videa.' });
}
toast({ status: 'success', title: 'Videa přidána', description: `${selected.length} videí bylo přidáno do seznamu.` });
};
const addItem = async () => {
setItems((prev) => [...prev, { ...emptyItem }]);
if (videosSource !== 'manual') {
setVideosSource('manual');
try {
await updateAdminSettings({ videos_source: 'manual' });
} catch {
// ignore
}
const addDirectLink = async () => {
const url = (directUrl || '').trim();
if (!url) {
toast({ status: 'warning', title: 'Zadejte odkaz', description: 'Vložte URL videa.' });
return;
}
const today = new Date().toISOString().slice(0,10);
const it: AdminVideoItem = { url, uploaded_at: today, thumbnail_url: getThumbFromUrl(url) };
if (items.find((p) => p.url === it.url)) {
toast({ status: 'info', title: 'Video už existuje', description: 'Tento odkaz je již v seznamu.' });
return;
}
const merged = [...items, it];
try {
await updateAdminSettings({ videos_items: merged, videos_module_enabled: videosEnabled });
setItems(merged);
setDirectUrl('');
toast({ status: 'success', title: 'Video přidáno', description: 'Video bylo přidáno k automatickým videím.' });
} catch {
toast({ status: 'error', title: 'Chyba', description: 'Nepodařilo se uložit video.' });
}
};
const removeItem = (idx: number) => setItems((prev) => prev.filter((_, i) => i !== idx));
const updateField = (idx: number, key: keyof AdminVideoItem, val: string) => {
setItems((prev) => prev.map((it, i) => i === idx ? { ...it, [key]: val } : it));
};
//
const save = async () => {
setSaving(true);
@@ -258,12 +324,7 @@ const AdminVideosPage: React.FC = () => {
}
};
const setDateQuick = (idx: number, daysAgo: number) => {
const d = new Date();
d.setDate(d.getDate() - daysAgo);
const iso = d.toISOString().slice(0,10);
setItems((prev) => prev.map((it, i) => i === idx ? { ...it, uploaded_at: iso } : it));
};
//
// Helper: derive YouTube thumbnail safely from a URL (supports youtube.com and youtu.be)
const getThumbFromUrl = (raw: string): string | undefined => {
@@ -298,36 +359,8 @@ const AdminVideosPage: React.FC = () => {
{/* Source toggle */}
<HStack justify="space-between" mb={3} flexWrap="wrap">
<HStack>
<Text fontWeight="semibold">Zdroj videí:</Text>
<ButtonGroup size="sm" isAttached>
<Button
variant={videosSource === 'auto' ? 'solid' : 'outline'}
onClick={async () => {
if (videosSource === 'auto') return;
setVideosSource('auto');
try {
await updateAdminSettings({ videos_source: 'auto' });
toast({ status: 'success', title: 'Zdroj nastaven', description: 'Videa se načítají automaticky z YouTube.', duration: 2500 });
} catch {
toast({ status: 'error', title: 'Chyba', description: 'Nelze uložit zdroj videí.', duration: 3000 });
}
}}
>Automaticky</Button>
<Button
variant={videosSource === 'manual' ? 'solid' : 'outline'}
onClick={async () => {
if (videosSource === 'manual') return;
setVideosSource('manual');
try {
await updateAdminSettings({ videos_source: 'manual' });
toast({ status: 'success', title: 'Zdroj nastaven', description: 'Videa spravujete ručně.', duration: 2500 });
} catch {
toast({ status: 'error', title: 'Chyba', description: 'Nelze uložit zdroj videí.', duration: 3000 });
}
}}
>Ručně</Button>
</ButtonGroup>
<HStack spacing={2}>
<Button leftIcon={<FiPlus />} colorScheme="green" size="sm" onClick={onOpenAdd}>Přidat video</Button>
</HStack>
<FormControl display="flex" alignItems="center" w="auto">
<FormLabel mb={0}>Zobrazit sekci Videa na titulní stránce</FormLabel>
@@ -363,57 +396,11 @@ const AdminVideosPage: React.FC = () => {
{videosSource === 'auto' && (
<Alert status="info" mb={3} borderRadius="md">
<AlertIcon />
Automatický režim je zapnutý. Videa se načítají z YouTube kanálu z Nastavení Sociální sítě (YouTube URL) a správy Videa (YouTube modul). Manuální seznam je v tomto režimu skryt.
Automatický režim je zapnutý. Videa se načítají z YouTube kanálu z Nastavení Sociální sítě (YouTube URL). Ručně přidaná videa se zobrazí před automatickými.
</Alert>
)}
{videosSource !== 'auto' && (
<Box borderWidth="1px" borderRadius="md" p={3} mb={4}>
<Heading size="sm" mb={2}>Import z YouTube kanálu</Heading>
<Text fontSize="sm" color="gray.600" mb={3}>
Použijte Scraper API. Zadejte handle (např. <code>@FotbalKunovice</code>) nebo URL kanálu a načtěte videa z karty Videa.
Služba: <Link href="https://youtube.tdvorak.dev/" isExternal color="blue.500">https://youtube.tdvorak.dev/</Link>
</Text>
<HStack align="start" spacing={3} flexWrap="wrap">
<FormControl maxW={{ base: '100%', md: '400px' }}>
<FormLabel>Kanál (handle nebo URL)</FormLabel>
<Input id="admin-videos-channel-input" placeholder="@FCBizoniUH nebo https://www.youtube.com/@FCBizoniUH/videos" value={channelInput} onChange={(e) => setChannelInput(e.target.value)} />
</FormControl>
<Button onClick={fetchChannelVideos} isLoading={ytLoading} variant="outline" flexShrink={0} minW="max-content">Načíst videa</Button>
<Button colorScheme="green" onClick={importSelected} isDisabled={ytVideos.length === 0} flexShrink={0} minW="max-content">Přidat vybraná</Button>
</HStack>
{ytError && (
<Alert status="error" mt={3} borderRadius="md">
<AlertIcon />
{ytError}
</Alert>
)}
{ytLoading && (
<HStack mt={3} color="gray.600"><Spinner size="sm" /><Text>Načítám videa</Text></HStack>
)}
{!ytLoading && ytVideos.length > 0 && (
<SimpleGrid columns={{ base: 1, sm: 2, md: 3, lg: 4 }} spacing={3} mt={3}>
{ytVideos.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" />
<Checkbox isChecked={!!selectedIds[v.video_id]} onChange={() => toggleSelect(v.video_id)}>
Vybrat
</Checkbox>
<Box>
<Text fontWeight="semibold" noOfLines={2}>{v.title}</Text>
<HStack spacing={2} color="gray.600" fontSize="sm">
{v.length && <Badge>{v.length}</Badge>}
{v.published_text && <Text>{v.published_text}</Text>}
</HStack>
</Box>
</VStack>
</Box>
))}
</SimpleGrid>
)}
</Box>
)}
{/* Always-visible preview of effective videos */}
<Box borderWidth="1px" borderRadius="md" p={3} mb={4}>
@@ -436,186 +423,169 @@ const AdminVideosPage: React.FC = () => {
<HStack color="gray.600"><Spinner size="sm" /><Text>Načítám videa</Text></HStack>
) : (
<>
<Text fontSize="sm" color="gray.600" mb={2}>Počet videí: {autoVideos.filter(v => (v.title || '').toLowerCase().includes(filter.toLowerCase())).length}</Text>
<Text fontSize="sm" color="gray.600" mb={2}>Počet videí: {combinedAutoPreview.count}</Text>
<SimpleGrid columns={{ base: 1, sm: 2, md: 3, lg: 4 }} spacing={3}>
{autoVideos
.filter(v => (v.title || '').toLowerCase().includes(filter.toLowerCase()))
.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"
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">
{v.published_date && <Badge>{new Date(v.published_date).toLocaleDateString('cs-CZ')}</Badge>}
</HStack>
{combinedAutoPreview.list.map((it) => (
<Box key={it.key} borderWidth="1px" borderRadius="md" p={2}>
<VStack align="stretch" spacing={2}>
<Image
src={it.thumbnail_url || (it.source === 'manual' ? getThumbFromUrl(it.url || '') : undefined)}
alt={it.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 = it.video_id || '';
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 (idx < chain.length) {
el.src = chain[idx];
el.dataset.fallbackIdx = String(idx + 1);
}
}}
/>
<Box>
<HStack justify="space-between" align="start">
<Box flex="1">
<Text fontWeight="semibold" noOfLines={2}>{(it.source === 'auto' && it.video_id && (titleOverrides[it.video_id]?.trim()?.length ? titleOverrides[it.video_id] : it.title)) || it.title}</Text>
<HStack spacing={2} color="gray.600" fontSize="sm">
{it.published_date && <Badge>{new Date(it.published_date).toLocaleDateString('cs-CZ')}</Badge>}
{it.source === 'manual' && <Badge colorScheme="purple">Ručně</Badge>}
</HStack>
</Box>
{it.source === 'manual' && (
<IconButton
aria-label="Smazat"
icon={<FiTrash2 />}
size="sm"
variant="ghost"
colorScheme="red"
onClick={async () => {
const next = items.filter((m) => m.url !== it.url);
try {
await updateAdminSettings({ videos_items: next, videos_module_enabled: videosEnabled });
setItems(next);
toast({ status: 'success', title: 'Smazáno', description: 'Video bylo odstraněno.' });
} catch {
toast({ status: 'error', title: 'Chyba', description: 'Odstranění se nepodařilo.' });
}
}}
/>
)}
</HStack>
{it.source === 'auto' && it.video_id && (
<FormControl mt={2}>
<FormLabel fontSize="xs" mb={1}>Přepis názvu (volitelné)</FormLabel>
<Input
size="sm"
placeholder="Např. Zápas A-týmu vs. B-tým"
value={(titleOverrides[v.video_id] ?? '')}
placeholder="Např. Zápas Atýmu vs. Btým"
value={titleOverrides[it.video_id] ?? ''}
onChange={(e) => {
const val = e.target.value;
setTitleOverrides(prev => ({ ...prev, [v.video_id]: val }));
setTitleOverrides(prev => ({ ...prev, [it.video_id!]: val }));
}}
/>
</FormControl>
{!!(titleOverrides[v.video_id]?.length) && (
<HStack justify="flex-end" mt={1}>
<Button size="xs" variant="ghost" onClick={() => setTitleOverrides(prev => { const n = { ...prev }; delete n[v.video_id]; return n; })}>Vymazat přepis</Button>
</HStack>
)}
</Box>
</VStack>
</Box>
))}
)}
</Box>
</VStack>
</Box>
))}
</SimpleGrid>
{autoVideos.length === 0 && (
<Text color="gray.600">Žádná videa v cache. Zkontrolujte YouTube URL v nastavení a použijte Aktualizovat cache.</Text>
{combinedAutoPreview.count === 0 && (
<Text color="gray.600">Zatím žádná videa.</Text>
)}
</>
)}
</>
) : (
<>
<Text fontSize="sm" color="gray.600" mb={2}>Počet videí: {items.length}</Text>
<SimpleGrid columns={{ base: 1, sm: 2, md: 3, lg: 4 }} spacing={3}>
{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"
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">
{it.uploaded_at && <Badge>{(new Date(it.uploaded_at)).toLocaleDateString('cs-CZ')}</Badge>}
</HStack>
</Box>
</VStack>
</Box>
))}
</SimpleGrid>
{items.length === 0 && (
<Text color="gray.600">Zatím žádná videa.</Text>
)}
</>
)}
) : null}
</Box>
<HStack justify="space-between" mb={3}>
<Button leftIcon={<FiPlus />} onClick={addItem}>Přidat video</Button>
<Button colorScheme="blue" leftIcon={<FiSave />} onClick={save} isLoading={saving}>Uložit</Button>
</HStack>
<Divider my={3} />
{loading ? (
<Text>Načítání</Text>
) : videosSource === 'auto' ? (
<Text color="gray.600">Automatický zdroj videí je aktivní. Pro ruční správu přepněte zdroj na Ručně.</Text>
) : (
<VStack align="stretch" spacing={4}>
{items.map((it, idx) => (
<Box key={idx} borderWidth="1px" borderRadius="md" p={3}>
<HStack justify="space-between">
<Heading size="sm">Video #{idx + 1}</Heading>
</HStack>
<SimpleGrid columns={{ base: 1, md: 2 }} spacing={3} mt={3}>
<FormControl>
<FormLabel>URL videa</FormLabel>
<Input value={it.url} onChange={(e) => updateField(idx, 'url', e.target.value)} placeholder="https://www.youtube.com/watch?v=..." />
</FormControl>
<FormControl>
<FormLabel>Thumbnail (volitelné)</FormLabel>
<Input value={it.thumbnail_url || ''} onChange={(e) => updateField(idx, 'thumbnail_url', e.target.value)} placeholder="https://example.com/thumb.jpg" />
</FormControl>
<FormControl>
<FormLabel>Název (volitelné)</FormLabel>
<Input value={it.title || ''} onChange={(e) => updateField(idx, 'title', e.target.value)} placeholder="Titulek videa" />
</FormControl>
<FormControl>
<FormLabel>Délka (volitelné)</FormLabel>
<Input value={it.length || ''} onChange={(e) => updateField(idx, 'length', e.target.value)} placeholder="3:45" />
</FormControl>
<FormControl>
<FormLabel>Datum nahrání (volitelné)</FormLabel>
<HStack>
<Input type="date" value={(it.uploaded_at || '').slice(0,10)} onChange={(e) => updateField(idx, 'uploaded_at', e.target.value)} />
<Tooltip label="Dnes">
<Button size="sm" variant="outline" onClick={() => setDateQuick(idx, 0)}>Dnes</Button>
</Tooltip>
<Tooltip label="Včera">
<Button size="sm" variant="outline" onClick={() => setDateQuick(idx, 1)}>Včera</Button>
</Tooltip>
<Tooltip label="Před týdnem">
<Button size="sm" variant="outline" onClick={() => setDateQuick(idx, 7)}>7 dní</Button>
</Tooltip>
<Tooltip label="Vymazat datum">
<Button size="sm" variant="ghost" onClick={() => updateField(idx, 'uploaded_at', '')}>Vymazat</Button>
</Tooltip>
</HStack>
</FormControl>
</SimpleGrid>
<HStack justify="flex-end" mt={2}>
<IconButton aria-label="Smazat" icon={<FiTrash2 />} onClick={() => removeItem(idx)} variant="outline" colorScheme="red" />
</HStack>
</Box>
))}
{items.length === 0 && (
<Text color="gray.600">Zatím žádná videa. Použijte tlačítko Přidat video.</Text>
)}
</VStack>
)}
<Modal isOpen={isAddOpen} onClose={onCloseAdd} size="4xl">
<ModalOverlay />
<ModalContent>
<ModalHeader>Přidat video</ModalHeader>
<ModalCloseButton />
<ModalBody>
<Tabs variant="enclosed">
<TabList>
<Tab>Odkaz na video</Tab>
<Tab>Načíst z YouTube kanálu</Tab>
</TabList>
<TabPanels>
<TabPanel>
<VStack align="stretch" spacing={3}>
<FormControl>
<FormLabel>URL videa</FormLabel>
<Input placeholder="https://www.youtube.com/watch?v=..." value={directUrl} onChange={(e) => setDirectUrl(e.target.value)} />
</FormControl>
<HStack>
<Button colorScheme="green" onClick={async () => { await addDirectLink(); onCloseAdd(); }}>Přidat</Button>
</HStack>
</VStack>
</TabPanel>
<TabPanel>
<VStack align="stretch" spacing={3}>
<Text fontSize="sm" color="gray.600">
Použijte Scraper API. Zadejte handle (např. <code>@FotbalKunovice</code>) nebo URL kanálu a načtěte videa z karty Videa. Služba: <Link href="https://youtube.tdvorak.dev/" isExternal color="blue.500">youtube.tdvorak.dev</Link>
</Text>
<HStack align="start" spacing={3} flexWrap="wrap">
<FormControl maxW={{ base: '100%', md: '400px' }}>
<FormLabel>Kanál (handle nebo URL)</FormLabel>
<Input id="admin-videos-channel-input" placeholder="@FCBizoniUH nebo https://www.youtube.com/@FCBizoniUH/videos" value={channelInput} onChange={(e) => setChannelInput(e.target.value)} />
</FormControl>
<Button onClick={fetchChannelVideos} isLoading={ytLoading} variant="outline" flexShrink={0} minW="max-content">Načíst videa</Button>
<Button colorScheme="green" onClick={async () => { await importSelected(); onCloseAdd(); }} isDisabled={ytVideos.length === 0} flexShrink={0} minW="max-content">Přidat vybraná</Button>
</HStack>
{ytError && (
<Alert status="error" borderRadius="md">
<AlertIcon />
{ytError}
</Alert>
)}
{ytLoading && (
<HStack color="gray.600"><Spinner size="sm" /><Text>Načítám videa</Text></HStack>
)}
{!ytLoading && ytVideos.length > 0 && (
<SimpleGrid columns={{ base: 1, sm: 2, md: 3, lg: 4 }} spacing={3}>
{ytVideos.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" />
<Checkbox isChecked={!!selectedIds[v.video_id]} onChange={() => toggleSelect(v.video_id)}>
Vybrat
</Checkbox>
<Box>
<Text fontWeight="semibold" noOfLines={2}>{v.title}</Text>
<HStack spacing={2} color="gray.600" fontSize="sm">
{v.length && <Badge>{v.length}</Badge>}
{v.published_text && <Text>{v.published_text}</Text>}
</HStack>
</Box>
</VStack>
</Box>
))}
</SimpleGrid>
)}
</VStack>
</TabPanel>
</TabPanels>
</Tabs>
</ModalBody>
<ModalFooter>
<Button variant="ghost" onClick={onCloseAdd}>Zavřít</Button>
</ModalFooter>
</ModalContent>
</Modal>
</Box>
</AdminLayout>
);
+1 -10
View File
@@ -15,7 +15,7 @@ type BannerPreset = {
width: number;
height: number;
aspectRatio: number;
position: 'top' | 'middle' | 'sidebar' | 'footer' | 'article' | 'under_table';
position: 'top' | 'middle' | 'footer' | 'article' | 'under_table';
};
const BANNER_PRESETS: BannerPreset[] = [
@@ -28,15 +28,6 @@ const BANNER_PRESETS: BannerPreset[] = [
aspectRatio: 3.88,
position: 'middle'
},
{
value: 'homepage_sidebar',
label: 'Postranní banner (Homepage - okraj obrazovky)',
description: 'Menší banner ukotvený u levého/pravého okraje obrazovky (nastavitelné v editoru: Sidebar varianta vlevo/vpravo)',
width: 300,
height: 250,
aspectRatio: 1.2,
position: 'sidebar'
},
{
value: 'homepage_footer',
label: 'Spodní banner (Homepage - zápatí)',
+245 -247
View File
@@ -37,6 +37,8 @@ import {
ModalBody,
ModalFooter,
ModalCloseButton,
Checkbox,
CheckboxGroup,
Wrap,
WrapItem,
} from '@chakra-ui/react';
@@ -58,6 +60,11 @@ import {
} from '../../services/admin/engagement';
import { FiTrash2, FiEdit2 } from 'react-icons/fi';
import api from '../../services/api';
import { assetUrl } from '../../utils/url';
// Quick presets for sizes and colors
const SIZE_OPTIONS = ['XS','S','M','L','XL','XXL','XXXL','UNI'];
const COLOR_OPTIONS = ['Černá','Bílá','Modrá','Červená','Zelená','Žlutá','Oranžová','Fialová','Šedá','Růžová','Hnědá','Navy','Béžová','Tyrkysová','Vínová'];
const EngagementAdminPage: React.FC = () => {
const toast = useToast();
@@ -77,30 +84,25 @@ const EngagementAdminPage: React.FC = () => {
const [form, setForm] = React.useState({
name: '',
type: 'avatar_static',
type: 'merch_digital',
cost_points: 50,
image_url: '',
stock: -1,
active: true,
});
// Create form helpers
const [validUnlimited, setValidUnlimited] = React.useState<boolean>(true);
const [sizeList, setSizeList] = React.useState<string[]>([]);
const [colorList, setColorList] = React.useState<string[]>([]);
const [sizeCustom, setSizeCustom] = React.useState<string>('');
const [colorCustom, setColorCustom] = React.useState<string>('');
const [editItem, setEditItem] = React.useState<AdminRewardItem | null>(null);
const editModal = useDisclosure();
const [editForm, setEditForm] = React.useState<Partial<AdminRewardItem>>({});
// Remove raw JSON editing, keep structured metadata only
const batchEnabled = false;
const [batch, setBatch] = React.useState({
base_url: '',
name_prefix: 'Avatar',
count: 5,
start_index: 1,
type: 'avatar_static' as string,
cost_points: 50,
stock: -1,
active: true,
});
const batchModal = useDisclosure();
// Structured metadata state (used for merch types, coupons, etc.)
const fileInputRef = React.useRef<HTMLInputElement | null>(null);
const [meta, setMeta] = React.useState<Record<string, any>>({});
@@ -119,33 +121,7 @@ const EngagementAdminPage: React.FC = () => {
return m;
}, [usersQ.data]);
// Reward template selector instead of many buttons
const [template, setTemplate] = React.useState<string>('avatar_upload_unlock');
const applyTemplate = (tpl: string) => {
setTemplate(tpl);
switch (tpl) {
case 'avatar_upload_unlock':
setForm((prev) => ({ ...prev, type: 'avatar_upload_unlock', cost_points: 50, stock: -1, image_url: '' }));
break;
case 'avatar_animated_upload_unlock':
setForm((prev) => ({ ...prev, type: 'avatar_animated_upload_unlock', cost_points: 150, stock: 0 }));
break;
case 'avatar_static_50':
setForm((prev) => ({ ...prev, type: 'avatar_static', cost_points: 50 }));
break;
case 'merch_coupon_1000':
setForm((prev) => ({ ...prev, type: 'merch_coupon', cost_points: 1000 }));
break;
case 'merch_coupon_2000':
setForm((prev) => ({ ...prev, type: 'merch_coupon', cost_points: 2000 }));
break;
case 'merch_physical_4000':
setForm((prev) => ({ ...prev, type: 'merch_physical', cost_points: 4000, stock: 1 }));
break;
default:
break;
}
};
// Removed reward templates UI
const handleUpload = async (file?: File) => {
try {
@@ -190,12 +166,18 @@ const EngagementAdminPage: React.FC = () => {
const createMut = useMutation({
mutationFn: async () => {
// Auto-generate metadata from structured fields
const metadata = Object.keys(meta).length ? meta : undefined;
// Build metadata including structured helpers
const md: Record<string, any> = { ...(Object.keys(meta).length ? meta : {}) };
if (validUnlimited) { delete md.valid_from; delete md.valid_to; }
if (sizeList.length) md.size = sizeList.join(',');
if (colorList.length) md.color = colorList.join(',');
const metadata = Object.keys(md).length ? md : undefined;
return adminCreateReward({ ...form, metadata });
},
onSuccess: async () => {
setForm({ name: '', type: 'avatar_static', cost_points: 50, image_url: '', stock: -1, active: true });
setForm({ name: '', type: 'merch_digital', cost_points: 50, image_url: '', stock: -1, active: true });
setValidUnlimited(true);
setSizeList([]); setColorList([]); setSizeCustom(''); setColorCustom(''); setMeta({});
await qc.invalidateQueries({ queryKey: ['admin-engagement-rewards'] });
toast({ status: 'success', title: 'Odměna vytvořena' });
},
@@ -217,32 +199,7 @@ const EngagementAdminPage: React.FC = () => {
onSuccess: async () => { await qc.invalidateQueries({ queryKey: ['admin-engagement-redemptions'] }); toast({ status: 'success', title: 'Status aktualizován' }); },
});
const batchMut = useMutation({
mutationFn: async () => {
const total = Math.max(0, Number(batch.count) || 0);
const start = Math.max(0, Number(batch.start_index) || 0);
if (!batch.base_url.trim() || total <= 0) throw new Error('Zadejte prosím základní URL a počet.');
for (let i = 0; i < total; i++) {
const idx = start + i;
const image_url = batch.base_url.replace('{i}', String(idx));
const name = `${batch.name_prefix} ${idx}`.trim();
await adminCreateReward({
name,
type: batch.type,
cost_points: batch.cost_points,
image_url,
stock: batch.stock,
active: batch.active,
});
}
},
onSuccess: async () => {
await qc.invalidateQueries({ queryKey: ['admin-engagement-rewards'] });
batchModal.onClose();
toast({ status: 'success', title: 'Dávka vytvořena' });
},
onError: (e: any) => toast({ status: 'error', title: e?.message || 'Chyba při dávkovém vytváření' }),
});
const rewards = rewardsQ.data || [];
const redemptions = redemptionsQ.data || [];
@@ -316,26 +273,7 @@ const EngagementAdminPage: React.FC = () => {
<Box>
<Heading size="sm" mb={2}>Vytvořit novou odměnu</Heading>
<VStack align="stretch" spacing={3} borderWidth="1px" borderRadius="md" p={3}>
<Wrap spacing={2}>
<WrapItem>
<FormControl>
<FormLabel m={0} fontSize="sm">Šablona odměny</FormLabel>
<Select size="sm" maxW="280px" value={template} onChange={(e)=>applyTemplate(e.target.value)}>
<option value="avatar_upload_unlock">Odemknutí vlastního avataru (50b)</option>
<option value="avatar_animated_upload_unlock">Odemknutí animovaného avataru (150b)</option>
<option value="avatar_static_50">Avatar (statický) 50b</option>
<option value="merch_coupon_1000">Merch kupon (1000b)</option>
<option value="merch_coupon_2000">Merch kupon (2000b)</option>
<option value="merch_physical_4000">Fyzická odměna (4000b)</option>
</Select>
</FormControl>
</WrapItem>
<WrapItem>
{batchEnabled && (
<Button size="sm" variant="outline" onClick={batchModal.onOpen}>Dávkové vytvoření</Button>
)}
</WrapItem>
</Wrap>
{/* Šablony odměn odstraněny */}
<HStack align="start" spacing={4}>
<VStack align="stretch" spacing={3} flex={1}>
<FormControl>
@@ -345,13 +283,9 @@ const EngagementAdminPage: React.FC = () => {
<FormControl>
<FormLabel>Typ odměny</FormLabel>
<Select value={form.type} onChange={(e) => setForm({ ...form, type: e.target.value })}>
<option value="avatar_static">Avatar (statický)</option>
<option value="avatar_animated">Avatar (animovaný)</option>
<option value="avatar_animated_upload_unlock">Odemknutí animovaného avataru (upload)</option>
<option value="avatar_upload_unlock">Odemknutí vlastního avataru</option>
<option value="merch_digital">Digitální odměna</option>
<option value="merch_coupon">Merch kupon</option>
<option value="merch_physical">Merch (fyzický)</option>
<option value="merch_digital">Merch (digitální)</option>
<option value="custom">Vlastní</option>
</Select>
<FormHelperText>Ovlivňuje chování po uplatnění (např. nastavení avataru).</FormHelperText>
@@ -364,36 +298,48 @@ const EngagementAdminPage: React.FC = () => {
</NumberInput>
<FormHelperText>~ {Math.round(Number(form.cost_points || 0) * 0.1)} </FormHelperText>
</FormControl>
{(form.type !== 'avatar_upload_unlock' && form.type !== 'avatar_animated_upload_unlock') && (
<FormControl>
<FormLabel>Sklad</FormLabel>
<NumberInput value={form.stock} min={-1} onChange={(_v, n) => setForm({ ...form, stock: Number.isFinite(n) ? n : -1 })}>
<FormControl>
<FormLabel>Množství/Sklad</FormLabel>
<HStack>
<NumberInput value={form.stock} min={-1} isDisabled={form.stock === -1} onChange={(_v, n) => setForm({ ...form, stock: Number.isFinite(n) ? n : -1 })}>
<NumberInputField placeholder="Ks (-1 = neomezeně, 0 = vyprodáno)" />
</NumberInput>
</FormControl>
)}
</HStack>
{(form.type === 'avatar_static' || form.type === 'avatar_animated') && (
<>
<FormControl>
<FormLabel>Obrázek URL</FormLabel>
<Input placeholder="https://.../avatar-1.png" value={form.image_url} onChange={(e) => setForm({ ...form, image_url: e.target.value })} />
<FormHelperText>Pro avatar uveďte URL obrázku.</FormHelperText>
</FormControl>
<HStack>
<input ref={fileInputRef} type="file" accept="image/*" style={{ display: 'none' }} onChange={(e)=>handleUpload(e.target.files?.[0])} />
<Button size="sm" variant="outline" onClick={() => fileInputRef.current?.click()}>Nahrát obrázek</Button>
<HStack>
<Text fontSize="sm">Neomezeně</Text>
<Switch isChecked={form.stock === -1} onChange={(e)=> setForm(prev => ({ ...prev, stock: e.target.checked ? -1 : Math.max(0, Number(prev.stock) === -1 ? 0 : Number(prev.stock)||0) }))} />
</HStack>
</HStack>
</>
)}
<VStack align="stretch" spacing={2}>
<FormHelperText>-1 = neomezeně, 0 = dočasně vyprodáno. Sklad platí pro ne-avatarové odměny.</FormHelperText>
</FormControl>
</HStack>
<>
<FormControl>
<FormLabel>Obrázek URL</FormLabel>
<Input placeholder="/uploads/… nebo https://…/obrazek.png" value={form.image_url} onChange={(e) => setForm({ ...form, image_url: e.target.value })} />
<FormHelperText>Vložte URL nebo použijte tlačítko níže. Cesty z /uploads se načtou přes proxy.</FormHelperText>
</FormControl>
<HStack>
<input ref={fileInputRef} type="file" accept="image/*" style={{ display: 'none' }} onChange={(e)=>handleUpload(e.target.files?.[0])} />
<Button size="sm" variant="outline" onClick={() => fileInputRef.current?.click()}>Nahrát obrázek</Button>
</HStack>
</>
<VStack align="stretch" spacing={2}>
<HStack>
<Text>Neomezená platnost</Text>
<Switch isChecked={validUnlimited} onChange={(e)=>{
const on = e.target.checked;
setValidUnlimited(on);
if (on) { setMetaField('valid_from', ''); setMetaField('valid_to', ''); }
}} />
</HStack>
<FormControl isDisabled={validUnlimited}>
<FormLabel>Platnost od</FormLabel>
<Input type="datetime-local" value={meta.valid_from || ''} onChange={(e)=>setMetaField('valid_from', e.target.value)} />
</FormControl>
<FormControl>
<FormControl isDisabled={validUnlimited}>
<FormLabel>Platnost do</FormLabel>
<Input type="datetime-local" value={meta.valid_to || ''} onChange={(e)=>setMetaField('valid_to', e.target.value)} />
<FormHelperText>Když je zapnuto Neomezená platnost, datumy se nevyžadují a ignorují.</FormHelperText>
</FormControl>
</VStack>
{/* Metadata helpers */}
@@ -411,12 +357,55 @@ const EngagementAdminPage: React.FC = () => {
)}
{form.type === 'merch_physical' && (
<VStack align="stretch" spacing={2}>
<FormControl><FormLabel>SKU</FormLabel><Input value={meta.sku || ''} onChange={(e)=>setMetaField('sku', e.target.value)} /></FormControl>
<HStack>
<FormControl><FormLabel>Velikost</FormLabel><Input value={meta.size || ''} onChange={(e)=>setMetaField('size', e.target.value)} placeholder="M / L / XL" /></FormControl>
<FormControl><FormLabel>Barva</FormLabel><Input value={meta.color || ''} onChange={(e)=>setMetaField('color', e.target.value)} /></FormControl>
</HStack>
<FormControl><FormLabel>Poznámka</FormLabel><Input value={meta.note || ''} onChange={(e)=>setMetaField('note', e.target.value)} /></FormControl>
<FormControl>
<FormLabel>SKU</FormLabel>
<Input value={meta.sku || ''} onChange={(e)=>setMetaField('sku', e.target.value)} />
<FormHelperText>SKU = skladové označení/kód produktu (není to množství). Množství nastavte v poli Množství/Sklad výše; Neomezeně zapnete přepínačem.</FormHelperText>
</FormControl>
<FormControl>
<FormLabel>Velikosti</FormLabel>
<CheckboxGroup value={sizeList} onChange={(vals)=>setSizeList(vals as string[])}>
<Wrap spacing={3}>
{SIZE_OPTIONS.map((s)=> (
<WrapItem key={s}><Checkbox value={s}>{s}</Checkbox></WrapItem>
))}
</Wrap>
</CheckboxGroup>
<HStack mt={1} spacing={2}>
<Input placeholder="Vlastní velikosti, oddělte čárkami" value={sizeCustom} onChange={(e)=>setSizeCustom(e.target.value)} />
<Button size="sm" onClick={()=>{
const parts = (sizeCustom || '').split(',').map(s=>s.trim()).filter(Boolean);
if (!parts.length) return;
setSizeList(prev => Array.from(new Set([...prev, ...parts])));
setSizeCustom('');
}}>Přidat</Button>
</HStack>
<FormHelperText>Vyberte z nabídky nebo zadejte vlastní hodnoty, oddělené čárkami (např. 122, 128).</FormHelperText>
</FormControl>
<FormControl>
<FormLabel>Barvy</FormLabel>
<CheckboxGroup value={colorList} onChange={(vals)=>setColorList(vals as string[])}>
<Wrap spacing={3}>
{COLOR_OPTIONS.map((c)=> (
<WrapItem key={c}><Checkbox value={c}>{c}</Checkbox></WrapItem>
))}
</Wrap>
</CheckboxGroup>
<HStack mt={1} spacing={2}>
<Input placeholder="Vlastní barvy, oddělte čárkami" value={colorCustom} onChange={(e)=>setColorCustom(e.target.value)} />
<Button size="sm" onClick={()=>{
const parts = (colorCustom || '').split(',').map(s=>s.trim()).filter(Boolean);
if (!parts.length) return;
setColorList(prev => Array.from(new Set([...prev, ...parts])));
setColorCustom('');
}}>Přidat</Button>
</HStack>
<FormHelperText>Vyberte více možností nebo přidejte vlastní barvy (oddělené čárkami).</FormHelperText>
</FormControl>
<FormControl>
<FormLabel>Poznámka</FormLabel>
<Input value={meta.note || ''} onChange={(e)=>setMetaField('note', e.target.value)} />
</FormControl>
</VStack>
)}
{form.type === 'merch_digital' && (
@@ -447,18 +436,16 @@ const EngagementAdminPage: React.FC = () => {
<Button colorScheme="blue" onClick={() => createMut.mutate()} isLoading={createMut.isPending} isDisabled={!form.name.trim()}>Vytvořit</Button>
</HStack>
</VStack>
{(form.type === 'avatar_static' || form.type === 'avatar_animated') && (
<Box>
<Text fontSize="sm" mb={2} color="gray.500">Náhled</Text>
<Box borderWidth="1px" borderRadius="md" p={2}>
{form.image_url ? (
<Image src={form.image_url} alt={form.name} boxSize="96px" objectFit="cover" borderRadius="md" />
) : (
<Box boxSize="96px" borderWidth="1px" borderRadius="md" display="flex" alignItems="center" justifyContent="center" color="gray.400">Bez obrázku</Box>
)}
</Box>
<Box>
<Text fontSize="sm" mb={2} color="gray.500">Náhled</Text>
<Box borderWidth="1px" borderRadius="md" p={2}>
{form.image_url ? (
<Image src={assetUrl(form.image_url)} alt={form.name} boxSize="96px" objectFit="cover" borderRadius="md" />
) : (
<Box boxSize="96px" borderWidth="1px" borderRadius="md" display="flex" alignItems="center" justifyContent="center" color="gray.400">Bez obrázku</Box>
)}
</Box>
)}
</Box>
</HStack>
</VStack>
</Box>
@@ -483,7 +470,7 @@ const EngagementAdminPage: React.FC = () => {
<Th>Název</Th>
<Th>Typ</Th>
<Th>Body</Th>
<Th>Sklad</Th>
<Th>Množství/Sklad</Th>
<Th>Obrázek</Th>
<Th>Platnost</Th>
<Th>Aktivní</Th>
@@ -497,7 +484,7 @@ const EngagementAdminPage: React.FC = () => {
<Td>{r.name}</Td>
<Td><Badge>{r.type}</Badge></Td>
<Td>
<NumberInput size="sm" value={r.cost_points} min={0} maxW="120px" onChange={(_v, n) => updateMut.mutate({ id: r.id, body: { cost_points: Number.isFinite(n) ? n : 0 } })}>
<NumberInput size="sm" value={r.cost_points} min={0} maxW="120px" isDisabled={!!r.type && r.type.startsWith('avatar_')} onChange={(_v, n) => updateMut.mutate({ id: r.id, body: { cost_points: Number.isFinite(n) ? n : 0 } })}>
<NumberInputField />
</NumberInput>
</Td>
@@ -507,13 +494,13 @@ const EngagementAdminPage: React.FC = () => {
value={r.stock ?? 0}
min={-1}
maxW="100px"
isDisabled={r.type === 'avatar_upload_unlock'}
isDisabled={!!r.type && r.type.startsWith('avatar_')}
onChange={(_v, n) => updateMut.mutate({ id: r.id, body: { stock: Number.isFinite(n) ? n : 0 } })}
>
<NumberInputField />
</NumberInput>
</Td>
<Td>{r.image_url ? <Image src={r.image_url} alt={r.name} boxSize="40px" objectFit="cover" borderRadius="md" /> : '-'}</Td>
<Td>{r.image_url ? <Image src={assetUrl(r.image_url)} alt={r.name} boxSize="40px" objectFit="cover" borderRadius="md" /> : '-'}</Td>
<Td>
{(() => {
const m = (r.metadata || {}) as any;
@@ -531,14 +518,27 @@ const EngagementAdminPage: React.FC = () => {
<Td>
<Switch
isChecked={!!r.active}
isDisabled={r.type === 'avatar_upload_unlock'}
isDisabled={!!r.type && r.type.startsWith('avatar_')}
onChange={(e) => updateMut.mutate({ id: r.id, body: { active: e.target.checked } })}
/>
</Td>
<Td>
<HStack>
<IconButton aria-label="Upravit" size="xs" icon={<FiEdit2 />} onClick={() => { setEditItem(r); setEditForm(r); setEditMeta(r.metadata || {}); editModal.onOpen(); }} />
{r.type !== 'avatar_upload_unlock' && (
<IconButton aria-label="Upravit" size="xs" icon={<FiEdit2 />} onClick={() => {
setEditItem(r);
setEditForm(r);
const m: any = r.metadata || {};
const prepared: any = { ...m };
try {
if (typeof m.size === 'string') prepared.__size_list = String(m.size).split(',').map((s:string)=>s.trim()).filter(Boolean);
} catch {}
try {
if (typeof m.color === 'string') prepared.__color_list = String(m.color).split(',').map((s:string)=>s.trim()).filter(Boolean);
} catch {}
setEditMeta(prepared);
editModal.onOpen();
}} />
{!r.type?.startsWith('avatar_') && (
<IconButton aria-label="Smazat" size="xs" icon={<FiTrash2 />} onClick={() => deleteMut.mutate(r.id)} />
)}
</HStack>
@@ -619,74 +619,125 @@ const EngagementAdminPage: React.FC = () => {
<VStack align="stretch" spacing={3}>
<FormControl>
<FormLabel>Název</FormLabel>
<Input value={editForm.name || ''} onChange={(e)=>setEditForm({ ...editForm, name: e.target.value })} isDisabled={editItem?.type === 'avatar_upload_unlock'} />
<Input value={editForm.name || ''} onChange={(e)=>setEditForm({ ...editForm, name: e.target.value })} isDisabled={!!editItem?.type && editItem.type.startsWith('avatar_')} />
</FormControl>
<FormControl>
<FormLabel>Typ</FormLabel>
<Select value={editForm.type || ''} onChange={(e)=>setEditForm({ ...editForm, type: e.target.value })} isDisabled={editItem?.type === 'avatar_upload_unlock'}>
<option value="avatar_static">Avatar (statický)</option>
<option value="avatar_animated">Avatar (animovaný)</option>
<option value="avatar_upload_unlock">Odemknutí vlastního avataru</option>
<Select value={editForm.type || ''} onChange={(e)=>setEditForm({ ...editForm, type: e.target.value })} isDisabled={!!editItem?.type && editItem.type.startsWith('avatar_')}>
<option value="merch_digital">Digitální odměna</option>
<option value="merch_coupon">Merch kupon</option>
<option value="merch_physical">Merch (fyzický)</option>
<option value="merch_digital">Merch (digitální)</option>
<option value="custom">Vlastní</option>
</Select>
</FormControl>
<HStack>
<FormControl>
<FormLabel>Body</FormLabel>
<NumberInput value={Number(editForm.cost_points || 0)} min={0} onChange={(_v, n)=>setEditForm({ ...editForm, cost_points: Number.isFinite(n)? n : 0 })}>
<NumberInput value={Number(editForm.cost_points || 0)} min={0} onChange={(_v, n)=>setEditForm({ ...editForm, cost_points: Number.isFinite(n)? n : 0 })} isDisabled={!editItem || (!!editItem.type && !editItem.type.startsWith('avatar_') && false)}>
<NumberInputField />
</NumberInput>
<FormHelperText>~ {Math.round(Number(editForm.cost_points || 0) * 0.1)} </FormHelperText>
</FormControl>
<FormControl>
<FormLabel>Sklad</FormLabel>
<NumberInput value={Number(editForm.stock || 0)} min={-1} onChange={(_v, n)=>setEditForm({ ...editForm, stock: Number.isFinite(n)? n : 0 })} isDisabled={editItem?.type === 'avatar_upload_unlock'}>
<NumberInputField />
</NumberInput>
<FormLabel>Množství/Sklad</FormLabel>
<HStack>
<NumberInput value={Number(editForm.stock || 0)} min={-1} isDisabled={(!!editItem?.type && editItem.type.startsWith('avatar_')) || Number(editForm.stock) === -1} onChange={(_v, n)=>setEditForm({ ...editForm, stock: Number.isFinite(n)? n : 0 })}>
<NumberInputField />
</NumberInput>
<HStack>
<Text fontSize="sm">Neomezeně</Text>
<Switch isChecked={Number(editForm.stock) === -1} onChange={(e)=> setEditForm(prev => ({ ...prev, stock: e.target.checked ? -1 : Math.max(0, Number(prev.stock) === -1 ? 0 : Number(prev.stock)||0) }))} isDisabled={!!editItem?.type && editItem.type.startsWith('avatar_')} />
</HStack>
</HStack>
<FormHelperText>-1 = neomezeně, 0 = vyprodáno.</FormHelperText>
</FormControl>
</HStack>
<FormControl>
<FormLabel>Obrázek URL</FormLabel>
<Input value={editForm.image_url || ''} onChange={(e)=>setEditForm({ ...editForm, image_url: e.target.value })} isDisabled={editItem?.type === 'avatar_upload_unlock'} />
<Input value={editForm.image_url || ''} onChange={(e)=>setEditForm({ ...editForm, image_url: e.target.value })} isDisabled={!!editItem?.type && editItem.type.startsWith('avatar_')} />
<FormHelperText>Vložte URL z /uploads nebo nahrávací tlačítko (proxy na frontend funguje).</FormHelperText>
</FormControl>
<HStack>
<input ref={editFileInputRef} type="file" accept="image/*" style={{ display: 'none' }} onChange={(e)=>handleUploadEdit(e.target.files?.[0])} />
<Button size="sm" variant="outline" onClick={() => editFileInputRef.current?.click()} isDisabled={editItem?.type === 'avatar_upload_unlock'}>Nahrát obrázek</Button>
<Button size="sm" variant="outline" onClick={() => editFileInputRef.current?.click()} isDisabled={!!editItem?.type && editItem.type.startsWith('avatar_')}>Nahrát obrázek</Button>
</HStack>
{/* Edit metadata helpers (structured) */}
{ (editForm.type === 'merch_coupon' || editForm.type === 'merch_physical' || editForm.type === 'merch_digital' || editForm.type === 'custom') && (
<VStack align="stretch" spacing={2}>
{editForm.type === 'merch_coupon' && (
<>
<FormControl><FormLabel>Kód kuponu</FormLabel><Input isDisabled={editItem?.type === 'avatar_upload_unlock'} value={(editMeta as any).coupon_code || ''} onChange={(e)=>setEditMetaField('coupon_code', e.target.value)} /></FormControl>
<FormControl><FormLabel>Poznámka</FormLabel><Input isDisabled={editItem?.type === 'avatar_upload_unlock'} value={(editMeta as any).note || ''} onChange={(e)=>setEditMetaField('note', e.target.value)} /></FormControl>
<FormControl><FormLabel>Kód kuponu</FormLabel><Input isDisabled={!!editItem?.type && editItem.type.startsWith('avatar_')} value={(editMeta as any).coupon_code || ''} onChange={(e)=>setEditMetaField('coupon_code', e.target.value)} /></FormControl>
<FormControl><FormLabel>Poznámka</FormLabel><Input isDisabled={!!editItem?.type && editItem.type.startsWith('avatar_')} value={(editMeta as any).note || ''} onChange={(e)=>setEditMetaField('note', e.target.value)} /></FormControl>
</>
)}
{editForm.type === 'merch_physical' && (
<>
<FormControl><FormLabel>SKU</FormLabel><Input isDisabled={editItem?.type === 'avatar_upload_unlock'} value={(editMeta as any).sku || ''} onChange={(e)=>setEditMetaField('sku', e.target.value)} /></FormControl>
<HStack>
<FormControl><FormLabel>Velikost</FormLabel><Input isDisabled={editItem?.type === 'avatar_upload_unlock'} value={(editMeta as any).size || ''} onChange={(e)=>setEditMetaField('size', e.target.value)} /></FormControl>
<FormControl><FormLabel>Barva</FormLabel><Input isDisabled={editItem?.type === 'avatar_upload_unlock'} value={(editMeta as any).color || ''} onChange={(e)=>setEditMetaField('color', e.target.value)} /></FormControl>
</HStack>
<FormControl><FormLabel>Poznámka</FormLabel><Input isDisabled={editItem?.type === 'avatar_upload_unlock'} value={(editMeta as any).note || ''} onChange={(e)=>setEditMetaField('note', e.target.value)} /></FormControl>
<FormControl>
<FormLabel>SKU</FormLabel>
<Input isDisabled={!!editItem?.type && editItem.type.startsWith('avatar_')} value={(editMeta as any).sku || ''} onChange={(e)=>setEditMetaField('sku', e.target.value)} />
<FormHelperText>Interní kód produktu (volitelné).</FormHelperText>
</FormControl>
<FormControl>
<FormLabel>Velikosti</FormLabel>
<CheckboxGroup value={(editMeta as any).__size_list || []} onChange={(vals)=>{
const arr = vals as string[];
setEditMeta(prev => ({ ...(prev as any), __size_list: arr } as any));
}}>
<Wrap spacing={3}>
{SIZE_OPTIONS.map((s)=> (
<WrapItem key={s}><Checkbox value={s} isDisabled={!!editItem?.type && editItem.type.startsWith('avatar_')}>{s}</Checkbox></WrapItem>
))}
</Wrap>
</CheckboxGroup>
<HStack mt={1} spacing={2}>
<Input placeholder="Vlastní velikosti, oddělte čárkami" isDisabled={!!editItem?.type && editItem.type.startsWith('avatar_')} value={(editMeta as any).__size_custom || ''} onChange={(e)=>setEditMetaField('__size_custom', e.target.value)} />
<Button size="sm" isDisabled={!!editItem?.type && editItem.type.startsWith('avatar_')} onClick={()=>{
const parts = String((editMeta as any).__size_custom || '').split(',').map((s:string)=>s.trim()).filter(Boolean);
const cur = Array.isArray((editMeta as any).__size_list) ? (editMeta as any).__size_list as string[] : [];
const merged = Array.from(new Set([...(cur as string[]), ...parts]));
setEditMeta(prev => ({ ...(prev as any), __size_list: merged, __size_custom: '' } as any));
}}>Přidat</Button>
</HStack>
<FormHelperText>Vyberte z nabídky nebo přidejte vlastní hodnoty (oddělené čárkami).</FormHelperText>
</FormControl>
<FormControl>
<FormLabel>Barvy</FormLabel>
<CheckboxGroup value={(editMeta as any).__color_list || []} onChange={(vals)=>{
const arr = vals as string[];
setEditMeta(prev => ({ ...(prev as any), __color_list: arr } as any));
}}>
<Wrap spacing={3}>
{COLOR_OPTIONS.map((c)=> (
<WrapItem key={c}><Checkbox value={c} isDisabled={!!editItem?.type && editItem.type.startsWith('avatar_')}>{c}</Checkbox></WrapItem>
))}
</Wrap>
</CheckboxGroup>
<HStack mt={1} spacing={2}>
<Input placeholder="Vlastní barvy, oddělte čárkami" isDisabled={!!editItem?.type && editItem.type.startsWith('avatar_')} value={(editMeta as any).__color_custom || ''} onChange={(e)=>setEditMetaField('__color_custom', e.target.value)} />
<Button size="sm" isDisabled={!!editItem?.type && editItem.type.startsWith('avatar_')} onClick={()=>{
const parts = String((editMeta as any).__color_custom || '').split(',').map((s:string)=>s.trim()).filter(Boolean);
const cur = Array.isArray((editMeta as any).__color_list) ? (editMeta as any).__color_list as string[] : [];
const merged = Array.from(new Set([...(cur as string[]), ...parts]));
setEditMeta(prev => ({ ...(prev as any), __color_list: merged, __color_custom: '' } as any));
}}>Přidat</Button>
</HStack>
<FormHelperText>Vyberte více možností nebo přidejte vlastní barvy (oddělené čárkami).</FormHelperText>
</FormControl>
<FormControl><FormLabel>Poznámka</FormLabel><Input isDisabled={!!editItem?.type && editItem.type.startsWith('avatar_')} value={(editMeta as any).note || ''} onChange={(e)=>setEditMetaField('note', e.target.value)} /></FormControl>
</>
)}
{editForm.type === 'merch_digital' && (
<>
<FormControl><FormLabel>Licenční klíč</FormLabel><Input isDisabled={editItem?.type === 'avatar_upload_unlock'} value={(editMeta as any).license_key || ''} onChange={(e)=>setEditMetaField('license_key', e.target.value)} /></FormControl>
<FormControl><FormLabel>Stažení (URL)</FormLabel><Input isDisabled={editItem?.type === 'avatar_upload_unlock'} value={(editMeta as any).download_url || ''} onChange={(e)=>setEditMetaField('download_url', e.target.value)} /></FormControl>
<FormControl><FormLabel>Poznámka</FormLabel><Input isDisabled={editItem?.type === 'avatar_upload_unlock'} value={(editMeta as any).note || ''} onChange={(e)=>setEditMetaField('note', e.target.value)} /></FormControl>
<FormControl><FormLabel>Licenční klíč</FormLabel><Input isDisabled={!!editItem?.type && editItem.type.startsWith('avatar_')} value={(editMeta as any).license_key || ''} onChange={(e)=>setEditMetaField('license_key', e.target.value)} /></FormControl>
<FormControl><FormLabel>Stažení (URL)</FormLabel><Input isDisabled={!!editItem?.type && editItem.type.startsWith('avatar_')} value={(editMeta as any).download_url || ''} onChange={(e)=>setEditMetaField('download_url', e.target.value)} /></FormControl>
<FormControl><FormLabel>Poznámka</FormLabel><Input isDisabled={!!editItem?.type && editItem.type.startsWith('avatar_')} value={(editMeta as any).note || ''} onChange={(e)=>setEditMetaField('note', e.target.value)} /></FormControl>
</>
)}
{editForm.type === 'custom' && (
<HStack>
<Input placeholder="klíč" id="edit-kv-key" isDisabled={editItem?.type === 'avatar_upload_unlock'} />
<Input placeholder="hodnota" id="edit-kv-value" isDisabled={editItem?.type === 'avatar_upload_unlock'} />
<Button size="sm" isDisabled={editItem?.type === 'avatar_upload_unlock'} onClick={()=>{
<Input placeholder="klíč" id="edit-kv-key" isDisabled={!!editItem?.type && editItem.type.startsWith('avatar_')} />
<Input placeholder="hodnota" id="edit-kv-value" isDisabled={!!editItem?.type && editItem.type.startsWith('avatar_')} />
<Button size="sm" isDisabled={!!editItem?.type && editItem.type.startsWith('avatar_')} onClick={()=>{
const k = (document.getElementById('edit-kv-key') as HTMLInputElement)?.value?.trim();
const v = (document.getElementById('edit-kv-value') as HTMLInputElement)?.value?.trim();
if (!k) return;
@@ -697,20 +748,28 @@ const EngagementAdminPage: React.FC = () => {
</VStack>
)}
<VStack align="stretch" spacing={2}>
<FormControl>
<HStack>
<Text>Neomezená platnost</Text>
<Switch isChecked={!(editMeta as any).valid_from && !(editMeta as any).valid_to} onChange={(e)=>{
const on = e.target.checked;
if (on) { setEditMeta(prev => ({ ...(prev as any), valid_from: '', valid_to: '' } as any)); }
}} isDisabled={!!editItem?.type && editItem.type.startsWith('avatar_')} />
</HStack>
<FormControl isDisabled={!!editItem?.type && editItem.type.startsWith('avatar_') || (!(editMeta as any).valid_from && !(editMeta as any).valid_to)}>
<FormLabel>Platnost od</FormLabel>
<Input type="datetime-local" isDisabled={editItem?.type === 'avatar_upload_unlock'} value={(editMeta as any).valid_from || ''} onChange={(e)=>setEditMetaField('valid_from', e.target.value)} />
<Input type="datetime-local" value={(editMeta as any).valid_from || ''} onChange={(e)=>setEditMetaField('valid_from', e.target.value)} />
</FormControl>
<FormControl>
<FormControl isDisabled={!!editItem?.type && editItem.type.startsWith('avatar_') || (!(editMeta as any).valid_from && !(editMeta as any).valid_to)}>
<FormLabel>Platnost do</FormLabel>
<Input type="datetime-local" isDisabled={editItem?.type === 'avatar_upload_unlock'} value={(editMeta as any).valid_to || ''} onChange={(e)=>setEditMetaField('valid_to', e.target.value)} />
<Input type="datetime-local" value={(editMeta as any).valid_to || ''} onChange={(e)=>setEditMetaField('valid_to', e.target.value)} />
<FormHelperText>Když je zapnuto Neomezená platnost, datumy se nevyžadují a ignorují.</FormHelperText>
</FormControl>
</VStack>
{/* Odstraněno: ruční JSON metadata v editoru. */}
<HStack>
<Text>Aktivní</Text>
<Switch isChecked={!!editForm.active} onChange={(e)=>setEditForm({ ...editForm, active: e.target.checked })} isDisabled={editItem?.type === 'avatar_upload_unlock'} />
{editForm.image_url ? <Image src={editForm.image_url} alt={String(editForm.name || '')} boxSize="56px" objectFit="cover" borderRadius="md" /> : null}
<Switch isChecked={!!editForm.active} onChange={(e)=>setEditForm({ ...editForm, active: e.target.checked })} isDisabled={!!editItem?.type && editItem.type.startsWith('avatar_')} />
{editForm.image_url ? <Image src={assetUrl(String(editForm.image_url))} alt={String(editForm.name || '')} boxSize="56px" objectFit="cover" borderRadius="md" /> : null}
</HStack>
</VStack>
</ModalBody>
@@ -719,10 +778,20 @@ const EngagementAdminPage: React.FC = () => {
<Button onClick={editModal.onClose}>Zrušit</Button>
<Button colorScheme="blue" isLoading={updateMut.isPending} onClick={async ()=>{
if (!editItem) return;
if (editItem.type === 'avatar_upload_unlock') {
if (editItem.type && editItem.type.startsWith('avatar_')) {
await updateMut.mutateAsync({ id: editItem.id, body: { cost_points: editForm.cost_points as any } });
} else {
const metadata: Record<string, any> | undefined = Object.keys(editMeta || {}).length ? (editMeta as any) : {} as any;
const metadata: Record<string, any> = { ...(Object.keys(editMeta || {}).length ? (editMeta as any) : {}) } as any;
// Merge structured lists to CSV in metadata
const sz = Array.isArray((metadata as any).__size_list) ? (metadata as any).__size_list as string[] : [];
const cz = Array.isArray((metadata as any).__color_list) ? (metadata as any).__color_list as string[] : [];
if (sz.length) (metadata as any).size = sz.join(',');
if (cz.length) (metadata as any).color = cz.join(',');
delete (metadata as any).__size_list; delete (metadata as any).__size_custom;
delete (metadata as any).__color_list; delete (metadata as any).__color_custom;
// If unlimited validity, clear dates
const unlimited = !(metadata as any).valid_from && !(metadata as any).valid_to;
if (unlimited) { delete (metadata as any).valid_from; delete (metadata as any).valid_to; }
await updateMut.mutateAsync({ id: editItem.id, body: {
name: editForm.name,
type: editForm.type,
@@ -730,7 +799,7 @@ const EngagementAdminPage: React.FC = () => {
stock: editForm.stock as any,
image_url: editForm.image_url,
active: editForm.active as any,
metadata: metadata as any,
metadata: Object.keys(metadata).length ? metadata as any : undefined,
} as any });
}
editModal.onClose();
@@ -740,78 +809,7 @@ const EngagementAdminPage: React.FC = () => {
</ModalContent>
</Modal>
{/* Batch create modal (hidden) */}
{batchEnabled && (
<Modal isOpen={batchModal.isOpen} onClose={batchModal.onClose} isCentered>
<ModalOverlay />
<ModalContent>
<ModalHeader>Dávkové vytvoření odměn</ModalHeader>
<ModalCloseButton />
<ModalBody>
<VStack align="stretch" spacing={3}>
<FormControl>
<FormLabel>Základní URL (použijte {`{i}`} pro index)</FormLabel>
<Input placeholder="https://cdn.example.com/avatars/avatar-{i}.png" value={batch.base_url} onChange={(e)=>setBatch({ ...batch, base_url: e.target.value })} />
<FormHelperText>Příklad: avatar-{`{i}`}.png avatar-1.png, avatar-2.png</FormHelperText>
</FormControl>
<HStack>
<FormControl>
<FormLabel>Počet</FormLabel>
<NumberInput min={1} value={batch.count} onChange={(_v,n)=>setBatch({ ...batch, count: Number.isFinite(n)? n : 1 })}>
<NumberInputField />
</NumberInput>
</FormControl>
<FormControl>
<FormLabel>Počáteční index</FormLabel>
<NumberInput min={0} value={batch.start_index} onChange={(_v,n)=>setBatch({ ...batch, start_index: Number.isFinite(n)? n : 1 })}>
<NumberInputField />
</NumberInput>
</FormControl>
</HStack>
<FormControl>
<FormLabel>Předpona názvu</FormLabel>
<Input value={batch.name_prefix} onChange={(e)=>setBatch({ ...batch, name_prefix: e.target.value })} />
</FormControl>
<HStack>
<FormControl>
<FormLabel>Typ</FormLabel>
<Select value={batch.type} onChange={(e)=>setBatch({ ...batch, type: e.target.value })}>
<option value="avatar_static">Avatar (statický)</option>
<option value="avatar_animated">Avatar (animovaný)</option>
<option value="merch_coupon">Merch kupon</option>
<option value="custom">Vlastní</option>
</Select>
</FormControl>
<FormControl>
<FormLabel>Body</FormLabel>
<NumberInput min={0} value={batch.cost_points} onChange={(_v,n)=>setBatch({ ...batch, cost_points: Number.isFinite(n)? n : 0 })}>
<NumberInputField />
</NumberInput>
</FormControl>
</HStack>
<HStack>
<FormControl>
<FormLabel>Sklad</FormLabel>
<NumberInput min={-1} value={batch.stock} onChange={(_v,n)=>setBatch({ ...batch, stock: Number.isFinite(n)? n : -1 })}>
<NumberInputField />
</NumberInput>
</FormControl>
<HStack>
<Text>Aktivní</Text>
<Switch isChecked={batch.active} onChange={(e)=>setBatch({ ...batch, active: e.target.checked })} />
</HStack>
</HStack>
</VStack>
</ModalBody>
<ModalFooter>
<HStack>
<Button onClick={batchModal.onClose}>Zrušit</Button>
<Button colorScheme="blue" isLoading={batchMut.isPending} onClick={()=>batchMut.mutate()}>Vytvořit dávku</Button>
</HStack>
</ModalFooter>
</ModalContent>
</Modal>
)}
</AdminLayout>
);
};
+39 -9
View File
@@ -37,7 +37,8 @@ import {
} from '@chakra-ui/react';
import { RefreshCw, ExternalLink, Calendar, Image as ImageIcon, Eye, Plus } from 'lucide-react';
import AdminLayout from '../../layouts/AdminLayout';
import api from '../../services/api';
import api, { API_URL } from '../../services/api';
import { getZoneramaManifestWithFallbacks } from '../../services/zonerama';
interface Album {
id: string;
@@ -57,9 +58,8 @@ const resolveBackendUrl = (path: string) => {
try {
if (/^https?:\/\//i.test(path)) return path;
if (path.startsWith('/cache') || path.startsWith('/uploads') || path.startsWith('/api/')) {
const base = process.env.REACT_APP_API_BASE_URL || process.env.REACT_APP_API_URL || 'http://localhost:8080/api/v1';
const b = new URL(base);
const abs = new URL(path, `${b.protocol}//${b.host}`);
const origin = new URL(API_URL, typeof window !== 'undefined' ? window.location.origin : 'http://localhost:3000').origin;
const abs = new URL(path, origin);
return abs.toString();
}
return path;
@@ -82,7 +82,7 @@ const GalleryAdminPage: React.FC = () => {
const [photoLimit, setPhotoLimit] = useState<number>(50);
const [adding, setAdding] = useState<boolean>(false);
const fetchAlbums = async () => {
const fetchAlbums = async (): Promise<Album[]> => {
setLoading(true);
setError('');
@@ -117,10 +117,34 @@ const GalleryAdminPage: React.FC = () => {
combinedAlbums = [...combinedAlbums, ...validBlogAlbums];
}
// Fallback: synthesize albums from flat manifest when both sources fail/empty
if (!combinedAlbums || combinedAlbums.length === 0) {
try {
const items = await getZoneramaManifestWithFallbacks();
if (Array.isArray(items) && items.length > 0) {
const byAlbum: Record<string, any[]> = {} as any;
items.forEach((it: any) => {
const aid = String(it.album_id || 'unknown');
(byAlbum[aid] = byAlbum[aid] || []).push(it);
});
const synthesized: Album[] = Object.entries(byAlbum).map(([aid, arr]) => ({
id: aid,
title: 'Album',
url: (arr[0] as any).page_url || '#',
date: '',
photos_count: (arr as any[]).length,
photos: (arr as any[]).slice(0, 12).map((p: any) => ({ id: String(p.id || ''), page_url: String(p.page_url || ''), image_1500: String(p.src || p.local || '') })),
}));
combinedAlbums = synthesized;
}
} catch {}
}
setAlbums(combinedAlbums);
return combinedAlbums;
} catch (err: any) {
setError(err.message || 'Nepodařilo se načíst alba');
return [];
} finally {
setLoading(false);
}
@@ -141,8 +165,14 @@ const GalleryAdminPage: React.FC = () => {
isClosable: true,
});
// Reload albums after refresh
await fetchAlbums();
// Reload albums after refresh with short polling (refresh runs async on server)
let loaded: Album[] = [];
for (let i = 0; i < 5; i++) {
// small delay before each attempt to allow backend to finish
await new Promise((r) => setTimeout(r, 1200));
loaded = await fetchAlbums();
if (loaded && loaded.length > 0) break;
}
} catch (err: any) {
const errorMessage = err.response?.data?.error || err.message || 'Nepodařilo se obnovit galerii';
@@ -342,7 +372,7 @@ const GalleryAdminPage: React.FC = () => {
<Td>
{coverPhoto ? (
<Image
src={coverPhoto.image_1500}
src={resolveBackendUrl(coverPhoto.image_1500)}
alt={album.title}
boxSize="60px"
objectFit="cover"
@@ -243,6 +243,7 @@ const NavItemCard: React.FC<NavItemCardProps> = ({
}) => {
const hasChildren = item.children && item.children.length > 0;
const indentPx = level * 32;
const isCategory = item.type === 'dropdown';
return (
<Box ml={`${indentPx}px`}>
@@ -375,12 +376,19 @@ const NavItemCard: React.FC<NavItemCardProps> = ({
</CardBody>
</Card>
{/* Render children with nested DnD if expanded */}
{hasChildren && isExpanded && (
{/* Always render a children Droppable for categories (dropdown type).
This allows dropping into collapsed or empty categories. */}
{isCategory && (
<Droppable droppableId={childrenDroppableId || `children-${item.id}`}>
{(provided) => (
<VStack spacing={2} align="stretch" mt={2} ref={provided.innerRef} {...provided.droppableProps}>
{item.children!.map((child, childIndex) => (
<VStack
spacing={2}
align="stretch"
mt={2}
ref={provided.innerRef}
{...provided.droppableProps}
>
{hasChildren && isExpanded && item.children!.map((child, childIndex) => (
<Draggable key={String(child.id)} draggableId={`${(draggableChildPrefix || 'child')}-${child.id}`} index={childIndex}>
{(dragProvided) => (
<Box ref={dragProvided.innerRef} {...dragProvided.draggableProps} {...dragProvided.dragHandleProps}>
@@ -410,6 +418,10 @@ const NavItemCard: React.FC<NavItemCardProps> = ({
)}
</Draggable>
))}
{/* Provide a minimal drop zone even when collapsed or empty */}
{!hasChildren && (
<Box minH="8px" />
)}
{provided.placeholder}
</VStack>
)}
@@ -521,6 +533,7 @@ const NavigationAdminPage = () => {
if (!result.destination) return;
const { source, destination } = result;
const parseAdminChildrenId = (id: string) => id.startsWith('admin-children-') ? parseInt(id.replace('admin-children-', ''), 10) : null;
const parseFrontChildrenId = (id: string) => id.startsWith('frontend-children-') ? parseInt(id.replace('frontend-children-', ''), 10) : null;
if (source.droppableId === 'frontend-nav') {
const items = Array.from(navItems);
@@ -554,7 +567,7 @@ const NavigationAdminPage = () => {
(source.droppableId.startsWith('admin-children-') && destination.droppableId === 'admin-nav')
) {
const srcParentId = parseAdminChildrenId(source.droppableId);
const destParentId = parseAdminChildrenId(destination.droppableId);
let destParentId = parseAdminChildrenId(destination.droppableId);
const items = Array.from(adminNavItems);
// Helper to find parent index by id
@@ -563,6 +576,34 @@ const NavigationAdminPage = () => {
return items.findIndex((it) => it.id === pid);
};
// If dropping onto top-level container, but near a category card, treat as drop INTO that category
let destChildIndex = destination.index;
if (destParentId === null) {
const at = items[destination.index];
const before = destination.index > 0 ? items[destination.index - 1] : undefined;
const after = destination.index < items.length - 1 ? items[destination.index + 1] : undefined;
let dropIntoId: number | null = null;
if (at && at.type === 'dropdown') dropIntoId = at.id!;
else if (before && before.type === 'dropdown') dropIntoId = before.id!;
else if (after && after.type === 'dropdown') dropIntoId = after.id!;
if (dropIntoId) {
destParentId = dropIntoId;
const dIdxProbe = findParentIndex(destParentId);
if (dIdxProbe >= 0) {
destChildIndex = Array.isArray(items[dIdxProbe].children) ? items[dIdxProbe].children!.length : 0;
} else {
destChildIndex = 0;
}
}
}
// Fallback: if still no destination parent but we moved a sub-item, keep original parent to avoid promoting to top-level
if (destParentId === null && srcParentId !== null) {
destParentId = srcParentId;
const dIdxProbe = findParentIndex(destParentId);
destChildIndex = dIdxProbe >= 0 && Array.isArray(items[dIdxProbe].children) ? items[dIdxProbe].children!.length : 0;
}
let moved: NavigationItem | null = null;
// Remove from source list
@@ -588,7 +629,7 @@ const NavigationAdminPage = () => {
const dIdx = findParentIndex(destParentId);
if (dIdx >= 0) {
const destChildren = Array.isArray(items[dIdx].children) ? Array.from(items[dIdx].children!) : [];
destChildren.splice(destination.index, 0, moved);
destChildren.splice(destChildIndex, 0, moved);
items[dIdx] = { ...items[dIdx], children: destChildren } as NavigationItem;
}
}
@@ -597,7 +638,7 @@ const NavigationAdminPage = () => {
// Persist parent change and reorder siblings at both source and destination
try {
await updateNavigationItem(moved.id!, { parent_id: destParentId === null ? null : destParentId, display_order: destination.index } as any);
await updateNavigationItem(moved.id!, { parent_id: destParentId === null ? null : destParentId, display_order: destParentId === null ? destination.index : destChildIndex } as any);
// Reorder source siblings
if (srcParentId === null) {
@@ -623,6 +664,101 @@ const NavigationAdminPage = () => {
}
}
toast({ title: 'Přesunuto', status: 'success', duration: 2000 });
} catch (error) {
toast({ title: 'Chyba při přesunu', status: 'error', duration: 3000 });
loadData();
}
}
// Frontend: moving between top-level and children or across categories
else if (
source.droppableId.startsWith('frontend-children-') || destination.droppableId.startsWith('frontend-children-') ||
(source.droppableId === 'frontend-nav' && destination.droppableId.startsWith('frontend-children-')) ||
(source.droppableId.startsWith('frontend-children-') && destination.droppableId === 'frontend-nav')
) {
const srcParentId = parseFrontChildrenId(source.droppableId);
let destParentId = parseFrontChildrenId(destination.droppableId);
const items = Array.from(navItems);
const findParentIndex = (pid: number | null) => {
if (pid === null) return -1;
return items.findIndex((it) => it.id === pid);
};
// If dropping onto top-level container, but near a category card, treat as drop INTO that category
let destChildIndex = destination.index;
if (destParentId === null) {
const at = items[destination.index];
const before = destination.index > 0 ? items[destination.index - 1] : undefined;
let dropIntoId: number | null = null;
if (at && at.type === 'dropdown') dropIntoId = at.id!;
else if (before && before.type === 'dropdown') dropIntoId = before.id!;
if (dropIntoId) {
destParentId = dropIntoId;
const dIdxProbe = findParentIndex(destParentId);
if (dIdxProbe >= 0) {
destChildIndex = Array.isArray(items[dIdxProbe].children) ? items[dIdxProbe].children!.length : 0;
} else {
destChildIndex = 0;
}
}
}
let moved: NavigationItem | null = null;
if (srcParentId === null) {
const [m] = items.splice(source.index, 1);
moved = m;
} else {
const pIdx = findParentIndex(srcParentId);
if (pIdx >= 0) {
const srcChildren = Array.isArray(items[pIdx].children) ? Array.from(items[pIdx].children!) : [];
const [m] = srcChildren.splice(source.index, 1);
moved = m;
items[pIdx] = { ...items[pIdx], children: srcChildren } as NavigationItem;
}
}
if (!moved) return;
if (destParentId === null) {
items.splice(destination.index, 0, moved);
} else {
const dIdx = findParentIndex(destParentId);
if (dIdx >= 0) {
const destChildren = Array.isArray(items[dIdx].children) ? Array.from(items[dIdx].children!) : [];
destChildren.splice(destChildIndex, 0, moved);
items[dIdx] = { ...items[dIdx], children: destChildren } as NavigationItem;
}
}
setNavItems(items);
try {
await updateNavigationItem(moved.id!, { parent_id: destParentId === null ? null : destParentId, display_order: destParentId === null ? destination.index : destChildIndex } as any);
if (srcParentId === null) {
const topOrders = items.map((it, idx) => ({ id: it.id!, display_order: idx }));
await reorderNavigationItems(topOrders);
} else {
const srcIdx = findParentIndex(srcParentId);
if (srcIdx >= 0) {
const orders = (items[srcIdx].children || []).map((c, idx) => ({ id: c.id!, display_order: idx }));
await reorderNavigationItems(orders);
}
}
if (destParentId === null) {
const topOrders = items.map((it, idx) => ({ id: it.id!, display_order: idx }));
await reorderNavigationItems(topOrders);
} else {
const destIdx = findParentIndex(destParentId);
if (destIdx >= 0) {
const orders = (items[destIdx].children || []).map((c, idx) => ({ id: c.id!, display_order: idx }));
await reorderNavigationItems(orders);
}
}
toast({ title: 'Přesunuto', status: 'success', duration: 2000 });
} catch (error) {
toast({ title: 'Chyba při přesunu', status: 'error', duration: 3000 });
@@ -778,6 +914,7 @@ const NavigationAdminPage = () => {
target: '_self',
parent_id: parentId,
requires_admin: forAdmin || false,
allow_editor: false,
} as NavigationItem);
}
onNavModalOpen();
@@ -1384,6 +1521,16 @@ const NavigationAdminPage = () => {
</FormControl>
)}
{isAdminNav && editingNav?.type !== 'dropdown' && (
<FormControl display="flex" alignItems="center">
<FormLabel mb="0">Povolit editorům</FormLabel>
<Switch
isChecked={!!editingNav?.allow_editor}
onChange={(e) => setEditingNav({ ...editingNav!, allow_editor: e.target.checked })}
/>
</FormControl>
)}
<FormControl display="flex" alignItems="center">
<FormLabel mb="0">Viditelné</FormLabel>
<Switch
@@ -168,6 +168,13 @@ export default function NewsletterAdminPage() {
};
const detailsClearComps = () => setDetailsCompetitions('');
const detailsSelectAllComps = () => setDetailsCompetitions(compOptions.map(o => o.code).join(', '));
// Fetch subscribers
const { data: subscribers = [], isLoading } = useQuery({
queryKey: ['admin', 'newsletter-subscribers'],
queryFn: getNewsletterSubscribers,
});
const recipientsForType = (t: MailType): string[] => {
const key = t === 'weekly' ? 'weekly' : t;
return subscribers
@@ -229,6 +236,22 @@ export default function NewsletterAdminPage() {
})();
}
}, [detailsOpen, activeType]);
// Prefetch preview subjects for status list when there are recipients
useEffect(() => {
const types: MailType[] = ['weekly','matches','scores','blogs','events'];
(async () => {
for (const t of types) {
try {
const count = recipientsForType(t).length;
if (count > 0 && !typePreview[t]) {
await loadPreviewForType(t);
}
} catch {}
}
})();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [subscribers]);
const { isOpen, onOpen, onClose } = useDisclosure();
const testModal = useDisclosure();
@@ -324,12 +347,6 @@ export default function NewsletterAdminPage() {
}
};
// Fetch subscribers
const { data: subscribers = [], isLoading } = useQuery({
queryKey: ['admin', 'newsletter-subscribers'],
queryFn: getNewsletterSubscribers,
});
// Filter subscribers based on search term
const filteredSubscribers = subscribers.filter((subscriber) =>
subscriber.email.toLowerCase().includes(searchTerm.toLowerCase())
@@ -655,6 +672,11 @@ export default function NewsletterAdminPage() {
</HStack>
<HStack spacing={4}>
<Text color={textSecondary}>Příjemci: <b>{count}</b></Text>
{typePreview[t]?.subject ? (
<Badge colorScheme="blue" title="Předmět připraveného emailu">
{typePreview[t]!.subject}
</Badge>
) : null}
<Button size="sm" onClick={()=> openDetails(t)}>Detail</Button>
</HStack>
</Flex>
@@ -1028,28 +1050,29 @@ export default function NewsletterAdminPage() {
</HStack>
<Box mt={2} p={3} bg={useColorModeValue('gray.50', 'gray.900')} borderRadius="md" borderWidth="1px">
<Box
bg={cardBg}
className="ql-editor"
p={3}
borderRadius="md"
borderWidth="1px"
dangerouslySetInnerHTML={{ __html: sanitizeHtml(previewHtml || '<em>Náhled se zobrazí zde</em>') }}
/>
</Box>
<Box mt={4} p={4} bg={useColorModeValue('gray.50', 'gray.900')} borderRadius="md">
<Text fontWeight="bold" mb={2}>Náhled:</Text>
<Box
border="1px"
borderColor="gray.200"
p={4}
borderRadius="md"
bg={cardBg}
className="ql-editor"
dangerouslySetInnerHTML={{ __html: sanitizeHtml(previewHtml || '<em>Náhled se zobrazí zde</em>') }}
/>
</Box>
</>
)}
<Box mt={4} p={4} bg={useColorModeValue('gray.50', 'gray.900')} borderRadius="md">
<Text fontWeight="bold" mb={2}>Náhled:</Text>
<Box
border="1px"
borderColor="gray.200"
p={4}
borderRadius="md"
bg={cardBg}
dangerouslySetInnerHTML={{ __html: sanitizeHtml(sendMode === 'custom' ? (newsletterData.content || '<em>Náhled se zobrazí zde</em>') : (previewHtml || '<em>Náhled se zobrazí zde</em>')) }}
/>
</Box>
</VStack>
</VStack>
</ModalBody>
<ModalFooter>
<Button variant="ghost" mr={3} onClick={onClose}>
@@ -1140,7 +1163,7 @@ export default function NewsletterAdminPage() {
<Button variant="outline" onClick={()=>{ if(!activeType) return; exportRecipientsCSV(activeType, detailsCompetitions); }}>Export CSV</Button>
</HStack>
<Box p={3} bg={useColorModeValue('gray.50', 'gray.900')} borderRadius="md" borderWidth="1px">
<Box bg={cardBg} p={3} borderRadius="md" borderWidth="1px" dangerouslySetInnerHTML={{ __html: sanitizeHtml(activeType ? (typePreview[activeType]?.html || '<em>Náhled se zobrazí zde</em>') : '<em>Náhled se zobrazí zde</em>') }} />
<Box className="ql-editor" bg={cardBg} p={3} borderRadius="md" borderWidth="1px" dangerouslySetInnerHTML={{ __html: sanitizeHtml(activeType ? (typePreview[activeType]?.html || '<em>Náhled se zobrazí zde</em>') : '<em>Náhled se zobrazí zde</em>') }} />
</Box>
<Box>
<Heading size="sm" mb={2}>Příjemci</Heading>
+166 -18
View File
@@ -1,4 +1,4 @@
import React, { useEffect, useMemo, useState } from 'react';
import React, { useEffect, useMemo, useRef, useState, useDeferredValue, startTransition } from 'react';
import {
Box,
Button,
@@ -58,6 +58,7 @@ import {
getQr,
uploadQr,
deleteQr,
swapSides,
} from '@/services/scoreboard';
import { useFacrApi } from '@/hooks/useFacrApi';
import { SearchResult } from '@/services/facr/types';
@@ -66,6 +67,7 @@ import { useQuery } from '@tanstack/react-query';
import { AdminMatch, fetchAdminMatches, fetchTeamLogoOverrides } from '@/services/adminMatches';
import { getFacrClubInfoCache } from '@/services/facr/cache';
import { createSponsor } from '@/services/sponsors';
import { pickTextColor } from '@/utils/colors';
const themes: ScoreboardTheme[] = ['classic', 'pill', 'var1', 'var2', 'var3', 'var4'];
@@ -79,12 +81,28 @@ const resolveLogoUrl = (u?: string | null) => {
return u;
};
const deproxify = (u?: string | null) => {
try {
if (!u) return u || undefined;
const base = new URL(API_URL || '', typeof window !== 'undefined' ? window.location.origin : undefined);
const parsed = new URL(u, base.origin);
if (/\/proxy\/image$/i.test(parsed.pathname)) {
const inner = parsed.searchParams.get('url');
return inner || u || undefined;
}
return u || undefined;
} catch {
return u || undefined;
}
};
const ScoreboardAdminPage: React.FC = () => {
const cardBg = useColorModeValue('white', 'gray.800');
const borderColor = useColorModeValue('gray.200', 'gray.700');
const inputBg = useColorModeValue('white', 'gray.700');
const [state, setState] = useState<ScoreboardState | null>(null);
const [loading, setLoading] = useState(true);
const deferredState = useDeferredValue(state);
const toast = useToast();
const [activeTab, setActiveTab] = useState<number>(0); // 0 upcoming, 1 recent
// Presets & sponsors state
@@ -96,6 +114,48 @@ const ScoreboardAdminPage: React.FC = () => {
const [qrBusy, setQrBusy] = useState(false);
const { isOpen: isSponsorModalOpen, onOpen: openSponsorModal, onClose: closeSponsorModal } = useDisclosure();
const [uploadedSponsorUrls, setUploadedSponsorUrls] = useState<string[]>([]);
const [homeColorBusy, setHomeColorBusy] = useState(false);
const [awayColorBusy, setAwayColorBusy] = useState(false);
const [isPickingColor, setIsPickingColor] = useState(false);
const saveDebounceRef = useRef<number | undefined>(undefined);
const pendingPatchRef = useRef<Partial<ScoreboardState>>({});
const setPartialDebounced = (patch: Partial<ScoreboardState>) => {
startTransition(() => {
setState((prev) => ({ ...(prev as ScoreboardState), ...patch }));
});
pendingPatchRef.current = { ...pendingPatchRef.current, ...patch };
if (saveDebounceRef.current) {
window.clearTimeout(saveDebounceRef.current);
}
saveDebounceRef.current = window.setTimeout(async () => {
const toSave = pendingPatchRef.current;
pendingPatchRef.current = {};
saveDebounceRef.current = undefined;
try {
const next = await saveScoreboardState(toSave);
setState(next);
} catch {}
}, 250);
};
// For performance-sensitive inputs (color pickers): queue save, but don't re-render immediately on every drag
const queueSaveOnly = (patch: Partial<ScoreboardState>) => {
pendingPatchRef.current = { ...pendingPatchRef.current, ...patch };
if (saveDebounceRef.current) {
window.clearTimeout(saveDebounceRef.current);
}
saveDebounceRef.current = window.setTimeout(() => {
const toSave = pendingPatchRef.current;
pendingPatchRef.current = {};
saveDebounceRef.current = undefined;
// Update UI immediately (non-urgent) without waiting for network
startTransition(() => {
setState((prev) => ({ ...(prev as ScoreboardState), ...toSave }));
});
// Persist asynchronously; ignore result to avoid blocking UI
try { void saveScoreboardState(toSave); } catch {}
}, 250);
};
// Club search inline (home/away target)
const [clubQuery, setClubQuery] = useState('');
@@ -114,9 +174,19 @@ const ScoreboardAdminPage: React.FC = () => {
})();
}, []);
useEffect(() => {
return () => {
if (saveDebounceRef.current) {
window.clearTimeout(saveDebounceRef.current);
saveDebounceRef.current = undefined;
}
pendingPatchRef.current = {};
};
}, []);
// Poll while timer is running to reflect live time
useEffect(() => {
if (!state?.running) return;
if (!state?.running || isPickingColor) return;
let mounted = true;
const id = setInterval(async () => {
try {
@@ -128,7 +198,7 @@ const ScoreboardAdminPage: React.FC = () => {
mounted = false;
clearInterval(id);
};
}, [state?.running]);
}, [state?.running, isPickingColor]);
// Load matches for linking
const { data: adminMatches = [] } = useQuery<AdminMatch[]>({
@@ -342,10 +412,14 @@ const ScoreboardAdminPage: React.FC = () => {
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 homeLogoRaw = (homeTeamId && byId?.[homeTeamId]?.logo_url)
? String(byId[homeTeamId].logo_url)
: getLogo(rawHomeName, m.home_logo_url || (m as any).homeLogoURL || '');
const awayLogoRaw = (awayTeamId && byId?.[awayTeamId]?.logo_url)
? String(byId[awayTeamId].logo_url)
: getLogo(rawAwayName, m.away_logo_url || (m as any).awayLogoURL || '');
const homeLogo = homeLogoRaw || '';
const awayLogo = awayLogoRaw || '';
const updates: Partial<ScoreboardState> = {
homeName,
awayName,
@@ -357,8 +431,8 @@ const ScoreboardAdminPage: React.FC = () => {
};
// Try to detect colors from logos
const [cHome, cAway] = await Promise.all([
derivePrimaryFromLogo(homeLogo || state.homeLogo),
derivePrimaryFromLogo(awayLogo || state.awayLogo),
derivePrimaryFromLogo(deproxify(homeLogo || state.homeLogo)),
derivePrimaryFromLogo(deproxify(awayLogo || state.awayLogo)),
]);
if (cHome) updates.primaryColor = cHome;
if (cAway) updates.secondaryColor = cAway;
@@ -368,20 +442,20 @@ const ScoreboardAdminPage: React.FC = () => {
};
const applyClub = async (club: SearchResult) => {
const logo = resolveLogoUrl(club.logo_url) || undefined;
const color = await derivePrimaryFromLogo(logo || undefined);
const logoRaw = club.logo_url || undefined;
const color = await derivePrimaryFromLogo(deproxify(logoRaw) || undefined);
if (assignTo === 'home') {
await setPartial({
homeName: club.name || 'DOMÁCÍ',
homeShort: deriveShort(club.name || ''),
homeLogo: logo,
homeLogo: logoRaw,
primaryColor: color || state?.primaryColor,
});
} else {
await setPartial({
awayName: club.name || 'HOSTÉ',
awayShort: deriveShort(club.name || ''),
awayLogo: logo,
awayLogo: logoRaw,
secondaryColor: color || state?.secondaryColor,
});
}
@@ -492,7 +566,7 @@ const ScoreboardAdminPage: React.FC = () => {
{/* Live preview */}
<Box display="flex" justifyContent="center" mb={6}>
<ScoreboardPreview state={state} />
<ScoreboardPreview state={(deferredState || state) as ScoreboardState} />
</Box>
<SimpleGrid columns={{ base: 1, md: 2 }} spacing={6}>
@@ -547,6 +621,22 @@ const ScoreboardAdminPage: React.FC = () => {
await setPartial({ homeLogo: e.target.value });
}}
/>
<Button mt={2} size="sm" variant="outline" isLoading={homeColorBusy} onClick={async ()=>{
if (!state.homeLogo) { toast({ title: 'Chybí logo domácích', status: 'warning' }); return; }
try {
setHomeColorBusy(true);
const c = await derivePrimaryFromLogo(deproxify(state.homeLogo));
if (c) {
const text = pickTextColor(c);
setPartialDebounced({ primaryColor: c, homeTextColor: text });
toast({ title: 'Barva nastavena z loga domácích', status: 'success' });
} else {
toast({ title: 'Nepodařilo se získat barvu z loga domácích', status: 'error' });
}
} finally {
setHomeColorBusy(false);
}
}}>Barva z loga</Button>
</FormControl>
<FormControl>
<FormLabel>Logo hostů (URL)</FormLabel>
@@ -556,6 +646,22 @@ const ScoreboardAdminPage: React.FC = () => {
await setPartial({ awayLogo: e.target.value });
}}
/>
<Button mt={2} size="sm" variant="outline" isLoading={awayColorBusy} onClick={async ()=>{
if (!state.awayLogo) { toast({ title: 'Chybí logo hostů', status: 'warning' }); return; }
try {
setAwayColorBusy(true);
const c = await derivePrimaryFromLogo(deproxify(state.awayLogo));
if (c) {
const text = pickTextColor(c);
setPartialDebounced({ secondaryColor: c, awayTextColor: text });
toast({ title: 'Barva nastavena z loga hostů', status: 'success' });
} else {
toast({ title: 'Nepodařilo se získat barvu z loga hostů', status: 'error' });
}
} finally {
setAwayColorBusy(false);
}
}}>Barva z loga</Button>
</FormControl>
<FormControl>
<FormLabel>Délka poločasu (min)</FormLabel>
@@ -586,19 +692,51 @@ const ScoreboardAdminPage: React.FC = () => {
<SimpleGrid columns={2} spacing={4}>
<FormControl>
<FormLabel>Barva domácích</FormLabel>
<Input type="color" value={state.primaryColor || '#1e3a8a'} onChange={async (e) => setPartial({ primaryColor: e.target.value })} />
<Input
type="color"
defaultValue={state.primaryColor || '#1e3a8a'}
key={`pc-${state.primaryColor || '#1e3a8a'}`}
onPointerDown={() => setIsPickingColor(true)}
onPointerUp={() => setIsPickingColor(false)}
onBlur={() => setIsPickingColor(false)}
onChange={(e) => queueSaveOnly({ primaryColor: e.target.value })}
/>
</FormControl>
<FormControl>
<FormLabel>Barva hostů</FormLabel>
<Input type="color" value={state.secondaryColor || '#2563eb'} onChange={async (e) => setPartial({ secondaryColor: e.target.value })} />
<Input
type="color"
defaultValue={state.secondaryColor || '#2563eb'}
key={`sc-${state.secondaryColor || '#2563eb'}`}
onPointerDown={() => setIsPickingColor(true)}
onPointerUp={() => setIsPickingColor(false)}
onBlur={() => setIsPickingColor(false)}
onChange={(e) => queueSaveOnly({ 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 })} />
<Input
type="color"
defaultValue={state.homeTextColor || '#ffffff'}
key={`htc-${state.homeTextColor || '#ffffff'}`}
onPointerDown={() => setIsPickingColor(true)}
onPointerUp={() => setIsPickingColor(false)}
onBlur={() => setIsPickingColor(false)}
onChange={(e) => queueSaveOnly({ 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 })} />
<Input
type="color"
defaultValue={state.awayTextColor || '#ffffff'}
key={`atc-${state.awayTextColor || '#ffffff'}`}
onPointerDown={() => setIsPickingColor(true)}
onPointerUp={() => setIsPickingColor(false)}
onBlur={() => setIsPickingColor(false)}
onChange={(e) => queueSaveOnly({ awayTextColor: e.target.value })}
/>
</FormControl>
<FormControl>
<FormLabel>QR interval (minuty)</FormLabel>
@@ -646,6 +784,16 @@ const ScoreboardAdminPage: React.FC = () => {
<Button variant="outline" onClick={() => setPartial({ homeScore: 0, awayScore: 0 })}>Reset skóre</Button>
<Button variant="outline" onClick={async () => { await resetTimer(); const s = await getScoreboardState(); setState(s); }}>Reset čas</Button>
<Button variant="outline" onClick={() => setPartial({ homeFouls: 0, awayFouls: 0 })}>Reset fauly</Button>
<Button variant="outline" onClick={async () => {
try {
await swapSides();
const s = await getScoreboardState();
setState(s);
toast({ title: 'Strany prohozeny', status: 'success' });
} catch {
toast({ title: 'Akce selhala', status: 'error' });
}
}}>Prohodit strany</Button>
</HStack>
<Divider my={6} />
+117 -36
View File
@@ -22,7 +22,7 @@ const SweepstakeVisualPage: React.FC = () => {
const toast = useToast();
const [data, setData] = useState<VisualData | null>(null);
const [loading, setLoading] = useState(true);
const [variant, setVariant] = useState<'cycler' | 'wheel'>('cycler');
const [variant, setVariant] = useState<'roulette'>('roulette');
const [theme, setTheme] = useState<'dark' | 'light'>('dark');
const [confettiOn, setConfettiOn] = useState<boolean>(true);
const [soundOn, setSoundOn] = useState<boolean>(true);
@@ -37,6 +37,16 @@ const SweepstakeVisualPage: React.FC = () => {
const imgCacheRef = useRef<Record<number, HTMLImageElement>>({});
const [prizes, setPrizes] = useState<SweepstakePrize[]>([]);
// Roulette scroller state
const railRef = useRef<HTMLDivElement | null>(null);
const [stripItems, setStripItems] = useState<typeof entries>([]);
const [scrollPx, setScrollPx] = useState<number>(0);
const [rouletteKey, setRouletteKey] = useState<number>(0); // force re-render/reflow per run
const [weightingOn, setWeightingOn] = useState<boolean>(true);
const [speed, setSpeed] = useState<'slow'|'normal'|'fast'>('normal');
const [drama, setDrama] = useState<number>(3);
const [transitionMs, setTransitionMs] = useState<number>(4600);
const entries = data?.entries || [];
const winners = data?.winners || [];
const { data: publicSettings } = usePublicSettings();
@@ -226,11 +236,69 @@ const SweepstakeVisualPage: React.FC = () => {
}, duration);
};
const onStart = () => {
if (variant === 'cycler') startCycler();
else startWheel();
const startRoulette = () => {
if (!entries.length || revealIndex >= winners.length) return;
const target = targetIndex;
if (target < 0) { startCycler(); return; }
setPlaying(true);
// Build a long strip of avatars.
// Pool selection: when weighting is ON, keep duplicates from entries; when OFF, use unique users (flat odds).
const uniqueMap = new Map<number, typeof entries[0]>();
for (const e of entries) { if (!uniqueMap.has(e.user_id)) uniqueMap.set(e.user_id, e); }
const pool = weightingOn ? entries : Array.from(uniqueMap.values());
const total = Math.max(80, Math.min(240, pool.length * 4));
const rnd = (n: number) => Math.floor(Math.random() * n);
const list: typeof entries = [] as any;
for (let i = 0; i < total - 10; i++) {
list.push(pool[rnd(pool.length)]);
}
// Ensure target appears near the end, centered under pointer at stop
const targetEntry = entries[target];
const tailPad = 6;
for (let i = 0; i < tailPad - 1; i++) list.push(pool[rnd(pool.length)]);
list.push(targetEntry);
setStripItems(list);
// Next tick measure viewport + compute scroll distance
window.requestAnimationFrame(() => {
try {
const host = document.getElementById('visual-host');
const rail = railRef.current;
if (!host || !rail) { setPlaying(false); return; }
const viewport = host.getBoundingClientRect();
const cardW = 72; // width incl. margin approx
const gap = 8;
const itemSize = cardW + gap;
const landingIndex = list.length - 1; // last item is target
const centerOffset = Math.max(0, (viewport.width - cardW) / 2);
const distance = landingIndex * itemSize - centerOffset;
// Prime initial position
setScrollPx(0);
setRouletteKey((k) => k + 1);
// Start animation in next frame
setTimeout(() => {
// Add extra laps based on drama level (1..5)
const dramaFactor = Math.max(0, Math.min(5, Number(drama) || 3));
const extra = viewport.width * dramaFactor + rnd(viewport.width);
setScrollPx(distance + extra);
// Duration based on speed
const mul = speed === 'slow' ? 1.25 : (speed === 'fast' ? 0.75 : 1.0);
const duration = Math.round(4600 * mul);
setTransitionMs(duration);
window.setTimeout(() => {
setPlaying(false);
setRevealIndex((i) => i + 1);
beep(); fireConfetti();
}, duration + 50);
}, 40);
} catch {
setPlaying(false);
}
});
};
const onStart = () => { startRoulette(); };
// Reveal All logic
const [revealAll, setRevealAll] = useState(false);
useEffect(() => {
@@ -257,8 +325,7 @@ const SweepstakeVisualPage: React.FC = () => {
const res = await adminGetVisualData(Number(id));
if (!active) return;
setData(res);
const def = (res.sweepstake as any)?.picker_style;
if (def === 'wheel' || def === 'cycler') setVariant(def);
setVariant('roulette');
try { const list = await adminListPrizes(Number(id)); if (active) setPrizes(list); } catch {}
} catch (e: any) {
toast({ status: 'error', title: 'Nelze načíst data vizualizace' });
@@ -269,7 +336,7 @@ const SweepstakeVisualPage: React.FC = () => {
return () => { active = false; if (timerRef.current) window.clearTimeout(timerRef.current); };
}, [id]);
useEffect(() => { if (variant === 'wheel') drawWheel(); }, [variant, data, theme]);
// Wheel variant removed no canvas redraw needed
if (loading) {
return (
@@ -287,7 +354,6 @@ const SweepstakeVisualPage: React.FC = () => {
}
const shownWinners = winners.slice(0, revealIndex);
const current = entries[currentIdx];
return (
<AdminLayout>
@@ -300,17 +366,26 @@ const SweepstakeVisualPage: React.FC = () => {
</HStack>
</HStack>
<HStack mb={4} spacing={4}>
<Select value={variant} onChange={(e)=>setVariant(e.target.value as any)} maxW="220px">
<option value="cycler">Náhodný přepínač</option>
<option value="wheel">Kolo štěstí (základní)</option>
</Select>
<Select value={theme} onChange={(e)=>setTheme(e.target.value as any)} maxW="200px">
<option value="dark">Tmavé pozadí</option>
<option value="light">Světlé pozadí</option>
</Select>
<Select value={speed} onChange={(e)=>setSpeed(e.target.value as any)} maxW="200px">
<option value="slow">Rychlost: Pomalá</option>
<option value="normal">Rychlost: Normální</option>
<option value="fast">Rychlost: Rychlá</option>
</Select>
<Select value={String(drama)} onChange={(e)=>setDrama(Number(e.target.value)||3)} maxW="200px">
<option value="1">Drama: 1</option>
<option value="2">Drama: 2</option>
<option value="3">Drama: 3</option>
<option value="4">Drama: 4</option>
<option value="5">Drama: 5</option>
</Select>
<HStack>
<Button size="sm" variant={confettiOn? 'solid':'outline'} onClick={()=>setConfettiOn(v=>!v)}>{confettiOn? 'Konfety: Zap' : 'Konfety: Vyp'}</Button>
<Button size="sm" variant={soundOn? 'solid':'outline'} onClick={()=>setSoundOn(v=>!v)}>{soundOn? 'Zvuk: Zap' : 'Zvuk: Vyp'}</Button>
<Button size="sm" variant={weightingOn? 'solid':'outline'} onClick={()=>setWeightingOn(v=>!v)}>{weightingOn? 'Vážit účastí: Zap' : 'Vážit účastí: Vyp'}</Button>
</HStack>
<Button colorScheme="blue" onClick={onStart} isDisabled={playing || revealIndex >= winners.length}>
{revealIndex >= winners.length ? 'Všichni výherci odhaleni' : (playing ? 'Probíhá…' : 'Start')}
@@ -331,31 +406,37 @@ const SweepstakeVisualPage: React.FC = () => {
</HStack>
<Box id="visual-host" borderWidth="1px" borderRadius="md" p={4} minH="400px" position="relative" overflow="hidden" bg={theme==='dark' ? 'black' : 'white'} color={theme==='dark' ? 'white' : 'black'}>
{variant === 'cycler' ? (
<Center h="380px" flexDir="column">
<Text fontSize="sm" opacity={0.7} mb={2}>Losuji</Text>
<Text fontSize="5xl" fontWeight="800" textAlign="center">{(current?.display_name || '').trim() || '—'}</Text>
{current?.avatar_url && (
// eslint-disable-next-line jsx-a11y/alt-text
<img src={current.avatar_url} style={{ width: 120, height: 120, borderRadius: '50%', marginTop: 16, objectFit: 'cover' }} />
)}
</Center>
) : (
<Center h="380px" flexDir="column">
<Box position="relative" w="440px" h="440px">
<Box position="absolute" left="50%" top="-2px" transform="translateX(-50%)" zIndex={2}
borderLeft="10px solid transparent" borderRight="10px solid transparent" borderBottom={`18px solid ${theme==='dark'?'#e2e8f0':'#1a202c'}`} />
<Box ref={wheelRef} position="absolute" inset={0} style={{ transform: `rotate(${wheelAngle}deg)`, transition: playing ? 'transform 4.2s cubic-bezier(.2,.8,.2,1)' : undefined }}>
<canvas ref={canvasRef} width={440} height={440} style={{ width: 440, height: 440 }} />
</Box>
{clubLogo && (
// eslint-disable-next-line jsx-a11y/alt-text
<img src={clubLogo} style={{ position:'absolute', left:'50%', top:'50%', transform:'translate(-50%,-50%)', width: 96, height: 96, objectFit:'contain', borderRadius: '50%', boxShadow: theme==='dark'? '0 0 0 4px rgba(255,255,255,0.9)':'0 0 0 4px rgba(0,0,0,0.6)' }} />
)}
<Center h="380px" flexDir="column">
<Box position="relative" w="100%" maxW="960px" h="220px">
{/* pointer */}
<Box position="absolute" left="50%" top="10px" transform="translateX(-50%)" zIndex={3}
borderLeft="10px solid transparent" borderRight="10px solid transparent" borderBottom={`16px solid ${theme==='dark'?'#e2e8f0':'#1a202c'}`} />
{/* center divider */}
<Box pointerEvents="none" position="absolute" left="50%" top={42} bottom={22} width="2px" transform="translateX(-1px)" zIndex={2}
style={{ background: theme==='dark' ? 'rgba(255,255,255,0.25)' : 'rgba(0,0,0,0.2)' }} />
{/* scrolling rail */}
<Box key={rouletteKey} position="absolute" left={0} right={0} top={40} bottom={20} overflow="hidden" borderRadius="md" borderWidth="1px" bg={theme==='dark'?'#0b0b0b':'#f9fafb'}>
<div ref={railRef} style={{ display:'flex', alignItems:'center', gap:8, padding:'8px', transform:`translateX(-${scrollPx}px)`, transition: playing? `transform ${transitionMs/1000}s cubic-bezier(.2,.8,.2,1)` : undefined }}>
{stripItems.map((it, idx) => (
<div key={idx} style={{ width:72, height:72, borderRadius:12, background: theme==='dark'?'#111':'#fff', boxShadow: theme==='dark'?'0 1px 2px rgba(255,255,255,0.08)':'0 1px 2px rgba(0,0,0,0.08)', display:'flex', alignItems:'center', justifyContent:'center', overflow:'hidden', border:'1px solid rgba(0,0,0,0.08)' }}>
{it?.avatar_url ? (
// eslint-disable-next-line jsx-a11y/alt-text
<img src={it.avatar_url} style={{ width:'100%', height:'100%', objectFit:'cover' }} />
) : (
<span style={{ fontSize:24, fontWeight:800 }}>{(it?.display_name||'?').slice(0,1)}</span>
)}
</div>
))}
</div>
{/* fade edges */}
<Box pointerEvents="none" position="absolute" left={0} top={0} bottom={0} width="120px" zIndex={1}
style={{ background: theme==='dark' ? 'linear-gradient(to right, rgba(0,0,0,0.85), rgba(0,0,0,0))' : 'linear-gradient(to right, rgba(255,255,255,0.95), rgba(255,255,255,0))' }} />
<Box pointerEvents="none" position="absolute" right={0} top={0} bottom={0} width="120px" zIndex={1}
style={{ background: theme==='dark' ? 'linear-gradient(to left, rgba(0,0,0,0.85), rgba(0,0,0,0))' : 'linear-gradient(to left, rgba(255,255,255,0.95), rgba(255,255,255,0))' }} />
</Box>
<Text mt={4} opacity={0.8}>Kolo štěstí</Text>
</Center>
)}
<Text mt={4} opacity={0.8} textAlign="center">Ruleta</Text>
</Box>
</Center>
</Box>
<VStack align="stretch" mt={6} spacing={2}>
@@ -62,6 +62,7 @@ import {
import { AddIcon, ArrowUpIcon, ArrowDownIcon, DeleteIcon, EditIcon } from '@chakra-ui/icons';
import { FiUpload } from 'react-icons/fi';
import { uploadFile, createArticle } from '../../services/articles';
import { getImageUrl } from '../../utils/imageUtils';
const fmt = (iso?: string | null) => {
if (!iso) return '';
@@ -76,7 +77,7 @@ const defaultForm = {
rules_url: '',
start_at: '',
end_at: '',
picker_style: 'wheel',
picker_style: 'cycler',
total_prizes: 1,
prize_summary: '',
entry_cost_points: 0,
@@ -114,6 +115,39 @@ const SweepstakesAdminPage: React.FC = () => {
}
};
// Ensure sweepstake exists (auto-create draft) so prizes can be added without manual save
const ensureCreated = async (): Promise<Sweepstake | null> => {
try {
if (editing) return editing;
const title = (form.title && String(form.title).trim()) || 'Nová soutěž';
const now = new Date();
const start = form.start_at ? new Date(form.start_at) : new Date(now.getTime() + 60 * 60 * 1000);
const end = form.end_at ? new Date(form.end_at) : new Date(start.getTime() + 14 * 24 * 60 * 60 * 1000);
const payload = {
title,
description: form.description || '',
image_url: form.image_url || '',
rules_url: form.rules_url || '',
start_at: isNaN(start.getTime()) ? new Date(now.getTime() + 60 * 60 * 1000).toISOString() : start.toISOString(),
end_at: isNaN(end.getTime()) ? new Date(now.getTime() + 15 * 24 * 60 * 60 * 1000).toISOString() : end.toISOString(),
picker_style: form.picker_style || 'cycler',
total_prizes: Number(form.total_prizes) || 1,
prize_summary: form.prize_summary || '',
entry_cost_points: Number(form.entry_cost_points) || 0,
max_entries_per_user: Number(form.max_entries_per_user) || 1,
} as any;
const created = await adminCreateSweepstake(payload);
setEditing(created);
try { setPrizes(await adminListPrizes(created.id)); } catch { setPrizes([]); }
setActiveTab(2);
toast({ status: 'success', title: 'Koncept soutěže vytvořen' });
return created;
} catch {
toast({ status: 'error', title: 'Nelze automaticky vytvořit soutěž' });
return null;
}
};
const onUploadRules = async (file?: File | null) => {
if (!file) return;
try {
@@ -219,12 +253,16 @@ const SweepstakesAdminPage: React.FC = () => {
const tpRaw = Number(form.total_prizes || 1);
const tp = Number.isFinite(tpRaw) ? Math.floor(tpRaw) : 1;
const total_prizes = tp < 1 ? 1 : (tp > 100 ? 100 : tp);
const entry_cost_points = Math.max(0, Number(form.entry_cost_points) || 0);
const max_entries_per_user = Math.max(1, Number(form.max_entries_per_user) || 1);
// Normalize datetime-local (YYYY-MM-DDTHH:mm) to RFC3339 with timezone for Go backend
const s = new Date(form.start_at);
const e = new Date(form.end_at);
const payload = {
...form,
total_prizes,
entry_cost_points,
max_entries_per_user,
start_at: isNaN(s.getTime()) ? form.start_at : s.toISOString(),
end_at: isNaN(e.getTime()) ? form.end_at : e.toISOString(),
};
@@ -360,7 +398,7 @@ const SweepstakesAdminPage: React.FC = () => {
<FormLabel>Titulní obrázek</FormLabel>
<VStack align="start" spacing={2}>
<HStack>
<Image src={coverPreview || form.image_url || '/dist/img/logo-club-empty.svg'} alt="cover" boxSize="80px" objectFit="cover" borderRadius="md" />
<Image src={coverPreview || getImageUrl(form.image_url) || '/dist/img/logo-club-empty.svg'} alt="cover" boxSize="80px" objectFit="cover" borderRadius="md" />
<Button as="label" leftIcon={<FiUpload />} variant="outline">
Nahrát
<Input ref={imageInputRef} type="file" display="none" accept="image/*" onChange={async (e)=>{ const f=e.target.files?.[0]; if(!f) return; try { setCoverPreview(URL.createObjectURL(f)); const r=await uploadFile(f); setForm((prev:any)=>({ ...prev, image_url: r.url })); setCoverPreview(''); toast({ status:'success', title:'Obrázek nahrán' }); } catch { toast({ status:'error', title:'Nahrávání selhalo' }); } }} />
@@ -379,7 +417,7 @@ const SweepstakesAdminPage: React.FC = () => {
<Input ref={rulesInputRef} type="file" display="none" accept="image/*,application/pdf" onChange={(e)=>onUploadRules(e.target.files?.[0])} />
</Button>
<Button variant="outline" onClick={onCreateRulesArticle}>Vytvořit stránku</Button>
{form.rules_url && (<Button as={RouterLink} to={form.rules_url} target="_blank" rel="noreferrer noopener" variant="ghost">Otevřít</Button>)}
{form.rules_url && (<Button as="a" href={getImageUrl(form.rules_url) || form.rules_url} target="_blank" rel="noreferrer noopener" variant="ghost">Otevřít</Button>)}
</HStack>
<Input placeholder="nebo vložte URL" value={form.rules_url} onChange={(e)=>setForm({ ...form, rules_url: e.target.value })} />
</VStack>
@@ -409,7 +447,7 @@ const SweepstakesAdminPage: React.FC = () => {
</FormControl>
<FormControl isInvalid={Number(form.total_prizes) < 1 || Number(form.total_prizes) > 100}>
<FormLabel>Počet výherců</FormLabel>
<NumberInput value={Number(form.total_prizes)||1} min={1} keepWithinRange={false} clampValueOnBlur={false} onChange={(_v, n)=>setForm({ ...form, total_prizes: Number.isFinite(n) ? Math.floor(n) : 1 })}>
<NumberInput value={String(form.total_prizes ?? '')} min={1} keepWithinRange={false} clampValueOnBlur={false} onChange={(v)=>setForm({ ...form, total_prizes: v })}>
<NumberInputField />
</NumberInput>
<FormHelperText>Max. 100 výherců</FormHelperText>
@@ -424,7 +462,7 @@ const SweepstakesAdminPage: React.FC = () => {
</FormControl>
<FormControl>
<FormLabel>Max. účastí / uživatel</FormLabel>
<NumberInput min={1} value={Number(form.max_entries_per_user)||1} onChange={(v)=>setForm({ ...form, max_entries_per_user: Number(v) || 1 })}>
<NumberInput min={1} keepWithinRange={false} clampValueOnBlur={false} value={String(form.max_entries_per_user ?? '')} onChange={(v)=>setForm({ ...form, max_entries_per_user: v })}>
<NumberInputField />
</NumberInput>
</FormControl>
@@ -436,20 +474,28 @@ const SweepstakesAdminPage: React.FC = () => {
<HStack>
<Button size="sm" onClick={()=>setActiveTab(0)} variant="outline">Zpět na základní</Button>
<Button size="sm" onClick={async ()=>{
if (!editing) { toast({ status:'info', title:'Uložte soutěž pro přidání výher' }); return; }
try { await adminCreatePrize(editing.id, { name: 'Hlavní výhra', quantity: 1 }); toast({ status:'success', title:'Přidáno: Hlavní výhra' }); setPrizes(await adminListPrizes(editing.id)); } catch { toast({ status:'error', title:'Nelze přidat výhru' }); }
if (!editing) { await ensureCreated(); }
setActiveTab(2);
setPrizeForm({ ...prizeForm, name: 'Hlavní výhra', quantity: 1, kind: 'physical', value: '' });
toast({ status:'info', title:'Předvyplněno: Hlavní výhra', description:'Upravte a klikněte Přidat' });
}}>1× Hlavní výhra</Button>
<Button size="sm" onClick={async ()=>{
if (!editing) { toast({ status:'info', title:'Uložte soutěž pro přidání výher' }); return; }
try { await adminCreatePrize(editing.id, { name: 'Menší výhra', quantity: 3 }); toast({ status:'success', title:'Přidáno: 3× Menší výhra' }); setPrizes(await adminListPrizes(editing.id)); } catch { toast({ status:'error', title:'Nelze přidat výhry' }); }
if (!editing) { await ensureCreated(); }
setActiveTab(2);
setPrizeForm({ ...prizeForm, name: 'Menší výhra', quantity: 3, kind: 'physical', value: '' });
toast({ status:'info', title:'Předvyplněno: 3× Menší výhra', description:'Upravte a klikněte Přidat' });
}}>3× Menší výhry</Button>
<Button size="sm" onClick={async ()=>{
if (!editing) { toast({ status:'info', title:'Uložte soutěž pro přidání výher' }); return; }
try { await adminCreatePrize(editing.id, { name: '100 bodů', kind:'points', points: 100, quantity: 10 }); toast({ status:'success', title:'Přidáno: 10× 100 bodů' }); setPrizes(await adminListPrizes(editing.id)); } catch { toast({ status:'error', title:'Nelze přidat body' }); }
if (!editing) { await ensureCreated(); }
setActiveTab(2);
setPrizeForm({ ...prizeForm, name: '100 bodů', quantity: 10, kind:'points', points: 100 });
toast({ status:'info', title:'Předvyplněno: 10× 100 bodů', description:'Upravte a klikněte Přidat' });
}}>10× 100 bodů</Button>
<Button size="sm" onClick={async ()=>{
if (!editing) { toast({ status:'info', title:'Uložte soutěž pro přidání výher' }); return; }
try { await adminCreatePrize(editing.id, { name: '500 XP', kind:'xp', xp: 500, quantity: 5 }); toast({ status:'success', title:'Přidáno: 5× 500 XP' }); setPrizes(await adminListPrizes(editing.id)); } catch { toast({ status:'error', title:'Nelze přidat XP' }); }
if (!editing) { await ensureCreated(); }
setActiveTab(2);
setPrizeForm({ ...prizeForm, name: '500 XP', quantity: 5, kind:'xp', xp: 500 });
toast({ status:'info', title:'Předvyplněno: 5× 500 XP', description:'Upravte a klikněte Přidat' });
}}>5× 500 XP</Button>
</HStack>
<Divider />
+9 -1
View File
@@ -47,7 +47,15 @@ export async function patchMatchOverride(externalMatchId: string, payload: Parti
body.date_time_override = d.toISOString();
}
}
return (await api.patch(`/admin/match-overrides/${encodeURIComponent(externalMatchId)}`, body)).data;
try {
return (await api.patch(`/admin/match-overrides/${encodeURIComponent(externalMatchId)}`, body)).data;
} catch (err: any) {
const status = err?.response?.status ?? err?.status;
if (status === 404) {
return putMatchOverride(externalMatchId, body);
}
throw err;
}
}
export async function putTeamLogoOverride(externalTeamId: string, teamName: string, logoUrl: string) {
+1 -1
View File
@@ -26,7 +26,7 @@ export async function patchProfile(body: { username?: string }): Promise<{ ok: b
export type RewardItem = {
id: number;
name: string;
type: 'avatar_static' | 'avatar_animated' | 'merch_coupon' | 'custom' | string;
type: 'avatar_static' | 'avatar_animated' | 'merch_coupon' | 'merch_physical' | 'merch_digital' | 'custom' | string;
cost_points: number;
image_url?: string;
stock?: number;
+9
View File
@@ -16,6 +16,7 @@ export interface NavigationItem {
css_class?: string;
requires_auth?: boolean;
requires_admin?: boolean;
allow_editor?: boolean;
}
export interface SocialLink {
@@ -50,6 +51,7 @@ function normalizeNavItem(raw: any): NavigationItem {
css_class: raw.css_class,
requires_auth: raw.requires_auth,
requires_admin: raw.requires_admin,
allow_editor: raw.allow_editor,
} as NavigationItem;
}
@@ -104,6 +106,13 @@ export const reorderNavigationItems = async (orders: { id: number; display_order
await api.post(`/admin/navigation/reorder`, orders);
};
// Editor-allowed admin navigation (for editors' sidebar)
export const getEditorAllowedAdminNav = async (): Promise<NavigationItem[]> => {
const response = await api.get(`/admin/navigation/editor`);
const data = Array.isArray(response.data) ? response.data : [];
return data.map((it: any) => normalizeNavItem(it));
};
// Social links admin endpoints
export const getAllSocialLinks = async (): Promise<SocialLink[]> => {
const response = await api.get(`/admin/social-links`);
+13 -3
View File
@@ -90,9 +90,19 @@ export async function getScoreboardState(): Promise<ScoreboardState> {
}
export async function saveScoreboardState(state: Partial<ScoreboardState>): Promise<ScoreboardState> {
const current = await getScoreboardState();
const next = { ...current, ...state } as ScoreboardState;
localStorage.setItem(STORAGE_KEY, JSON.stringify(next));
// Avoid an extra GET on every save: use the last known local snapshot as base
let base: ScoreboardState = { ...DEFAULT_STATE } as ScoreboardState;
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (raw) {
const parsed = JSON.parse(raw);
base = { ...DEFAULT_STATE, ...(parsed || {}) } as ScoreboardState;
}
} catch {}
const next = { ...base, ...state } as ScoreboardState;
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(next));
} catch {}
// Attempt to persist to backend if admin
try {
await api.put('/admin/scoreboard', toApiPayload(state));
+2
View File
@@ -51,6 +51,8 @@ export type CurrentSweepstakeResponse = {
state?: 'upcoming' | 'active' | 'finalized';
has_entered?: boolean;
visual_played_at?: string | null;
my_entries_count?: number;
can_enter?: boolean;
};
export async function getCurrentSweepstake(): Promise<CurrentSweepstakeResponse> {
+30
View File
@@ -298,6 +298,36 @@
text-align: right;
}
/* --- Bullet/Number Fallbacks (robust visibility) --- */
/* Make sure the UI span exists visually and inherits color */
.ql-editor li > .ql-ui {
display: inline-block;
color: inherit;
}
/* Fallback default bullet if theme rules are missing */
.ql-editor li[data-list="bullet"] > .ql-ui::before {
content: '\2022';
}
/* Ordered list fallback using CSS counters (aligns with Quill v2 behavior) */
.ql-editor {
counter-reset: list-0 list-1 list-2 list-3 list-4 list-5 list-6 list-7 list-8 list-9;
}
.ql-editor ol { counter-reset: list-0; }
.ql-editor ol li { counter-increment: list-0; }
.ql-editor li[data-list="ordered"] > .ql-ui::before {
content: counters(list-0, '.') '. ';
}
/* Nested ordered lists (basic support for a few levels) */
.ql-editor ol ol { counter-reset: list-1; }
.ql-editor ol ol li { counter-increment: list-1; }
.ql-editor ol ol li[data-list="ordered"] > .ql-ui::before { content: counters(list-1, '.') '. '; }
.ql-editor ol ol ol { counter-reset: list-2; }
.ql-editor ol ol ol li { counter-increment: list-2; }
.ql-editor ol ol ol li[data-list="ordered"] > .ql-ui::before { content: counters(list-2, '.') '. '; }
.ql-editor blockquote {
border-left: 4px solid #3182ce;
padding-left: 16px;
+6
View File
@@ -39,6 +39,12 @@ body.style-pack-sparta .sponsor-tile:hover { transform: translateY(-8px) scale(1
box-shadow: var(--pack-shadow, none);
}
/* Frontpage CTA card styling */
.newsletter-cta .card {
background: white;
padding: 30px;
}
/* Header & Footer tweaks */
[data-element="header"][data-variant="fullwidth"] { box-shadow: none; }
[data-element="footer"] { border-top: 1px solid var(--card-border, rgba(0,0,0,0.08)); }
+9 -5
View File
@@ -12,8 +12,13 @@ export async function extractPalette(imageUrl: string, maxColors = 12): Promise<
const ctx = canvas.getContext('2d');
if (!ctx) return resolve([]);
// Downscale for performance
const w = 160; // slightly larger for better color sampling
const h = Math.max(1, Math.round((img.height / img.width) * w));
const targetW = 160; // slightly larger for better color sampling
// Prefer naturalWidth/Height; fall back to width/height; if zero (e.g., some SVGs), assume square
const iw = (img as HTMLImageElement).naturalWidth || (img as any).width || 0;
const ih = (img as HTMLImageElement).naturalHeight || (img as any).height || 0;
const ratio = (iw > 0 && ih > 0) ? (ih / iw) : 1;
const w = targetW;
const h = Math.max(1, Math.round(w * ratio));
canvas.width = w;
canvas.height = h;
ctx.drawImage(img, 0, 0, w, h);
@@ -94,10 +99,9 @@ export async function extractPalette(imageUrl: string, maxColors = 12): Promise<
const u = new URL(candidate, window.location.origin);
const isData = u.protocol === 'data:';
const sameOriginAsWindow = u.origin === window.location.origin;
const sameOriginAsBackend = u.origin === backendOrigin;
// Use direct URL if it's same-origin with either the window (served by dev server) or backend (static uploads)
if (isData || sameOriginAsWindow || sameOriginAsBackend) {
// Use direct URL only if it's same-origin with the window; otherwise proxy to enable CORS for Canvas
if (isData || sameOriginAsWindow) {
img.src = u.toString();
} else {
// Otherwise, use backend proxy to obtain CORS-eligible bytes for Canvas
+1 -1
View File
@@ -121,7 +121,7 @@ export function getRewardTypeDisplayName(type: string): string {
avatar_upload_unlock: 'Odemknutí vlastního avataru',
merch_coupon: 'Slevový kupon',
merch_physical: 'Fyzické zboží',
merch_digital: 'Digitální produkt',
merch_digital: 'Digitální odměna',
custom: 'Vlastní',
};
return names[type] || type;