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