mirror of
https://github.com/Dvorinka/MyClubServer.git
synced 2026-06-03 18:22:57 +00:00
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:
+27
-27
@@ -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 (won’t use)
|
- [x] Remove avatar templates (won’t 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
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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 />} />
|
||||||
|
|||||||
@@ -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) => {
|
||||||
|
|||||||
@@ -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 }}>Už 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>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 }} />
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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 A‑týmu vs. B‑tý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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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í)',
|
||||||
|
|||||||
@@ -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)} Kč</FormHelperText>
|
<FormHelperText>~ {Math.round(Number(form.cost_points || 0) * 0.1)} Kč</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)} Kč</FormHelperText>
|
<FormHelperText>~ {Math.round(Number(editForm.cost_points || 0) * 0.1)} Kč</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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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 e‑mailu">
|
||||||
|
{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>
|
||||||
|
|||||||
@@ -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} />
|
||||||
|
|||||||
@@ -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 />
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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`);
|
||||||
|
|||||||
@@ -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));
|
||||||
|
|||||||
@@ -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> {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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)); }
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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", ""),
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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(¬if)
|
na.db.Create(¬if)
|
||||||
|
|
||||||
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(¬if)
|
na.db.Create(¬if)
|
||||||
|
|
||||||
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:00–22:00 => quiet when between start and end
|
||||||
|
inQuiet = currentHour >= quietStart && currentHour < quietEnd
|
||||||
|
} else {
|
||||||
|
// Cross-midnight interval, e.g., 22:00–08: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(¬if)
|
na.db.Create(¬if)
|
||||||
|
|
||||||
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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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 (best‑effort)
|
||||||
// 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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user