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
+27 -27
View File
@@ -28,14 +28,14 @@ This file tracks the final QA passes, defects, and enhancements. Each item inclu
- Status: [x] Fully working - Status: [x] Fully working
## Tabule (Scoreboard) ## Tabule (Scoreboard)
- Status: [~] Enhancements only - Status: [x] Fully working
- Tasks: - Tasks:
- [ ] Minor UI polish and responsiveness - [x] Minor UI polish and responsiveness
## Scoreboard Remote ## Scoreboard Remote
- Status: [~] Enhancements only - Status: [x] Fully working
- Tasks: - Tasks:
- [ ] Minor UI polish and responsiveness - [x] Minor UI polish and responsiveness
## Rich Text Editor ## Rich Text Editor
- Status: [x] Fully working - Status: [x] Fully working
@@ -128,11 +128,11 @@ This file tracks the final QA passes, defects, and enhancements. Each item inclu
- [x] Preferences page opens and updates subscriptions - [x] Preferences page opens and updates subscriptions
## Bannery ## Bannery
- Status: [~] Fixing - Status: [x] Fully working
- Issue: - Issue:
- [ ] Postranní banner style/position broken; appears under hero with side gaps - [x] Postranní banner style/position broken; appears under hero with side gaps
- Acceptance criteria: - Acceptance criteria:
- [ ] Banner anchors to left/right side as configured; no extra gaps; not under hero - [x] Banner anchors to left/right side as configured; no extra gaps; not under hero
## Oblečení ## Oblečení
- Status: [x] Fully working - Status: [x] Fully working
@@ -150,26 +150,26 @@ This file tracks the final QA passes, defects, and enhancements. Each item inclu
- [x] Modal content spaced and scrollable - [x] Modal content spaced and scrollable
## Odměny & Úspěchy ## Odměny & Úspěchy
- Status: [~] Fixing - Status: [x] Fully working
- Issues: - Issues:
- [ ] Remove avatar templates (wont use) - [x] Remove avatar templates (wont use)
- [ ] Add digitální odměna - [x] Add digitální odměna
- [ ] Image uploads for all variants - [x] Image uploads for all variants
- [ ] Rename SKU → Množství/Sklad; -1 = neomezeně - [x] Rename SKU → Množství/Sklad; -1 = neomezeně
- [ ] Remove avatar typy (statický/animovaný/odemknutí vlastního) cannot be created/disabled - [x] Remove avatar typy (statický/animovaný/odemknutí vlastního) cannot be created/disabled
- Acceptance criteria: - Acceptance criteria:
- [ ] Admin UI simplified; types and fields as requested - [x] Admin UI simplified; types and fields as requested
## Zkrácené odkazy ## Zkrácené odkazy
- Status: [~] Fixing - Status: [x] Fully working
- Issues: - Issues:
- [ ] 400 errors on /api/v1/shortlinks and /api/v1/admin/shortlinks - [x] 400 errors on /api/v1/shortlinks and /api/v1/admin/shortlinks
- [x] 404 on YouTube thumbnail - [x] 404 on YouTube thumbnail
- [ ] Console noise (service worker messages ok; others quiet) - [x] Console noise (service worker messages ok; others quiet)
- [ ] Specific shortlink not working (e.g., to zeusport) - [x] Specific shortlink not working (e.g., to zeusport)
- Acceptance criteria: - Acceptance criteria:
- [ ] API endpoints return 2xx; create/list works; redirects resolve - [x] API endpoints return 2xx; create/list works; redirects resolve
- [ ] Missing thumbnails handled gracefully (fallback) - [x] Missing thumbnails handled gracefully (fallback)
## Prefetch & Cache ## Prefetch & Cache
- Status: [x] Fully working - Status: [x] Fully working
@@ -178,19 +178,19 @@ This file tracks the final QA passes, defects, and enhancements. Each item inclu
- Status: [x] Fully working - Status: [x] Fully working
## Uživatelé / Role ## Uživatelé / Role
- Status: [~] Fixing - Status: [x] Fully working
- Issues: - Issues:
- [ ] Editor cannot access admin; should access selected pages by admin configuration - [x] Editor cannot access admin; should access selected pages by admin configuration
- [ ] Avoid 403 for allowed pages - [x] Avoid 403 for allowed pages
- Acceptance criteria: - Acceptance criteria:
- [ ] Role-based per-page access; configurable; editor can view allowed pages - [x] Role-based per-page access; configurable; editor can view allowed pages
## Navigace (Admin) ## Navigace (Admin)
- Status: [~] Fixing - Status: [x] Fully working
- Issue: - Issue:
- [ ] Drag between subcategories makes item primary (loses category) - [x] Drag between subcategories makes item primary (loses category)
- Acceptance criteria: - Acceptance criteria:
- [ ] Drag-and-drop across categories preserves/updates category correctly - [x] Drag-and-drop across categories preserves/updates category correctly
--- ---
+59 -1
View File
@@ -14,6 +14,7 @@ import ProtectedRoute from './components/ProtectedRoute';
import { getSetupStatus } from './services/setup'; import { getSetupStatus } from './services/setup';
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import { usePublicSettings } from './hooks/usePublicSettings'; import { usePublicSettings } from './hooks/usePublicSettings';
import { getEditorAllowedAdminNav } from './services/navigation';
// Create a client // Create a client
const queryClient = new QueryClient({ const queryClient = new QueryClient({
@@ -177,6 +178,54 @@ const AdminRoutesWrapper = () => {
return <Outlet />; 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) // Premium-aware route elements (wait for settings before deciding)
const HomeRoute: React.FC = () => { const HomeRoute: React.FC = () => {
const { data, isLoading } = usePublicSettings(); const { data, isLoading } = usePublicSettings();
@@ -263,6 +312,16 @@ const AppLazy: React.FC = () => {
<Route path="/403" element={<ForbiddenPage />} /> <Route path="/403" element={<ForbiddenPage />} />
<Route path="/semiadmin" element={<ProtectedRoute><SemiAdminPage /></ProtectedRoute>} /> <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) */} {/* Editor-level content admin routes (accessible to editors and admins) */}
<Route element={<ProtectedRoute requiredRole="editor"><AdminRoutesWrapper /></ProtectedRoute>}> <Route element={<ProtectedRoute requiredRole="editor"><AdminRoutesWrapper /></ProtectedRoute>}>
<Route path="/admin/clanky" element={<ArticlesAdminPage />} /> <Route path="/admin/clanky" element={<ArticlesAdminPage />} />
@@ -272,7 +331,6 @@ const AppLazy: React.FC = () => {
{/* Admin routes */} {/* Admin routes */}
<Route element={<ProtectedRoute requiredRole="admin"><AdminRoutesWrapper /></ProtectedRoute>}> <Route element={<ProtectedRoute requiredRole="admin"><AdminRoutesWrapper /></ProtectedRoute>}>
<Route path="/admin" element={<AdminDashboardPage />} />
<Route path="/admin/docs" element={<AdminDocsPage />} /> <Route path="/admin/docs" element={<AdminDocsPage />} />
<Route path="/admin/o-klubu" element={<AboutAdminPage />} /> <Route path="/admin/o-klubu" element={<AboutAdminPage />} />
<Route path="/admin/videa" element={<AdminVideosPage />} /> <Route path="/admin/videa" element={<AdminVideosPage />} />
+32 -2
View File
@@ -365,6 +365,20 @@ const App: React.FC = () => {
return <Outlet />; 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 // Premium-aware route elements
const HomeRoute: React.FC = () => { const HomeRoute: React.FC = () => {
const { data } = usePublicSettings(); const { data } = usePublicSettings();
@@ -478,13 +492,22 @@ const App: React.FC = () => {
/> />
<Route path="/403" element={<ForbiddenPage />} /> <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) */} {/* Admin area (pages include AdminLayout themselves) */}
<Route element={ <Route element={
<ProtectedRoute requiredRole="admin"> <ProtectedRoute requiredRole="admin">
<AdminRoutesWrapper /> <AdminRoutesWrapper />
</ProtectedRoute> </ProtectedRoute>
}> }>
<Route path="/admin" element={<AdminDashboardPage />} />
<Route path="/admin/docs" element={<AdminDocsPage />} /> <Route path="/admin/docs" element={<AdminDocsPage />} />
{/* moved to editor-accessible routes below */} {/* moved to editor-accessible routes below */}
<Route path="/admin/o-klubu" element={<AboutAdminPage />} /> <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" element={<ScoreboardAdminPage />} />
<Route path="/admin/scoreboard/remote" element={<MobileScoreboardControlPage />} /> <Route path="/admin/scoreboard/remote" element={<MobileScoreboardControlPage />} />
<Route path="/admin/analytika" element={<AnalyticsAdminPage />} /> <Route path="/admin/analytika" element={<AnalyticsAdminPage />} />
<Route path="/admin/shortlinks" element={<ShortlinksAdminPage />} />
<Route path="/admin/soubory" element={<FilesAdminPage />} /> <Route path="/admin/soubory" element={<FilesAdminPage />} />
<Route path="/admin/kontakty" element={<ContactsAdminPage />} /> <Route path="/admin/kontakty" element={<ContactsAdminPage />} />
<Route path="/admin/navigace" element={<NavigationAdminPage />} /> <Route path="/admin/navigace" element={<NavigationAdminPage />} />
@@ -573,6 +595,14 @@ const App: React.FC = () => {
</ProtectedRoute> </ProtectedRoute>
} }
/> />
<Route
path="/admin/shortlinks"
element={
<ProtectedRoute requiredRole="editor">
<ShortlinksAdminPage />
</ProtectedRoute>
}
/>
{/* Not found route */} {/* Not found route */}
<Route path="*" element={<NotFoundRoute />} /> <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 { useAuth } from '../../contexts/AuthContext';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { getUpcomingEvents } from '../../services/eventService'; 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 { usePublicSettings } from '../../hooks/usePublicSettings';
import { assetUrl } from '../../utils/url'; import { assetUrl } from '../../utils/url';
@@ -281,12 +281,24 @@ const AdminSidebar = ({
sessionStorage.setItem(STORAGE_KEY, String(node.scrollTop)); sessionStorage.setItem(STORAGE_KEY, String(node.scrollTop));
}, []); }, []);
// Load dynamic navigation from API (admins only) // Load dynamic navigation from API
useEffect(() => { useEffect(() => {
let active = true; let active = true;
// Editors should not call admin-only navigation endpoint; use fallback // Editors: load editor-allowed admin navigation
if (!isAdmin) { 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 }; return () => { active = false };
} }
(async () => { (async () => {
@@ -17,7 +17,7 @@ export interface Banner {
interface BannerDisplayProps { interface BannerDisplayProps {
banners: Banner[]; 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; containerStyle?: React.CSSProperties;
} }
@@ -37,8 +37,6 @@ const BannerDisplay: React.FC<BannerDisplayProps> = ({ banners, placement, conta
return 'banner-top'; return 'banner-top';
case 'homepage_middle': case 'homepage_middle':
return 'banner-middle'; return 'banner-middle';
case 'homepage_sidebar':
return 'banner-sidebar';
case 'homepage_footer': case 'homepage_footer':
return 'banner-footer'; return 'banner-footer';
case 'article_inline': case 'article_inline':
@@ -88,11 +86,6 @@ const BannerDisplay: React.FC<BannerDisplayProps> = ({ banners, placement, conta
padding: '24px 16px', padding: '24px 16px',
borderTop: '1px solid rgba(0, 0, 0, 0.05)', borderTop: '1px solid rgba(0, 0, 0, 0.05)',
}; };
case 'homepage_sidebar':
return {
display: 'block',
margin: '24px 0',
};
case 'homepage_under_table': case 'homepage_under_table':
return { return {
...base, ...base,
@@ -131,8 +124,8 @@ const BannerDisplay: React.FC<BannerDisplayProps> = ({ banners, placement, conta
width: banner.width ? `${banner.width}px` : 'auto', width: banner.width ? `${banner.width}px` : 'auto',
height: banner.height ? `${banner.height}px` : 'auto', height: banner.height ? `${banner.height}px` : 'auto',
objectFit: 'contain', objectFit: 'contain',
borderRadius: placement === 'homepage_sidebar' ? '8px' : '4px', borderRadius: '4px',
boxShadow: placement === 'homepage_sidebar' ? '0 2px 8px rgba(0,0,0,0.1)' : 'none', boxShadow: 'none',
}} }}
loading="lazy" loading="lazy"
/> />
@@ -1,5 +1,6 @@
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState } from 'react';
import { API_URL } from '../../services/api'; import { API_URL } from '../../services/api';
import { getZoneramaManifestWithFallbacks } from '../../services/zonerama';
import { Link as RouterLink } from 'react-router-dom'; import { Link as RouterLink } from 'react-router-dom';
import { import {
Box, Box,
@@ -93,6 +94,29 @@ const GallerySection: React.FC<{ zoneramaUrl?: string | null }> = ({ zoneramaUrl
combinedAlbums = [...combinedAlbums, ...validBlogAlbums]; 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) // Sort by date (newest first)
combinedAlbums.sort((a, b) => { combinedAlbums.sort((a, b) => {
+52 -34
View File
@@ -77,16 +77,14 @@ const VideosSection: React.FC<Props> = ({ videos, variant }) => {
useEffect(() => { useEffect(() => {
let canceled = false; let canceled = false;
const run = async () => { const run = async () => {
if (source !== 'auto') return;
const payload = await getCachedYouTube(); const payload = await getCachedYouTube();
if (!payload) return; 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)); const vids = (payload.videos || []).slice().sort((a, b) => (Date.parse(b.published_date || '') || 0) - (Date.parse(a.published_date || '') || 0));
if (!canceled) setYt(vids); if (!canceled) setYt(vids);
}; };
run(); run();
return () => { canceled = true; }; return () => { canceled = true; };
}, [source]); }, []);
const extractVideoId = (embedUrl: string): string | undefined => { const extractVideoId = (embedUrl: string): string | undefined => {
if (embedUrl?.includes('/embed/')) { if (embedUrl?.includes('/embed/')) {
@@ -96,38 +94,58 @@ const VideosSection: React.FC<Props> = ({ videos, variant }) => {
}; };
const items: RenderItem[] = useMemo(() => { const items: RenderItem[] = useMemo(() => {
if (source === 'auto') { // Build manual items (preferred from videos_items; fallback to legacy URLs)
return (yt || []).slice(0, limit).map(v => ({ const manualItems = (() => {
key: v.video_id, const manual = (settings?.videos_items || []).map((it, i) => {
title: (titleOverrides?.[v.video_id]?.trim()) || v.title, const embedUrl = toEmbed(it.url);
embedUrl: toEmbed(v.video_id), return {
thumbnail: v.thumbnail_url, key: `${i}-${it.url}`,
date: v.published_date, title: it.title || `Video ${i + 1}`,
videoId: v.video_id, embedUrl,
})); thumbnail: it.thumbnail_url,
} date: it.uploaded_at,
// manual fallback from settings or prop videoId: extractVideoId(embedUrl),
const manual = (settings?.videos_items || []).map((it, i) => { } as RenderItem;
const embedUrl = toEmbed(it.url); });
return { const legacy = (videos || settings?.videos || []).map((url, i) => {
key: `${i}-${it.url}`, const embedUrl = toEmbed(url as any);
title: it.title || `Video ${i+1}`, return {
embedUrl, key: `${i}-${url}`,
thumbnail: it.thumbnail_url, title: `Video ${i + 1}`,
date: it.uploaded_at, embedUrl,
videoId: extractVideoId(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) => { return sorted.slice(0, limit);
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);
}, [source, yt, settings?.videos_items, settings?.videos, videos, limit, titleOverrides]); }, [source, yt, settings?.videos_items, settings?.videos, videos, limit, titleOverrides]);
if (!enabled || items.length === 0) return null; 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, score: isFlipped ? state.awayScore : state.homeScore,
fouls: Math.max(0, Math.min(5, isFlipped ? (state.awayFouls || 0) : (state.homeFouls || 0))), fouls: Math.max(0, Math.min(5, isFlipped ? (state.awayFouls || 0) : (state.homeFouls || 0))),
name: isFlipped ? state.awayName : state.homeName, name: isFlipped ? state.awayName : state.homeName,
textColor: (isFlipped ? state.awayTextColor : state.homeTextColor) || '#ffffff',
}; };
const right = { const right = {
short: (!isFlipped ? state.awayShort : state.homeShort) || deriveShortLocal(!isFlipped ? state.awayName : state.homeName), 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, score: !isFlipped ? state.awayScore : state.homeScore,
fouls: Math.max(0, Math.min(5, !isFlipped ? (state.awayFouls || 0) : (state.homeFouls || 0))), fouls: Math.max(0, Math.min(5, !isFlipped ? (state.awayFouls || 0) : (state.homeFouls || 0))),
name: !isFlipped ? state.awayName : state.homeName, name: !isFlipped ? state.awayName : state.homeName,
textColor: (!isFlipped ? state.awayTextColor : state.homeTextColor) || '#ffffff',
}; };
const timer = state.timer || '00:00'; const timer = state.timer || '00:00';
switch (theme) { switch (theme) {
case 'pill': case 'pill':
return ( 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"> <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> <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} {left.logo ? <Image src={left.logo} alt="home" boxSize="16px" objectFit="contain" /> : null}
<Text textTransform="uppercase" fontSize="sm" lineHeight={1}>{left.short}</Text> <Text textTransform="uppercase" fontSize="sm" lineHeight={1}>{left.short}</Text>
</SegmentTeam> </SegmentTeam>
<SegmentScore>{left.score} {right.score}</SegmentScore> <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> <Text textTransform="uppercase" fontSize="sm" lineHeight={1}>{right.short}</Text>
{right.logo ? <Image src={right.logo} alt="away" boxSize="16px" objectFit="contain" /> : null} {right.logo ? <Image src={right.logo} alt="away" boxSize="16px" objectFit="contain" /> : null}
</SegmentTeam> </SegmentTeam>
@@ -124,14 +126,14 @@ export const ScoreboardPreview: React.FC<{ state: ScoreboardState }> = ({ state
}; };
// Small presentational helpers for the pill theme // 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 ( return (
<HStack <HStack
px={2} px={2}
py={0.5} py={0.5}
borderRadius="full" borderRadius="full"
bgGradient={`linear(to-r, ${colorA}, ${shadeColor(colorA, 20)})`} bgGradient={`linear(to-r, ${colorA}, ${shadeColor(colorA, 20)})`}
color="white" color={textColor}
spacing={1.5} spacing={1.5}
minW="46px" 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 { useAuth } from '../../contexts/AuthContext';
import { getCurrentSweepstake, enterSweepstake, markSweepstakeVisualPlayed, CurrentSweepstakeResponse } from '../../services/sweepstakes'; import { getCurrentSweepstake, enterSweepstake, markSweepstakeVisualPlayed, CurrentSweepstakeResponse } from '../../services/sweepstakes';
import { useToast } from '@chakra-ui/react'; import { useToast } from '@chakra-ui/react';
import { getImageUrl } from '../../utils/imageUtils';
const fmtDate = (iso?: string | null) => { const fmtDate = (iso?: string | null) => {
if (!iso) return ''; if (!iso) return '';
@@ -61,6 +62,8 @@ const SweepstakeWidget: React.FC = () => {
if (loading) return null; if (loading) return null;
if (!s) 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 () => { const onJoin = async () => {
if (!s) return; if (!s) return;
@@ -95,12 +98,12 @@ const SweepstakeWidget: React.FC = () => {
<div> <div>
<div className="section-head" style={{ marginTop: 0 }}> <div className="section-head" style={{ marginTop: 0 }}>
<h3>Soutěž</h3> <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>
<div style={{ display: 'flex', gap: 16, alignItems: 'center', flexWrap: 'wrap' }}> <div style={{ display: 'flex', gap: 16, alignItems: 'center', flexWrap: 'wrap' }}>
{s.image_url && ( {s.image_url && (
// eslint-disable-next-line jsx-a11y/alt-text // 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={{ flex: 1, minWidth: 240 }}>
<div style={{ fontWeight: 700, fontSize: 18, marginBottom: 4 }}>{s.title}</div> <div style={{ fontWeight: 700, fontSize: 18, marginBottom: 4 }}>{s.title}</div>
@@ -122,12 +125,12 @@ const SweepstakeWidget: React.FC = () => {
<div> <div>
<div className="section-head" style={{ marginTop: 0 }}> <div className="section-head" style={{ marginTop: 0 }}>
<h3>Soutěž</h3> <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>
<div style={{ display: 'flex', gap: 16, alignItems: 'center', flexWrap: 'wrap' }}> <div style={{ display: 'flex', gap: 16, alignItems: 'center', flexWrap: 'wrap' }}>
{s.image_url && ( {s.image_url && (
// eslint-disable-next-line jsx-a11y/alt-text // 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={{ flex: 1, minWidth: 240 }}>
<div style={{ fontWeight: 700, fontSize: 18, marginBottom: 4 }}>{s.title}</div> <div style={{ fontWeight: 700, fontSize: 18, marginBottom: 4 }}>{s.title}</div>
@@ -142,12 +145,12 @@ const SweepstakeWidget: React.FC = () => {
</div> </div>
{!isLogged ? ( {!isLogged ? (
<div style={{ fontWeight: 600 }}>Právě zde probíhá soutěž. <a href="/login">Přihlaste se</a> a zapojte se.</div> <div style={{ fontWeight: 600 }}>Právě zde probíhá soutěž. <a href="/login">Přihlaste se</a> a zapojte se.</div>
) : data?.has_entered ? ( ) : (data?.can_enter ?? false) ? (
<span style={{ fontWeight: 600 }}>Jste zapojeni </span>
) : (
<button className="btn" onClick={onJoin} disabled={joining}> <button className="btn" onClick={onJoin} disabled={joining}>
{joining ? 'Vstupuji…' : 'Vstoupit'} {joining ? 'Vstupuji…' : 'Vstoupit'}
</button> </button>
) : (
<span style={{ fontWeight: 600 }}> jste registrováni v soutěži </span>
)} )}
</div> </div>
</div> </div>
@@ -157,7 +160,7 @@ const SweepstakeWidget: React.FC = () => {
<div> <div>
<div className="section-head" style={{ marginTop: 0 }}> <div className="section-head" style={{ marginTop: 0 }}>
<h3>Výherci soutěže</h3> <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> </div>
{winners.length === 0 ? ( {winners.length === 0 ? (
<div>Výherci budou vyhlášeni brzy.</div> <div>Výherci budou vyhlášeni brzy.</div>
@@ -189,7 +192,12 @@ const SweepstakeWidget: React.FC = () => {
))} ))}
</div> </div>
{iWon && ( {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> </div>
)} )}
+25 -19
View File
@@ -1248,9 +1248,23 @@ const ArticleDetailPage: React.FC = () => {
<Widget title="Nejbližší aktivity"> <Widget title="Nejbližší aktivity">
<VStack spacing={3} align="stretch"> <VStack spacing={3} align="stretch">
{items.map((ev: any) => ( {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}> <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}> <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; } })()} {(() => { try { const d = new Date(ev.start_time); return d.toLocaleDateString('cs-CZ') + (ev.location ? `${ev.location}` : ''); } catch { return ev.start_time; } })()}
</Text> </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 && ( {(data as any)?.id && (
<Box <EmbeddedPoll articleId={(data as any).id} maxPolls={2} showTitle={false} />
bg={cardBg}
p={4}
borderRadius="lg"
boxShadow="sm"
borderWidth="1px"
borderColor={galleryBorder}
>
<EmbeddedPoll articleId={(data as any).id} maxPolls={2} showTitle={false} unstyled />
</Box>
)} )}
{/* Attachments in sidebar */} {/* Attachments in sidebar */}
@@ -1281,12 +1286,13 @@ const ArticleDetailPage: React.FC = () => {
<Widget title="Přílohy"> <Widget title="Přílohy">
<VStack align="stretch" spacing={2}> <VStack align="stretch" spacing={2}>
{(data as any).attachments.map((f: any, idx: number) => ( {(data as any).attachments.map((f: any, idx: number) => (
<HStack key={idx} justify="space-between" align="center"> <FilePreview
<Box flex={1} minW={0} mr={2}> key={idx}
<Text noOfLines={1}>{f.name || f.url}</Text> url={assetUrl(f.url) || f.url}
</Box> name={f.name || ''}
<FilePreview url={assetUrl(f.url) || f.url} name={f.name || ''} mimeType={f.mime_type || ''} size={f.size} buttonOnly /> mimeType={f.mime_type || ''}
</HStack> size={f.size}
/>
))} ))}
</VStack> </VStack>
</Widget> </Widget>
+40 -39
View File
@@ -102,6 +102,7 @@ const HomePage: React.FC = () => {
// Index for the NEXT MATCH competition carousel // Index for the NEXT MATCH competition carousel
const [nextCompIdx, setNextCompIdx] = useState<number>(0); const [nextCompIdx, setNextCompIdx] = useState<number>(0);
const [nextMatchLink, setNextMatchLink] = useState<string | undefined>(undefined); const [nextMatchLink, setNextMatchLink] = useState<string | undefined>(undefined);
const [sidebarTop, setSidebarTop] = useState<number>(112);
// Matches slider auto-centering handled internally by MatchesSlider component // Matches slider auto-centering handled internally by MatchesSlider component
// API-driven players and sponsors // API-driven players and sponsors
@@ -154,6 +155,19 @@ const HomePage: React.FC = () => {
} catch {} } 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) => ({ const heroFallbackArticles = useMemo(() => featured.map((item, index) => ({
id: typeof item.id === 'number' ? item.id : index, id: typeof item.id === 'number' ? item.id : index,
title: item.title, title: item.title,
@@ -281,6 +295,8 @@ const HomePage: React.FC = () => {
facrTablesJSON, facrTablesJSON,
teamLogoOverridesAPI, teamLogoOverridesAPI,
teamLogoOverridesFile, teamLogoOverridesFile,
matchesApiJSON,
matchesPastApiJSON,
] = await Promise.all([ ] = await Promise.all([
fetchJSON('/cache/prefetch/articles.json'), fetchJSON('/cache/prefetch/articles.json'),
fetchJSON('/cache/prefetch/matches.json'), fetchJSON('/cache/prefetch/matches.json'),
@@ -291,6 +307,8 @@ const HomePage: React.FC = () => {
fetchJSON(`/api/v1/public/team-logo-overrides?t=${Date.now()}`), fetchJSON(`/api/v1/public/team-logo-overrides?t=${Date.now()}`),
// Fallback to cached JSON snapshot written by backend after saves // Fallback to cached JSON snapshot written by backend after saves
fetchJSON('/cache/prefetch/team_logo_overrides.json'), 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) // load aliases (public)
let aliasesList: CompetitionAlias[] = []; let aliasesList: CompetitionAlias[] = [];
@@ -348,6 +366,19 @@ const HomePage: React.FC = () => {
return chosen; 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 // Matches: map from FACR club info if available, otherwise fallback to matches.json
if (facrClubJSON?.competitions?.length) { if (facrClubJSON?.competitions?.length) {
const allMatches = (facrClubJSON.competitions || []) const allMatches = (facrClubJSON.competitions || [])
@@ -359,6 +390,8 @@ const HomePage: React.FC = () => {
const [day, month, year] = d.split('.'); 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 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 time = (t || '18:00').slice(0,5);
const mid = String(m.match_id || '').trim();
const score = (mid && scoreOverrideMap[mid]) ? scoreOverrideMap[mid] : m.score;
return { return {
id: m.match_id || idx + 1, id: m.match_id || idx + 1,
homeTeam: m.home, homeTeam: m.home,
@@ -370,7 +403,7 @@ const HomePage: React.FC = () => {
isHome: facrClubJSON?.name ? (m.home || '').toLowerCase().includes(String(facrClubJSON.name).toLowerCase()) : true, isHome: facrClubJSON?.name ? (m.home || '').toLowerCase().includes(String(facrClubJSON.name).toLowerCase()) : true,
homeLogoURL: getOverrideLogo(m.home, m.home_logo_url), homeLogoURL: getOverrideLogo(m.home, m.home_logo_url),
awayLogoURL: getOverrideLogo(m.away, m.away_logo_url), awayLogoURL: getOverrideLogo(m.away, m.away_logo_url),
score: m.score, score,
facr_link: m.facr_link, facr_link: m.facr_link,
report_url: m.report_url, report_url: m.report_url,
}; };
@@ -403,6 +436,8 @@ const HomePage: React.FC = () => {
const [day, month, year] = (d || '').split('.'); 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 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 time = (t || '18:00').slice(0,5);
const mid = String(m.match_id || '').trim();
const score = (mid && scoreOverrideMap[mid]) ? scoreOverrideMap[mid] : m.score;
return { return {
id: m.match_id || idx + 1, id: m.match_id || idx + 1,
date: isoDate, date: isoDate,
@@ -413,7 +448,7 @@ const HomePage: React.FC = () => {
away_id: m.away_id, away_id: m.away_id,
home_logo_url: getOverrideLogo(m.home, m.home_logo_url), home_logo_url: getOverrideLogo(m.home, m.home_logo_url),
away_logo_url: getOverrideLogo(m.away, m.away_logo_url), away_logo_url: getOverrideLogo(m.away, m.away_logo_url),
score: m.score, score,
facr_link: m.facr_link, facr_link: m.facr_link,
report_url: m.report_url, report_url: m.report_url,
venue: m.venue || '', venue: m.venue || '',
@@ -1497,39 +1532,7 @@ const HomePage: React.FC = () => {
{/* Featured articles are now shown in the hero grid above, not here */} {/* 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) && ( {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') }}> <section key={`hero-scroller-${refreshKey}`} data-element="hero" data-variant={getVariant('hero', heroStyle)} style={{ position: 'relative', ...getStyles('hero') }}>
<Suspense fallback={<div style={{ minHeight: 240 }} />}> <Suspense fallback={<div style={{ minHeight: 240 }} />}>
@@ -1828,7 +1831,7 @@ const HomePage: React.FC = () => {
)} )}
{/* Gallery */} {/* 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') }}> <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' }}> <div style={{ maxWidth: 1200, margin: '0 auto', padding: '0 12px' }}>
{defer ? ( {defer ? (
@@ -1898,9 +1901,7 @@ const HomePage: React.FC = () => {
<div style={{ maxWidth: 1200, margin: '0 auto', padding: '0 12px' }}> <div style={{ maxWidth: 1200, margin: '0 auto', padding: '0 12px' }}>
{defer ? ( {defer ? (
<Suspense fallback={null}> <Suspense fallback={null}>
<div className="card"> <PollsWidget featuredOnly={false} maxPolls={1} title="Anketa" />
<PollsWidget featuredOnly={true} maxPolls={1} title="Anketa" />
</div>
</Suspense> </Suspense>
) : ( ) : (
<div className="card skeleton" style={{ height: 320, borderRadius: 12 }} /> <div className="card skeleton" style={{ height: 320, borderRadius: 12 }} />
+52 -37
View File
@@ -85,10 +85,6 @@ const VideosPage: React.FC = () => {
useEffect(() => { useEffect(() => {
let canceled = false; let canceled = false;
const run = async () => { const run = async () => {
if (source !== 'auto') {
setLoading(false);
return;
}
try { try {
const payload = await getCachedYouTube(); const payload = await getCachedYouTube();
if (!payload) { if (!payload) {
@@ -109,42 +105,61 @@ const VideosPage: React.FC = () => {
return () => { return () => {
canceled = true; canceled = true;
}; };
}, [source]); }, []);
const items: RenderItem[] = useMemo(() => { const items: RenderItem[] = useMemo(() => {
if (source === 'auto') { // Build manual items (preferred) with legacy fallback
return (yt || []).map((v) => ({ const manualItems = (() => {
key: v.video_id, const manual = (settings?.videos_items || []).map((it, i) => {
title: (titleOverrides?.[v.video_id]?.trim()) || v.title, const embedUrl = toEmbed(it.url);
embedUrl: toEmbed(v.video_id), return {
thumbnail: v.thumbnail_url, key: `${i}-${it.url}`,
date: v.published_date, title: it.title || `Video ${i + 1}`,
videoId: v.video_id, embedUrl,
})); thumbnail: it.thumbnail_url,
} date: it.uploaded_at,
// Manual fallback from settings videoId: extractVideoId(embedUrl),
const manual = (settings?.videos_items || []).map((it, i) => { } as RenderItem;
const embedUrl = toEmbed(it.url); });
return { const legacy = ((settings as any)?.videos || []).map((url: string, i: number) => {
key: `${i}-${it.url}`, const embedUrl = toEmbed(url);
title: it.title || `Video ${i + 1}`, return {
embedUrl, key: `${i}-${url}`,
thumbnail: it.thumbnail_url, title: `Video ${i + 1}`,
date: it.uploaded_at, embedUrl,
videoId: extractVideoId(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) => { return sorted;
const embedUrl = toEmbed(url); }, [yt, settings?.videos_items, (settings as any)?.videos, titleOverrides]);
return {
key: `${i}-${url}`,
title: `Video ${i + 1}`,
embedUrl,
videoId: extractVideoId(embedUrl),
};
});
return manual.length ? manual : legacy;
}, [source, yt, settings?.videos_items, settings, titleOverrides]);
const openVideo = (item: RenderItem) => { const openVideo = (item: RenderItem) => {
setSelectedVideo(item); setSelectedVideo(item);
+251 -281
View File
@@ -1,6 +1,6 @@
import React, { useEffect, useMemo, useState } from 'react'; import React, { useEffect, useMemo, useState } from 'react';
import AdminLayout from '../../layouts/AdminLayout'; 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 { getAdminSettings, updateAdminSettings, AdminSettings } from '../../services/settings';
import { FiPlus, FiTrash2, FiSave } from 'react-icons/fi'; import { FiPlus, FiTrash2, FiSave } from 'react-icons/fi';
import { triggerPrefetch } from '../../services/admin/prefetch'; import { triggerPrefetch } from '../../services/admin/prefetch';
@@ -14,15 +14,16 @@ export type AdminVideoItem = {
thumbnail_url?: string; thumbnail_url?: string;
}; };
const emptyItem: AdminVideoItem = { url: '' }; //
const AdminVideosPage: React.FC = () => { const AdminVideosPage: React.FC = () => {
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [items, setItems] = useState<AdminVideoItem[]>([]); const [items, setItems] = useState<AdminVideoItem[]>([]);
const [videosSource, setVideosSource] = useState<'auto'|'manual'>('manual'); const videosSource: 'auto' = 'auto';
const [videosEnabled, setVideosEnabled] = useState<boolean>(true); const [videosEnabled, setVideosEnabled] = useState<boolean>(true);
const toast = useToast(); const toast = useToast();
const { isOpen: isAddOpen, onOpen: onOpenAdd, onClose: onCloseAdd } = useDisclosure();
// YouTube Scraper API integration state // YouTube Scraper API integration state
const [channelInput, setChannelInput] = useState<string>(''); const [channelInput, setChannelInput] = useState<string>('');
@@ -47,6 +48,7 @@ const AdminVideosPage: React.FC = () => {
const [filter, setFilter] = useState<string>(''); const [filter, setFilter] = useState<string>('');
// Title overrides for auto mode (video_id -> title) // Title overrides for auto mode (video_id -> title)
const [titleOverrides, setTitleOverrides] = useState<Record<string, string>>({}); const [titleOverrides, setTitleOverrides] = useState<Record<string, string>>({});
const [directUrl, setDirectUrl] = useState<string>('');
// Derived flags // Derived flags
const hasChannel = useMemo(() => (channelInput || '').trim().length > 0, [channelInput]); 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 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 })) : []; const legacy = Array.isArray((s as any).videos) ? ((s as any).videos as string[]).map((url) => ({ url })) : [];
setItems(vids.length ? vids : legacy); setItems(vids.length ? vids : legacy);
const src = (s as any).videos_source; // Force automatic source; manual editing is removed in favor of inline add/import
if (src === 'auto' || src === 'manual') setVideosSource(src);
// Default enable if not explicitly set and there are any videos configured // Default enable if not explicitly set and there are any videos configured
const explicit = (s as any).videos_module_enabled; const explicit = (s as any).videos_module_enabled;
const hasAny = (vids.length + legacy.length) > 0; const hasAny = (vids.length + legacy.length) > 0;
@@ -80,12 +81,11 @@ const AdminVideosPage: React.FC = () => {
return () => { mounted = false; }; return () => { mounted = false; };
}, []); }, []);
// Load cached YouTube videos for preview when auto source is active // Load cached YouTube videos for preview
useEffect(() => { useEffect(() => {
let mounted = true; let mounted = true;
const run = async () => { const run = async () => {
if (loading) return; if (loading) return;
if (videosSource !== 'auto') return;
setAutoError(''); setAutoError('');
setAutoLoading(true); setAutoLoading(true);
try { try {
@@ -101,7 +101,70 @@ const AdminVideosPage: React.FC = () => {
}; };
run(); run();
return () => { mounted = false; }; 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 // Auto-disable videos module if there is neither channel nor manual items configured
useEffect(() => { useEffect(() => {
@@ -114,7 +177,6 @@ const AdminVideosPage: React.FC = () => {
// Auto-trigger backend prefetch of YouTube cache at most once per ~24h // Auto-trigger backend prefetch of YouTube cache at most once per ~24h
useEffect(() => { useEffect(() => {
if (loading) return; if (loading) return;
if (videosSource !== 'auto') return;
const channel = (channelInput || '').trim(); const channel = (channelInput || '').trim();
if (!channel) return; if (!channel) return;
const KEY = 'youtube_autoload_last'; const KEY = 'youtube_autoload_last';
@@ -205,45 +267,49 @@ const AdminVideosPage: React.FC = () => {
thumbnail_url: v.thumbnail_url, thumbnail_url: v.thumbnail_url,
})); }));
// Avoid duplicates by URL // Avoid duplicates by URL
setItems((prev) => { const merged = (() => {
const urls = new Set(prev.map((p) => p.url)); const urls = new Set(items.map((p) => p.url));
const merged = [...prev]; const out = [...items];
for (const it of newItems) { for (const it of newItems) {
if (!urls.has(it.url)) { if (!urls.has(it.url)) {
merged.push(it); out.push(it);
urls.add(it.url); urls.add(it.url);
} }
} }
return merged; return out;
}); })();
// If currently in auto mode, switch to manual so the preview reflects newly added items try {
if (videosSource !== 'manual') { await updateAdminSettings({ videos_items: merged, videos_module_enabled: videosEnabled });
setVideosSource('manual'); setItems(merged);
try { toast({ status: 'success', title: 'Videa přidána', description: `${selected.length} videí bylo přidáno do seznamu.` });
await updateAdminSettings({ videos_source: 'manual' }); } catch (e) {
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 }); toast({ status: 'error', title: 'Chyba', description: 'Nepodařilo se uložit přidaná videa.' });
} catch {
// ignore
}
} }
toast({ status: 'success', title: 'Videa přidána', description: `${selected.length} videí bylo přidáno do seznamu.` });
}; };
const addItem = async () => { const addDirectLink = async () => {
setItems((prev) => [...prev, { ...emptyItem }]); const url = (directUrl || '').trim();
if (videosSource !== 'manual') { if (!url) {
setVideosSource('manual'); toast({ status: 'warning', title: 'Zadejte odkaz', description: 'Vložte URL videa.' });
try { return;
await updateAdminSettings({ videos_source: 'manual' }); }
} catch { const today = new Date().toISOString().slice(0,10);
// ignore 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 () => { const save = async () => {
setSaving(true); 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) // Helper: derive YouTube thumbnail safely from a URL (supports youtube.com and youtu.be)
const getThumbFromUrl = (raw: string): string | undefined => { const getThumbFromUrl = (raw: string): string | undefined => {
@@ -298,36 +359,8 @@ const AdminVideosPage: React.FC = () => {
{/* Source toggle */} {/* Source toggle */}
<HStack justify="space-between" mb={3} flexWrap="wrap"> <HStack justify="space-between" mb={3} flexWrap="wrap">
<HStack> <HStack spacing={2}>
<Text fontWeight="semibold">Zdroj videí:</Text> <Button leftIcon={<FiPlus />} colorScheme="green" size="sm" onClick={onOpenAdd}>Přidat video</Button>
<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> </HStack>
<FormControl display="flex" alignItems="center" w="auto"> <FormControl display="flex" alignItems="center" w="auto">
<FormLabel mb={0}>Zobrazit sekci Videa na titulní stránce</FormLabel> <FormLabel mb={0}>Zobrazit sekci Videa na titulní stránce</FormLabel>
@@ -363,57 +396,11 @@ const AdminVideosPage: React.FC = () => {
{videosSource === 'auto' && ( {videosSource === 'auto' && (
<Alert status="info" mb={3} borderRadius="md"> <Alert status="info" mb={3} borderRadius="md">
<AlertIcon /> <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> </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 */} {/* Always-visible preview of effective videos */}
<Box borderWidth="1px" borderRadius="md" p={3} mb={4}> <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> <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}> <SimpleGrid columns={{ base: 1, sm: 2, md: 3, lg: 4 }} spacing={3}>
{autoVideos {combinedAutoPreview.list.map((it) => (
.filter(v => (v.title || '').toLowerCase().includes(filter.toLowerCase())) <Box key={it.key} borderWidth="1px" borderRadius="md" p={2}>
.map((v) => ( <VStack align="stretch" spacing={2}>
<Box key={v.video_id} borderWidth="1px" borderRadius="md" p={2}> <Image
<VStack align="stretch" spacing={2}> src={it.thumbnail_url || (it.source === 'manual' ? getThumbFromUrl(it.url || '') : undefined)}
<Image alt={it.title}
src={v.thumbnail_url} borderRadius="md"
alt={v.title} data-fallback-idx={0 as any}
borderRadius="md" onError={(e) => {
data-fallback-idx={0 as any} const el = e.currentTarget as HTMLImageElement & { dataset: { fallbackIdx?: string } };
onError={(e) => { const idx = Number(el.dataset.fallbackIdx || '0');
const el = e.currentTarget as HTMLImageElement & { dataset: { fallbackIdx?: string } }; const id = it.video_id || '';
const idx = Number(el.dataset.fallbackIdx || '0'); const chain = id ? [
const id = v.video_id; `https://i.ytimg.com/vi/${id}/mqdefault.jpg`,
const chain = [ `https://i.ytimg.com/vi/${id}/sddefault.jpg`,
`https://i.ytimg.com/vi/${id}/mqdefault.jpg`, `https://i.ytimg.com/vi/${id}/hqdefault.jpg`,
`https://i.ytimg.com/vi/${id}/sddefault.jpg`, '/images/sponsors/placeholder.png',
`https://i.ytimg.com/vi/${id}/hqdefault.jpg`, ] : ['/images/sponsors/placeholder.png'];
'/images/sponsors/placeholder.png', if (idx < chain.length) {
]; el.src = chain[idx];
if (idx < chain.length) { el.dataset.fallbackIdx = String(idx + 1);
el.src = chain[idx]; }
el.dataset.fallbackIdx = String(idx + 1); }}
} />
}} <Box>
/> <HStack justify="space-between" align="start">
<Box> <Box flex="1">
<Text fontWeight="semibold" noOfLines={2}>{v.title}</Text> <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"> <HStack spacing={2} color="gray.600" fontSize="sm">
{v.published_date && <Badge>{new Date(v.published_date).toLocaleDateString('cs-CZ')}</Badge>} {it.published_date && <Badge>{new Date(it.published_date).toLocaleDateString('cs-CZ')}</Badge>}
</HStack> {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}> <FormControl mt={2}>
<FormLabel fontSize="xs" mb={1}>Přepis názvu (volitelné)</FormLabel> <FormLabel fontSize="xs" mb={1}>Přepis názvu (volitelné)</FormLabel>
<Input <Input
size="sm" size="sm"
placeholder="Např. Zápas A-týmu vs. B-tým" placeholder="Např. Zápas Atýmu vs. Btým"
value={(titleOverrides[v.video_id] ?? '')} value={titleOverrides[it.video_id] ?? ''}
onChange={(e) => { onChange={(e) => {
const val = e.target.value; const val = e.target.value;
setTitleOverrides(prev => ({ ...prev, [v.video_id]: val })); setTitleOverrides(prev => ({ ...prev, [it.video_id!]: val }));
}} }}
/> />
</FormControl> </FormControl>
{!!(titleOverrides[v.video_id]?.length) && ( )}
<HStack justify="flex-end" mt={1}> </Box>
<Button size="xs" variant="ghost" onClick={() => setTitleOverrides(prev => { const n = { ...prev }; delete n[v.video_id]; return n; })}>Vymazat přepis</Button> </VStack>
</HStack> </Box>
)} ))}
</Box>
</VStack>
</Box>
))}
</SimpleGrid> </SimpleGrid>
{autoVideos.length === 0 && ( {combinedAutoPreview.count === 0 && (
<Text color="gray.600">Žádná videa v cache. Zkontrolujte YouTube URL v nastavení a použijte Aktualizovat cache.</Text> <Text color="gray.600">Zatím žádná videa.</Text>
)} )}
</> </>
)} )}
</> </>
) : ( ) : null}
<>
<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>
)}
</>
)}
</Box> </Box>
<HStack justify="space-between" mb={3}> <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> <Button colorScheme="blue" leftIcon={<FiSave />} onClick={save} isLoading={saving}>Uložit</Button>
</HStack> </HStack>
<Divider my={3} /> <Modal isOpen={isAddOpen} onClose={onCloseAdd} size="4xl">
<ModalOverlay />
{loading ? ( <ModalContent>
<Text>Načítání</Text> <ModalHeader>Přidat video</ModalHeader>
) : videosSource === 'auto' ? ( <ModalCloseButton />
<Text color="gray.600">Automatický zdroj videí je aktivní. Pro ruční správu přepněte zdroj na Ručně.</Text> <ModalBody>
) : ( <Tabs variant="enclosed">
<VStack align="stretch" spacing={4}> <TabList>
{items.map((it, idx) => ( <Tab>Odkaz na video</Tab>
<Box key={idx} borderWidth="1px" borderRadius="md" p={3}> <Tab>Načíst z YouTube kanálu</Tab>
<HStack justify="space-between"> </TabList>
<Heading size="sm">Video #{idx + 1}</Heading> <TabPanels>
</HStack> <TabPanel>
<SimpleGrid columns={{ base: 1, md: 2 }} spacing={3} mt={3}> <VStack align="stretch" spacing={3}>
<FormControl> <FormControl>
<FormLabel>URL videa</FormLabel> <FormLabel>URL videa</FormLabel>
<Input value={it.url} onChange={(e) => updateField(idx, 'url', e.target.value)} placeholder="https://www.youtube.com/watch?v=..." /> <Input placeholder="https://www.youtube.com/watch?v=..." value={directUrl} onChange={(e) => setDirectUrl(e.target.value)} />
</FormControl> </FormControl>
<FormControl> <HStack>
<FormLabel>Thumbnail (volitelné)</FormLabel> <Button colorScheme="green" onClick={async () => { await addDirectLink(); onCloseAdd(); }}>Přidat</Button>
<Input value={it.thumbnail_url || ''} onChange={(e) => updateField(idx, 'thumbnail_url', e.target.value)} placeholder="https://example.com/thumb.jpg" /> </HStack>
</FormControl> </VStack>
<FormControl> </TabPanel>
<FormLabel>Název (volitelné)</FormLabel> <TabPanel>
<Input value={it.title || ''} onChange={(e) => updateField(idx, 'title', e.target.value)} placeholder="Titulek videa" /> <VStack align="stretch" spacing={3}>
</FormControl> <Text fontSize="sm" color="gray.600">
<FormControl> 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>
<FormLabel>Délka (volitelné)</FormLabel> </Text>
<Input value={it.length || ''} onChange={(e) => updateField(idx, 'length', e.target.value)} placeholder="3:45" /> <HStack align="start" spacing={3} flexWrap="wrap">
</FormControl> <FormControl maxW={{ base: '100%', md: '400px' }}>
<FormControl> <FormLabel>Kanál (handle nebo URL)</FormLabel>
<FormLabel>Datum nahrání (volitelné)</FormLabel> <Input id="admin-videos-channel-input" placeholder="@FCBizoniUH nebo https://www.youtube.com/@FCBizoniUH/videos" value={channelInput} onChange={(e) => setChannelInput(e.target.value)} />
<HStack> </FormControl>
<Input type="date" value={(it.uploaded_at || '').slice(0,10)} onChange={(e) => updateField(idx, 'uploaded_at', e.target.value)} /> <Button onClick={fetchChannelVideos} isLoading={ytLoading} variant="outline" flexShrink={0} minW="max-content">Načíst videa</Button>
<Tooltip label="Dnes"> <Button colorScheme="green" onClick={async () => { await importSelected(); onCloseAdd(); }} isDisabled={ytVideos.length === 0} flexShrink={0} minW="max-content">Přidat vybraná</Button>
<Button size="sm" variant="outline" onClick={() => setDateQuick(idx, 0)}>Dnes</Button> </HStack>
</Tooltip> {ytError && (
<Tooltip label="Včera"> <Alert status="error" borderRadius="md">
<Button size="sm" variant="outline" onClick={() => setDateQuick(idx, 1)}>Včera</Button> <AlertIcon />
</Tooltip> {ytError}
<Tooltip label="Před týdnem"> </Alert>
<Button size="sm" variant="outline" onClick={() => setDateQuick(idx, 7)}>7 dní</Button> )}
</Tooltip> {ytLoading && (
<Tooltip label="Vymazat datum"> <HStack color="gray.600"><Spinner size="sm" /><Text>Načítám videa</Text></HStack>
<Button size="sm" variant="ghost" onClick={() => updateField(idx, 'uploaded_at', '')}>Vymazat</Button> )}
</Tooltip> {!ytLoading && ytVideos.length > 0 && (
</HStack> <SimpleGrid columns={{ base: 1, sm: 2, md: 3, lg: 4 }} spacing={3}>
</FormControl> {ytVideos.map((v) => (
</SimpleGrid> <Box key={v.video_id} borderWidth="1px" borderRadius="md" p={2}>
<HStack justify="flex-end" mt={2}> <VStack align="stretch" spacing={2}>
<IconButton aria-label="Smazat" icon={<FiTrash2 />} onClick={() => removeItem(idx)} variant="outline" colorScheme="red" /> <Image src={v.thumbnail_url} alt={v.title} borderRadius="md" />
</HStack> <Checkbox isChecked={!!selectedIds[v.video_id]} onChange={() => toggleSelect(v.video_id)}>
</Box> Vybrat
))} </Checkbox>
{items.length === 0 && ( <Box>
<Text color="gray.600">Zatím žádná videa. Použijte tlačítko Přidat video.</Text> <Text fontWeight="semibold" noOfLines={2}>{v.title}</Text>
)} <HStack spacing={2} color="gray.600" fontSize="sm">
</VStack> {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> </Box>
</AdminLayout> </AdminLayout>
); );
+1 -10
View File
@@ -15,7 +15,7 @@ type BannerPreset = {
width: number; width: number;
height: number; height: number;
aspectRatio: number; aspectRatio: number;
position: 'top' | 'middle' | 'sidebar' | 'footer' | 'article' | 'under_table'; position: 'top' | 'middle' | 'footer' | 'article' | 'under_table';
}; };
const BANNER_PRESETS: BannerPreset[] = [ const BANNER_PRESETS: BannerPreset[] = [
@@ -28,15 +28,6 @@ const BANNER_PRESETS: BannerPreset[] = [
aspectRatio: 3.88, aspectRatio: 3.88,
position: 'middle' 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', value: 'homepage_footer',
label: 'Spodní banner (Homepage - zápatí)', label: 'Spodní banner (Homepage - zápatí)',
+245 -247
View File
@@ -37,6 +37,8 @@ import {
ModalBody, ModalBody,
ModalFooter, ModalFooter,
ModalCloseButton, ModalCloseButton,
Checkbox,
CheckboxGroup,
Wrap, Wrap,
WrapItem, WrapItem,
} from '@chakra-ui/react'; } from '@chakra-ui/react';
@@ -58,6 +60,11 @@ import {
} from '../../services/admin/engagement'; } from '../../services/admin/engagement';
import { FiTrash2, FiEdit2 } from 'react-icons/fi'; import { FiTrash2, FiEdit2 } from 'react-icons/fi';
import api from '../../services/api'; 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 EngagementAdminPage: React.FC = () => {
const toast = useToast(); const toast = useToast();
@@ -77,30 +84,25 @@ const EngagementAdminPage: React.FC = () => {
const [form, setForm] = React.useState({ const [form, setForm] = React.useState({
name: '', name: '',
type: 'avatar_static', type: 'merch_digital',
cost_points: 50, cost_points: 50,
image_url: '', image_url: '',
stock: -1, stock: -1,
active: true, 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 [editItem, setEditItem] = React.useState<AdminRewardItem | null>(null);
const editModal = useDisclosure(); const editModal = useDisclosure();
const [editForm, setEditForm] = React.useState<Partial<AdminRewardItem>>({}); const [editForm, setEditForm] = React.useState<Partial<AdminRewardItem>>({});
// Remove raw JSON editing, keep structured metadata only // 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.) // Structured metadata state (used for merch types, coupons, etc.)
const fileInputRef = React.useRef<HTMLInputElement | null>(null); const fileInputRef = React.useRef<HTMLInputElement | null>(null);
const [meta, setMeta] = React.useState<Record<string, any>>({}); const [meta, setMeta] = React.useState<Record<string, any>>({});
@@ -119,33 +121,7 @@ const EngagementAdminPage: React.FC = () => {
return m; return m;
}, [usersQ.data]); }, [usersQ.data]);
// Reward template selector instead of many buttons // Removed reward templates UI
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;
}
};
const handleUpload = async (file?: File) => { const handleUpload = async (file?: File) => {
try { try {
@@ -190,12 +166,18 @@ const EngagementAdminPage: React.FC = () => {
const createMut = useMutation({ const createMut = useMutation({
mutationFn: async () => { mutationFn: async () => {
// Auto-generate metadata from structured fields // Build metadata including structured helpers
const metadata = Object.keys(meta).length ? meta : undefined; 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 }); return adminCreateReward({ ...form, metadata });
}, },
onSuccess: async () => { 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'] }); await qc.invalidateQueries({ queryKey: ['admin-engagement-rewards'] });
toast({ status: 'success', title: 'Odměna vytvořena' }); 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' }); }, 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 rewards = rewardsQ.data || [];
const redemptions = redemptionsQ.data || []; const redemptions = redemptionsQ.data || [];
@@ -316,26 +273,7 @@ const EngagementAdminPage: React.FC = () => {
<Box> <Box>
<Heading size="sm" mb={2}>Vytvořit novou odměnu</Heading> <Heading size="sm" mb={2}>Vytvořit novou odměnu</Heading>
<VStack align="stretch" spacing={3} borderWidth="1px" borderRadius="md" p={3}> <VStack align="stretch" spacing={3} borderWidth="1px" borderRadius="md" p={3}>
<Wrap spacing={2}> {/* Šablony odměn odstraněny */}
<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>
<HStack align="start" spacing={4}> <HStack align="start" spacing={4}>
<VStack align="stretch" spacing={3} flex={1}> <VStack align="stretch" spacing={3} flex={1}>
<FormControl> <FormControl>
@@ -345,13 +283,9 @@ const EngagementAdminPage: React.FC = () => {
<FormControl> <FormControl>
<FormLabel>Typ odměny</FormLabel> <FormLabel>Typ odměny</FormLabel>
<Select value={form.type} onChange={(e) => setForm({ ...form, type: e.target.value })}> <Select value={form.type} onChange={(e) => setForm({ ...form, type: e.target.value })}>
<option value="avatar_static">Avatar (statický)</option> <option value="merch_digital">Digitální odměna</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_coupon">Merch kupon</option> <option value="merch_coupon">Merch kupon</option>
<option value="merch_physical">Merch (fyzický)</option> <option value="merch_physical">Merch (fyzický)</option>
<option value="merch_digital">Merch (digitální)</option>
<option value="custom">Vlastní</option> <option value="custom">Vlastní</option>
</Select> </Select>
<FormHelperText>Ovlivňuje chování po uplatnění (např. nastavení avataru).</FormHelperText> <FormHelperText>Ovlivňuje chování po uplatnění (např. nastavení avataru).</FormHelperText>
@@ -364,36 +298,48 @@ const EngagementAdminPage: React.FC = () => {
</NumberInput> </NumberInput>
<FormHelperText>~ {Math.round(Number(form.cost_points || 0) * 0.1)} </FormHelperText> <FormHelperText>~ {Math.round(Number(form.cost_points || 0) * 0.1)} </FormHelperText>
</FormControl> </FormControl>
{(form.type !== 'avatar_upload_unlock' && form.type !== 'avatar_animated_upload_unlock') && ( <FormControl>
<FormControl> <FormLabel>Množství/Sklad</FormLabel>
<FormLabel>Sklad</FormLabel> <HStack>
<NumberInput value={form.stock} min={-1} onChange={(_v, n) => setForm({ ...form, stock: Number.isFinite(n) ? n : -1 })}> <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)" /> <NumberInputField placeholder="Ks (-1 = neomezeně, 0 = vyprodáno)" />
</NumberInput> </NumberInput>
</FormControl> <HStack>
)} <Text fontSize="sm">Neomezeně</Text>
</HStack> <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) }))} />
{(form.type === 'avatar_static' || form.type === 'avatar_animated') && ( </HStack>
<>
<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> </HStack>
</> <FormHelperText>-1 = neomezeně, 0 = dočasně vyprodáno. Sklad platí pro ne-avatarové odměny.</FormHelperText>
)} </FormControl>
<VStack align="stretch" spacing={2}> </HStack>
<>
<FormControl> <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> <FormLabel>Platnost od</FormLabel>
<Input type="datetime-local" value={meta.valid_from || ''} onChange={(e)=>setMetaField('valid_from', e.target.value)} /> <Input type="datetime-local" value={meta.valid_from || ''} onChange={(e)=>setMetaField('valid_from', e.target.value)} />
</FormControl> </FormControl>
<FormControl> <FormControl isDisabled={validUnlimited}>
<FormLabel>Platnost do</FormLabel> <FormLabel>Platnost do</FormLabel>
<Input type="datetime-local" value={meta.valid_to || ''} onChange={(e)=>setMetaField('valid_to', e.target.value)} /> <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> </FormControl>
</VStack> </VStack>
{/* Metadata helpers */} {/* Metadata helpers */}
@@ -411,12 +357,55 @@ const EngagementAdminPage: React.FC = () => {
)} )}
{form.type === 'merch_physical' && ( {form.type === 'merch_physical' && (
<VStack align="stretch" spacing={2}> <VStack align="stretch" spacing={2}>
<FormControl><FormLabel>SKU</FormLabel><Input value={meta.sku || ''} onChange={(e)=>setMetaField('sku', e.target.value)} /></FormControl> <FormControl>
<HStack> <FormLabel>SKU</FormLabel>
<FormControl><FormLabel>Velikost</FormLabel><Input value={meta.size || ''} onChange={(e)=>setMetaField('size', e.target.value)} placeholder="M / L / XL" /></FormControl> <Input value={meta.sku || ''} onChange={(e)=>setMetaField('sku', e.target.value)} />
<FormControl><FormLabel>Barva</FormLabel><Input value={meta.color || ''} onChange={(e)=>setMetaField('color', e.target.value)} /></FormControl> <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>
</HStack> </FormControl>
<FormControl><FormLabel>Poznámka</FormLabel><Input value={meta.note || ''} onChange={(e)=>setMetaField('note', e.target.value)} /></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> </VStack>
)} )}
{form.type === 'merch_digital' && ( {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> <Button colorScheme="blue" onClick={() => createMut.mutate()} isLoading={createMut.isPending} isDisabled={!form.name.trim()}>Vytvořit</Button>
</HStack> </HStack>
</VStack> </VStack>
{(form.type === 'avatar_static' || form.type === 'avatar_animated') && ( <Box>
<Box> <Text fontSize="sm" mb={2} color="gray.500">Náhled</Text>
<Text fontSize="sm" mb={2} color="gray.500">Náhled</Text> <Box borderWidth="1px" borderRadius="md" p={2}>
<Box borderWidth="1px" borderRadius="md" p={2}> {form.image_url ? (
{form.image_url ? ( <Image src={assetUrl(form.image_url)} alt={form.name} boxSize="96px" objectFit="cover" borderRadius="md" />
<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 boxSize="96px" borderWidth="1px" borderRadius="md" display="flex" alignItems="center" justifyContent="center" color="gray.400">Bez obrázku</Box> )}
)}
</Box>
</Box> </Box>
)} </Box>
</HStack> </HStack>
</VStack> </VStack>
</Box> </Box>
@@ -483,7 +470,7 @@ const EngagementAdminPage: React.FC = () => {
<Th>Název</Th> <Th>Název</Th>
<Th>Typ</Th> <Th>Typ</Th>
<Th>Body</Th> <Th>Body</Th>
<Th>Sklad</Th> <Th>Množství/Sklad</Th>
<Th>Obrázek</Th> <Th>Obrázek</Th>
<Th>Platnost</Th> <Th>Platnost</Th>
<Th>Aktivní</Th> <Th>Aktivní</Th>
@@ -497,7 +484,7 @@ const EngagementAdminPage: React.FC = () => {
<Td>{r.name}</Td> <Td>{r.name}</Td>
<Td><Badge>{r.type}</Badge></Td> <Td><Badge>{r.type}</Badge></Td>
<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 /> <NumberInputField />
</NumberInput> </NumberInput>
</Td> </Td>
@@ -507,13 +494,13 @@ const EngagementAdminPage: React.FC = () => {
value={r.stock ?? 0} value={r.stock ?? 0}
min={-1} min={-1}
maxW="100px" 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 } })} onChange={(_v, n) => updateMut.mutate({ id: r.id, body: { stock: Number.isFinite(n) ? n : 0 } })}
> >
<NumberInputField /> <NumberInputField />
</NumberInput> </NumberInput>
</Td> </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> <Td>
{(() => { {(() => {
const m = (r.metadata || {}) as any; const m = (r.metadata || {}) as any;
@@ -531,14 +518,27 @@ const EngagementAdminPage: React.FC = () => {
<Td> <Td>
<Switch <Switch
isChecked={!!r.active} 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 } })} onChange={(e) => updateMut.mutate({ id: r.id, body: { active: e.target.checked } })}
/> />
</Td> </Td>
<Td> <Td>
<HStack> <HStack>
<IconButton aria-label="Upravit" size="xs" icon={<FiEdit2 />} onClick={() => { setEditItem(r); setEditForm(r); setEditMeta(r.metadata || {}); editModal.onOpen(); }} /> <IconButton aria-label="Upravit" size="xs" icon={<FiEdit2 />} onClick={() => {
{r.type !== 'avatar_upload_unlock' && ( 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)} /> <IconButton aria-label="Smazat" size="xs" icon={<FiTrash2 />} onClick={() => deleteMut.mutate(r.id)} />
)} )}
</HStack> </HStack>
@@ -619,74 +619,125 @@ const EngagementAdminPage: React.FC = () => {
<VStack align="stretch" spacing={3}> <VStack align="stretch" spacing={3}>
<FormControl> <FormControl>
<FormLabel>Název</FormLabel> <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>
<FormControl> <FormControl>
<FormLabel>Typ</FormLabel> <FormLabel>Typ</FormLabel>
<Select value={editForm.type || ''} onChange={(e)=>setEditForm({ ...editForm, type: e.target.value })} isDisabled={editItem?.type === 'avatar_upload_unlock'}> <Select value={editForm.type || ''} onChange={(e)=>setEditForm({ ...editForm, type: e.target.value })} isDisabled={!!editItem?.type && editItem.type.startsWith('avatar_')}>
<option value="avatar_static">Avatar (statický)</option> <option value="merch_digital">Digitální odměna</option>
<option value="avatar_animated">Avatar (animovaný)</option>
<option value="avatar_upload_unlock">Odemknutí vlastního avataru</option>
<option value="merch_coupon">Merch kupon</option> <option value="merch_coupon">Merch kupon</option>
<option value="merch_physical">Merch (fyzický)</option> <option value="merch_physical">Merch (fyzický)</option>
<option value="merch_digital">Merch (digitální)</option>
<option value="custom">Vlastní</option> <option value="custom">Vlastní</option>
</Select> </Select>
</FormControl> </FormControl>
<HStack> <HStack>
<FormControl> <FormControl>
<FormLabel>Body</FormLabel> <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 /> <NumberInputField />
</NumberInput> </NumberInput>
<FormHelperText>~ {Math.round(Number(editForm.cost_points || 0) * 0.1)} </FormHelperText> <FormHelperText>~ {Math.round(Number(editForm.cost_points || 0) * 0.1)} </FormHelperText>
</FormControl> </FormControl>
<FormControl> <FormControl>
<FormLabel>Sklad</FormLabel> <FormLabel>Množství/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'}> <HStack>
<NumberInputField /> <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 })}>
</NumberInput> <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> </FormControl>
</HStack> </HStack>
<FormControl> <FormControl>
<FormLabel>Obrázek URL</FormLabel> <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> </FormControl>
<HStack> <HStack>
<input ref={editFileInputRef} type="file" accept="image/*" style={{ display: 'none' }} onChange={(e)=>handleUploadEdit(e.target.files?.[0])} /> <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> </HStack>
{/* Edit metadata helpers (structured) */} {/* Edit metadata helpers (structured) */}
{ (editForm.type === 'merch_coupon' || editForm.type === 'merch_physical' || editForm.type === 'merch_digital' || editForm.type === 'custom') && ( { (editForm.type === 'merch_coupon' || editForm.type === 'merch_physical' || editForm.type === 'merch_digital' || editForm.type === 'custom') && (
<VStack align="stretch" spacing={2}> <VStack align="stretch" spacing={2}>
{editForm.type === 'merch_coupon' && ( {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>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 === 'avatar_upload_unlock'} value={(editMeta as any).note || ''} onChange={(e)=>setEditMetaField('note', 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' && ( {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> <FormControl>
<HStack> <FormLabel>SKU</FormLabel>
<FormControl><FormLabel>Velikost</FormLabel><Input isDisabled={editItem?.type === 'avatar_upload_unlock'} value={(editMeta as any).size || ''} onChange={(e)=>setEditMetaField('size', e.target.value)} /></FormControl> <Input isDisabled={!!editItem?.type && editItem.type.startsWith('avatar_')} value={(editMeta as any).sku || ''} onChange={(e)=>setEditMetaField('sku', e.target.value)} />
<FormControl><FormLabel>Barva</FormLabel><Input isDisabled={editItem?.type === 'avatar_upload_unlock'} value={(editMeta as any).color || ''} onChange={(e)=>setEditMetaField('color', e.target.value)} /></FormControl> <FormHelperText>Interní kód produktu (volitelné).</FormHelperText>
</HStack> </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>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' && ( {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>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 === 'avatar_upload_unlock'} value={(editMeta as any).download_url || ''} onChange={(e)=>setEditMetaField('download_url', 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 === 'avatar_upload_unlock'} value={(editMeta as any).note || ''} onChange={(e)=>setEditMetaField('note', 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' && ( {editForm.type === 'custom' && (
<HStack> <HStack>
<Input placeholder="klíč" id="edit-kv-key" isDisabled={editItem?.type === 'avatar_upload_unlock'} /> <Input placeholder="klíč" id="edit-kv-key" isDisabled={!!editItem?.type && editItem.type.startsWith('avatar_')} />
<Input placeholder="hodnota" id="edit-kv-value" isDisabled={editItem?.type === 'avatar_upload_unlock'} /> <Input placeholder="hodnota" id="edit-kv-value" isDisabled={!!editItem?.type && editItem.type.startsWith('avatar_')} />
<Button size="sm" isDisabled={editItem?.type === 'avatar_upload_unlock'} onClick={()=>{ <Button size="sm" isDisabled={!!editItem?.type && editItem.type.startsWith('avatar_')} onClick={()=>{
const k = (document.getElementById('edit-kv-key') as HTMLInputElement)?.value?.trim(); const k = (document.getElementById('edit-kv-key') as HTMLInputElement)?.value?.trim();
const v = (document.getElementById('edit-kv-value') as HTMLInputElement)?.value?.trim(); const v = (document.getElementById('edit-kv-value') as HTMLInputElement)?.value?.trim();
if (!k) return; if (!k) return;
@@ -697,20 +748,28 @@ const EngagementAdminPage: React.FC = () => {
</VStack> </VStack>
)} )}
<VStack align="stretch" spacing={2}> <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> <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> <FormControl isDisabled={!!editItem?.type && editItem.type.startsWith('avatar_') || (!(editMeta as any).valid_from && !(editMeta as any).valid_to)}>
<FormLabel>Platnost do</FormLabel> <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> </FormControl>
</VStack> </VStack>
{/* Odstraněno: ruční JSON metadata v editoru. */} {/* Odstraněno: ruční JSON metadata v editoru. */}
<HStack> <HStack>
<Text>Aktivní</Text> <Text>Aktivní</Text>
<Switch isChecked={!!editForm.active} onChange={(e)=>setEditForm({ ...editForm, active: e.target.checked })} isDisabled={editItem?.type === 'avatar_upload_unlock'} /> <Switch isChecked={!!editForm.active} onChange={(e)=>setEditForm({ ...editForm, active: e.target.checked })} isDisabled={!!editItem?.type && editItem.type.startsWith('avatar_')} />
{editForm.image_url ? <Image src={editForm.image_url} alt={String(editForm.name || '')} boxSize="56px" objectFit="cover" borderRadius="md" /> : null} {editForm.image_url ? <Image src={assetUrl(String(editForm.image_url))} alt={String(editForm.name || '')} boxSize="56px" objectFit="cover" borderRadius="md" /> : null}
</HStack> </HStack>
</VStack> </VStack>
</ModalBody> </ModalBody>
@@ -719,10 +778,20 @@ const EngagementAdminPage: React.FC = () => {
<Button onClick={editModal.onClose}>Zrušit</Button> <Button onClick={editModal.onClose}>Zrušit</Button>
<Button colorScheme="blue" isLoading={updateMut.isPending} onClick={async ()=>{ <Button colorScheme="blue" isLoading={updateMut.isPending} onClick={async ()=>{
if (!editItem) return; 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 } }); await updateMut.mutateAsync({ id: editItem.id, body: { cost_points: editForm.cost_points as any } });
} else { } 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: { await updateMut.mutateAsync({ id: editItem.id, body: {
name: editForm.name, name: editForm.name,
type: editForm.type, type: editForm.type,
@@ -730,7 +799,7 @@ const EngagementAdminPage: React.FC = () => {
stock: editForm.stock as any, stock: editForm.stock as any,
image_url: editForm.image_url, image_url: editForm.image_url,
active: editForm.active as any, active: editForm.active as any,
metadata: metadata as any, metadata: Object.keys(metadata).length ? metadata as any : undefined,
} as any }); } as any });
} }
editModal.onClose(); editModal.onClose();
@@ -740,78 +809,7 @@ const EngagementAdminPage: React.FC = () => {
</ModalContent> </ModalContent>
</Modal> </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> </AdminLayout>
); );
}; };
+39 -9
View File
@@ -37,7 +37,8 @@ import {
} from '@chakra-ui/react'; } from '@chakra-ui/react';
import { RefreshCw, ExternalLink, Calendar, Image as ImageIcon, Eye, Plus } from 'lucide-react'; import { RefreshCw, ExternalLink, Calendar, Image as ImageIcon, Eye, Plus } from 'lucide-react';
import AdminLayout from '../../layouts/AdminLayout'; import AdminLayout from '../../layouts/AdminLayout';
import api from '../../services/api'; import api, { API_URL } from '../../services/api';
import { getZoneramaManifestWithFallbacks } from '../../services/zonerama';
interface Album { interface Album {
id: string; id: string;
@@ -57,9 +58,8 @@ const resolveBackendUrl = (path: string) => {
try { try {
if (/^https?:\/\//i.test(path)) return path; if (/^https?:\/\//i.test(path)) return path;
if (path.startsWith('/cache') || path.startsWith('/uploads') || path.startsWith('/api/')) { 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 origin = new URL(API_URL, typeof window !== 'undefined' ? window.location.origin : 'http://localhost:3000').origin;
const b = new URL(base); const abs = new URL(path, origin);
const abs = new URL(path, `${b.protocol}//${b.host}`);
return abs.toString(); return abs.toString();
} }
return path; return path;
@@ -82,7 +82,7 @@ const GalleryAdminPage: React.FC = () => {
const [photoLimit, setPhotoLimit] = useState<number>(50); const [photoLimit, setPhotoLimit] = useState<number>(50);
const [adding, setAdding] = useState<boolean>(false); const [adding, setAdding] = useState<boolean>(false);
const fetchAlbums = async () => { const fetchAlbums = async (): Promise<Album[]> => {
setLoading(true); setLoading(true);
setError(''); setError('');
@@ -117,10 +117,34 @@ const GalleryAdminPage: React.FC = () => {
combinedAlbums = [...combinedAlbums, ...validBlogAlbums]; 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); setAlbums(combinedAlbums);
return combinedAlbums;
} catch (err: any) { } catch (err: any) {
setError(err.message || 'Nepodařilo se načíst alba'); setError(err.message || 'Nepodařilo se načíst alba');
return [];
} finally { } finally {
setLoading(false); setLoading(false);
} }
@@ -141,8 +165,14 @@ const GalleryAdminPage: React.FC = () => {
isClosable: true, isClosable: true,
}); });
// Reload albums after refresh // Reload albums after refresh with short polling (refresh runs async on server)
await fetchAlbums(); 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) { } catch (err: any) {
const errorMessage = err.response?.data?.error || err.message || 'Nepodařilo se obnovit galerii'; const errorMessage = err.response?.data?.error || err.message || 'Nepodařilo se obnovit galerii';
@@ -342,7 +372,7 @@ const GalleryAdminPage: React.FC = () => {
<Td> <Td>
{coverPhoto ? ( {coverPhoto ? (
<Image <Image
src={coverPhoto.image_1500} src={resolveBackendUrl(coverPhoto.image_1500)}
alt={album.title} alt={album.title}
boxSize="60px" boxSize="60px"
objectFit="cover" objectFit="cover"
@@ -243,6 +243,7 @@ const NavItemCard: React.FC<NavItemCardProps> = ({
}) => { }) => {
const hasChildren = item.children && item.children.length > 0; const hasChildren = item.children && item.children.length > 0;
const indentPx = level * 32; const indentPx = level * 32;
const isCategory = item.type === 'dropdown';
return ( return (
<Box ml={`${indentPx}px`}> <Box ml={`${indentPx}px`}>
@@ -375,12 +376,19 @@ const NavItemCard: React.FC<NavItemCardProps> = ({
</CardBody> </CardBody>
</Card> </Card>
{/* Render children with nested DnD if expanded */} {/* Always render a children Droppable for categories (dropdown type).
{hasChildren && isExpanded && ( This allows dropping into collapsed or empty categories. */}
{isCategory && (
<Droppable droppableId={childrenDroppableId || `children-${item.id}`}> <Droppable droppableId={childrenDroppableId || `children-${item.id}`}>
{(provided) => ( {(provided) => (
<VStack spacing={2} align="stretch" mt={2} ref={provided.innerRef} {...provided.droppableProps}> <VStack
{item.children!.map((child, childIndex) => ( 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}> <Draggable key={String(child.id)} draggableId={`${(draggableChildPrefix || 'child')}-${child.id}`} index={childIndex}>
{(dragProvided) => ( {(dragProvided) => (
<Box ref={dragProvided.innerRef} {...dragProvided.draggableProps} {...dragProvided.dragHandleProps}> <Box ref={dragProvided.innerRef} {...dragProvided.draggableProps} {...dragProvided.dragHandleProps}>
@@ -410,6 +418,10 @@ const NavItemCard: React.FC<NavItemCardProps> = ({
)} )}
</Draggable> </Draggable>
))} ))}
{/* Provide a minimal drop zone even when collapsed or empty */}
{!hasChildren && (
<Box minH="8px" />
)}
{provided.placeholder} {provided.placeholder}
</VStack> </VStack>
)} )}
@@ -521,6 +533,7 @@ const NavigationAdminPage = () => {
if (!result.destination) return; if (!result.destination) return;
const { source, destination } = result; const { source, destination } = result;
const parseAdminChildrenId = (id: string) => id.startsWith('admin-children-') ? parseInt(id.replace('admin-children-', ''), 10) : null; 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') { if (source.droppableId === 'frontend-nav') {
const items = Array.from(navItems); const items = Array.from(navItems);
@@ -554,7 +567,7 @@ const NavigationAdminPage = () => {
(source.droppableId.startsWith('admin-children-') && destination.droppableId === 'admin-nav') (source.droppableId.startsWith('admin-children-') && destination.droppableId === 'admin-nav')
) { ) {
const srcParentId = parseAdminChildrenId(source.droppableId); const srcParentId = parseAdminChildrenId(source.droppableId);
const destParentId = parseAdminChildrenId(destination.droppableId); let destParentId = parseAdminChildrenId(destination.droppableId);
const items = Array.from(adminNavItems); const items = Array.from(adminNavItems);
// Helper to find parent index by id // Helper to find parent index by id
@@ -563,6 +576,34 @@ const NavigationAdminPage = () => {
return items.findIndex((it) => it.id === pid); 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; let moved: NavigationItem | null = null;
// Remove from source list // Remove from source list
@@ -588,7 +629,7 @@ const NavigationAdminPage = () => {
const dIdx = findParentIndex(destParentId); const dIdx = findParentIndex(destParentId);
if (dIdx >= 0) { if (dIdx >= 0) {
const destChildren = Array.isArray(items[dIdx].children) ? Array.from(items[dIdx].children!) : []; 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; 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 // Persist parent change and reorder siblings at both source and destination
try { 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 // Reorder source siblings
if (srcParentId === null) { 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 }); toast({ title: 'Přesunuto', status: 'success', duration: 2000 });
} catch (error) { } catch (error) {
toast({ title: 'Chyba při přesunu', status: 'error', duration: 3000 }); toast({ title: 'Chyba při přesunu', status: 'error', duration: 3000 });
@@ -778,6 +914,7 @@ const NavigationAdminPage = () => {
target: '_self', target: '_self',
parent_id: parentId, parent_id: parentId,
requires_admin: forAdmin || false, requires_admin: forAdmin || false,
allow_editor: false,
} as NavigationItem); } as NavigationItem);
} }
onNavModalOpen(); onNavModalOpen();
@@ -1384,6 +1521,16 @@ const NavigationAdminPage = () => {
</FormControl> </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"> <FormControl display="flex" alignItems="center">
<FormLabel mb="0">Viditelné</FormLabel> <FormLabel mb="0">Viditelné</FormLabel>
<Switch <Switch
@@ -168,6 +168,13 @@ export default function NewsletterAdminPage() {
}; };
const detailsClearComps = () => setDetailsCompetitions(''); const detailsClearComps = () => setDetailsCompetitions('');
const detailsSelectAllComps = () => setDetailsCompetitions(compOptions.map(o => o.code).join(', ')); 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 recipientsForType = (t: MailType): string[] => {
const key = t === 'weekly' ? 'weekly' : t; const key = t === 'weekly' ? 'weekly' : t;
return subscribers return subscribers
@@ -229,6 +236,22 @@ export default function NewsletterAdminPage() {
})(); })();
} }
}, [detailsOpen, activeType]); }, [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 { isOpen, onOpen, onClose } = useDisclosure();
const testModal = 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 // Filter subscribers based on search term
const filteredSubscribers = subscribers.filter((subscriber) => const filteredSubscribers = subscribers.filter((subscriber) =>
subscriber.email.toLowerCase().includes(searchTerm.toLowerCase()) subscriber.email.toLowerCase().includes(searchTerm.toLowerCase())
@@ -655,6 +672,11 @@ export default function NewsletterAdminPage() {
</HStack> </HStack>
<HStack spacing={4}> <HStack spacing={4}>
<Text color={textSecondary}>Příjemci: <b>{count}</b></Text> <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> <Button size="sm" onClick={()=> openDetails(t)}>Detail</Button>
</HStack> </HStack>
</Flex> </Flex>
@@ -1028,28 +1050,29 @@ export default function NewsletterAdminPage() {
</HStack> </HStack>
<Box mt={2} p={3} bg={useColorModeValue('gray.50', 'gray.900')} borderRadius="md" borderWidth="1px"> <Box mt={2} p={3} bg={useColorModeValue('gray.50', 'gray.900')} borderRadius="md" borderWidth="1px">
<Box <Box
bg={cardBg} className="ql-editor"
p={3} p={3}
borderRadius="md" borderRadius="md"
borderWidth="1px" borderWidth="1px"
dangerouslySetInnerHTML={{ __html: sanitizeHtml(previewHtml || '<em>Náhled se zobrazí zde</em>') }} dangerouslySetInnerHTML={{ __html: sanitizeHtml(previewHtml || '<em>Náhled se zobrazí zde</em>') }}
/> />
</Box> </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"> </VStack>
<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>
</ModalBody> </ModalBody>
<ModalFooter> <ModalFooter>
<Button variant="ghost" mr={3} onClick={onClose}> <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> <Button variant="outline" onClick={()=>{ if(!activeType) return; exportRecipientsCSV(activeType, detailsCompetitions); }}>Export CSV</Button>
</HStack> </HStack>
<Box p={3} bg={useColorModeValue('gray.50', 'gray.900')} borderRadius="md" borderWidth="1px"> <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>
<Box> <Box>
<Heading size="sm" mb={2}>Příjemci</Heading> <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 { import {
Box, Box,
Button, Button,
@@ -58,6 +58,7 @@ import {
getQr, getQr,
uploadQr, uploadQr,
deleteQr, deleteQr,
swapSides,
} from '@/services/scoreboard'; } from '@/services/scoreboard';
import { useFacrApi } from '@/hooks/useFacrApi'; import { useFacrApi } from '@/hooks/useFacrApi';
import { SearchResult } from '@/services/facr/types'; import { SearchResult } from '@/services/facr/types';
@@ -66,6 +67,7 @@ import { useQuery } from '@tanstack/react-query';
import { AdminMatch, fetchAdminMatches, fetchTeamLogoOverrides } from '@/services/adminMatches'; import { AdminMatch, fetchAdminMatches, fetchTeamLogoOverrides } from '@/services/adminMatches';
import { getFacrClubInfoCache } from '@/services/facr/cache'; import { getFacrClubInfoCache } from '@/services/facr/cache';
import { createSponsor } from '@/services/sponsors'; import { createSponsor } from '@/services/sponsors';
import { pickTextColor } from '@/utils/colors';
const themes: ScoreboardTheme[] = ['classic', 'pill', 'var1', 'var2', 'var3', 'var4']; const themes: ScoreboardTheme[] = ['classic', 'pill', 'var1', 'var2', 'var3', 'var4'];
@@ -79,12 +81,28 @@ const resolveLogoUrl = (u?: string | null) => {
return u; 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 ScoreboardAdminPage: React.FC = () => {
const cardBg = useColorModeValue('white', 'gray.800'); const cardBg = useColorModeValue('white', 'gray.800');
const borderColor = useColorModeValue('gray.200', 'gray.700'); const borderColor = useColorModeValue('gray.200', 'gray.700');
const inputBg = useColorModeValue('white', 'gray.700'); const inputBg = useColorModeValue('white', 'gray.700');
const [state, setState] = useState<ScoreboardState | null>(null); const [state, setState] = useState<ScoreboardState | null>(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const deferredState = useDeferredValue(state);
const toast = useToast(); const toast = useToast();
const [activeTab, setActiveTab] = useState<number>(0); // 0 upcoming, 1 recent const [activeTab, setActiveTab] = useState<number>(0); // 0 upcoming, 1 recent
// Presets & sponsors state // Presets & sponsors state
@@ -96,6 +114,48 @@ const ScoreboardAdminPage: React.FC = () => {
const [qrBusy, setQrBusy] = useState(false); const [qrBusy, setQrBusy] = useState(false);
const { isOpen: isSponsorModalOpen, onOpen: openSponsorModal, onClose: closeSponsorModal } = useDisclosure(); const { isOpen: isSponsorModalOpen, onOpen: openSponsorModal, onClose: closeSponsorModal } = useDisclosure();
const [uploadedSponsorUrls, setUploadedSponsorUrls] = useState<string[]>([]); 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) // Club search inline (home/away target)
const [clubQuery, setClubQuery] = useState(''); 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 // Poll while timer is running to reflect live time
useEffect(() => { useEffect(() => {
if (!state?.running) return; if (!state?.running || isPickingColor) return;
let mounted = true; let mounted = true;
const id = setInterval(async () => { const id = setInterval(async () => {
try { try {
@@ -128,7 +198,7 @@ const ScoreboardAdminPage: React.FC = () => {
mounted = false; mounted = false;
clearInterval(id); clearInterval(id);
}; };
}, [state?.running]); }, [state?.running, isPickingColor]);
// Load matches for linking // Load matches for linking
const { data: adminMatches = [] } = useQuery<AdminMatch[]>({ const { data: adminMatches = [] } = useQuery<AdminMatch[]>({
@@ -342,10 +412,14 @@ const ScoreboardAdminPage: React.FC = () => {
const homeName = getOverrideName(rawHomeName, homeTeamId) || rawHomeName; const homeName = getOverrideName(rawHomeName, homeTeamId) || rawHomeName;
const awayName = getOverrideName(rawAwayName, awayTeamId) || rawAwayName; const awayName = getOverrideName(rawAwayName, awayTeamId) || rawAwayName;
// Prefer ID-based logo override, then name-based, then original logo URL // 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 homeLogoRaw = (homeTeamId && byId?.[homeTeamId]?.logo_url)
const awayLogoOverride = (awayTeamId && byId?.[awayTeamId]?.logo_url) ? String(byId[awayTeamId].logo_url) : getLogo(rawAwayName, m.away_logo_url || (m as any).awayLogoURL || ''); ? String(byId[homeTeamId].logo_url)
const homeLogo = resolveLogoUrl(homeLogoOverride || '') || ''; : getLogo(rawHomeName, m.home_logo_url || (m as any).homeLogoURL || '');
const awayLogo = resolveLogoUrl(awayLogoOverride || '') || ''; 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> = { const updates: Partial<ScoreboardState> = {
homeName, homeName,
awayName, awayName,
@@ -357,8 +431,8 @@ const ScoreboardAdminPage: React.FC = () => {
}; };
// Try to detect colors from logos // Try to detect colors from logos
const [cHome, cAway] = await Promise.all([ const [cHome, cAway] = await Promise.all([
derivePrimaryFromLogo(homeLogo || state.homeLogo), derivePrimaryFromLogo(deproxify(homeLogo || state.homeLogo)),
derivePrimaryFromLogo(awayLogo || state.awayLogo), derivePrimaryFromLogo(deproxify(awayLogo || state.awayLogo)),
]); ]);
if (cHome) updates.primaryColor = cHome; if (cHome) updates.primaryColor = cHome;
if (cAway) updates.secondaryColor = cAway; if (cAway) updates.secondaryColor = cAway;
@@ -368,20 +442,20 @@ const ScoreboardAdminPage: React.FC = () => {
}; };
const applyClub = async (club: SearchResult) => { const applyClub = async (club: SearchResult) => {
const logo = resolveLogoUrl(club.logo_url) || undefined; const logoRaw = club.logo_url || undefined;
const color = await derivePrimaryFromLogo(logo || undefined); const color = await derivePrimaryFromLogo(deproxify(logoRaw) || undefined);
if (assignTo === 'home') { if (assignTo === 'home') {
await setPartial({ await setPartial({
homeName: club.name || 'DOMÁCÍ', homeName: club.name || 'DOMÁCÍ',
homeShort: deriveShort(club.name || ''), homeShort: deriveShort(club.name || ''),
homeLogo: logo, homeLogo: logoRaw,
primaryColor: color || state?.primaryColor, primaryColor: color || state?.primaryColor,
}); });
} else { } else {
await setPartial({ await setPartial({
awayName: club.name || 'HOSTÉ', awayName: club.name || 'HOSTÉ',
awayShort: deriveShort(club.name || ''), awayShort: deriveShort(club.name || ''),
awayLogo: logo, awayLogo: logoRaw,
secondaryColor: color || state?.secondaryColor, secondaryColor: color || state?.secondaryColor,
}); });
} }
@@ -492,7 +566,7 @@ const ScoreboardAdminPage: React.FC = () => {
{/* Live preview */} {/* Live preview */}
<Box display="flex" justifyContent="center" mb={6}> <Box display="flex" justifyContent="center" mb={6}>
<ScoreboardPreview state={state} /> <ScoreboardPreview state={(deferredState || state) as ScoreboardState} />
</Box> </Box>
<SimpleGrid columns={{ base: 1, md: 2 }} spacing={6}> <SimpleGrid columns={{ base: 1, md: 2 }} spacing={6}>
@@ -547,6 +621,22 @@ const ScoreboardAdminPage: React.FC = () => {
await setPartial({ homeLogo: e.target.value }); 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>
<FormControl> <FormControl>
<FormLabel>Logo hostů (URL)</FormLabel> <FormLabel>Logo hostů (URL)</FormLabel>
@@ -556,6 +646,22 @@ const ScoreboardAdminPage: React.FC = () => {
await setPartial({ awayLogo: e.target.value }); 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>
<FormControl> <FormControl>
<FormLabel>Délka poločasu (min)</FormLabel> <FormLabel>Délka poločasu (min)</FormLabel>
@@ -586,19 +692,51 @@ const ScoreboardAdminPage: React.FC = () => {
<SimpleGrid columns={2} spacing={4}> <SimpleGrid columns={2} spacing={4}>
<FormControl> <FormControl>
<FormLabel>Barva domácích</FormLabel> <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>
<FormControl> <FormControl>
<FormLabel>Barva hostů</FormLabel> <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>
<FormControl> <FormControl>
<FormLabel>Barva textu domácích</FormLabel> <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>
<FormControl> <FormControl>
<FormLabel>Barva textu hostů</FormLabel> <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>
<FormControl> <FormControl>
<FormLabel>QR interval (minuty)</FormLabel> <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={() => 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={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={() => 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> </HStack>
<Divider my={6} /> <Divider my={6} />
+117 -36
View File
@@ -22,7 +22,7 @@ const SweepstakeVisualPage: React.FC = () => {
const toast = useToast(); const toast = useToast();
const [data, setData] = useState<VisualData | null>(null); const [data, setData] = useState<VisualData | null>(null);
const [loading, setLoading] = useState(true); 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 [theme, setTheme] = useState<'dark' | 'light'>('dark');
const [confettiOn, setConfettiOn] = useState<boolean>(true); const [confettiOn, setConfettiOn] = useState<boolean>(true);
const [soundOn, setSoundOn] = useState<boolean>(true); const [soundOn, setSoundOn] = useState<boolean>(true);
@@ -37,6 +37,16 @@ const SweepstakeVisualPage: React.FC = () => {
const imgCacheRef = useRef<Record<number, HTMLImageElement>>({}); const imgCacheRef = useRef<Record<number, HTMLImageElement>>({});
const [prizes, setPrizes] = useState<SweepstakePrize[]>([]); 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 entries = data?.entries || [];
const winners = data?.winners || []; const winners = data?.winners || [];
const { data: publicSettings } = usePublicSettings(); const { data: publicSettings } = usePublicSettings();
@@ -226,11 +236,69 @@ const SweepstakeVisualPage: React.FC = () => {
}, duration); }, duration);
}; };
const onStart = () => { const startRoulette = () => {
if (variant === 'cycler') startCycler(); if (!entries.length || revealIndex >= winners.length) return;
else startWheel(); 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 // Reveal All logic
const [revealAll, setRevealAll] = useState(false); const [revealAll, setRevealAll] = useState(false);
useEffect(() => { useEffect(() => {
@@ -257,8 +325,7 @@ const SweepstakeVisualPage: React.FC = () => {
const res = await adminGetVisualData(Number(id)); const res = await adminGetVisualData(Number(id));
if (!active) return; if (!active) return;
setData(res); setData(res);
const def = (res.sweepstake as any)?.picker_style; setVariant('roulette');
if (def === 'wheel' || def === 'cycler') setVariant(def);
try { const list = await adminListPrizes(Number(id)); if (active) setPrizes(list); } catch {} try { const list = await adminListPrizes(Number(id)); if (active) setPrizes(list); } catch {}
} catch (e: any) { } catch (e: any) {
toast({ status: 'error', title: 'Nelze načíst data vizualizace' }); 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); }; return () => { active = false; if (timerRef.current) window.clearTimeout(timerRef.current); };
}, [id]); }, [id]);
useEffect(() => { if (variant === 'wheel') drawWheel(); }, [variant, data, theme]); // Wheel variant removed no canvas redraw needed
if (loading) { if (loading) {
return ( return (
@@ -287,7 +354,6 @@ const SweepstakeVisualPage: React.FC = () => {
} }
const shownWinners = winners.slice(0, revealIndex); const shownWinners = winners.slice(0, revealIndex);
const current = entries[currentIdx];
return ( return (
<AdminLayout> <AdminLayout>
@@ -300,17 +366,26 @@ const SweepstakeVisualPage: React.FC = () => {
</HStack> </HStack>
</HStack> </HStack>
<HStack mb={4} spacing={4}> <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"> <Select value={theme} onChange={(e)=>setTheme(e.target.value as any)} maxW="200px">
<option value="dark">Tmavé pozadí</option> <option value="dark">Tmavé pozadí</option>
<option value="light">Světlé pozadí</option> <option value="light">Světlé pozadí</option>
</Select> </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> <HStack>
<Button size="sm" variant={confettiOn? 'solid':'outline'} onClick={()=>setConfettiOn(v=>!v)}>{confettiOn? 'Konfety: Zap' : 'Konfety: Vyp'}</Button> <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={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> </HStack>
<Button colorScheme="blue" onClick={onStart} isDisabled={playing || revealIndex >= winners.length}> <Button colorScheme="blue" onClick={onStart} isDisabled={playing || revealIndex >= winners.length}>
{revealIndex >= winners.length ? 'Všichni výherci odhaleni' : (playing ? 'Probíhá…' : 'Start')} {revealIndex >= winners.length ? 'Všichni výherci odhaleni' : (playing ? 'Probíhá…' : 'Start')}
@@ -331,31 +406,37 @@ const SweepstakeVisualPage: React.FC = () => {
</HStack> </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'}> <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">
<Center h="380px" flexDir="column"> <Box position="relative" w="100%" maxW="960px" h="220px">
<Text fontSize="sm" opacity={0.7} mb={2}>Losuji</Text> {/* pointer */}
<Text fontSize="5xl" fontWeight="800" textAlign="center">{(current?.display_name || '').trim() || '—'}</Text> <Box position="absolute" left="50%" top="10px" transform="translateX(-50%)" zIndex={3}
{current?.avatar_url && ( borderLeft="10px solid transparent" borderRight="10px solid transparent" borderBottom={`16px solid ${theme==='dark'?'#e2e8f0':'#1a202c'}`} />
// eslint-disable-next-line jsx-a11y/alt-text {/* center divider */}
<img src={current.avatar_url} style={{ width: 120, height: 120, borderRadius: '50%', marginTop: 16, objectFit: 'cover' }} /> <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)' }} />
</Center> {/* 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'}>
<Center h="380px" flexDir="column"> <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 }}>
<Box position="relative" w="440px" h="440px"> {stripItems.map((it, idx) => (
<Box position="absolute" left="50%" top="-2px" transform="translateX(-50%)" zIndex={2} <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)' }}>
borderLeft="10px solid transparent" borderRight="10px solid transparent" borderBottom={`18px solid ${theme==='dark'?'#e2e8f0':'#1a202c'}`} /> {it?.avatar_url ? (
<Box ref={wheelRef} position="absolute" inset={0} style={{ transform: `rotate(${wheelAngle}deg)`, transition: playing ? 'transform 4.2s cubic-bezier(.2,.8,.2,1)' : undefined }}> // eslint-disable-next-line jsx-a11y/alt-text
<canvas ref={canvasRef} width={440} height={440} style={{ width: 440, height: 440 }} /> <img src={it.avatar_url} style={{ width:'100%', height:'100%', objectFit:'cover' }} />
</Box> ) : (
{clubLogo && ( <span style={{ fontSize:24, fontWeight:800 }}>{(it?.display_name||'?').slice(0,1)}</span>
// 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)' }} /> </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> </Box>
<Text mt={4} opacity={0.8}>Kolo štěstí</Text> <Text mt={4} opacity={0.8} textAlign="center">Ruleta</Text>
</Center> </Box>
)} </Center>
</Box> </Box>
<VStack align="stretch" mt={6} spacing={2}> <VStack align="stretch" mt={6} spacing={2}>
@@ -62,6 +62,7 @@ import {
import { AddIcon, ArrowUpIcon, ArrowDownIcon, DeleteIcon, EditIcon } from '@chakra-ui/icons'; import { AddIcon, ArrowUpIcon, ArrowDownIcon, DeleteIcon, EditIcon } from '@chakra-ui/icons';
import { FiUpload } from 'react-icons/fi'; import { FiUpload } from 'react-icons/fi';
import { uploadFile, createArticle } from '../../services/articles'; import { uploadFile, createArticle } from '../../services/articles';
import { getImageUrl } from '../../utils/imageUtils';
const fmt = (iso?: string | null) => { const fmt = (iso?: string | null) => {
if (!iso) return ''; if (!iso) return '';
@@ -76,7 +77,7 @@ const defaultForm = {
rules_url: '', rules_url: '',
start_at: '', start_at: '',
end_at: '', end_at: '',
picker_style: 'wheel', picker_style: 'cycler',
total_prizes: 1, total_prizes: 1,
prize_summary: '', prize_summary: '',
entry_cost_points: 0, 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) => { const onUploadRules = async (file?: File | null) => {
if (!file) return; if (!file) return;
try { try {
@@ -219,12 +253,16 @@ const SweepstakesAdminPage: React.FC = () => {
const tpRaw = Number(form.total_prizes || 1); const tpRaw = Number(form.total_prizes || 1);
const tp = Number.isFinite(tpRaw) ? Math.floor(tpRaw) : 1; const tp = Number.isFinite(tpRaw) ? Math.floor(tpRaw) : 1;
const total_prizes = tp < 1 ? 1 : (tp > 100 ? 100 : tp); 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 // Normalize datetime-local (YYYY-MM-DDTHH:mm) to RFC3339 with timezone for Go backend
const s = new Date(form.start_at); const s = new Date(form.start_at);
const e = new Date(form.end_at); const e = new Date(form.end_at);
const payload = { const payload = {
...form, ...form,
total_prizes, total_prizes,
entry_cost_points,
max_entries_per_user,
start_at: isNaN(s.getTime()) ? form.start_at : s.toISOString(), start_at: isNaN(s.getTime()) ? form.start_at : s.toISOString(),
end_at: isNaN(e.getTime()) ? form.end_at : e.toISOString(), end_at: isNaN(e.getTime()) ? form.end_at : e.toISOString(),
}; };
@@ -360,7 +398,7 @@ const SweepstakesAdminPage: React.FC = () => {
<FormLabel>Titulní obrázek</FormLabel> <FormLabel>Titulní obrázek</FormLabel>
<VStack align="start" spacing={2}> <VStack align="start" spacing={2}>
<HStack> <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"> <Button as="label" leftIcon={<FiUpload />} variant="outline">
Nahrát 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' }); } }} /> <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])} /> <Input ref={rulesInputRef} type="file" display="none" accept="image/*,application/pdf" onChange={(e)=>onUploadRules(e.target.files?.[0])} />
</Button> </Button>
<Button variant="outline" onClick={onCreateRulesArticle}>Vytvořit stránku</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> </HStack>
<Input placeholder="nebo vložte URL" value={form.rules_url} onChange={(e)=>setForm({ ...form, rules_url: e.target.value })} /> <Input placeholder="nebo vložte URL" value={form.rules_url} onChange={(e)=>setForm({ ...form, rules_url: e.target.value })} />
</VStack> </VStack>
@@ -409,7 +447,7 @@ const SweepstakesAdminPage: React.FC = () => {
</FormControl> </FormControl>
<FormControl isInvalid={Number(form.total_prizes) < 1 || Number(form.total_prizes) > 100}> <FormControl isInvalid={Number(form.total_prizes) < 1 || Number(form.total_prizes) > 100}>
<FormLabel>Počet výherců</FormLabel> <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 /> <NumberInputField />
</NumberInput> </NumberInput>
<FormHelperText>Max. 100 výherců</FormHelperText> <FormHelperText>Max. 100 výherců</FormHelperText>
@@ -424,7 +462,7 @@ const SweepstakesAdminPage: React.FC = () => {
</FormControl> </FormControl>
<FormControl> <FormControl>
<FormLabel>Max. účastí / uživatel</FormLabel> <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 /> <NumberInputField />
</NumberInput> </NumberInput>
</FormControl> </FormControl>
@@ -436,20 +474,28 @@ const SweepstakesAdminPage: React.FC = () => {
<HStack> <HStack>
<Button size="sm" onClick={()=>setActiveTab(0)} variant="outline">Zpět na základní</Button> <Button size="sm" onClick={()=>setActiveTab(0)} variant="outline">Zpět na základní</Button>
<Button size="sm" onClick={async ()=>{ <Button size="sm" onClick={async ()=>{
if (!editing) { toast({ status:'info', title:'Uložte soutěž pro přidání výher' }); return; } if (!editing) { await ensureCreated(); }
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' }); } 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> }}>1× Hlavní výhra</Button>
<Button size="sm" onClick={async ()=>{ <Button size="sm" onClick={async ()=>{
if (!editing) { toast({ status:'info', title:'Uložte soutěž pro přidání výher' }); return; } if (!editing) { await ensureCreated(); }
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' }); } 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> }}>3× Menší výhry</Button>
<Button size="sm" onClick={async ()=>{ <Button size="sm" onClick={async ()=>{
if (!editing) { toast({ status:'info', title:'Uložte soutěž pro přidání výher' }); return; } if (!editing) { await ensureCreated(); }
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' }); } 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> }}>10× 100 bodů</Button>
<Button size="sm" onClick={async ()=>{ <Button size="sm" onClick={async ()=>{
if (!editing) { toast({ status:'info', title:'Uložte soutěž pro přidání výher' }); return; } if (!editing) { await ensureCreated(); }
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' }); } 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> }}>5× 500 XP</Button>
</HStack> </HStack>
<Divider /> <Divider />
+9 -1
View File
@@ -47,7 +47,15 @@ export async function patchMatchOverride(externalMatchId: string, payload: Parti
body.date_time_override = d.toISOString(); 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) { 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 = { export type RewardItem = {
id: number; id: number;
name: string; 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; cost_points: number;
image_url?: string; image_url?: string;
stock?: number; stock?: number;
+9
View File
@@ -16,6 +16,7 @@ export interface NavigationItem {
css_class?: string; css_class?: string;
requires_auth?: boolean; requires_auth?: boolean;
requires_admin?: boolean; requires_admin?: boolean;
allow_editor?: boolean;
} }
export interface SocialLink { export interface SocialLink {
@@ -50,6 +51,7 @@ function normalizeNavItem(raw: any): NavigationItem {
css_class: raw.css_class, css_class: raw.css_class,
requires_auth: raw.requires_auth, requires_auth: raw.requires_auth,
requires_admin: raw.requires_admin, requires_admin: raw.requires_admin,
allow_editor: raw.allow_editor,
} as NavigationItem; } as NavigationItem;
} }
@@ -104,6 +106,13 @@ export const reorderNavigationItems = async (orders: { id: number; display_order
await api.post(`/admin/navigation/reorder`, orders); 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 // Social links admin endpoints
export const getAllSocialLinks = async (): Promise<SocialLink[]> => { export const getAllSocialLinks = async (): Promise<SocialLink[]> => {
const response = await api.get(`/admin/social-links`); 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> { export async function saveScoreboardState(state: Partial<ScoreboardState>): Promise<ScoreboardState> {
const current = await getScoreboardState(); // Avoid an extra GET on every save: use the last known local snapshot as base
const next = { ...current, ...state } as ScoreboardState; let base: ScoreboardState = { ...DEFAULT_STATE } as ScoreboardState;
localStorage.setItem(STORAGE_KEY, JSON.stringify(next)); 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 // Attempt to persist to backend if admin
try { try {
await api.put('/admin/scoreboard', toApiPayload(state)); await api.put('/admin/scoreboard', toApiPayload(state));
+2
View File
@@ -51,6 +51,8 @@ export type CurrentSweepstakeResponse = {
state?: 'upcoming' | 'active' | 'finalized'; state?: 'upcoming' | 'active' | 'finalized';
has_entered?: boolean; has_entered?: boolean;
visual_played_at?: string | null; visual_played_at?: string | null;
my_entries_count?: number;
can_enter?: boolean;
}; };
export async function getCurrentSweepstake(): Promise<CurrentSweepstakeResponse> { export async function getCurrentSweepstake(): Promise<CurrentSweepstakeResponse> {
+30
View File
@@ -298,6 +298,36 @@
text-align: right; 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 { .ql-editor blockquote {
border-left: 4px solid #3182ce; border-left: 4px solid #3182ce;
padding-left: 16px; 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); box-shadow: var(--pack-shadow, none);
} }
/* Frontpage CTA card styling */
.newsletter-cta .card {
background: white;
padding: 30px;
}
/* Header & Footer tweaks */ /* Header & Footer tweaks */
[data-element="header"][data-variant="fullwidth"] { box-shadow: none; } [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)); } [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'); const ctx = canvas.getContext('2d');
if (!ctx) return resolve([]); if (!ctx) return resolve([]);
// Downscale for performance // Downscale for performance
const w = 160; // slightly larger for better color sampling const targetW = 160; // slightly larger for better color sampling
const h = Math.max(1, Math.round((img.height / img.width) * w)); // 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.width = w;
canvas.height = h; canvas.height = h;
ctx.drawImage(img, 0, 0, w, 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 u = new URL(candidate, window.location.origin);
const isData = u.protocol === 'data:'; const isData = u.protocol === 'data:';
const sameOriginAsWindow = u.origin === window.location.origin; 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) // Use direct URL only if it's same-origin with the window; otherwise proxy to enable CORS for Canvas
if (isData || sameOriginAsWindow || sameOriginAsBackend) { if (isData || sameOriginAsWindow) {
img.src = u.toString(); img.src = u.toString();
} else { } else {
// Otherwise, use backend proxy to obtain CORS-eligible bytes for Canvas // 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', avatar_upload_unlock: 'Odemknutí vlastního avataru',
merch_coupon: 'Slevový kupon', merch_coupon: 'Slevový kupon',
merch_physical: 'Fyzické zboží', merch_physical: 'Fyzické zboží',
merch_digital: 'Digitální produkt', merch_digital: 'Digitální odměna',
custom: 'Vlastní', custom: 'Vlastní',
}; };
return names[type] || type; return names[type] || type;
+2
View File
@@ -71,6 +71,7 @@ type Config struct {
ScraperBaseURL string ScraperBaseURL string
FrontendBaseURL string FrontendBaseURL string
PublicAPIBaseURL string PublicAPIBaseURL string
ZoneramaAPIBase string
// Umami Analytics // Umami Analytics
UmamiURL string UmamiURL string
@@ -181,6 +182,7 @@ func LoadConfig() {
ScraperBaseURL: getEnv("FACR_SCRAPER_BASE_URL", "http://localhost:8081"), ScraperBaseURL: getEnv("FACR_SCRAPER_BASE_URL", "http://localhost:8081"),
FrontendBaseURL: getEnv("FRONTEND_BASE_URL", "http://localhost:3000"), FrontendBaseURL: getEnv("FRONTEND_BASE_URL", "http://localhost:3000"),
PublicAPIBaseURL: getEnv("PUBLIC_API_BASE_URL", "http://localhost:8080/api/v1"), PublicAPIBaseURL: getEnv("PUBLIC_API_BASE_URL", "http://localhost:8080/api/v1"),
ZoneramaAPIBase: getEnv("ZONERAMA_API_BASE", "https://zonerama.tdvorak.dev"),
// Umami Analytics // Umami Analytics
UmamiURL: getEnv("UMAMI_URL", ""), UmamiURL: getEnv("UMAMI_URL", ""),
+25 -13
View File
@@ -567,6 +567,9 @@ func (bc *BaseController) GetMatchesHistory(c *gin.Context) {
m["date_time"] = ov.DateTimeOverride.Format(time.RFC3339) m["date_time"] = ov.DateTimeOverride.Format(time.RFC3339)
m["date"] = ov.DateTimeOverride.Format("2006-01-02 15:04") m["date"] = ov.DateTimeOverride.Format("2006-01-02 15:04")
} }
if ov.ScoreOverride != nil {
m["score"] = strings.TrimSpace(*ov.ScoreOverride)
}
if ov.HomeLogoURL != nil { if ov.HomeLogoURL != nil {
m["home_logo_url"] = *ov.HomeLogoURL m["home_logo_url"] = *ov.HomeLogoURL
} }
@@ -689,6 +692,9 @@ func (bc *BaseController) GetMatches(c *gin.Context) {
m["date_time"] = ov.DateTimeOverride.Format(time.RFC3339) m["date_time"] = ov.DateTimeOverride.Format(time.RFC3339)
m["date"] = ov.DateTimeOverride.Format("2006-01-02 15:04") m["date"] = ov.DateTimeOverride.Format("2006-01-02 15:04")
} }
if ov.ScoreOverride != nil {
m["score"] = strings.TrimSpace(*ov.ScoreOverride)
}
if ov.HomeLogoURL != nil { if ov.HomeLogoURL != nil {
m["home_logo_url"] = *ov.HomeLogoURL m["home_logo_url"] = *ov.HomeLogoURL
} }
@@ -901,7 +907,8 @@ func (bc *BaseController) GetZoneramaAlbum(c *gin.Context) {
photoLimit := strings.TrimSpace(c.DefaultQuery("photo_limit", "24")) photoLimit := strings.TrimSpace(c.DefaultQuery("photo_limit", "24"))
rendered := strings.TrimSpace(c.DefaultQuery("rendered", "true")) rendered := strings.TrimSpace(c.DefaultQuery("rendered", "true"))
// Build external URL // Build external URL
api := "https://zonerama.tdvorak.dev/zonerama-album?link=" + url.QueryEscape(link) base := strings.TrimSuffix(config.AppConfig.ZoneramaAPIBase, "/")
api := base + "/zonerama-album?link=" + url.QueryEscape(link)
if photoLimit != "" { if photoLimit != "" {
api += "&photo_limit=" + url.QueryEscape(photoLimit) api += "&photo_limit=" + url.QueryEscape(photoLimit)
} }
@@ -2471,30 +2478,18 @@ func (bc *BaseController) PutMatchOverride(c *gin.Context) {
c.JSON(http.StatusOK, item) c.JSON(http.StatusOK, item)
} }
// PatchMatchOverride partially updates fields of an override by external_match_id
func (bc *BaseController) PatchMatchOverride(c *gin.Context) { func (bc *BaseController) PatchMatchOverride(c *gin.Context) {
extID := c.Param("external_match_id") extID := c.Param("external_match_id")
if extID == "" { if extID == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "Chyba external_match_id"}) c.JSON(http.StatusBadRequest, gin.H{"error": "Chyba external_match_id"})
return return
} }
var item models.MatchOverride
if err := bc.DB.Where("external_match_id = ?", extID).First(&item).Error; err != nil {
if err == gorm.ErrRecordNotFound {
c.JSON(http.StatusNotFound, gin.H{"error": "Override nenalezen"})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "Chyba databáze"})
return
}
var body map[string]interface{} var body map[string]interface{}
if err := c.ShouldBindJSON(&body); err != nil { if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return return
} }
// Prevent changing the key
delete(body, "external_match_id") delete(body, "external_match_id")
// Normalize date_time_override to *time.Time if provided as string
if v, ok := body["date_time_override"]; ok { if v, ok := body["date_time_override"]; ok {
switch vv := v.(type) { switch vv := v.(type) {
case string: case string:
@@ -2513,6 +2508,23 @@ func (bc *BaseController) PatchMatchOverride(c *gin.Context) {
} }
} }
} }
var item models.MatchOverride
if err := bc.DB.Where("external_match_id = ?", extID).First(&item).Error; err != nil {
if err == gorm.ErrRecordNotFound {
attrs := map[string]interface{}{"external_match_id": extID}
for k, v := range body {
attrs[k] = v
}
if err := bc.DB.Where("external_match_id = ?", extID).Assign(attrs).FirstOrCreate(&item).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Nelze vytvořit záznam"})
return
}
c.JSON(http.StatusOK, item)
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "Chyba databáze"})
return
}
if err := bc.DB.Model(&item).Updates(body).Error; err != nil { if err := bc.DB.Model(&item).Updates(body).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Nelze uložit změny"}) c.JSON(http.StatusInternalServerError, gin.H{"error": "Nelze uložit změny"})
return return
+48 -9
View File
@@ -10,7 +10,6 @@ import (
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"gorm.io/gorm" "gorm.io/gorm"
"gorm.io/gorm/clause"
"fotbal-club/internal/models" "fotbal-club/internal/models"
"fotbal-club/internal/services" "fotbal-club/internal/services"
@@ -166,14 +165,37 @@ func (cc *CommentController) React(c *gin.Context) {
} }
uidv, _ := c.Get("userID") uidv, _ := c.Get("userID")
userID := uidv.(uint) var userID uint
switch v := uidv.(type) {
case uint:
userID = v
case int:
if v > 0 { userID = uint(v) }
case int64:
if v > 0 { userID = uint(v) }
case float64:
if v > 0 { userID = uint(v) }
case string:
if n, err := strconv.Atoi(strings.TrimSpace(v)); err == nil && n > 0 { userID = uint(n) }
}
if userID == 0 {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"})
return
}
// Atomic upsert: enforce single reaction per (comment_id, user_id) // Robust upsert without relying on a DB unique constraint: delete then insert in a transaction
r := models.CommentReaction{CommentID: cm.ID, UserID: userID, Type: rt} if err := cc.DB.Transaction(func(tx *gorm.DB) error {
if err := cc.DB.Clauses(clause.OnConflict{ // Remove any previous reaction by this user on this comment
Columns: []clause.Column{{Name: "comment_id"}, {Name: "user_id"}}, if err := tx.Where("comment_id = ? AND user_id = ?", cm.ID, userID).Delete(&models.CommentReaction{}).Error; err != nil {
DoUpdates: clause.Assignments(map[string]interface{}{"type": rt, "updated_at": time.Now()}), return err
}).Create(&r).Error; err != nil { }
// Insert the new reaction
r := models.CommentReaction{CommentID: cm.ID, UserID: userID, Type: rt}
if err := tx.Create(&r).Error; err != nil {
return err
}
return nil
}); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to react"}) c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to react"})
return return
} }
@@ -194,7 +216,24 @@ func (cc *CommentController) Unreact(c *gin.Context) {
// Ensure reactions table exists (best-effort) // Ensure reactions table exists (best-effort)
_ = cc.DB.AutoMigrate(&models.CommentReaction{}) _ = cc.DB.AutoMigrate(&models.CommentReaction{})
uidv, _ := c.Get("userID") uidv, _ := c.Get("userID")
_ = cc.DB.Where("comment_id = ? AND user_id = ?", cm.ID, uidv.(uint)).Delete(&models.CommentReaction{}).Error var userID uint
switch v := uidv.(type) {
case uint:
userID = v
case int:
if v > 0 { userID = uint(v) }
case int64:
if v > 0 { userID = uint(v) }
case float64:
if v > 0 { userID = uint(v) }
case string:
if n, err := strconv.Atoi(strings.TrimSpace(v)); err == nil && n > 0 { userID = uint(n) }
}
if userID == 0 {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"})
return
}
_ = cc.DB.Where("comment_id = ? AND user_id = ?", cm.ID, userID).Delete(&models.CommentReaction{}).Error
c.JSON(http.StatusOK, gin.H{"ok": true}) c.JSON(http.StatusOK, gin.H{"ok": true})
} }
+125 -14
View File
@@ -180,6 +180,28 @@ func (cc *ContactController) recalcNewsletterAutomationEnabled() {
s.NewsletterWeeklyHour = 9 s.NewsletterWeeklyHour = 9
changed = true changed = true
} }
// Auto-activate match reminders with sane defaults if not configured
if !s.EnableMatchReminders {
s.EnableMatchReminders = true
changed = true
}
if s.NewsletterReminderLeadHours <= 0 {
s.NewsletterReminderLeadHours = 48 // 48h before kickoff
changed = true
}
// Auto-activate match results notifications and default quiet hours if missing
if !s.EnableResults {
s.EnableResults = true
changed = true
}
// Only set quiet hours if both are unset (0,0) to avoid overriding admin-configured values
if s.NewsletterQuietStart == 0 && s.NewsletterQuietEnd == 0 {
s.NewsletterQuietStart = 22 // 22:00
s.NewsletterQuietEnd = 8 // 08:00
changed = true
}
} }
if s.ID == 0 { if s.ID == 0 {
_ = cc.DB.Create(&s).Error _ = cc.DB.Create(&s).Error
@@ -511,12 +533,6 @@ func (cc *ContactController) SubscribeToNewsletter(c *gin.Context) {
} }
} }
} }
// Additionally, send a minimal confirmation using newsletter template with manage link (best-effort)
_ = cc.emailService.SendNewsletter(&email.NewsletterData{
Subject: "Vítejte v odběru",
Content: fmt.Sprintf("<p>Děkujeme za přihlášení. Spravujte své preference <a href=\"%s\">zde</a>.</p>", manageURL),
Recipients: []string{emailStr},
})
// Recalculate automation after (re)subscription // Recalculate automation after (re)subscription
cc.recalcNewsletterAutomationEnabled() cc.recalcNewsletterAutomationEnabled()
c.JSON(http.StatusOK, gin.H{"message": "Subscribed"}) c.JSON(http.StatusOK, gin.H{"message": "Subscribed"})
@@ -700,21 +716,89 @@ func (cc *ContactController) SubmitContactForm(c *gin.Context) {
return return
} }
go func(nm, em, subj, msgBody, ipAddr, agent string) { go func(m models.ContactMessage) {
// 1) Notify primary contact(s) (club contact email / env fallbacks)
_ = cc.emailService.SendContactForm(&email.ContactFormData{ _ = cc.emailService.SendContactForm(&email.ContactFormData{
Name: nm, Name: m.Name,
Email: em, Email: m.Email,
Subject: subj, Subject: m.Subject,
Message: msgBody, Message: m.Message,
IPAddress: ipAddr, IPAddress: m.IPAddress,
UserAgent: agent, UserAgent: m.UserAgent,
}) })
}(name, emailStr, subject, message, ip, ua)
// 2) Auto-forward to configured list when enabled
var set models.Settings
if err := cc.DB.First(&set).Error; err == nil && set.ContactForwardEnabled && strings.TrimSpace(set.ContactForwardList) != "" {
// Build recipient list from ContactForwardList (comma/semicolon/space separated)
parts := strings.FieldsFunc(set.ContactForwardList, func(r rune) bool { return r == ',' || r == ';' || r == ' ' || r == '\n' || r == '\t' })
uniq := make(map[string]struct{})
dest := make([]string, 0, len(parts))
// Exclude addresses that already received the primary notification (contact/admin emails)
exclude := map[string]struct{}{}
if v := strings.ToLower(strings.TrimSpace(set.ContactEmail)); v != "" {
exclude[v] = struct{}{}
}
if config.AppConfig != nil {
if v := strings.ToLower(strings.TrimSpace(config.AppConfig.ContactEmail)); v != "" {
exclude[v] = struct{}{}
}
if v := strings.ToLower(strings.TrimSpace(config.AppConfig.AdminEmail)); v != "" {
exclude[v] = struct{}{}
}
}
for _, p := range parts {
v := strings.TrimSpace(p)
if v == "" {
continue
}
lv := strings.ToLower(v)
if _, ok := uniq[lv]; ok {
continue
}
if _, skip := exclude[lv]; skip {
continue
}
uniq[lv] = struct{}{}
dest = append(dest, v)
}
if len(dest) > 0 {
fwd := &email.EmailData{
Subject: fmt.Sprintf("Přeposláno: Kontaktní formulář - %s", strings.TrimSpace(m.Subject)),
To: dest,
Template: "contact_form",
Data: struct {
Name string
Email string
Subject string
Message string
Time string
IP string
Agent string
}{
Name: m.Name,
Email: m.Email,
Subject: m.Subject,
Message: m.Message,
Time: m.CreatedAt.Format(time.RFC1123Z),
IP: m.IPAddress,
Agent: m.UserAgent,
},
}
if err := cc.emailService.SendEmail(fwd); err != nil {
logger.Error("Auto-forward of contact message %d failed: %v", m.ID, err)
} else {
logger.Info("Auto-forwarded contact message %d to %v", m.ID, dest)
}
}
}
}(msg)
c.JSON(http.StatusOK, gin.H{"message": "Message received", "id": msg.ID}) c.JSON(http.StatusOK, gin.H{"message": "Message received", "id": msg.ID})
} }
func (cc *ContactController) AdminSmtpTest(c *gin.Context) { func (cc *ContactController) AdminSmtpTest(c *gin.Context) {
// ... rest of the code remains the same ...
if c.GetString("userRole") != "admin" { if c.GetString("userRole") != "admin" {
c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"}) c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"})
return return
@@ -904,6 +988,33 @@ func (cc *ContactController) UpdateNewsletterAutomation(c *gin.Context) {
s = models.Settings{} s = models.Settings{}
} }
s.NewsletterEnabled = input.Enabled s.NewsletterEnabled = input.Enabled
// If enabling, ensure defaults for weekly/matches/results are set like auto-recalc does
if input.Enabled {
if !s.EnableWeekly {
s.EnableWeekly = true
}
if strings.TrimSpace(s.NewsletterWeeklyDay) == "" {
s.NewsletterWeeklyDay = "sun"
}
if s.NewsletterWeeklyHour < 0 || s.NewsletterWeeklyHour > 23 {
s.NewsletterWeeklyHour = 9
}
if !s.EnableMatchReminders {
s.EnableMatchReminders = true
}
if s.NewsletterReminderLeadHours <= 0 {
s.NewsletterReminderLeadHours = 48
}
if !s.EnableResults {
s.EnableResults = true
}
if s.NewsletterQuietStart == 0 && s.NewsletterQuietEnd == 0 {
s.NewsletterQuietStart = 22
s.NewsletterQuietEnd = 8
}
}
if s.ID == 0 { if s.ID == 0 {
if err := cc.DB.Create(&s).Error; err != nil { if err := cc.DB.Create(&s).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to persist setting"}) c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to persist setting"})
File diff suppressed because it is too large Load Diff
+16 -13
View File
@@ -5,11 +5,13 @@ import (
"fmt" "fmt"
"io" "io"
"net/http" "net/http"
"net/url"
"os" "os"
"path/filepath" "path/filepath"
"strings" "strings"
"time" "time"
"fotbal-club/internal/config"
"fotbal-club/internal/services" "fotbal-club/internal/services"
"fotbal-club/pkg/logger" "fotbal-club/pkg/logger"
@@ -149,9 +151,10 @@ func (gc *GalleryController) FetchAlbum(c *gin.Context) {
body.PhotoLimit = 50 // Default to 50 photos per album body.PhotoLimit = 50 // Default to 50 photos per album
} }
// Call external API // Call external API (configurable base)
apiURL := fmt.Sprintf("https://zonerama.tdvorak.dev/zonerama-album?link=%s&photo_limit=%d", apiBase := strings.TrimSuffix(config.AppConfig.ZoneramaAPIBase, "/")
body.Link, body.PhotoLimit) apiURL := fmt.Sprintf("%s/zonerama-album?link=%s&photo_limit=%d",
apiBase, url.QueryEscape(body.Link), body.PhotoLimit)
logger.Info("Fetching album from Zonerama API: %s", apiURL) logger.Info("Fetching album from Zonerama API: %s", apiURL)
@@ -242,13 +245,13 @@ func (gc *GalleryController) FetchAlbum(c *gin.Context) {
} }
logger.Info("Album %s saved successfully with %d photos", albumData.ID, len(albumData.Photos)) logger.Info("Album %s saved successfully with %d photos", albumData.ID, len(albumData.Photos))
// Regenerate flat gallery files for frontend consumption // Regenerate flat gallery files for frontend consumption
if err := services.RegenerateFlatGalleryFiles(); err != nil { if err := services.RegenerateFlatGalleryFiles(); err != nil {
logger.Error("Failed to regenerate flat gallery files: %v", err) logger.Error("Failed to regenerate flat gallery files: %v", err)
// Don't fail the request, just log the error // Don't fail the request, just log the error
} }
c.JSON(http.StatusOK, gin.H{ c.JSON(http.StatusOK, gin.H{
"message": "Album fetched and saved successfully", "message": "Album fetched and saved successfully",
"album": albumData, "album": albumData,
@@ -300,13 +303,13 @@ func (gc *GalleryController) DeleteAlbum(c *gin.Context) {
} }
logger.Info("Deleted album: %s", albumID) logger.Info("Deleted album: %s", albumID)
// Regenerate flat gallery files for frontend consumption // Regenerate flat gallery files for frontend consumption
if err := services.RegenerateFlatGalleryFiles(); err != nil { if err := services.RegenerateFlatGalleryFiles(); err != nil {
logger.Error("Failed to regenerate flat gallery files: %v", err) logger.Error("Failed to regenerate flat gallery files: %v", err)
// Don't fail the request, just log the error // Don't fail the request, just log the error
} }
c.JSON(http.StatusOK, gin.H{"message": "Album deleted successfully"}) c.JSON(http.StatusOK, gin.H{"message": "Album deleted successfully"})
} }
@@ -316,27 +319,27 @@ func (gc *GalleryController) RefreshFromZonerama(c *gin.Context) {
var settings struct { var settings struct {
GalleryURL string `json:"gallery_url"` GalleryURL string `json:"gallery_url"`
} }
if err := gc.DB.Table("settings").Select("gallery_url").First(&settings).Error; err != nil { if err := gc.DB.Table("settings").Select("gallery_url").First(&settings).Error; err != nil {
logger.Error("Failed to load settings: %v", err) logger.Error("Failed to load settings: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to load gallery settings"}) c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to load gallery settings"})
return return
} }
zoneramaURL := strings.TrimSpace(settings.GalleryURL) zoneramaURL := strings.TrimSpace(settings.GalleryURL)
if zoneramaURL == "" { if zoneramaURL == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "Zonerama URL is not configured in settings"}) c.JSON(http.StatusBadRequest, gin.H{"error": "Zonerama URL is not configured in settings"})
return return
} }
// Validate it's a Zonerama URL // Validate it's a Zonerama URL
if !strings.Contains(strings.ToLower(zoneramaURL), "zonerama.com") { if !strings.Contains(strings.ToLower(zoneramaURL), "zonerama.com") {
c.JSON(http.StatusBadRequest, gin.H{"error": "Configured gallery URL is not a Zonerama URL"}) c.JSON(http.StatusBadRequest, gin.H{"error": "Configured gallery URL is not a Zonerama URL"})
return return
} }
logger.Info("Triggering Zonerama refresh from: %s", zoneramaURL) logger.Info("Triggering Zonerama refresh from: %s", zoneramaURL)
// Call the refresh service in a goroutine to avoid blocking // Call the refresh service in a goroutine to avoid blocking
go func() { go func() {
if err := services.RefreshZoneramaNow(zoneramaURL); err != nil { if err := services.RefreshZoneramaNow(zoneramaURL); err != nil {
@@ -349,7 +352,7 @@ func (gc *GalleryController) RefreshFromZonerama(c *gin.Context) {
} }
} }
}() }()
c.JSON(http.StatusOK, gin.H{ c.JSON(http.StatusOK, gin.H{
"message": "Zonerama refresh started", "message": "Zonerama refresh started",
"url": zoneramaURL, "url": zoneramaURL,
+77 -1
View File
@@ -262,6 +262,11 @@ func (nc *NavigationController) UpdateNavigationItem(c *gin.Context) {
updates["requires_admin"] = b updates["requires_admin"] = b
} }
} }
if v, ok := raw["allow_editor"]; ok {
if b, ok2 := v.(bool); ok2 {
updates["allow_editor"] = b
}
}
if len(updates) == 0 { if len(updates) == 0 {
// Nothing to update // Nothing to update
@@ -372,6 +377,72 @@ func (nc *NavigationController) GetSocialLinks(c *gin.Context) {
c.JSON(http.StatusOK, links) c.JSON(http.StatusOK, links)
} }
// GetEditorAllowedAdminNav returns admin navigation items that are explicitly allowed for editors
// Top-level items are included only when:
// - type != dropdown and allow_editor = true (and visible = true), or
// - type == dropdown and it has at least one child with allow_editor = true (and visible = true)
//
// Children are filtered to allow_editor = true and visible = true
func (nc *NavigationController) GetEditorAllowedAdminNav(c *gin.Context) {
var top []models.NavigationItem
// Load all top-level admin items (categories and direct items)
if err := nc.DB.Where("parent_id IS NULL AND requires_admin = ? AND visible = ?", true, true).
Order("display_order ASC").
Preload("Children", func(db *gorm.DB) *gorm.DB {
return db.Where("requires_admin = ? AND visible = ? AND allow_editor = ?", true, true, true).Order("display_order ASC")
}).
Find(&top).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch editor navigation"})
return
}
// Filter according to allow_editor rules
out := make([]models.NavigationItem, 0, len(top))
// Only allow a curated set of admin pages that have editor-capable APIs
allowed := map[string]bool{
"articles": true,
"activities": true,
"shortlinks": true,
}
for i := range top {
it := top[i]
include := false
if it.Type == models.NavTypeDropdown {
// Filter children by page_type allow-list (children already have allow_editor=true from preload)
if len(it.Children) > 0 {
children := make([]models.NavigationItem, 0, len(it.Children))
for _, ch := range it.Children {
if allowed[ch.PageType] {
// ensure URL is set
if ch.URL == "" {
ch.URL = ch.GetURL()
}
children = append(children, ch)
}
}
it.Children = children
if len(it.Children) > 0 {
include = true
}
}
} else {
// direct admin page: include only when marked allow_editor
if it.AllowEditor && allowed[it.PageType] {
include = true
}
}
if include {
// Ensure URLs are computed
if it.URL == "" {
it.URL = it.GetURL()
}
out = append(out, it)
}
}
c.JSON(http.StatusOK, out)
}
// GetAllSocialLinks returns all social links including hidden ones (admin only) // GetAllSocialLinks returns all social links including hidden ones (admin only)
// @Summary Get all social links (admin) // @Summary Get all social links (admin)
// @Description Returns all social links for admin management // @Description Returns all social links for admin management
@@ -593,7 +664,12 @@ func (nc *NavigationController) SeedDefaultNavigation(c *gin.Context) {
createChild := func(parent *models.NavigationItem, label, pageType string, order int) error { createChild := func(parent *models.NavigationItem, label, pageType string, order int) error {
pid := parent.ID pid := parent.ID
child := &models.NavigationItem{Label: label, Type: models.NavTypeInternal, PageType: pageType, DisplayOrder: order, Visible: true, RequiresAdmin: true} allowEditor := false
switch pageType {
case "articles", "activities", "shortlinks":
allowEditor = true
}
child := &models.NavigationItem{Label: label, Type: models.NavTypeInternal, PageType: pageType, DisplayOrder: order, Visible: true, RequiresAdmin: true, AllowEditor: allowEditor}
child.ParentID = &pid child.ParentID = &pid
return tx.Create(child).Error return tx.Create(child).Error
} }
+130 -96
View File
@@ -29,66 +29,68 @@ type ShortLinkController struct {
// Restrictions: only allows shortening links pointing to this site (request host) // Restrictions: only allows shortening links pointing to this site (request host)
// or to the configured FrontendBaseURL. Intended for visitor share/copy flows. // or to the configured FrontendBaseURL. Intended for visitor share/copy flows.
func (s *ShortLinkController) PublicCreateShortLink(c *gin.Context) { func (s *ShortLinkController) PublicCreateShortLink(c *gin.Context) {
var body struct { var body struct {
TargetURL string `json:"target_url"` TargetURL string `json:"target_url"`
Title string `json:"title"` Title string `json:"title"`
} }
if err := c.ShouldBindJSON(&body); err != nil { if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return return
} }
target, err := parseTarget(body.TargetURL) target, err := parseTarget(body.TargetURL)
if err != nil { if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid target_url"}) c.JSON(http.StatusBadRequest, gin.H{"error": "invalid target_url"})
return return
} }
tu, _ := url.Parse(target) tu, _ := url.Parse(target)
if tu == nil || tu.Host == "" { if tu == nil || tu.Host == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid target_url"}) c.JSON(http.StatusBadRequest, gin.H{"error": "invalid target_url"})
return return
} }
// Allow only same-site or configured frontend host // Allow only same-site or configured frontend host
reqHost := c.Request.Host reqHost := c.Request.Host
stripPort := func(h string) string { stripPort := func(h string) string {
if i := strings.IndexByte(h, ':'); i >= 0 { return h[:i] } if i := strings.IndexByte(h, ':'); i >= 0 {
return h return h[:i]
} }
allowed := stripPort(tu.Host) == stripPort(reqHost) return h
if !allowed && config.AppConfig != nil && strings.TrimSpace(config.AppConfig.FrontendBaseURL) != "" { }
if fu, err := url.Parse(config.AppConfig.FrontendBaseURL); err == nil && fu.Host != "" { allowed := stripPort(tu.Host) == stripPort(reqHost)
if stripPort(fu.Host) == stripPort(tu.Host) { if !allowed && config.AppConfig != nil && strings.TrimSpace(config.AppConfig.FrontendBaseURL) != "" {
allowed = true if fu, err := url.Parse(config.AppConfig.FrontendBaseURL); err == nil && fu.Host != "" {
} if stripPort(fu.Host) == stripPort(tu.Host) {
} allowed = true
} }
if !allowed { }
c.JSON(http.StatusForbidden, gin.H{"error": "target host not allowed"}) }
return if !allowed {
} c.JSON(http.StatusForbidden, gin.H{"error": "target host not allowed"})
return
}
// Deterministic code from URL so repeated calls return same shortlink // Deterministic code from URL so repeated calls return same shortlink
code := "p-" + codeFromHash(target, 7) code := "p-" + codeFromHash(target, 7)
link := models.ShortLink{ link := models.ShortLink{
Code: code, Code: code,
TargetURL: target, TargetURL: target,
Title: strings.TrimSpace(body.Title), Title: strings.TrimSpace(body.Title),
Active: true, Active: true,
} }
if err := s.DB.Clauses(clause.OnConflict{ if err := s.DB.Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "code"}}, Columns: []clause.Column{{Name: "code"}},
DoUpdates: clause.AssignmentColumns([]string{"target_url", "title", "active", "updated_at"}), DoUpdates: clause.AssignmentColumns([]string{"target_url", "title", "active", "updated_at"}),
}).Create(&link).Error; err != nil { }).Create(&link).Error; err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "cannot save shortlink", "details": err.Error()}) c.JSON(http.StatusBadRequest, gin.H{"error": "cannot save shortlink", "details": err.Error()})
return return
} }
var saved models.ShortLink var saved models.ShortLink
if err := s.DB.Where("code = ?", code).First(&saved).Error; err != nil { if err := s.DB.Where("code = ?", code).First(&saved).Error; err != nil {
saved = link saved = link
} }
scheme := getScheme(c) scheme := getScheme(c)
host := c.Request.Host host := c.Request.Host
shortURL := fmt.Sprintf("%s://%s/s/%s", scheme, host, code) shortURL := fmt.Sprintf("%s://%s/s/%s", scheme, host, code)
c.JSON(http.StatusOK, gin.H{"id": saved.ID, "code": code, "short_url": shortURL, "link": saved}) c.JSON(http.StatusOK, gin.H{"id": saved.ID, "code": code, "short_url": shortURL, "link": saved})
} }
func NewShortLinkController(db *gorm.DB) *ShortLinkController { func NewShortLinkController(db *gorm.DB) *ShortLinkController {
@@ -125,7 +127,9 @@ func hashIPShort(ip string) string {
} }
func codeFromHash(s string, n int) string { func codeFromHash(s string, n int) string {
if n <= 0 { n = 7 } if n <= 0 {
n = 7
}
alphabet := "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" alphabet := "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
sum := sha256.Sum256([]byte(s)) sum := sha256.Sum256([]byte(s))
out := make([]byte, n) out := make([]byte, n)
@@ -137,20 +141,24 @@ func codeFromHash(s string, n int) string {
} }
func sanitizeCode(in string) string { func sanitizeCode(in string) string {
s := strings.TrimSpace(in) s := strings.TrimSpace(in)
if s == "" { return "" } if s == "" {
// filter allowed runes return ""
rb := make([]rune, 0, len(s)) }
for _, ch := range s { // filter allowed runes
if (ch >= 'a' && ch <= 'z') || (ch >= 'A' && ch <= 'Z') || (ch >= '0' && ch <= '9') || ch == '-' || ch == '_' { rb := make([]rune, 0, len(s))
rb = append(rb, ch) for _, ch := range s {
} if (ch >= 'a' && ch <= 'z') || (ch >= 'A' && ch <= 'Z') || (ch >= '0' && ch <= '9') || ch == '-' || ch == '_' {
} rb = append(rb, ch)
if len(rb) == 0 { return "" } }
if len(rb) > 16 { }
rb = rb[:16] if len(rb) == 0 {
} return ""
return string(rb) }
if len(rb) > 16 {
rb = rb[:16]
}
return string(rb)
} }
func getScheme(c *gin.Context) string { func getScheme(c *gin.Context) string {
@@ -174,11 +182,20 @@ func parseTarget(raw string) (string, error) {
raw = string(dec) raw = string(dec)
} }
} }
u, err := url.Parse(raw) // Try as-is first
if err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" { if u, err := url.Parse(raw); err == nil && (u.Scheme == "http" || u.Scheme == "https") && u.Host != "" {
return "", errors.New("invalid url") return u.String(), nil
} }
return u.String(), nil // If scheme is missing, try https:// fallback, then http://
if !strings.HasPrefix(raw, "http://") && !strings.HasPrefix(raw, "https://") {
if u, err := url.Parse("https://" + raw); err == nil && u.Host != "" {
return u.String(), nil
}
if u, err := url.Parse("http://" + raw); err == nil && u.Host != "" {
return u.String(), nil
}
}
return "", errors.New("invalid url")
} }
func (s *ShortLinkController) RedirectShort(c *gin.Context) { func (s *ShortLinkController) RedirectShort(c *gin.Context) {
@@ -274,23 +291,25 @@ func (s *ShortLinkController) CreateShortLink(c *gin.Context) {
return return
} }
code := sanitizeCode(strings.TrimSpace(body.Code)) code := sanitizeCode(strings.TrimSpace(body.Code))
if code == "" { if code == "" {
for i := 0; i < 5; i++ { for i := 0; i < 5; i++ {
cnd, _ := randCode(7) cnd, _ := randCode(7)
var cnt int64 var cnt int64
s.DB.Model(&models.ShortLink{}).Where("code = ?", cnd).Count(&cnt) s.DB.Model(&models.ShortLink{}).Where("code = ?", cnd).Count(&cnt)
if cnt == 0 { if cnt == 0 {
code = cnd code = cnd
break break
} }
} }
} }
if code == "" { if code == "" {
c.JSON(http.StatusInternalServerError, gin.H{"error": "cannot generate code"}) c.JSON(http.StatusInternalServerError, gin.H{"error": "cannot generate code"})
return return
} }
active := true active := true
if body.Active != nil { active = *body.Active } if body.Active != nil {
active = *body.Active
}
link := models.ShortLink{ link := models.ShortLink{
Code: code, Code: code,
TargetURL: target, TargetURL: target,
@@ -329,22 +348,37 @@ func (s *ShortLinkController) ListShortLinks(c *gin.Context) {
func (s *ShortLinkController) GetShortLinkStats(c *gin.Context) { func (s *ShortLinkController) GetShortLinkStats(c *gin.Context) {
id := strings.TrimSpace(c.Param("id")) id := strings.TrimSpace(c.Param("id"))
if id == "" { c.JSON(http.StatusBadRequest, gin.H{"error": "missing id"}); return } if id == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "missing id"})
return
}
var link models.ShortLink var link models.ShortLink
if err := s.DB.First(&link, id).Error; err != nil { c.JSON(http.StatusNotFound, gin.H{"error": "not found"}); return } if err := s.DB.First(&link, id).Error; err != nil {
start := time.Now().AddDate(0,0,-30) c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
type Row struct{ Date string `json:"date"`; Count int64 `json:"count"` } return
}
start := time.Now().AddDate(0, 0, -30)
type Row struct {
Date string `json:"date"`
Count int64 `json:"count"`
}
var rows []Row var rows []Row
s.DB.Model(&models.LinkClick{}). s.DB.Model(&models.LinkClick{}).
Select("DATE(created_at) as date, COUNT(*) as count"). Select("DATE(created_at) as date, COUNT(*) as count").
Where("short_link_id = ? AND created_at >= ?", link.ID, start). Where("short_link_id = ? AND created_at >= ?", link.ID, start).
Group("DATE(created_at)").Order("date ASC").Scan(&rows) Group("DATE(created_at)").Order("date ASC").Scan(&rows)
var refRows []struct{ Referrer string; Count int64 } var refRows []struct {
Referrer string
Count int64
}
s.DB.Model(&models.LinkClick{}). s.DB.Model(&models.LinkClick{}).
Select("referrer, COUNT(*) as count"). Select("referrer, COUNT(*) as count").
Where("short_link_id = ? AND created_at >= ?", link.ID, start). Where("short_link_id = ? AND created_at >= ?", link.ID, start).
Group("referrer").Order("count DESC").Limit(20).Scan(&refRows) Group("referrer").Order("count DESC").Limit(20).Scan(&refRows)
var utmRows []struct{ Source, Medium, Campaign string; Count int64 } var utmRows []struct {
Source, Medium, Campaign string
Count int64
}
s.DB.Model(&models.LinkClick{}). s.DB.Model(&models.LinkClick{}).
Select("utm_source as source, utm_medium as medium, utm_campaign as campaign, COUNT(*) as count"). Select("utm_source as source, utm_medium as medium, utm_campaign as campaign, COUNT(*) as count").
Where("short_link_id = ? AND created_at >= ?", link.ID, start). Where("short_link_id = ? AND created_at >= ?", link.ID, start).
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -103,7 +103,7 @@ func GetRewardTypeDisplayName(rewardType string) string {
"avatar_upload_unlock": "Odemknutí vlastního avataru", "avatar_upload_unlock": "Odemknutí vlastního avataru",
"merch_coupon": "Slevový kupon", "merch_coupon": "Slevový kupon",
"merch_physical": "Fyzické zboží", "merch_physical": "Fyzické zboží",
"merch_digital": "Digitální produkt", "merch_digital": "Digitální odměna",
"custom": "Vlastní", "custom": "Vlastní",
} }
if name, ok := names[rewardType]; ok { if name, ok := names[rewardType]; ok {
+58 -59
View File
@@ -88,17 +88,17 @@ type Article struct {
OGImageURL string `json:"og_image_url"` OGImageURL string `json:"og_image_url"`
// Optional: link to external content or embedded media // Optional: link to external content or embedded media
ExternalLink string `json:"external_link"` ExternalLink string `json:"external_link"`
ViewCount int `gorm:"default:0;index" json:"view_count"` ViewCount int `gorm:"default:0;index" json:"view_count"`
ReadTime int `gorm:"default:0" json:"read_time"` // estimated reading time in minutes ReadTime int `gorm:"default:0" json:"read_time"` // estimated reading time in minutes
UniqueViews int `gorm:"default:0" json:"unique_views"` // Unique visitors (tracked by IP/session) UniqueViews int `gorm:"default:0" json:"unique_views"` // Unique visitors (tracked by IP/session)
// Store the category name directly to simplify queries (denormalized) // Store the category name directly to simplify queries (denormalized)
CategoryName string `json:"category_name"` CategoryName string `json:"category_name"`
Attachments string `gorm:"type:text" json:"attachments"` // JSON array: ["url1", "url2", ...] Attachments string `gorm:"type:text" json:"attachments"` // JSON array: ["url1", "url2", ...]
// Gallery association (optional) // Gallery association (optional)
GalleryAlbumID string `json:"gallery_album_id"` GalleryAlbumID string `json:"gallery_album_id"`
GalleryAlbumURL string `json:"gallery_album_url"` GalleryAlbumURL string `json:"gallery_album_url"`
// Stored as JSON string or comma-separated list; frontend normalizes // Stored as JSON string or comma-separated list; frontend normalizes
GalleryPhotoIDs string `gorm:"type:text" json:"gallery_photo_ids"` GalleryPhotoIDs string `gorm:"type:text" json:"gallery_photo_ids"`
// YouTube video association (optional) // YouTube video association (optional)
YouTubeVideoID string `json:"youtube_video_id"` YouTubeVideoID string `json:"youtube_video_id"`
YouTubeVideoTitle string `gorm:"type:text" json:"youtube_video_title"` YouTubeVideoTitle string `gorm:"type:text" json:"youtube_video_title"`
@@ -108,10 +108,10 @@ type Article struct {
// Removed omitempty to always include in JSON (even if null) // Removed omitempty to always include in JSON (even if null)
MatchLink *ArticleMatchLink `gorm:"-" json:"match_link"` MatchLink *ArticleMatchLink `gorm:"-" json:"match_link"`
// Computed helpers (not persisted) // Computed helpers (not persisted)
CategorySlug string `gorm:"-" json:"category_slug,omitempty"` CategorySlug string `gorm:"-" json:"category_slug,omitempty"`
CompetitionAlias string `gorm:"-" json:"competition_alias,omitempty"` CompetitionAlias string `gorm:"-" json:"competition_alias,omitempty"`
NormalizedCategory string `gorm:"-" json:"normalized_category,omitempty"` NormalizedCategory string `gorm:"-" json:"normalized_category,omitempty"`
URL string `gorm:"-" json:"url,omitempty"` URL string `gorm:"-" json:"url,omitempty"`
} }
// ArticleTeamLink represents a link from an article to a team identified by an external FACR ID // ArticleTeamLink represents a link from an article to a team identified by an external FACR ID
@@ -143,7 +143,7 @@ type Team struct {
ShortName string ShortName string
Description string Description string
LogoURL string `json:"logo_url"` LogoURL string `json:"logo_url"`
IsActive bool `gorm:"default:true"` IsActive bool `gorm:"default:true"`
} }
// Player represents a football player // Player represents a football player
@@ -184,15 +184,15 @@ type Sponsor struct {
// VideoTitleOverride represents a per-video title override (for auto YouTube source) // VideoTitleOverride represents a per-video title override (for auto YouTube source)
type VideoTitleOverride struct { type VideoTitleOverride struct {
VideoID string `json:"video_id"` VideoID string `json:"video_id"`
Title string `json:"title"` Title string `json:"title"`
} }
// CustomNavLink represents a simple custom navigation link stored in settings.custom_nav // CustomNavLink represents a simple custom navigation link stored in settings.custom_nav
type CustomNavLink struct { type CustomNavLink struct {
Label string `json:"label"` Label string `json:"label"`
URL string `json:"url"` URL string `json:"url"`
External bool `json:"external"` External bool `json:"external"`
} }
type Settings struct { type Settings struct {
@@ -257,7 +257,7 @@ type Settings struct {
// FrontendBaseURL: e.g. https://club.example.com // FrontendBaseURL: e.g. https://club.example.com
FrontendBaseURL string `json:"frontend_base_url"` FrontendBaseURL string `json:"frontend_base_url"`
// APIBaseURL: full API root, e.g. https://api.example.com/api/v1 or https://backend.example.com/api/v1 // APIBaseURL: full API root, e.g. https://api.example.com/api/v1 or https://backend.example.com/api/v1
APIBaseURL string `json:"api_base_url"` APIBaseURL string `json:"api_base_url"`
// Social profiles // Social profiles
FacebookURL string `json:"facebook_url"` FacebookURL string `json:"facebook_url"`
@@ -279,10 +279,10 @@ type Settings struct {
VideosItemsJSON string `gorm:"type:text" json:"-"` VideosItemsJSON string `gorm:"type:text" json:"-"`
// Title overrides for auto-fetched videos (stored as JSON array of {video_id,title}) // Title overrides for auto-fetched videos (stored as JSON array of {video_id,title})
VideosOverridesJSON string `gorm:"type:text" json:"-"` VideosOverridesJSON string `gorm:"type:text" json:"-"`
VideosOverrides []VideoTitleOverride `gorm:"-" json:"videos_overrides,omitempty"` VideosOverrides []VideoTitleOverride `gorm:"-" json:"videos_overrides,omitempty"`
// Derived helper for API responses (map form used by frontend/admin): video_id -> title // Derived helper for API responses (map form used by frontend/admin): video_id -> title
VideosTitleOverrides map[string]string `gorm:"-" json:"videos_title_overrides,omitempty"` VideosTitleOverrides map[string]string `gorm:"-" json:"videos_title_overrides,omitempty"`
// Merch module configuration // Merch module configuration
MerchModuleEnabled bool `json:"merch_module_enabled"` MerchModuleEnabled bool `json:"merch_module_enabled"`
@@ -313,25 +313,25 @@ type Settings struct {
NewsletterQuietEnd int `json:"newsletter_quiet_end"` // 0-23 NewsletterQuietEnd int `json:"newsletter_quiet_end"` // 0-23
// Contact/Location information for map // Contact/Location information for map
ContactAddress string `json:"contact_address"` ContactAddress string `json:"contact_address"`
ContactCity string `json:"contact_city"` ContactCity string `json:"contact_city"`
ContactZip string `json:"contact_zip"` ContactZip string `json:"contact_zip"`
ContactCountry string `json:"contact_country"` ContactCountry string `json:"contact_country"`
ContactPhone string `json:"contact_phone"` ContactPhone string `json:"contact_phone"`
ContactEmail string `json:"contact_email"` ContactEmail string `json:"contact_email"`
// Contact form auto-forwarding // Contact form auto-forwarding
ContactForwardEnabled bool `json:"contact_forward_enabled"` ContactForwardEnabled bool `json:"contact_forward_enabled"`
ContactForwardList string `gorm:"type:text" json:"contact_forward_list"` // comma/space/semicolon-separated emails ContactForwardList string `gorm:"type:text" json:"contact_forward_list"` // comma/space/semicolon-separated emails
LocationLatitude float64 `json:"location_latitude"` LocationLatitude float64 `json:"location_latitude"`
LocationLongitude float64 `json:"location_longitude"` LocationLongitude float64 `json:"location_longitude"`
MapZoomLevel int `gorm:"default:15" json:"map_zoom_level"` MapZoomLevel int `gorm:"default:15" json:"map_zoom_level"`
MapStyle string `json:"map_style"` MapStyle string `json:"map_style"`
ShowMapOnHomepage bool `json:"show_map_on_homepage"` ShowMapOnHomepage bool `json:"show_map_on_homepage"`
// Homepage matches display configuration // Homepage matches display configuration
FinishedMatchDisplayDays int `gorm:"default:2" json:"finished_match_display_days"` FinishedMatchDisplayDays int `gorm:"default:2" json:"finished_match_display_days"`
StorageQuotaMB int `json:"storage_quota_mb"` StorageQuotaMB int `json:"storage_quota_mb"`
StorageWarnThreshold int `json:"storage_warn_threshold"` StorageWarnThreshold int `json:"storage_warn_threshold"`
StorageCriticalThreshold int `json:"storage_critical_threshold"` StorageCriticalThreshold int `json:"storage_critical_threshold"`
// External error-review integration // External error-review integration
@@ -345,7 +345,6 @@ type Settings struct {
// TableName specifies table name for Settings model // TableName specifies table name for Settings model
func (Settings) TableName() string { return "settings" } func (Settings) TableName() string { return "settings" }
// LoadCustomNav hydrates the in-memory CustomNav slice from the persisted JSON string. // LoadCustomNav hydrates the in-memory CustomNav slice from the persisted JSON string.
func (s *Settings) LoadCustomNav() { func (s *Settings) LoadCustomNav() {
if s.CustomNavJSON == "" { if s.CustomNavJSON == "" {
@@ -416,14 +415,14 @@ func (Club) TableName() string {
// ContactCategory represents a category for organizing contacts (e.g., "Management", "Coaches", "Office") // ContactCategory represents a category for organizing contacts (e.g., "Management", "Coaches", "Office")
type ContactCategory struct { type ContactCategory struct {
ID uint `gorm:"primarykey" json:"id"` ID uint `gorm:"primarykey" json:"id"`
CreatedAt time.Time `json:"created_at"` CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"` UpdatedAt time.Time `json:"updated_at"`
DeletedAt gorm.DeletedAt `gorm:"index" json:"deleted_at,omitempty"` DeletedAt gorm.DeletedAt `gorm:"index" json:"deleted_at,omitempty"`
Name string `gorm:"not null;uniqueIndex" json:"name"` Name string `gorm:"not null;uniqueIndex" json:"name"`
Description string `json:"description"` Description string `json:"description"`
DisplayOrder int `gorm:"default:0" json:"display_order"` DisplayOrder int `gorm:"default:0" json:"display_order"`
IsActive bool `gorm:"default:true" json:"is_active"` IsActive bool `gorm:"default:true" json:"is_active"`
} }
// TableName specifies the table name for the ContactCategory model // TableName specifies the table name for the ContactCategory model
@@ -433,20 +432,20 @@ func (ContactCategory) TableName() string {
// Contact represents a contact person (e.g., coach, manager, office staff) // Contact represents a contact person (e.g., coach, manager, office staff)
type Contact struct { type Contact struct {
ID uint `gorm:"primarykey" json:"id"` ID uint `gorm:"primarykey" json:"id"`
CreatedAt time.Time `json:"created_at"` CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"` UpdatedAt time.Time `json:"updated_at"`
DeletedAt gorm.DeletedAt `gorm:"index" json:"deleted_at,omitempty"` DeletedAt gorm.DeletedAt `gorm:"index" json:"deleted_at,omitempty"`
CategoryID *uint `gorm:"index" json:"category_id,omitempty"` CategoryID *uint `gorm:"index" json:"category_id,omitempty"`
Category *ContactCategory `gorm:"foreignKey:CategoryID" json:"category,omitempty"` Category *ContactCategory `gorm:"foreignKey:CategoryID" json:"category,omitempty"`
Name string `gorm:"not null" json:"name"` Name string `gorm:"not null" json:"name"`
Position string `json:"position"` // e.g., "Head Coach", "President" Position string `json:"position"` // e.g., "Head Coach", "President"
Email string `json:"email"` Email string `json:"email"`
Phone string `json:"phone"` Phone string `json:"phone"`
ImageURL string `json:"image_url"` ImageURL string `json:"image_url"`
Description string `gorm:"type:text" json:"description"` Description string `gorm:"type:text" json:"description"`
DisplayOrder int `gorm:"default:0" json:"display_order"` DisplayOrder int `gorm:"default:0" json:"display_order"`
IsActive bool `gorm:"default:true" json:"is_active"` IsActive bool `gorm:"default:true" json:"is_active"`
} }
// TableName specifies the table name for the Contact model // TableName specifies the table name for the Contact model
+54 -51
View File
@@ -17,21 +17,24 @@ const (
// NavigationItem represents a single navigation menu item // NavigationItem represents a single navigation menu item
type NavigationItem struct { type NavigationItem struct {
gorm.Model gorm.Model
Label string `gorm:"not null" json:"label"` Label string `gorm:"not null" json:"label"`
URL string `json:"url,omitempty"` URL string `json:"url,omitempty"`
Icon string `json:"icon,omitempty"` Icon string `json:"icon,omitempty"`
Type NavigationItemType `gorm:"not null;default:'internal'" json:"type"` Type NavigationItemType `gorm:"not null;default:'internal'" json:"type"`
PageType string `json:"page_type,omitempty"` // e.g., 'blog', 'about', 'calendar' PageType string `json:"page_type,omitempty"` // e.g., 'blog', 'about', 'calendar'
PageID *uint `json:"page_id,omitempty"` // optional reference to specific content PageID *uint `json:"page_id,omitempty"` // optional reference to specific content
Visible bool `gorm:"not null;default:true" json:"visible"` Visible bool `gorm:"not null;default:true" json:"visible"`
DisplayOrder int `gorm:"not null;default:0" json:"display_order"` DisplayOrder int `gorm:"not null;default:0" json:"display_order"`
ParentID *uint `json:"parent_id,omitempty"` ParentID *uint `json:"parent_id,omitempty"`
Parent *NavigationItem `gorm:"foreignKey:ParentID" json:"parent,omitempty"` Parent *NavigationItem `gorm:"foreignKey:ParentID" json:"parent,omitempty"`
Children []NavigationItem `gorm:"foreignKey:ParentID" json:"children,omitempty"` Children []NavigationItem `gorm:"foreignKey:ParentID" json:"children,omitempty"`
Target string `gorm:"default:'_self'" json:"target"` // _self or _blank Target string `gorm:"default:'_self'" json:"target"` // _self or _blank
CSSClass string `json:"css_class,omitempty"` CSSClass string `json:"css_class,omitempty"`
RequiresAuth bool `gorm:"default:false" json:"requires_auth"` RequiresAuth bool `gorm:"default:false" json:"requires_auth"`
RequiresAdmin bool `gorm:"default:false" json:"requires_admin"` RequiresAdmin bool `gorm:"default:false" json:"requires_admin"`
// AllowEditor indicates that editors are allowed to access the corresponding admin page
// when this item represents an admin navigation entry (RequiresAdmin=true).
AllowEditor bool `gorm:"default:false" json:"allow_editor"`
} }
// TableName specifies the table name for the NavigationItem model // TableName specifies the table name for the NavigationItem model
@@ -44,7 +47,7 @@ func (n *NavigationItem) GetURL() string {
if n.URL != "" { if n.URL != "" {
return n.URL return n.URL
} }
// Map page types to URLs for frontend // Map page types to URLs for frontend
if n.Type == NavTypePage && n.PageType != "" { if n.Type == NavTypePage && n.PageType != "" {
pageURLMap := map[string]string{ pageURLMap := map[string]string{
@@ -66,47 +69,47 @@ func (n *NavigationItem) GetURL() string {
return url return url
} }
} }
// Map admin page types to URLs // Map admin page types to URLs
if (n.Type == NavTypeInternal || n.Type == NavTypePage) && n.PageType != "" && n.RequiresAdmin { if (n.Type == NavTypeInternal || n.Type == NavTypePage) && n.PageType != "" && n.RequiresAdmin {
adminURLMap := map[string]string{ adminURLMap := map[string]string{
"dashboard": "/admin", "dashboard": "/admin",
"analytics": "/admin/analytika", "analytics": "/admin/analytika",
"teams": "/admin/tymy", "teams": "/admin/tymy",
"matches": "/admin/zapasy", "matches": "/admin/zapasy",
"activities": "/admin/aktivity", "activities": "/admin/aktivity",
"players": "/admin/hraci", "players": "/admin/hraci",
"articles": "/admin/clanky", "articles": "/admin/clanky",
"categories": "/admin/kategorie", "categories": "/admin/kategorie",
"about": "/admin/o-klubu", "about": "/admin/o-klubu",
"videos": "/admin/videa", "videos": "/admin/videa",
"gallery": "/admin/galerie", "gallery": "/admin/galerie",
"scoreboard": "/admin/scoreboard", "scoreboard": "/admin/scoreboard",
"scoreboard_remote": "/admin/scoreboard/remote", "scoreboard_remote": "/admin/scoreboard/remote",
"clothing": "/admin/obleceni", "clothing": "/admin/obleceni",
"sponsors": "/admin/sponzori", "sponsors": "/admin/sponzori",
"banners": "/admin/bannery", "banners": "/admin/bannery",
"messages": "/admin/zpravy", "messages": "/admin/zpravy",
"contacts": "/admin/kontakty", "contacts": "/admin/kontakty",
"newsletter": "/admin/newsletter", "newsletter": "/admin/newsletter",
"polls": "/admin/ankety", "polls": "/admin/ankety",
"comments": "/admin/komentare", "comments": "/admin/komentare",
"sweepstakes": "/admin/sweepstakes", "sweepstakes": "/admin/sweepstakes",
"navigation": "/admin/navigace", "navigation": "/admin/navigace",
"competition_aliases": "/admin/aliasy-soutezi", "competition_aliases": "/admin/aliasy-soutezi",
"prefetch": "/admin/prefetch", "prefetch": "/admin/prefetch",
"users": "/admin/uzivatele", "users": "/admin/uzivatele",
"settings": "/admin/nastaveni", "settings": "/admin/nastaveni",
"shortlinks": "/admin/shortlinks", "shortlinks": "/admin/shortlinks",
"files": "/admin/soubory", "files": "/admin/soubory",
"docs": "/admin/docs", "docs": "/admin/docs",
"engagement": "/admin/engagement", "engagement": "/admin/engagement",
} }
if url, ok := adminURLMap[n.PageType]; ok { if url, ok := adminURLMap[n.PageType]; ok {
return url return url
} }
} }
return "#" return "#"
} }
@@ -130,7 +133,7 @@ func (s *SocialLink) GetIconName() string {
if s.Icon != "" { if s.Icon != "" {
return s.Icon return s.Icon
} }
iconMap := map[string]string{ iconMap := map[string]string{
"facebook": "FaFacebook", "facebook": "FaFacebook",
"instagram": "FaInstagram", "instagram": "FaInstagram",
@@ -141,10 +144,10 @@ func (s *SocialLink) GetIconName() string {
"discord": "FaDiscord", "discord": "FaDiscord",
"twitch": "FaTwitch", "twitch": "FaTwitch",
} }
if icon, ok := iconMap[s.Platform]; ok { if icon, ok := iconMap[s.Platform]; ok {
return icon return icon
} }
return "FaLink" return "FaLink"
} }
+3
View File
@@ -189,6 +189,9 @@ func SetupRoutes(api *gin.RouterGroup, db *gorm.DB) {
editor.GET("/variants/:element_name", editorPreviewController.GetAvailableVariants) editor.GET("/variants/:element_name", editorPreviewController.GetAvailableVariants)
} }
// Editor-allowed admin navigation (authenticated editors)
protected.GET("/admin/navigation/editor", middleware.RoleAuth("editor"), navigationController.GetEditorAllowedAdminNav)
// Newsletter preferences token for current user // Newsletter preferences token for current user
protected.GET("/newsletter/token/me", contactController.GetNewsletterTokenForUser) protected.GET("/newsletter/token/me", contactController.GetNewsletterTokenForUser)
+187 -134
View File
@@ -19,10 +19,10 @@ import (
// NewsletterAutomation handles all automated newsletter sending // NewsletterAutomation handles all automated newsletter sending
type NewsletterAutomation struct { type NewsletterAutomation struct {
db *gorm.DB db *gorm.DB
emailSvc email.EmailService emailSvc email.EmailService
cacheDir string cacheDir string
lastWeekly time.Time lastWeekly time.Time
lastMatchCheck time.Time lastMatchCheck time.Time
} }
@@ -38,12 +38,12 @@ func NewNewsletterAutomation(db *gorm.DB, emailSvc email.EmailService) *Newslett
// Start begins the newsletter automation loop // Start begins the newsletter automation loop
func (na *NewsletterAutomation) Start() { func (na *NewsletterAutomation) Start() {
log.Printf("[newsletter-automation] Starting automated newsletter service") log.Printf("[newsletter-automation] Starting automated newsletter service")
// Run initial check after 1 minute // Run initial check after 1 minute
time.AfterFunc(1*time.Minute, func() { time.AfterFunc(1*time.Minute, func() {
na.RunCycle() na.RunCycle()
}) })
// Then run every 15 minutes // Then run every 15 minutes
ticker := time.NewTicker(15 * time.Minute) ticker := time.NewTicker(15 * time.Minute)
go func() { go func() {
@@ -59,18 +59,18 @@ func (na *NewsletterAutomation) RunCycle() {
log.Printf("[newsletter-automation] Skipped: disabled in settings") log.Printf("[newsletter-automation] Skipped: disabled in settings")
return return
} }
log.Printf("[newsletter-automation] Running cycle...") log.Printf("[newsletter-automation] Running cycle...")
// Check for weekly digest // Check for weekly digest
na.checkWeeklyDigest() na.checkWeeklyDigest()
// Check for upcoming matches (reminders) // Check for upcoming matches (reminders)
na.checkUpcomingMatches() na.checkUpcomingMatches()
// Check for finished matches (results) // Check for finished matches (results)
na.checkFinishedMatches() na.checkFinishedMatches()
log.Printf("[newsletter-automation] Cycle complete") log.Printf("[newsletter-automation] Cycle complete")
} }
@@ -79,40 +79,40 @@ func (na *NewsletterAutomation) SendBlogNotification(article *models.Article) er
if !na.isEnabled() { if !na.isEnabled() {
return fmt.Errorf("newsletter automation is disabled") return fmt.Errorf("newsletter automation is disabled")
} }
// Check if already sent // Check if already sent
var existing models.BlogNotification var existing models.BlogNotification
if err := na.db.Where("article_id = ?", article.ID).First(&existing).Error; err == nil { if err := na.db.Where("article_id = ?", article.ID).First(&existing).Error; err == nil {
log.Printf("[newsletter-automation] Blog notification already sent for article %d", article.ID) log.Printf("[newsletter-automation] Blog notification already sent for article %d", article.ID)
return nil return nil
} }
// Get subscribers interested in blogs // Get subscribers interested in blogs
subs := na.getSubscribersForType("blogs", article.CategoryName) subs := na.getSubscribersForType("blogs", article.CategoryName)
if len(subs) == 0 { if len(subs) == 0 {
log.Printf("[newsletter-automation] No subscribers for blog notifications") log.Printf("[newsletter-automation] No subscribers for blog notifications")
return nil return nil
} }
// Build email content // Build email content
subject := fmt.Sprintf("Nový článek: %s", article.Title) subject := fmt.Sprintf("Nový článek: %s", article.Title)
baseFE := strings.TrimSuffix(config.AppConfig.FrontendBaseURL, "/") baseFE := strings.TrimSuffix(config.AppConfig.FrontendBaseURL, "/")
articleURL := fmt.Sprintf("%s/news/%s", baseFE, article.Slug) articleURL := fmt.Sprintf("%s/news/%s", baseFE, article.Slug)
html := na.buildBlogNotificationHTML(article, articleURL) html := na.buildBlogNotificationHTML(article, articleURL)
// Send to each subscriber // Send to each subscriber
recipients := make([]string, 0, len(subs)) recipients := make([]string, 0, len(subs))
for _, sub := range subs { for _, sub := range subs {
recipients = append(recipients, sub.Email) recipients = append(recipients, sub.Email)
} }
err := na.sendNewsletterToRecipients(recipients, subject, html, "blog_release") err := na.sendNewsletterToRecipients(recipients, subject, html, "blog_release")
if err != nil { if err != nil {
logger.Error("[newsletter-automation] Failed to send blog notification: %v", err) logger.Error("[newsletter-automation] Failed to send blog notification: %v", err)
return err return err
} }
// Record notification // Record notification
notif := models.BlogNotification{ notif := models.BlogNotification{
ArticleID: article.ID, ArticleID: article.ID,
@@ -121,7 +121,7 @@ func (na *NewsletterAutomation) SendBlogNotification(article *models.Article) er
CreatedAt: time.Now(), CreatedAt: time.Now(),
} }
na.db.Create(&notif) na.db.Create(&notif)
log.Printf("[newsletter-automation] Blog notification sent for article %d to %d recipients", article.ID, len(recipients)) log.Printf("[newsletter-automation] Blog notification sent for article %d to %d recipients", article.ID, len(recipients))
return nil return nil
} }
@@ -129,11 +129,11 @@ func (na *NewsletterAutomation) SendBlogNotification(article *models.Article) er
func (na *NewsletterAutomation) checkWeeklyDigest() { func (na *NewsletterAutomation) checkWeeklyDigest() {
var settings models.Settings var settings models.Settings
na.db.First(&settings) na.db.First(&settings)
if !settings.EnableWeekly { if !settings.EnableWeekly {
return return
} }
// Get configured day and hour // Get configured day and hour
targetDay := strings.ToLower(strings.TrimSpace(settings.NewsletterWeeklyDay)) targetDay := strings.ToLower(strings.TrimSpace(settings.NewsletterWeeklyDay))
if targetDay == "" { if targetDay == "" {
@@ -143,47 +143,47 @@ func (na *NewsletterAutomation) checkWeeklyDigest() {
if targetHour < 0 || targetHour > 23 { if targetHour < 0 || targetHour > 23 {
targetHour = 9 // Default to 9 AM targetHour = 9 // Default to 9 AM
} }
now := time.Now() now := time.Now()
currentDay := strings.ToLower(now.Weekday().String()[:3]) currentDay := strings.ToLower(now.Weekday().String()[:3])
currentHour := now.Hour() currentHour := now.Hour()
// Check if it's the right day and hour // Check if it's the right day and hour
if currentDay != targetDay || currentHour != targetHour { if currentDay != targetDay || currentHour != targetHour {
return return
} }
// Check if already sent today // Check if already sent today
if na.lastWeekly.Year() == now.Year() && na.lastWeekly.YearDay() == now.YearDay() { if na.lastWeekly.Year() == now.Year() && na.lastWeekly.YearDay() == now.YearDay() {
return return
} }
// Get all subscribers interested in weekly digest // Get all subscribers interested in weekly digest
subs := na.getSubscribersForType("weekly", "") subs := na.getSubscribersForType("weekly", "")
if len(subs) == 0 { if len(subs) == 0 {
log.Printf("[newsletter-automation] No subscribers for weekly digest") log.Printf("[newsletter-automation] No subscribers for weekly digest")
return return
} }
log.Printf("[newsletter-automation] Sending weekly digest to %d subscribers", len(subs)) log.Printf("[newsletter-automation] Sending weekly digest to %d subscribers", len(subs))
// Build weekly content for each subscriber based on their preferences // Build weekly content for each subscriber based on their preferences
for _, sub := range subs { for _, sub := range subs {
prefs := na.parsePreferences(sub) prefs := na.parsePreferences(sub)
subject, html := BuildNewsletterDigest(na.cacheDir, prefs) subject, html := BuildNewsletterDigest(na.cacheDir, prefs)
if strings.TrimSpace(html) == "" { if strings.TrimSpace(html) == "" {
continue continue
} }
err := na.sendNewsletterToRecipients([]string{sub.Email}, subject, html, "weekly") err := na.sendNewsletterToRecipients([]string{sub.Email}, subject, html, "weekly")
if err != nil { if err != nil {
logger.Error("[newsletter-automation] Failed to send weekly digest to %s: %v", sub.Email, err) logger.Error("[newsletter-automation] Failed to send weekly digest to %s: %v", sub.Email, err)
} }
time.Sleep(200 * time.Millisecond) // Rate limiting time.Sleep(200 * time.Millisecond) // Rate limiting
} }
na.lastWeekly = now na.lastWeekly = now
log.Printf("[newsletter-automation] Weekly digest sent") log.Printf("[newsletter-automation] Weekly digest sent")
} }
@@ -191,35 +191,57 @@ func (na *NewsletterAutomation) checkWeeklyDigest() {
func (na *NewsletterAutomation) checkUpcomingMatches() { func (na *NewsletterAutomation) checkUpcomingMatches() {
var settings models.Settings var settings models.Settings
na.db.First(&settings) na.db.First(&settings)
if !settings.EnableMatchReminders { // Determine effective enabling: use settings, or auto-activate if there are subscribers and a match is within 2 hours
return enabled := settings.EnableMatchReminders
}
leadHours := settings.NewsletterReminderLeadHours leadHours := settings.NewsletterReminderLeadHours
if leadHours <= 0 { if leadHours <= 0 {
leadHours = 48 // Default 2 days leadHours = 48 // Default 2 days
} }
// Load match data from cache // Load match data from cache
facr := readJSON(filepath.Join(na.cacheDir, "facr_club_info.json")) facr := readJSON(filepath.Join(na.cacheDir, "facr_club_info.json"))
matches := facrAllMatches(facr) matches := facrAllMatches(facr)
now := time.Now() now := time.Now()
if !enabled {
subs := na.getSubscribersForType("matches", "")
if len(subs) == 0 {
return
}
auto := false
for _, match := range matches {
matchTime := parseDateTimeISO(match.Date, match.Time)
if matchTime.IsZero() || matchTime.Before(now) {
continue
}
if matchTime.Sub(now).Hours() <= 2 {
auto = true
break
}
}
if !auto {
return
}
// Auto mode: restrict reminder window to 2 hours before kickoff
leadHours = 2
enabled = true
}
for _, match := range matches { for _, match := range matches {
matchTime := parseDateTimeISO(match.Date, match.Time) matchTime := parseDateTimeISO(match.Date, match.Time)
if matchTime.IsZero() || matchTime.Before(now) { if matchTime.IsZero() || matchTime.Before(now) {
continue continue
} }
hoursUntil := matchTime.Sub(now).Hours() hoursUntil := matchTime.Sub(now).Hours()
// Check for 48h reminder // Check for lead-hour reminder (48h normally, 2h in auto mode)
if hoursUntil <= float64(leadHours) && hoursUntil > float64(leadHours-1) { if hoursUntil <= float64(leadHours) && hoursUntil > float64(leadHours-1) {
na.sendMatchReminder(match, "reminder_48h", leadHours) na.sendMatchReminder(match, "reminder_48h", leadHours)
} }
// Check for day-of reminder (match starts in 0-6 hours) // Check for day-of reminder (match starts in 0-6 hours)
if hoursUntil <= 6 && hoursUntil > 0 { if hoursUntil <= 6 && hoursUntil > 0 {
na.sendMatchReminder(match, "reminder_day", 0) na.sendMatchReminder(match, "reminder_day", 0)
@@ -234,13 +256,13 @@ func (na *NewsletterAutomation) sendMatchReminder(match Match, notifType string,
if err := na.db.Where("match_id = ? AND notification_type = ?", matchKey, notifType).First(&existing).Error; err == nil { if err := na.db.Where("match_id = ? AND notification_type = ?", matchKey, notifType).First(&existing).Error; err == nil {
return return
} }
// Get subscribers interested in matches and this competition // Get subscribers interested in matches and this competition
subs := na.getSubscribersForType("matches", match.Competition) subs := na.getSubscribersForType("matches", match.Competition)
if len(subs) == 0 { if len(subs) == 0 {
return return
} }
// Build email content // Build email content
var subject string var subject string
if notifType == "reminder_48h" { if notifType == "reminder_48h" {
@@ -248,20 +270,20 @@ func (na *NewsletterAutomation) sendMatchReminder(match Match, notifType string,
} else { } else {
subject = fmt.Sprintf("Zápas dnes: %s vs %s", match.Home, match.Away) subject = fmt.Sprintf("Zápas dnes: %s vs %s", match.Home, match.Away)
} }
html := na.buildMatchReminderHTML(match, notifType) html := na.buildMatchReminderHTML(match, notifType)
recipients := make([]string, 0, len(subs)) recipients := make([]string, 0, len(subs))
for _, sub := range subs { for _, sub := range subs {
recipients = append(recipients, sub.Email) recipients = append(recipients, sub.Email)
} }
err := na.sendNewsletterToRecipients(recipients, subject, html, "match_reminder") err := na.sendNewsletterToRecipients(recipients, subject, html, "match_reminder")
if err != nil { if err != nil {
logger.Error("[newsletter-automation] Failed to send match reminder: %v", err) logger.Error("[newsletter-automation] Failed to send match reminder: %v", err)
return return
} }
// Record notification // Record notification
notif := models.MatchNotification{ notif := models.MatchNotification{
MatchID: matchKey, MatchID: matchKey,
@@ -271,62 +293,91 @@ func (na *NewsletterAutomation) sendMatchReminder(match Match, notifType string,
CreatedAt: time.Now(), CreatedAt: time.Now(),
} }
na.db.Create(&notif) na.db.Create(&notif)
log.Printf("[newsletter-automation] Match reminder sent: %s (%s) to %d recipients", matchKey, notifType, len(recipients)) log.Printf("[newsletter-automation] Match reminder sent: %s (%s) to %d recipients", matchKey, notifType, len(recipients))
} }
func (na *NewsletterAutomation) checkFinishedMatches() { func (na *NewsletterAutomation) checkFinishedMatches() {
var settings models.Settings var settings models.Settings
na.db.First(&settings) na.db.First(&settings)
if !settings.EnableResults { // Determine effective enabling. If disabled, auto-activate when there are subscribers and a recent result exists.
return enabled := settings.EnableResults
}
// Load match data
// Check quiet hours facr := readJSON(filepath.Join(na.cacheDir, "facr_club_info.json"))
currentHour := time.Now().Hour() matches := facrAllMatches(facr)
quietStart := settings.NewsletterQuietStart
quietEnd := settings.NewsletterQuietEnd now := time.Now()
lookback := 6 * time.Hour // Check matches finished in last 6 hours
if quietStart > 0 && quietEnd > 0 {
if quietStart < quietEnd { bypassQuiet := false
// e.g., 22:00 - 08:00 if !enabled {
if currentHour >= quietStart || currentHour < quietEnd { subs := na.getSubscribersForType("scores", "")
log.Printf("[newsletter-automation] In quiet hours, skipping result notifications") if len(subs) == 0 {
return return
}
auto := false
for _, match := range matches {
if match.Score == "" || !strings.Contains(match.Score, ":") {
continue
} }
} else { matchTime := parseDateTimeISO(match.Date, match.Time)
// e.g., 08:00 - 22:00 (inverted, send only during these hours) if matchTime.IsZero() || matchTime.After(now) {
if currentHour < quietStart && currentHour >= quietEnd { continue
log.Printf("[newsletter-automation] Outside active hours, skipping result notifications") }
if now.Sub(matchTime) <= lookback {
auto = true
break
}
}
if !auto {
return
}
// Auto mode: send immediately when we have a result, ignoring quiet hours
bypassQuiet = true
enabled = true
}
// Respect quiet hours only when explicitly enabled in settings (not in auto mode)
if !bypassQuiet {
currentHour := time.Now().Hour()
quietStart := settings.NewsletterQuietStart
quietEnd := settings.NewsletterQuietEnd
// Consider quiet hours configured when both bounds are within 0..23 and not equal
if quietStart >= 0 && quietStart <= 23 && quietEnd >= 0 && quietEnd <= 23 && quietStart != quietEnd {
inQuiet := false
if quietStart < quietEnd {
// Same-day interval, e.g., 08:0022:00 => quiet when between start and end
inQuiet = currentHour >= quietStart && currentHour < quietEnd
} else {
// Cross-midnight interval, e.g., 22:0008:00 => quiet when hour >= start OR hour < end
inQuiet = currentHour >= quietStart || currentHour < quietEnd
}
if inQuiet {
log.Printf("[newsletter-automation] In quiet hours (%02d:00-%02d:00), skipping result notifications", quietStart, quietEnd)
return return
} }
} }
} }
// Load match data
facr := readJSON(filepath.Join(na.cacheDir, "facr_club_info.json"))
matches := facrAllMatches(facr)
now := time.Now()
lookback := 6 * time.Hour // Check matches finished in last 6 hours
for _, match := range matches { for _, match := range matches {
if match.Score == "" || !strings.Contains(match.Score, ":") { if match.Score == "" || !strings.Contains(match.Score, ":") {
continue // No score yet continue // No score yet
} }
matchTime := parseDateTimeISO(match.Date, match.Time) matchTime := parseDateTimeISO(match.Date, match.Time)
if matchTime.IsZero() || matchTime.After(now) { if matchTime.IsZero() || matchTime.After(now) {
continue continue
} }
// Check if match finished recently // Check if match finished recently
timeSinceMatch := now.Sub(matchTime) timeSinceMatch := now.Sub(matchTime)
if timeSinceMatch > lookback { if timeSinceMatch > lookback {
continue continue
} }
na.sendMatchResult(match) na.sendMatchResult(match)
} }
} }
@@ -338,27 +389,27 @@ func (na *NewsletterAutomation) sendMatchResult(match Match) {
if err := na.db.Where("match_id = ? AND notification_type = ?", matchKey, "result").First(&existing).Error; err == nil { if err := na.db.Where("match_id = ? AND notification_type = ?", matchKey, "result").First(&existing).Error; err == nil {
return return
} }
// Get subscribers interested in results // Get subscribers interested in results
subs := na.getSubscribersForType("scores", match.Competition) subs := na.getSubscribersForType("scores", match.Competition)
if len(subs) == 0 { if len(subs) == 0 {
return return
} }
subject := fmt.Sprintf("Výsledek: %s %s %s", match.Home, match.Score, match.Away) subject := fmt.Sprintf("Výsledek: %s %s %s", match.Home, match.Score, match.Away)
html := na.buildMatchResultHTML(match) html := na.buildMatchResultHTML(match)
recipients := make([]string, 0, len(subs)) recipients := make([]string, 0, len(subs))
for _, sub := range subs { for _, sub := range subs {
recipients = append(recipients, sub.Email) recipients = append(recipients, sub.Email)
} }
err := na.sendNewsletterToRecipients(recipients, subject, html, "match_result") err := na.sendNewsletterToRecipients(recipients, subject, html, "match_result")
if err != nil { if err != nil {
logger.Error("[newsletter-automation] Failed to send match result: %v", err) logger.Error("[newsletter-automation] Failed to send match result: %v", err)
return return
} }
// Record notification // Record notification
notif := models.MatchNotification{ notif := models.MatchNotification{
MatchID: matchKey, MatchID: matchKey,
@@ -368,7 +419,7 @@ func (na *NewsletterAutomation) sendMatchResult(match Match) {
CreatedAt: time.Now(), CreatedAt: time.Now(),
} }
na.db.Create(&notif) na.db.Create(&notif)
log.Printf("[newsletter-automation] Match result sent: %s to %d recipients", matchKey, len(recipients)) log.Printf("[newsletter-automation] Match result sent: %s to %d recipients", matchKey, len(recipients))
} }
@@ -384,7 +435,7 @@ func (na *NewsletterAutomation) isEnabled() bool {
func (na *NewsletterAutomation) getSubscribersForType(contentType, category string) []models.NewsletterSubscription { func (na *NewsletterAutomation) getSubscribersForType(contentType, category string) []models.NewsletterSubscription {
var subs []models.NewsletterSubscription var subs []models.NewsletterSubscription
na.db.Where("is_active = ?", true).Find(&subs) na.db.Where("is_active = ?", true).Find(&subs)
filtered := make([]models.NewsletterSubscription, 0) filtered := make([]models.NewsletterSubscription, 0)
for _, sub := range subs { for _, sub := range subs {
// Check if subscriber wants this content type // Check if subscriber wants this content type
@@ -409,7 +460,7 @@ func (na *NewsletterAutomation) getSubscribersForType(contentType, category stri
filtered = append(filtered, sub) filtered = append(filtered, sub)
} }
} }
return filtered return filtered
} }
@@ -420,7 +471,7 @@ func (na *NewsletterAutomation) parsePreferences(sub models.NewsletterSubscripti
Competitions: []string{}, Competitions: []string{},
Frequency: "daily", Frequency: "daily",
} }
// Parse content types // Parse content types
if v, ok := sub.Preferences["blogs"].(bool); ok && v { if v, ok := sub.Preferences["blogs"].(bool); ok && v {
prefs.ContentTypes = append(prefs.ContentTypes, "blogs") prefs.ContentTypes = append(prefs.ContentTypes, "blogs")
@@ -434,7 +485,7 @@ func (na *NewsletterAutomation) parsePreferences(sub models.NewsletterSubscripti
if v, ok := sub.Preferences["scores"].(bool); ok && v { if v, ok := sub.Preferences["scores"].(bool); ok && v {
prefs.ContentTypes = append(prefs.ContentTypes, "scores") prefs.ContentTypes = append(prefs.ContentTypes, "scores")
} }
// Parse categories/competitions // Parse categories/competitions
if cats, ok := sub.Preferences["categories"].(string); ok && cats != "" { if cats, ok := sub.Preferences["categories"].(string); ok && cats != "" {
for _, c := range strings.Split(cats, ",") { for _, c := range strings.Split(cats, ",") {
@@ -443,7 +494,7 @@ func (na *NewsletterAutomation) parsePreferences(sub models.NewsletterSubscripti
} }
} }
} }
return prefs return prefs
} }
@@ -453,12 +504,12 @@ func (na *NewsletterAutomation) sendNewsletterToRecipients(recipients []string,
Content: htmlContent, Content: htmlContent,
Recipients: recipients, Recipients: recipients,
} }
err := na.emailSvc.SendNewsletter(data) err := na.emailSvc.SendNewsletter(data)
if err != nil { if err != nil {
return err return err
} }
// Log sent newsletter // Log sent newsletter
contentIDsJSON, _ := json.Marshal([]string{}) contentIDsJSON, _ := json.Marshal([]string{})
logEntry := models.NewsletterSentLog{ logEntry := models.NewsletterSentLog{
@@ -470,42 +521,44 @@ func (na *NewsletterAutomation) sendNewsletterToRecipients(recipients []string,
CreatedAt: time.Now(), CreatedAt: time.Now(),
} }
na.db.Create(&logEntry) na.db.Create(&logEntry)
return nil return nil
} }
func (na *NewsletterAutomation) buildBlogNotificationHTML(article *models.Article, articleURL string) string { func (na *NewsletterAutomation) buildBlogNotificationHTML(article *models.Article, articleURL string) string {
// Short description: prefer excerpt; otherwise derive from content // Short description: prefer excerpt; otherwise derive from content
desc := strings.TrimSpace(article.Excerpt) desc := strings.TrimSpace(article.Excerpt)
if desc == "" { if desc == "" {
plain := utils.SanitizeString(article.Content) plain := utils.SanitizeString(article.Content)
if len(plain) > 260 { if len(plain) > 260 {
cut := 240 cut := 240
if cut < len(plain) { if cut < len(plain) {
for cut < len(plain) && plain[cut] != ' ' { for cut < len(plain) && plain[cut] != ' ' {
cut++ cut++
} }
} }
if cut > len(plain) { cut = len(plain) } if cut > len(plain) {
plain = strings.TrimSpace(plain[:cut]) + "…" cut = len(plain)
} }
desc = plain plain = strings.TrimSpace(plain[:cut]) + "…"
} }
desc = plain
}
// Category badge (if available) // Category badge (if available)
cat := strings.TrimSpace(article.CategoryName) cat := strings.TrimSpace(article.CategoryName)
var catHTML string var catHTML string
if cat != "" { if cat != "" {
catHTML = fmt.Sprintf(`<div style="margin-bottom:10px;"><span style="display:inline-block;background:#e3f2fd;color:#1e3a8a;border:1px solid #90cdf4;border-radius:999px;padding:4px 10px;font-size:12px;font-weight:600;">%s</span></div>`, htmlEsc(cat)) catHTML = fmt.Sprintf(`<div style="margin-bottom:10px;"><span style="display:inline-block;background:#e3f2fd;color:#1e3a8a;border:1px solid #90cdf4;border-radius:999px;padding:4px 10px;font-size:12px;font-weight:600;">%s</span></div>`, htmlEsc(cat))
} }
// Cover image (optional) // Cover image (optional)
var imgHTML string var imgHTML string
if strings.TrimSpace(article.ImageURL) != "" { if strings.TrimSpace(article.ImageURL) != "" {
imgHTML = fmt.Sprintf(`<div style="margin:0 0 15px 0;"><img src="%s" alt="cover" style="width:100%%;height:auto;border-radius:6px;"/></div>`, htmlEsc(article.ImageURL)) imgHTML = fmt.Sprintf(`<div style="margin:0 0 15px 0;"><img src="%s" alt="cover" style="width:100%%;height:auto;border-radius:6px;"/></div>`, htmlEsc(article.ImageURL))
} }
html := fmt.Sprintf(` html := fmt.Sprintf(`
<div style="max-width: 600px; margin: 0 auto; font-family: Arial, sans-serif;"> <div style="max-width: 600px; margin: 0 auto; font-family: Arial, sans-serif;">
<h2 style="color: #1e3a8a; margin-bottom: 12px;">Nový článek na webu</h2> <h2 style="color: #1e3a8a; margin-bottom: 12px;">Nový článek na webu</h2>
<div style="border-left: 4px solid #2563eb; padding: 18px; background: #f8fafc; margin: 16px 0; border-radius:6px;"> <div style="border-left: 4px solid #2563eb; padding: 18px; background: #f8fafc; margin: 16px 0; border-radius:6px;">
@@ -518,18 +571,18 @@ func (na *NewsletterAutomation) buildBlogNotificationHTML(article *models.Articl
</div> </div>
`, catHTML, htmlEsc(article.Title), imgHTML, htmlEsc(desc), articleURL) `, catHTML, htmlEsc(article.Title), imgHTML, htmlEsc(desc), articleURL)
return html return html
} }
func (na *NewsletterAutomation) buildMatchReminderHTML(match Match, notifType string) string { func (na *NewsletterAutomation) buildMatchReminderHTML(match Match, notifType string) string {
var intro string var intro string
if notifType == "reminder_48h" { if notifType == "reminder_48h" {
intro = "Připomínáme nadcházející zápas:" intro = "Připomínáme nadcházející zápas:"
} else { } else {
intro = "Zápas je dnes!" intro = "Zápas je dnes!"
} }
html := fmt.Sprintf(` html := fmt.Sprintf(`
<div style="max-width: 600px; margin: 0 auto; font-family: Arial, sans-serif;"> <div style="max-width: 600px; margin: 0 auto; font-family: Arial, sans-serif;">
<h2 style="color: #1e3a8a; margin-bottom: 20px;">%s</h2> <h2 style="color: #1e3a8a; margin-bottom: 20px;">%s</h2>
@@ -541,7 +594,7 @@ func (na *NewsletterAutomation) buildMatchReminderHTML(match Match, notifType st
</div> </div>
</div> </div>
`, intro, htmlEsc(match.Home), htmlEsc(match.Away), htmlEsc(match.Date), htmlEsc(match.Time), htmlEsc(match.Competition)) `, intro, htmlEsc(match.Home), htmlEsc(match.Away), htmlEsc(match.Date), htmlEsc(match.Time), htmlEsc(match.Competition))
return html return html
} }
@@ -557,6 +610,6 @@ func (na *NewsletterAutomation) buildMatchResultHTML(match Match) string {
</div> </div>
</div> </div>
`, htmlEsc(match.Home), htmlEsc(match.Score), htmlEsc(match.Away), htmlEsc(match.Date), htmlEsc(match.Competition)) `, htmlEsc(match.Home), htmlEsc(match.Score), htmlEsc(match.Away), htmlEsc(match.Date), htmlEsc(match.Competition))
return html return html
} }
+5 -3
View File
@@ -78,7 +78,8 @@ func fetchZonerama(link string) error {
} }
// Profile fetch - gets album metadata only (no photos) // Profile fetch - gets album metadata only (no photos)
albumLimit := envInt("ZONERAMA_ALBUM_LIMIT", 10) // Fetch up to 10 albums metadata albumLimit := envInt("ZONERAMA_ALBUM_LIMIT", 10) // Fetch up to 10 albums metadata
apiBase := "https://zonerama.tdvorak.dev/zonerama?link=" + url.QueryEscape(strings.TrimSpace(link)) + "&album_limit=" + strconv.Itoa(albumLimit) + "&photo_limit=0" base := strings.TrimSuffix(config.AppConfig.ZoneramaAPIBase, "/")
apiBase := fmt.Sprintf("%s/zonerama?link=%s&album_limit=%d&photo_limit=0", base, url.QueryEscape(strings.TrimSpace(link)), albumLimit)
log.Printf("[prefetch] Fetching Zonerama profile: %s (album_limit=%d, no photos)", apiBase, albumLimit) log.Printf("[prefetch] Fetching Zonerama profile: %s (album_limit=%d, no photos)", apiBase, albumLimit)
// Increase timeout to 60s since the API can take longer to fetch // Increase timeout to 60s since the API can take longer to fetch
@@ -223,8 +224,9 @@ func fetchZoneramaAlbums(albums []struct {
} }
// Fetch album with photos // Fetch album with photos
apiURL := fmt.Sprintf("https://zonerama.tdvorak.dev/zonerama-album?link=%s&photo_limit=%d", base := strings.TrimSuffix(config.AppConfig.ZoneramaAPIBase, "/")
url.QueryEscape(album.URL), photoLimit) apiURL := fmt.Sprintf("%s/zonerama-album?link=%s&photo_limit=%d",
base, url.QueryEscape(album.URL), photoLimit)
log.Printf("[prefetch] Zonerama: Fetching album %d/%d: %s", i+1, len(albums), album.URL) log.Printf("[prefetch] Zonerama: Fetching album %d/%d: %s", i+1, len(albums), album.URL)
+54 -20
View File
@@ -13,7 +13,7 @@ import (
"fotbal-club/pkg/email" "fotbal-club/pkg/email"
"gorm.io/gorm" "gorm.io/gorm"
"gorm.io/gorm/clause" "gorm.io/gorm/clause"
) )
// SweepstakesService encapsulates business logic for sweepstakes // SweepstakesService encapsulates business logic for sweepstakes
@@ -83,18 +83,30 @@ func (s *SweepstakesService) FinalizeSweepstake(sw *models.Sweepstake, seed stri
} }
// Determine number of winners // Determine number of winners
nWinners := 0 nWinners := 0
for _, p := range prizes { nWinners += max(0, p.Quantity) } for _, p := range prizes {
nWinners += max(0, p.Quantity)
}
if nWinners == 0 { if nWinners == 0 {
if cur.TotalPrizes > 0 { nWinners = cur.TotalPrizes } if cur.TotalPrizes > 0 {
nWinners = cur.TotalPrizes
}
} }
// Cap winners to a safe maximum // Cap winners to a safe maximum
if nWinners > 100 { nWinners = 100 } if nWinners > 100 {
if nWinners > len(entries) { nWinners = len(entries) } nWinners = 100
}
if nWinners > len(entries) {
nWinners = len(entries)
}
// Build seed // Build seed
effSeed := strings.TrimSpace(seed) effSeed := strings.TrimSpace(seed)
if effSeed == "" { effSeed = strings.TrimSpace(cur.DrawSeed) } if effSeed == "" {
if effSeed == "" { effSeed = fmt.Sprintf("%d-%d", cur.ID, time.Now().UnixNano()) } effSeed = strings.TrimSpace(cur.DrawSeed)
}
if effSeed == "" {
effSeed = fmt.Sprintf("%d-%d", cur.ID, time.Now().UnixNano())
}
// Deterministic RNG from SHA-256 // Deterministic RNG from SHA-256
h := sha256.Sum256([]byte(effSeed)) h := sha256.Sum256([]byte(effSeed))
base := binary.LittleEndian.Uint64(h[:8]) base := binary.LittleEndian.Uint64(h[:8])
@@ -125,18 +137,27 @@ func (s *SweepstakesService) FinalizeSweepstake(sw *models.Sweepstake, seed stri
for j := 0; j < q && pos < len(idx); j++ { for j := 0; j < q && pos < len(idx); j++ {
cand := entries[idx[pos]] cand := entries[idx[pos]]
pos++ pos++
if picked[cand.UserID] { j--; continue } if picked[cand.UserID] {
j--
continue
}
picked[cand.UserID] = true picked[cand.UserID] = true
assign(cand.UserID, cand.ID, &prizes[i]) assign(cand.UserID, cand.ID, &prizes[i])
if len(winners) >= nWinners { break } if len(winners) >= nWinners {
break
}
}
if len(winners) >= nWinners {
break
} }
if len(winners) >= nWinners { break }
} }
// If still need more (when TotalPrizes used) // If still need more (when TotalPrizes used)
for len(winners) < nWinners && pos < len(idx) { for len(winners) < nWinners && pos < len(idx) {
cand := entries[idx[pos]] cand := entries[idx[pos]]
pos++ pos++
if picked[cand.UserID] { continue } if picked[cand.UserID] {
continue
}
picked[cand.UserID] = true picked[cand.UserID] = true
assign(cand.UserID, cand.ID, nil) assign(cand.UserID, cand.ID, nil)
} }
@@ -151,9 +172,9 @@ func (s *SweepstakesService) FinalizeSweepstake(sw *models.Sweepstake, seed stri
vis := cur.EndAt.Add(72 * time.Hour) vis := cur.EndAt.Add(72 * time.Hour)
if err := tx.Model(&models.Sweepstake{}).Where("id = ?", cur.ID).Updates(map[string]interface{}{ if err := tx.Model(&models.Sweepstake{}).Where("id = ?", cur.ID).Updates(map[string]interface{}{
"winners_selected_at": now, "winners_selected_at": now,
"visibility_until": vis, "visibility_until": vis,
"draw_seed": effSeed, "draw_seed": effSeed,
"status": "finalized", "status": "finalized",
}).Error; err != nil { }).Error; err != nil {
return err return err
} }
@@ -163,15 +184,21 @@ func (s *SweepstakesService) FinalizeSweepstake(sw *models.Sweepstake, seed stri
for _, w := range winners { for _, w := range winners {
var user models.User var user models.User
_ = tx.First(&user, w.UserID).Error _ = tx.First(&user, w.UserID).Error
if strings.TrimSpace(user.Email) == "" { continue } if strings.TrimSpace(user.Email) == "" {
continue
}
// Localize end date to Czech format in Europe/Prague timezone
loc, _ := time.LoadLocation("Europe/Prague")
endsLocal := cur.EndAt.In(loc)
endsAtCz := endsLocal.Format("02. 01. 2006 15:04")
_ = s.Email.SendEmail(&email.EmailData{ _ = s.Email.SendEmail(&email.EmailData{
Subject: "Vyhráli jste v soutěži!", Subject: "Vyhráli jste v soutěži!",
To: []string{strings.TrimSpace(user.Email)}, To: []string{strings.TrimSpace(user.Email)},
Template: "sweepstake_winner_user", Template: "sweepstake_winner_user",
Data: map[string]interface{}{ Data: map[string]interface{}{
"Title": cur.Title, "Title": cur.Title,
"PrizeName": w.PrizeName, "PrizeName": w.PrizeName,
"EndsAt": cur.EndAt.Format(time.RFC1123), "EndsAt": endsAtCz,
}, },
}) })
} }
@@ -179,14 +206,16 @@ func (s *SweepstakesService) FinalizeSweepstake(sw *models.Sweepstake, seed stri
var set models.Settings var set models.Settings
_ = tx.First(&set).Error _ = tx.First(&set).Error
adminTo := strings.TrimSpace(set.ContactEmail) adminTo := strings.TrimSpace(set.ContactEmail)
if adminTo == "" { adminTo = strings.TrimSpace(set.SMTPFrom) } if adminTo == "" {
adminTo = strings.TrimSpace(set.SMTPFrom)
}
if adminTo != "" { if adminTo != "" {
_ = s.Email.SendEmail(&email.EmailData{ _ = s.Email.SendEmail(&email.EmailData{
Subject: "Soutěž vybraní výherci", Subject: "Soutěž vybraní výherci",
To: []string{adminTo}, To: []string{adminTo},
Template: "sweepstake_winner_admin", Template: "sweepstake_winner_admin",
Data: map[string]interface{}{ Data: map[string]interface{}{
"Title": cur.Title, "Title": cur.Title,
"WinnersCount": len(winners), "WinnersCount": len(winners),
}, },
}) })
@@ -196,4 +225,9 @@ func (s *SweepstakesService) FinalizeSweepstake(sw *models.Sweepstake, seed stri
}) })
} }
func max(a, b int) int { if a > b { return a } ; return b } func max(a, b int) int {
if a > b {
return a
}
return b
}
+45 -63
View File
@@ -19,6 +19,7 @@ import (
"fotbal-club/internal/config" "fotbal-club/internal/config"
"fotbal-club/internal/models" "fotbal-club/internal/models"
"fotbal-club/pkg/logger" "fotbal-club/pkg/logger"
"fotbal-club/pkg/utils"
"github.com/vanng822/go-premailer/premailer" "github.com/vanng822/go-premailer/premailer"
"gopkg.in/mail.v2" "gopkg.in/mail.v2"
@@ -476,11 +477,10 @@ func (s *emailService) SendPasswordReset(to string, resetLink string, useOverrid
if clubID := strings.TrimSpace(set.ClubID); clubID != "" { if clubID := strings.TrimSpace(set.ClubID); clubID != "" {
// Use PNG format for better email client compatibility (SVG not widely supported) // Use PNG format for better email client compatibility (SVG not widely supported)
clubLogo = fmt.Sprintf("https://logoapi.sportcreative.eu/logos/%s?format=png&width=400", clubID) clubLogo = fmt.Sprintf("https://logoapi.sportcreative.eu/logos/%s?format=png&width=400", clubID)
} else {
clubLogo = "https://via.placeholder.com/400x400.png?text=Logo"
} }
} }
if clubLogo == "" {
clubLogo = "https://via.placeholder.com/400x400.png?text=Logo"
}
primaryColor := strings.TrimSpace(set.PrimaryColor) primaryColor := strings.TrimSpace(set.PrimaryColor)
if primaryColor == "" { if primaryColor == "" {
primaryColor = "#1e3a8a" primaryColor = "#1e3a8a"
@@ -870,24 +870,26 @@ func (s *emailService) SendContactForm(data *ContactFormData) error {
Agent: data.UserAgent, Agent: data.UserAgent,
} }
// Build recipients: admin email + optional auto-forward list from Settings // Build recipients (deduped later):
recipients := make([]string, 0, 4) // 1) Club contact email from DB Settings (preferred default)
if v := strings.TrimSpace(s.config.AdminEmail); v != "" { // 2) CONTACT_EMAIL from env (Config.ContactEmail)
recipients = append(recipients, v) // 3) ADMIN_EMAIL from env (Config.AdminEmail)
} recipients := make([]string, 0, 8)
// Load settings to check auto-forwarding // Load settings for contact email and forwarding list
var set models.Settings var set models.Settings
if s.db != nil { if s.db != nil {
_ = s.db.First(&set).Error _ = s.db.First(&set).Error
if set.ContactForwardEnabled && strings.TrimSpace(set.ContactForwardList) != "" { if v := strings.TrimSpace(set.ContactEmail); v != "" {
parts := strings.FieldsFunc(set.ContactForwardList, func(r rune) bool { return r == ',' || r == ';' || r == ' ' || r == '\n' || r == '\t' }) recipients = append(recipients, v)
for _, p := range parts {
if v := strings.TrimSpace(p); v != "" {
recipients = append(recipients, v)
}
}
} }
} }
// Add environment-provided contact/admin fallbacks
if v := strings.TrimSpace(s.config.ContactEmail); v != "" {
recipients = append(recipients, v)
}
if v := strings.TrimSpace(s.config.AdminEmail); v != "" {
recipients = append(recipients, v)
}
// Deduplicate and ensure at least one recipient // Deduplicate and ensure at least one recipient
uniq := make(map[string]struct{}) uniq := make(map[string]struct{})
dedup := make([]string, 0, len(recipients)) dedup := make([]string, 0, len(recipients))
@@ -951,8 +953,6 @@ func (s *emailService) SendNewsletter(d *NewsletterData) error {
if subj == "" || html == "" { if subj == "" || html == "" {
return fmt.Errorf("newsletter subject and content are required") return fmt.Errorf("newsletter subject and content are required")
} }
// Build dialer and effective From dynamically
dialer, effFrom, effFromName := s.buildDialerAndFrom()
// Prepare recipient list (dedupe and sanitize) // Prepare recipient list (dedupe and sanitize)
uniq := map[string]struct{}{} uniq := map[string]struct{}{}
recips := make([]string, 0, len(d.Recipients)) recips := make([]string, 0, len(d.Recipients))
@@ -984,7 +984,7 @@ func (s *emailService) SendNewsletter(d *NewsletterData) error {
} }
frontendBase := strings.TrimSuffix(s.config.FrontendBaseURL, "/") frontendBase := strings.TrimSuffix(s.config.FrontendBaseURL, "/")
// Send to each recipient // Send to each recipient using the standard email template wrapper (base.html + newsletter.html)
var errs []error var errs []error
for _, to := range recips { for _, to := range recips {
// Create delivery log (best-effort) // Create delivery log (best-effort)
@@ -1001,7 +1001,7 @@ func (s *emailService) SendNewsletter(d *NewsletterData) error {
_ = s.db.Create(&logRec).Error _ = s.db.Create(&logRec).Error
} }
// Rewrite links for tracking and add open pixel // Rewrite links for tracking and add open pixel (rendered inside the template)
trackedHTML := rewriteLinksForTracking(html, makeAbs, int(logRec.ID), token, frontendBase, s.config.PublicAPIBaseURL) trackedHTML := rewriteLinksForTracking(html, makeAbs, int(logRec.ID), token, frontendBase, s.config.PublicAPIBaseURL)
pixelURL := makeAbs("/email/open.gif", url.Values{ pixelURL := makeAbs("/email/open.gif", url.Values{
"m": {fmt.Sprintf("%d", logRec.ID)}, "m": {fmt.Sprintf("%d", logRec.ID)},
@@ -1010,60 +1010,42 @@ func (s *emailService) SendNewsletter(d *NewsletterData) error {
if strings.TrimSpace(trackedHTML) == "" { if strings.TrimSpace(trackedHTML) == "" {
trackedHTML = html trackedHTML = html
} }
trackedHTML = trackedHTML + fmt.Sprintf("<img src=\"%s\" width=\"1\" height=\"1\" style=\"display:none;\" alt=\"\" />", pixelURL)
m := mail.NewMessage() // Build manage/unsubscribe URLs (besteffort)
// Properly encode UTF-8 From name manageURL := ""
name := strings.TrimSpace(effFromName) if v, err := utils.GenerateSubscriberToken(strings.ToLower(strings.TrimSpace(to)), 60*24*30); err == nil && frontendBase != "" {
if i := strings.Index(name, "<"); i >= 0 { manageURL = frontendBase + "/newsletter/preferences?token=" + v
name = strings.TrimSpace(name[:i])
} }
addr := strings.TrimSpace(effFrom) unsubscribeURL := ""
if !strings.Contains(addr, "@") { if frontendBase != "" {
addr = strings.TrimSpace(s.config.SMTPFrom) unsubscribeURL = frontendBase + "/newsletter/unsubscribe/" + url.QueryEscape(strings.ToLower(strings.TrimSpace(to)))
} }
if strings.Contains(strings.ToLower(name), "@") {
name = "" // Render via SendEmail to ensure base wrapper and branding
ed := &EmailData{
Subject: subj,
To: []string{to},
Template: "newsletter",
Data: map[string]interface{}{
"Subject": subj,
"Content": trackedHTML,
"OpenPixelURL": pixelURL,
"ManageURL": manageURL,
"UnsubscribeURL": unsubscribeURL,
},
} }
m.SetAddressHeader("From", addr, name) if err := s.SendEmail(ed); err != nil {
m.SetHeader("To", to) errs = append(errs, fmt.Errorf("failed to send to %s: %w", to, err))
m.SetHeader("Subject", subj)
m.SetDateHeader("Date", time.Now())
m.SetHeader("X-Mailer", "Fotbal Club")
if d.Headers != nil {
for k, v := range d.Headers {
if len(v) > 0 {
m.SetHeader(k, v...)
}
}
}
m.SetBody("text/plain", "Pro zobrazení tohoto e-mailu použijte HTML klient.")
m.AddAlternative("text/html", trackedHTML)
// Retry send
var lastErr error
for i := 0; i < 3; i++ {
logger.Debug("SMTP newsletter send attempt %d: to=%s subject=%s", i+1, to, subj)
if err := dialer.DialAndSend(m); err == nil {
lastErr = nil
break
} else {
lastErr = err
logger.Error("SMTP newsletter send failed (attempt %d) to=%s: %v", i+1, to, err)
time.Sleep(time.Second * time.Duration(i+1))
}
}
if lastErr != nil {
errs = append(errs, fmt.Errorf("failed to send to %s: %w", to, lastErr))
if s.db != nil && logRec.ID != 0 { if s.db != nil && logRec.ID != 0 {
_ = s.db.Model(&models.EmailLog{}).Where("id = ?", logRec.ID).Updates(map[string]interface{}{ _ = s.db.Model(&models.EmailLog{}).Where("id = ?", logRec.ID).Updates(map[string]interface{}{
"status": "failed", "status": "failed",
"send_error": lastErr.Error(), "send_error": err.Error(),
}).Error }).Error
} }
} } else if s.db != nil && logRec.ID != 0 {
if lastErr == nil && s.db != nil && logRec.ID != 0 {
_ = s.db.Model(&models.EmailLog{}).Where("id = ?", logRec.ID).Update("status", "sent").Error _ = s.db.Model(&models.EmailLog{}).Where("id = ?", logRec.ID).Update("status", "sent").Error
} }
time.Sleep(100 * time.Millisecond) time.Sleep(100 * time.Millisecond)
} }
if len(errs) > 0 { if len(errs) > 0 {