This commit is contained in:
Tomas Dvorak
2025-11-14 15:53:12 +01:00
parent f3db65d350
commit c941313fd5
149 changed files with 4366 additions and 12935 deletions
+12
View File
@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg width="256" height="256" viewBox="0 0 256 256" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Player placeholder">
<defs>
<linearGradient id="g" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#EDF2F7"/>
<stop offset="1" stop-color="#E2E8F0"/>
</linearGradient>
</defs>
<rect width="256" height="256" rx="24" fill="url(#g)"/>
<circle cx="128" cy="92" r="44" fill="#CBD5E0"/>
<path d="M48 212c0-40 36-64 80-64s80 24 80 64v12H48v-12z" fill="#CBD5E0"/>
</svg>

After

Width:  |  Height:  |  Size: 552 B

@@ -129,7 +129,7 @@ export default function AdminSearchModal({ isOpen, onClose, onSelectPath }: { is
else if (e.key === 'Enter') {
const chosen = idx >= 0 ? results[idx] : results[0];
if (chosen) onSelect(chosen.path);
} else if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'k') {
} else if ((e.ctrlKey || e.metaKey) && String(e.key || '').toLowerCase() === 'k') {
e.preventDefault(); onClose();
} else if (e.key === 'Escape') {
onClose();
@@ -19,7 +19,6 @@ import {
Badge,
useColorModeValue,
useToast,
Avatar,
} from '@chakra-ui/react';
import { FaLifeRing } from 'react-icons/fa';
import { useLocation } from 'react-router-dom';
@@ -27,8 +26,6 @@ import { getRecentActions } from '../../services/actionLog';
import { reportError } from '../../services/errorReporter';
import { useAuth } from '../../contexts/AuthContext';
import { getAdminSettings } from '../../services/settings';
import { usePublicSettings } from '../../hooks/usePublicSettings';
import { assetUrl } from '../../utils/url';
export default function AdminSupportButton() {
const { isOpen, onOpen, onClose } = useDisclosure();
@@ -39,7 +36,6 @@ export default function AdminSupportButton() {
const { user } = useAuth();
const [extUI, setExtUI] = useState<string>('');
const [extToken, setExtToken] = useState<string>('');
const { data: publicSettings } = usePublicSettings();
const actions = useMemo(() => getRecentActions(12), [isOpen]);
const path = location.pathname + location.search;
@@ -95,7 +91,7 @@ export default function AdminSupportButton() {
<Tooltip label="Zákaznická podpora" hasArrow>
<IconButton
aria-label="Zákaznická podpora"
icon={(publicSettings?.club_logo_url ? <Avatar size="sm" src={assetUrl(publicSettings.club_logo_url) || publicSettings.club_logo_url} /> : <FaLifeRing />)}
icon={<FaLifeRing />}
onClick={onOpen}
colorScheme="blue"
borderRadius="full"
@@ -190,7 +190,11 @@ const CommentsSection: React.FC<Props> = ({ targetType, targetId }) => {
</HStack>
</VStack>
) : (
<Text whiteSpace="pre-wrap">{c.content}</Text>
c.content_html ? (
<Box sx={{ '.cw': { textDecoration: 'underline dotted', cursor: 'help' } }} dangerouslySetInnerHTML={{ __html: c.content_html }} />
) : (
<Text whiteSpace="pre-wrap">{c.content}</Text>
)
)}
<ReactionBar c={c} />
{c.admin_liked && (
@@ -81,58 +81,12 @@ const CustomRichEditor: React.FC<CustomRichEditorProps> = ({
const selectImageByIdRef = useRef<(id: string) => void>(() => {});
const toolbarDragRef = useRef<{ active: boolean; startX: number; startY: number; startLeft: number; startTop: number }>({ active: false, startX: 0, startY: 0, startLeft: 0, startTop: 0 });
const [isMounted, setIsMounted] = useState(false);
const [isVisible, setIsVisible] = useState(false);
// Ensure component is mounted before rendering Quill
useEffect(() => {
setIsMounted(true);
return () => setIsMounted(false);
}, []);
// Track visibility of the editor container (avoid mounting Quill while hidden)
useEffect(() => {
const el = containerRef.current;
if (!el) return;
let ro: ResizeObserver | null = null;
let io: IntersectionObserver | null = null;
let mo: MutationObserver | null = null;
const check = () => {
try {
const inDoc = document.contains(el);
const rects = el.getClientRects();
const style = window.getComputedStyle(el);
const visible = inDoc && rects.length > 0 && style.display !== 'none' && style.visibility !== 'hidden' && style.opacity !== '0';
setIsVisible(visible);
} catch {}
};
// Observe size/visibility changes
try {
ro = new ResizeObserver(() => check());
ro.observe(el);
} catch {}
try {
io = new IntersectionObserver((entries) => {
const entry = entries[0];
setIsVisible(!!entry && (entry.isIntersecting || entry.intersectionRatio > 0));
}, { root: null, threshold: [0, 0.01] });
io.observe(el);
} catch {}
try {
mo = new MutationObserver(() => check());
mo.observe(document.body, { attributes: true, childList: true, subtree: true });
} catch {}
// Initial check
check();
return () => {
try { ro && ro.disconnect(); } catch {}
try { io && io.disconnect(); } catch {}
try { mo && mo.disconnect(); } catch {}
};
}, [containerRef]);
// Keep onChange ref up to date
useEffect(() => {
@@ -185,6 +139,23 @@ const CustomRichEditor: React.FC<CustomRichEditorProps> = ({
const [widthPercent, setWidthPercent] = useState<number>(0);
const [isListStyleOpen, setIsListStyleOpen] = useState(false);
// Helper: wait for Quill editor/root to exist in DOM before manipulating toolbar or attaching listeners
const withEditor = useCallback((fn: (ed: any) => void) => {
let attempts = 0;
const tryRun = () => {
const ed = quillRef.current?.getEditor();
if (ed && ed.root && typeof document !== 'undefined' && document.contains(ed.root)) {
try { fn(ed); } catch {}
return;
}
if (attempts < 40) {
attempts++;
setTimeout(tryRun, 25);
}
};
tryRun();
}, []);
// Define toolbar configurations
const toolbarConfigs = {
full: [
@@ -358,16 +329,17 @@ const CustomRichEditor: React.FC<CustomRichEditorProps> = ({
useEffect(() => {
if (!isMounted) return;
try {
const ed = quillRef.current?.getEditor();
if (!ed) return;
const toolbarEl = ed.root.parentElement?.previousElementSibling as HTMLElement | null;
const btn = toolbarEl?.querySelector('.ql-liststyle') as HTMLButtonElement | null;
if (btn) {
btn.setAttribute('title', 'Styl odrážek');
}
} catch {}
}, [isMounted, toolbarConfig]);
let active = true;
withEditor((ed) => {
if (!active) return;
try {
const toolbarEl = ed.root.parentElement?.previousElementSibling as HTMLElement | null;
const btn = toolbarEl?.querySelector('.ql-liststyle') as HTMLButtonElement | null;
if (btn) btn.setAttribute('title', 'Styl odrážek');
} catch {}
});
return () => { active = false; };
}, [isMounted, toolbarConfig, withEditor]);
const quillFormats = useMemo(
() => [
@@ -391,95 +363,98 @@ const CustomRichEditor: React.FC<CustomRichEditorProps> = ({
// Localize Quill toolbar tooltips/labels to Czech
useEffect(() => {
if (!isMounted) return;
const editor = quillRef.current?.getEditor();
if (!editor) return;
const container = editor.root?.parentElement; // .ql-container
const toolbarEl = container?.previousElementSibling as HTMLElement | null; // .ql-toolbar
if (!toolbarEl) return;
let active = true;
withEditor((editor) => {
if (!active) return;
const container = editor.root?.parentElement; // .ql-container
const toolbarEl = container?.previousElementSibling as HTMLElement | null; // .ql-toolbar
if (!toolbarEl) return;
const setTitle = (selector: string, title: string) => {
toolbarEl.querySelectorAll(selector).forEach((el) => {
(el as HTMLElement).setAttribute('title', title);
(el as HTMLElement).setAttribute('aria-label', title);
});
};
// Basic formatting
setTitle('button.ql-bold', 'Tučné');
setTitle('button.ql-italic', 'Kurzíva');
setTitle('button.ql-underline', 'Podtržení');
setTitle('button.ql-strike', 'Přeškrtnutí');
setTitle('button.ql-link', 'Vložit odkaz');
setTitle('button.ql-image', 'Vložit obrázek');
setTitle('button.ql-blockquote', 'Citace');
setTitle('button.ql-clean', 'Vyčistit formátování');
// Lists
setTitle('button.ql-list[value="ordered"]', 'Číslovaný seznam');
setTitle('button.ql-list[value="bullet"]', 'Odrážkový seznam');
// Alignment
setTitle('button.ql-align', 'Zarovnání');
setTitle('button.ql-align[value=""]', 'Zarovnat vlevo');
setTitle('button.ql-align[value="center"]', 'Zarovnat na střed');
setTitle('button.ql-align[value="right"]', 'Zarovnat vpravo');
setTitle('button.ql-align[value="justify"]', 'Do bloku');
// Colors and background
setTitle('.ql-color .ql-picker-label', 'Barva textu');
setTitle('.ql-background .ql-picker-label', 'Barva pozadí');
// Inject reset option inside color/background pickers
try {
const injectReset = (
pickerSelector: string,
format: 'color' | 'background',
label: string
) => {
const picker = toolbarEl.querySelector(pickerSelector) as HTMLElement | null; // .ql-color or .ql-background
const options = picker?.querySelector('.ql-picker-options') as HTMLElement | null;
if (!options) return;
if (options.querySelector(`button.ql-picker-item[data-reset="${format}"]`)) return;
const btn = document.createElement('button');
btn.setAttribute('type', 'button');
btn.className = 'ql-picker-item';
btn.setAttribute('data-reset', format);
btn.setAttribute('title', label);
btn.setAttribute('aria-label', label);
btn.style.width = '16px';
btn.style.height = '16px';
btn.style.border = '1px solid #e2e8f0';
btn.style.borderRadius = '2px';
btn.style.position = 'relative';
btn.style.background = '#ffffff';
const slash = document.createElement('span');
slash.style.position = 'absolute';
slash.style.left = '2px';
slash.style.right = '2px';
slash.style.top = '7px';
slash.style.height = '2px';
slash.style.background = '#e53e3e';
slash.style.transform = 'rotate(-45deg)';
btn.appendChild(slash);
btn.addEventListener('click', (e) => {
e.preventDefault();
const q = quillRef.current?.getEditor();
if (!q) return;
q.format(format, false);
try { picker?.classList.remove('ql-expanded'); } catch {}
const setTitle = (selector: string, title: string) => {
toolbarEl.querySelectorAll(selector).forEach((el) => {
(el as HTMLElement).setAttribute('title', title);
(el as HTMLElement).setAttribute('aria-label', title);
});
options.insertBefore(btn, options.firstChild);
};
injectReset('.ql-color', 'color', 'Zrušit barvu');
injectReset('.ql-background', 'background', 'Zrušit pozadí');
} catch {}
// Headers
setTitle('.ql-header .ql-picker-label', 'Nadpis');
setTitle('.ql-header .ql-picker-item[data-value="1"]', 'Nadpis 1');
setTitle('.ql-header .ql-picker-item[data-value="2"]', 'Nadpis 2');
setTitle('.ql-header .ql-picker-item[data-value="3"]', 'Nadpis 3');
setTitle('button.ql-liststyle', 'Styl odrážek');
}, [isMounted, toolbar]);
// Basic formatting
setTitle('button.ql-bold', 'Tučné');
setTitle('button.ql-italic', 'Kurzíva');
setTitle('button.ql-underline', 'Podtržení');
setTitle('button.ql-strike', 'Přeškrtnutí');
setTitle('button.ql-link', 'Vložit odkaz');
setTitle('button.ql-image', 'Vložit obrázek');
setTitle('button.ql-blockquote', 'Citace');
setTitle('button.ql-clean', 'Vyčistit formátování');
// Lists
setTitle('button.ql-list[value="ordered"]', 'Číslovaný seznam');
setTitle('button.ql-list[value="bullet"]', 'Odrážkový seznam');
// Alignment
setTitle('button.ql-align', 'Zarovnání');
setTitle('button.ql-align[value=""]', 'Zarovnat vlevo');
setTitle('button.ql-align[value="center"]', 'Zarovnat na střed');
setTitle('button.ql-align[value="right"]', 'Zarovnat vpravo');
setTitle('button.ql-align[value="justify"]', 'Do bloku');
// Colors and background
setTitle('.ql-color .ql-picker-label', 'Barva textu');
setTitle('.ql-background .ql-picker-label', 'Barva pozadí');
// Inject reset option inside color/background pickers
try {
const injectReset = (
pickerSelector: string,
format: 'color' | 'background',
label: string
) => {
const picker = toolbarEl.querySelector(pickerSelector) as HTMLElement | null; // .ql-color or .ql-background
const options = picker?.querySelector('.ql-picker-options') as HTMLElement | null;
if (!options) return;
if (options.querySelector(`button.ql-picker-item[data-reset="${format}"]`)) return;
const btn = document.createElement('button');
btn.setAttribute('type', 'button');
btn.className = 'ql-picker-item';
btn.setAttribute('data-reset', format);
btn.setAttribute('title', label);
btn.setAttribute('aria-label', label);
btn.style.width = '16px';
btn.style.height = '16px';
btn.style.border = '1px solid #e2e8f0';
btn.style.borderRadius = '2px';
btn.style.position = 'relative';
btn.style.background = '#ffffff';
const slash = document.createElement('span');
slash.style.position = 'absolute';
slash.style.left = '2px';
slash.style.right = '2px';
slash.style.top = '7px';
slash.style.height = '2px';
slash.style.background = '#e53e3e';
slash.style.transform = 'rotate(-45deg)';
btn.appendChild(slash);
btn.addEventListener('click', (e) => {
e.preventDefault();
const q = quillRef.current?.getEditor();
if (!q) return;
q.format(format, false);
try { picker?.classList.remove('ql-expanded'); } catch {}
});
options.insertBefore(btn, options.firstChild);
};
injectReset('.ql-color', 'color', 'Zrušit barvu');
injectReset('.ql-background', 'background', 'Zrušit pozadí');
} catch {}
// Headers
setTitle('.ql-header .ql-picker-label', 'Nadpis');
setTitle('.ql-header .ql-picker-item[data-value="1"]', 'Nadpis 1');
setTitle('.ql-header .ql-picker-item[data-value="2"]', 'Nadpis 2');
setTitle('.ql-header .ql-picker-item[data-value="3"]', 'Nadpis 3');
setTitle('button.ql-liststyle', 'Styl odrážek');
});
return () => { active = false; };
}, [isMounted, toolbar, withEditor]);
// (Removed) Previously injected custom bullet-style group; now using a single toolbar button 'liststyle'.
@@ -617,8 +592,13 @@ const CustomRichEditor: React.FC<CustomRichEditorProps> = ({
try { targetImg.setAttribute('width', String(px)); } catch {}
}
} catch {}
// Move cursor after the image
quill.setSelection(index + 1, 0, 'api');
try {
if (document.contains(quill.root)) {
quill.setSelection(index + 1, 0, 'api');
} else {
setTimeout(() => { try { if (document.contains(quill.root)) quill.setSelection(index + 1, 0, 'api'); } catch {} }, 0);
}
} catch {}
// Persist content so default width is saved
onChangeRef.current(cleanEditorHTML(quill.root.innerHTML));
toast({ title: 'Obrázek vložen', status: 'success', duration: 2000 });
@@ -1499,10 +1479,22 @@ const CustomRichEditor: React.FC<CustomRichEditorProps> = ({
// Replace selected text with provided text and link
quill.deleteText(range.index, range.length, 'user');
quill.insertText(range.index, text || url, 'link', url, 'user');
quill.setSelection(range.index + (text || url).length, 0, 'user');
try {
if (document.contains(quill.root)) {
quill.setSelection(range.index + (text || url).length, 0, 'user');
} else {
setTimeout(() => { try { if (document.contains(quill.root)) quill.setSelection(range.index + (text || url).length, 0, 'user'); } catch {} }, 0);
}
} catch {}
} else {
quill.insertText(range.index, text || url, 'link', url, 'user');
quill.setSelection(range.index + (text || url).length, 0, 'user');
try {
if (document.contains(quill.root)) {
quill.setSelection(range.index + (text || url).length, 0, 'user');
} else {
setTimeout(() => { try { if (document.contains(quill.root)) quill.setSelection(range.index + (text || url).length, 0, 'user'); } catch {} }, 0);
}
} catch {}
}
onChangeRef.current(cleanEditorHTML(quill.root.innerHTML));
setIsLinkOpen(false);
@@ -1702,7 +1694,7 @@ const CustomRichEditor: React.FC<CustomRichEditorProps> = ({
// Allow user-chosen colors to show. White-on-white is handled during paste/sanitize only.
}}
>
{isMounted && isVisible && (
{isMounted && (
<ReactQuill
theme="snow"
value={value}
@@ -1714,8 +1706,14 @@ const CustomRichEditor: React.FC<CustomRichEditorProps> = ({
formats={quillFormats}
onBlur={(_prev, _source, editor) => {
try {
const html = editor?.getHTML ? editor.getHTML() : (quillRef.current?.getEditor().root.innerHTML || value);
onChangeRef.current(cleanEditorHTML(html));
const ed = quillRef.current?.getEditor();
const html = editor?.getHTML ? editor.getHTML() : (ed?.root?.innerHTML || value);
const cleaned = cleanEditorHTML(html);
if (cleaned !== value) {
setTimeout(() => {
try { onChangeRef.current(cleaned); } catch {}
}, 0);
}
} catch {}
}}
/>
@@ -0,0 +1,178 @@
import React from 'react';
import { ScoreboardState } from '@/services/scoreboard';
function deriveShortLocal(name?: string) {
if (!name) return '---';
const s = String(name).trim().toUpperCase();
if (!s) return '---';
const map: Record<string, string> = {
'Á':'A','Ä':'A','Å':'A','Â':'A','À':'A',
'Č':'C','Ć':'C','Ç':'C',
'Ď':'D',
'É':'E','Ě':'E','È':'E','Ë':'E','Ê':'E',
'Í':'I','Ì':'I','Ï':'I','Î':'I',
'Ň':'N','Ń':'N',
'Ó':'O','Ö':'O','Ô':'O','Ò':'O',
'Ř':'R',
'Š':'S','Ś':'S',
'Ť':'T',
'Ú':'U','Ů':'U','Ù':'U','Ü':'U','Û':'U',
'Ý':'Y',
'Ž':'Z',
};
let out = '';
for (const ch of s) {
const c = map[ch] || ch;
if (c >= 'A' && c <= 'Z') {
out += c;
if (out.length === 3) break;
}
}
while (out.length < 3) out += '-';
return out;
}
function shade(hex: string, percent: number) {
try {
const n = hex.replace('#','');
const num = parseInt(n.length === 3 ? n.split('').map((c)=>c+c).join('') : n, 16);
let r = (num >> 16) & 0xff;
let g = (num >> 8) & 0xff;
let b = num & 0xff;
r = Math.min(255, Math.max(0, Math.round(r + (percent/100)*255)));
g = Math.min(255, Math.max(0, Math.round(g + (percent/100)*255)));
b = Math.min(255, Math.max(0, Math.round(b + (percent/100)*255)));
return `#${[r,g,b].map(v=>v.toString(16).padStart(2,'0')).join('')}`;
} catch {
return hex;
}
}
const styleBlock = `
.pill-wrapper { width: max-content; margin: 24px auto; display: flex; flex-direction: column; align-items: center; gap: 18px; }
.scoreboard { display: flex; justify-content: space-between; align-items: center; background: rgba(0,0,0,0.75); color: #ffffff; padding: 18px 28px; font-size: 32px; font-weight: 700; border-radius: 14px; width: min(90vw, 900px); margin: 24px auto; gap: 20px; box-shadow: 0 8px 24px rgba(0,0,0,0.35); backdrop-filter: blur(6px); border: 1px solid rgba(255,255,255,0.15); }
.scoreboard.pill { background: var(--pill-bg, #f8fafc); color: var(--pill-text, #0f172a); border: 1px solid #e5e7eb; box-shadow: 0 10px 30px rgba(2,6,23,0.18); border-radius: 999px; padding: 4px 6px; width: max-content; margin: 0 auto; gap: 6px; backdrop-filter: none; font-size: 15px; transform: scale(var(--pill-scale, 1.7)); transform-origin: center; will-change: transform; }
.scoreboard.pill .seg { display: flex; align-items: center; justify-content: center; height: 36px; }
.scoreboard.pill .seg.timer { font-variant-numeric: tabular-nums; font-weight: 800; background: linear-gradient(180deg, #eef2f7 0%, #e2e8f0 100%); padding: 0 8px; border-radius: 999px; font-size: 15px; color: #0f172a; }
.scoreboard.pill .seg.team { color: #ffffff; padding: 0 10px; border-radius: 10px; font-weight: 800; letter-spacing: 0.5px; min-width: 46px; text-transform: uppercase; position: relative; overflow: visible; }
.scoreboard.pill .seg.team.home { background: linear-gradient(90deg, var(--home-dark), var(--home-light)); }
.scoreboard.pill .seg.team.away { background: linear-gradient(90deg, var(--away-dark), var(--away-light)); }
.scoreboard.pill .seg.team.home::before, .scoreboard.pill .seg.team.away::after { position: absolute; top: 0; width: 12px; height: 100%; background: inherit; content: ''; }
.scoreboard.pill .seg.team.home::before { left: -6px; border-top-left-radius: 999px; border-bottom-left-radius: 999px; }
.scoreboard.pill .seg.team.away::after { right: -6px; border-top-right-radius: 999px; border-bottom-right-radius: 999px; }
.scoreboard.pill .seg.score { background: linear-gradient(180deg, #ffffff 0%, #f3f4f6 100%); border: 1px solid #e5e7eb; border-radius: 10px; padding: 0 10px; font-weight: 800; color: #0f172a; min-width: 58px; box-shadow: inset 0 1px 0 rgba(255,255,255,0.9); font-size: 15px; }
.scoreboard.pill .divider { width: 2px; height: 14px; background: rgba(15,23,42,0.35); border-radius: 1px; align-self: center; }
.scoreboard.pill .team .logo { width: 24px; height: 24px; object-fit: contain; margin-right: 6px; filter: drop-shadow(0 1px 1px rgba(0,0,0,0.2)); }
.scoreboard.pill .team.away .logo { margin-left: 6px; margin-right: 0; }
.fouls-bar { display: flex; align-items: center; justify-content: space-between; gap: 32px; width: 100%; padding: 18px 18px 0; transform: scale(var(--pill-scale, 1.7)); transform-origin: center; }
.fouls-group { display: flex; gap: 6px; }
.foul-dot { width: 12px; height: 12px; border-radius: 50%; border: 2px solid rgba(255,255,255,0.9); background: rgba(255,255,255,0.15); box-shadow: 0 1px 2px rgba(0,0,0,0.25); }
.foul-dot.active { background: #ff1f1f; border-color: #ff1f1f; }
@media (max-width: 640px) {
.pill-wrapper { margin: 24px auto 16px; gap: 14px; }
.scoreboard.pill { padding: 6px 8px; transform: scale(1.0); transform-origin: center; }
.pill .seg.timer, .pill .seg.score { padding: 4px 10px; }
.fouls-bar { gap: 20px; padding: 12px 12px 0; transform: scale(1.0); }
}
`;
const MyClubOverlay: React.FC<{ state: ScoreboardState }> = ({ state }) => {
const theme = state.theme || 'pill';
const isFlipped = !!state.sidesFlipped;
const left = {
short: (isFlipped ? state.awayShort : state.homeShort) || deriveShortLocal(isFlipped ? state.awayName : state.homeName),
logo: isFlipped ? state.awayLogo : state.homeLogo,
color: (isFlipped ? state.secondaryColor : state.primaryColor) || '#1e3a8a',
score: isFlipped ? state.awayScore : state.homeScore,
fouls: Math.max(0, Math.min(5, isFlipped ? (state.awayFouls || 0) : (state.homeFouls || 0))),
};
const right = {
short: (!isFlipped ? state.awayShort : state.homeShort) || deriveShortLocal(!isFlipped ? state.awayName : state.homeName),
logo: !isFlipped ? state.awayLogo : state.homeLogo,
color: (!isFlipped ? state.secondaryColor : state.primaryColor) || '#2563eb',
score: !isFlipped ? state.awayScore : state.homeScore,
fouls: Math.max(0, Math.min(5, !isFlipped ? (state.awayFouls || 0) : (state.homeFouls || 0))),
};
const timer = state.timer || '00:00';
const cssVars: React.CSSProperties = {
// @ts-ignore
'--home-dark': left.color,
// @ts-ignore
'--home-light': shade(left.color, 20),
// @ts-ignore
'--away-dark': right.color,
// @ts-ignore
'--away-light': shade(right.color, 20),
} as any;
if (theme !== 'pill') {
return (
<>
<style>{styleBlock}</style>
<div className="pill-wrapper" style={cssVars as any}>
<div className="scoreboard pill">
<div className="seg timer"><span>{timer}</span></div>
<span className="divider" aria-hidden="true"></span>
<div className="seg team home"><img className="logo" alt="" src={left.logo || ''} />
<span>{left.short}</span>
</div>
<span className="divider" aria-hidden="true"></span>
<div className="seg score"><span>{left.score}-{right.score}</span></div>
<span className="divider" aria-hidden="true"></span>
<div className="seg team away"><span>{right.short}</span>
<img className="logo" alt="" src={right.logo || ''} />
</div>
</div>
<div className="fouls-bar">
<div className="fouls-group">
{Array.from({ length: 5 }).map((_, i) => (
<span key={i} className={`foul-dot${i < left.fouls ? ' active' : ''}`}></span>
))}
</div>
<div className="fouls-group">
{Array.from({ length: 5 }).map((_, i) => (
<span key={i} className={`foul-dot${i < right.fouls ? ' active' : ''}`}></span>
))}
</div>
</div>
</div>
</>
);
}
return (
<>
<style>{styleBlock}</style>
<div className="pill-wrapper" style={cssVars as any}>
<div className="scoreboard pill">
<div className="seg timer"><span>{timer}</span></div>
<span className="divider" aria-hidden="true"></span>
<div className="seg team home"><img className="logo" alt="" src={left.logo || ''} />
<span>{left.short}</span>
</div>
<span className="divider" aria-hidden="true"></span>
<div className="seg score"><span>{left.score}-{right.score}</span></div>
<span className="divider" aria-hidden="true"></span>
<div className="seg team away"><span>{right.short}</span>
<img className="logo" alt="" src={right.logo || ''} />
</div>
</div>
<div className="fouls-bar">
<div className="fouls-group">
{Array.from({ length: 5 }).map((_, i) => (
<span key={i} className={`foul-dot${i < left.fouls ? ' active' : ''}`}></span>
))}
</div>
<div className="fouls-group">
{Array.from({ length: 5 }).map((_, i) => (
<span key={i} className={`foul-dot${i < right.fouls ? ' active' : ''}`}></span>
))}
</div>
</div>
</div>
</>
);
};
export default MyClubOverlay;
+1 -1
View File
@@ -41,7 +41,7 @@ export const useUmami = () => {
// Fetch Umami configuration from backend
const fetchConfig = async () => {
try {
const response = await axios.get(`${API_URL}/umami/config`);
const response = await axios.get(`${API_URL}/insights/config`);
const umamiConfig = response.data as UmamiConfig;
setConfig(umamiConfig);
+1 -1
View File
@@ -55,7 +55,7 @@ const AdminLayout = ({ children, requireAdmin = true }: AdminLayoutProps) => {
// Keyboard shortcut: Ctrl/Cmd+K opens admin search
useEffect(() => {
const handler = (e: KeyboardEvent) => {
const key = e.key.toLowerCase();
const key = (e.key || '').toLowerCase();
if ((e.ctrlKey || e.metaKey) && key === 'k') {
e.preventDefault();
onSearchOpen();
+69 -36
View File
@@ -1,6 +1,6 @@
import { Box, Container, Heading, Image, Spinner, Stack, Text, HStack, Badge, Link, SimpleGrid, Button, AspectRatio, useColorModeValue, Flex, VStack, Tag, Breadcrumb, BreadcrumbItem, BreadcrumbLink } from '@chakra-ui/react';
import { useQuery } from '@tanstack/react-query';
import { useParams, Link as RouterLink } from 'react-router-dom';
import { useParams, Link as RouterLink, useNavigate } from 'react-router-dom';
import { getArticle, getArticleBySlug, getArticleMatchLink, trackArticleView, getArticles } from '../services/articles';
import { articleRead } from '../services/engagement';
import MainLayout from '../components/layout/MainLayout';
@@ -37,6 +37,7 @@ const toText = (html?: string) => {
const ArticleDetailPage: React.FC = () => {
const { id, slug } = useParams<{ id?: string; slug?: string }>();
const navigate = useNavigate();
const { data, isLoading, isError } = useQuery({
queryKey: ['article', slug ? `slug:${slug}` : `id:${id}`],
queryFn: () => (slug ? getArticleBySlug(slug!) : getArticle(id!)),
@@ -44,6 +45,8 @@ const ArticleDetailPage: React.FC = () => {
});
// Load competition aliases to resolve category → alias mapping for MatchesWidget filtering
const aliasesQ = useQuery<{ list: CompetitionAlias[] }>({
@@ -116,6 +119,21 @@ const ArticleDetailPage: React.FC = () => {
}
}, [data]);
// Ensure unified link: if opened by numeric ID and slug exists, redirect to /news/:slug
React.useEffect(() => {
if (!id) return;
try {
const s = (data as any)?.slug;
if (s && typeof s === 'string') {
const target = `/news/${s}`;
const cur = (typeof window !== 'undefined') ? (window.location.pathname + (window.location.search || '')) : '';
if (cur && !cur.startsWith(target)) {
navigate(target, { replace: true });
}
}
} catch {}
}, [id, (data as any)?.slug, navigate]);
// Award engagement for article read after 15s dwell (once per article per device)
React.useEffect(() => {
const aid = (data as any)?.id;
@@ -163,14 +181,6 @@ const ArticleDetailPage: React.FC = () => {
staleTime: 60_000,
});
// Track match view when resolved
React.useEffect(() => {
const mid = (matchLinkQuery.data as any)?.external_match_id;
if (mid) {
umamiTrackMatchView(mid);
}
}, [(matchLinkQuery.data as any)?.external_match_id]);
// From cached FACR club info, resolve the match details
const facrMatchQuery = useQuery({
queryKey: ['facr-cached-match', (matchLinkQuery.data as any)?.external_match_id],
@@ -196,6 +206,35 @@ const ArticleDetailPage: React.FC = () => {
staleTime: 60_000,
});
// Derive opponent color (away team) for right-side accent based on logo palette
React.useEffect(() => {
let cancelled = false;
(async () => {
try {
const m: any = facrMatchQuery?.data as any;
if (!m) { if (!cancelled) setOpponentColor(null); return; }
const awayId = String(m?.away_team_id || m?.away_id || '') || undefined;
const awayName = String(m?.away || m?.away_team || '') || undefined;
const facrLogo = (m as any)?.away_logo_url as string | undefined;
const url = await getTeamLogo(awayId, awayName, facrLogo);
const palette = await extractPalette(url);
const picked = Array.isArray(palette) && palette.length ? palette[0] : null;
if (!cancelled) setOpponentColor(picked);
} catch {
if (!cancelled) setOpponentColor(null);
}
})();
return () => { cancelled = true; };
}, [facrMatchQuery?.data]);
// Track match view when resolved
React.useEffect(() => {
const mid = (matchLinkQuery.data as any)?.external_match_id;
if (mid) {
umamiTrackMatchView(mid);
}
}, [(matchLinkQuery.data as any)?.external_match_id]);
// Build a snapshot usable for sharing if available (FACR data or article fallback)
const matchSnapshot: MatchSnapshot | null = React.useMemo(() => {
const m: any = facrMatchQuery?.data as any;
@@ -521,13 +560,13 @@ const ArticleDetailPage: React.FC = () => {
</HStack>
) : null}
{publishedAt && (
<Tag as={RouterLink} to={`/news?month=${monthParam}`} size="sm" variant="subtle">{new Date(publishedAt).toLocaleDateString('cs-CZ')}</Tag>
<Tag as={RouterLink} to={`/blog?month=${monthParam}`} size="sm" variant="subtle">{new Date(publishedAt).toLocaleDateString('cs-CZ')}</Tag>
)}
{(data as any)?.category?.id && (
<Tag as={RouterLink} to={`/news?category_id=${(data as any).category.id}`} size="sm" variant="subtle">{(data as any).category.name || 'Kategorie'}</Tag>
<Tag as={RouterLink} to={`/blog?category_id=${(data as any).category.id}`} size="sm" variant="subtle">{(data as any).category.name || 'Kategorie'}</Tag>
)}
{(matchLinkQuery.data as any)?.external_match_id && (
<Tag as={RouterLink} to={`/news?match_id=${(matchLinkQuery.data as any).external_match_id}`} size="sm" variant="subtle">Zápas</Tag>
<Tag as={RouterLink} to={`/blog?match_id=${(matchLinkQuery.data as any).external_match_id}`} size="sm" variant="subtle">Zápas</Tag>
)}
{(data as any).view_count ? (
<HStack spacing={1} ml={{ base: 0, md: 2 }}>
@@ -545,7 +584,7 @@ const ArticleDetailPage: React.FC = () => {
</BreadcrumbItem>
{(data as any)?.category?.id ? (
<BreadcrumbItem>
<BreadcrumbLink as={RouterLink} to={`/news?category_id=${(data as any).category.id}`}>{(data as any).category.name}</BreadcrumbLink>
<BreadcrumbLink as={RouterLink} to={`/blog?category_id=${(data as any).category.id}`}>{(data as any).category.name}</BreadcrumbLink>
</BreadcrumbItem>
) : null}
<BreadcrumbItem isCurrentPage>
@@ -592,13 +631,26 @@ const ArticleDetailPage: React.FC = () => {
</VStack>
<VStack minW={{ base: '100px', md: '140px' }}>
{(() => {
const dRaw = String((facrMatchQuery.data as any).date_time || (facrMatchQuery.data as any).date || '');
const d = new Date(dRaw);
const raw = String((facrMatchQuery.data as any).date_time || (facrMatchQuery.data as any).date || '');
const hasScore = ((facrMatchQuery.data as any).result_home != null && (facrMatchQuery.data as any).result_away != null) || Boolean((facrMatchQuery.data as any).score && (facrMatchQuery.data as any).score !== 'vs');
if (hasScore) {
const score = String((facrMatchQuery.data as any).score || `${(facrMatchQuery.data as any).result_home}:${(facrMatchQuery.data as any).result_away}`);
return (<Heading size="2xl">{score}</Heading>);
}
const parseFacr = (s: string): Date => {
const m = s.match(/^(\d{1,2})\.(\d{1,2})\.(\d{4})(?:\s+(\d{1,2}):(\d{2}))?/);
if (m) {
const d = parseInt(m[1],10);
const mo = parseInt(m[2],10)-1;
const y = parseInt(m[3],10);
const hh = m[4] ? parseInt(m[4],10) : 0;
const mm = m[5] ? parseInt(m[5],10) : 0;
return new Date(y, mo, d, hh, mm);
}
const t = Date.parse(s);
return isNaN(t) ? new Date() : new Date(t);
};
const d = parseFacr(raw);
const now = Date.now();
const ms = d.getTime() - now;
const days = Math.max(0, Math.floor(ms / (1000*60*60*24)));
@@ -719,7 +771,7 @@ const ArticleDetailPage: React.FC = () => {
rightIcon={<ArrowRight size={16} />}
onClick={() => umamiTrackEvent('Gallery Link Click', { album_id: (data as any).gallery_album_id })}
>
Zobrazit galerii
Zobrazit celou galerii
</Button>
</HStack>
{Array.isArray(galleryAlbumQuery.data?.photos) && (galleryAlbumQuery.data?.photos?.length || 0) > 0 && (() => {
@@ -746,7 +798,7 @@ const ArticleDetailPage: React.FC = () => {
<Image src={photos[2].image_1500} alt={String(photos[2].id)} sx={{ gridColumn: 2, gridRow: '1 / span 2' }} objectFit="cover" w="100%" h="100%" borderRadius="md" />
<Image src={photos[3].image_1500} alt={String(photos[3].id)} sx={{ gridColumn: 3, gridRow: 1 }} objectFit="cover" w="100%" h="100%" borderRadius="md" />
<Image src={photos[4].image_1500} alt={String(photos[4].id)} sx={{ gridColumn: 3, gridRow: 2 }} objectFit="cover" w="100%" h="100%" borderRadius="md" />
<Button as={RouterLink} to={(data as any).gallery_album_id ? `/galerie/album/${(data as any).gallery_album_id}` : '#'} size="sm" colorScheme="blue" position="absolute" top="50%" left="50%" transform="translate(-50%, -50%)" onClick={() => umamiTrackEvent('Gallery Link Click', { album_id: (data as any).gallery_album_id })}>Zobrazit galerii</Button>
<Button as={RouterLink} to={(data as any).gallery_album_id ? `/galerie/album/${(data as any).gallery_album_id}` : '#'} size="sm" colorScheme="blue" position="absolute" top="50%" left="50%" transform="translate(-50%, -50%)" onClick={() => umamiTrackEvent('Gallery Link Click', { album_id: (data as any).gallery_album_id })}>Zobrazit celou galerii</Button>
</Box>
);
})()}
@@ -841,25 +893,6 @@ const ArticleDetailPage: React.FC = () => {
</SimpleGrid>
</Container>
</Box>
{/* Polls (Ankety) above attachments */}
{data?.id && <EmbeddedPoll articleId={(data as any).id} maxPolls={3} />}
{/* Attachments - bottom above comments */}
{Array.isArray((data as any)?.attachments) && (data as any).attachments.length > 0 && (
<Container maxW="7xl" mt={4}>
<Box borderWidth="1px" borderRadius="lg" p={{ base: 3, md: 4 }} bg={attachmentsBg}>
<Heading as="h3" size="md" mb={2}>Přílohy</Heading>
<Stack spacing={2}>
{(data as any).attachments.map((f: any, idx: number) => (
<HStack key={idx} justify="space-between">
<Text noOfLines={1}>{f.name || f.url}</Text>
<FilePreview url={assetUrl(f.url) || f.url} name={f.name || ''} mimeType={f.mime_type || ''} size={f.size} />
</HStack>
))}
</Stack>
</Box>
</Container>
)}
{/* Comments at the end */}
{(data as any)?.id ? (
<Container maxW="7xl" mt={4}>
+11 -5
View File
@@ -193,6 +193,12 @@ const HomePage: React.FC = () => {
} catch {}
}, [upcomingCompIndices, nextCompIdx]);
useEffect(() => {
try {
setNextCompIdx(matchesTab);
} catch {}
}, [matchesTab]);
useEffect(() => {
let cancelled = false;
@@ -1544,9 +1550,7 @@ const HomePage: React.FC = () => {
facrCompetitions.length > 0 ? (
upcomingCompIndices.length > 0 ? (
(() => {
const safeIndex = Math.max(0, Math.min(nextCompIdx, facrCompetitions.length - 1));
const pos = upcomingCompIndices.indexOf(safeIndex);
const effectiveIndex = pos >= 0 ? upcomingCompIndices[pos] : upcomingCompIndices[0];
const effectiveIndex = Math.max(0, Math.min(matchesTab, facrCompetitions.length - 1));
const comp = facrCompetitions[effectiveIndex];
const items = Array.isArray(comp?.matches) ? comp.matches : [];
const upcoming = items
@@ -1555,6 +1559,8 @@ const HomePage: React.FC = () => {
.sort((a: any, b: any) => a.t - b.t)[0]?.m;
const show = upcoming || null;
const link = (show && (show.facr_link || show.report_url)) || comp?.matches_link || nextMatchLink;
// Compute prev/next among competitions that actually have upcoming matches
const pos = upcomingCompIndices.indexOf(effectiveIndex);
const prevIdx = upcomingCompIndices[(Math.max(0, pos) - 1 + upcomingCompIndices.length) % upcomingCompIndices.length];
const nextIdx = upcomingCompIndices[(Math.max(0, pos) + 1) % upcomingCompIndices.length];
const handleNextMatchClick = () => {
@@ -1574,8 +1580,8 @@ const HomePage: React.FC = () => {
data={show}
competitionName={comp?.name}
countdown={countdown}
onPrev={() => setNextCompIdx(prevIdx)}
onNext={() => setNextCompIdx(nextIdx)}
onPrev={() => { setNextCompIdx(prevIdx); setMatchesTab(prevIdx); }}
onNext={() => { setNextCompIdx(nextIdx); setMatchesTab(nextIdx); }}
onOpen={handleNextMatchClick}
elementProps={{
'data-element': 'matches' as any,
+3 -2
View File
@@ -2,7 +2,8 @@ import React from 'react';
import { Box, Center, Spinner, useColorModeValue } from '@chakra-ui/react';
import { useQuery } from '@tanstack/react-query';
import { getPublicScoreboard, ScoreboardState } from '@/services/scoreboard';
import ScoreboardDisplay from '@/components/scoreboard/ScoreboardDisplay';
import MyClubOverlay from '@/components/scoreboard/MyClubOverlay';
import ScoreboardPreview from '@/components/scoreboard/ScoreboardPreview';
// Public overlay page intended for OBS/browser source.
// Minimal chrome, transparent-friendly background.
@@ -20,7 +21,7 @@ const OverlayScoreboardPage: React.FC = () => {
{isLoading || !data ? (
<Center><Spinner /></Center>
) : (
<ScoreboardDisplay state={data} />
(data.theme === 'pill' ? <MyClubOverlay state={data} /> : <ScoreboardPreview state={data} />)
)}
</Box>
);
+122 -15
View File
@@ -1,28 +1,135 @@
import React from 'react';
import { Box, Center, Spinner, SimpleGrid, Image, useColorModeValue } from '@chakra-ui/react';
import React, { useEffect, useMemo, useRef, useState } from 'react';
import { Box, Center, Spinner, useColorModeValue } from '@chakra-ui/react';
import { useQuery } from '@tanstack/react-query';
import { listSponsorsPublic } from '@/services/scoreboard';
import { getPublicScoreboard, getQr, listSponsorsPublic } from '@/services/scoreboard';
const css = `
html, body { margin: 0; padding: 0; background: transparent; height: 100%; overflow: hidden; }
.bar { position: fixed; left: 0; right: 0; bottom: 0; height: 80px; background: #000000; display: flex; align-items: center; padding: 0; box-sizing: border-box; overflow: hidden; }
.scroller { position: relative; width: 100%; height: 100%; overflow: hidden; background: #ffffff; }
.track { display: inline-flex; align-items: center; gap: 48px; height: 100%; white-space: nowrap; will-change: transform; animation: scroll linear infinite; animation-duration: var(--scroll-duration, 40s); }
.item { height: 60px; width: auto; display: flex; align-items: center; justify-content: center; flex-shrink: 0; }
.item img { height: 30%; width: auto; max-width: 280px; min-width: 60px; object-fit: contain; display: block; filter: none; image-rendering: -webkit-optimize-contrast; image-rendering: crisp-edges; -webkit-backface-visibility: hidden; backface-visibility: hidden; transform: translateZ(0); }
@keyframes scroll { from { transform: translateX(0); } to { transform: translateX(-50%); } }
.qr-float { position: fixed; right: 16px; bottom: 100px; width: 160px; height: 160px; background: #ffffff; border: 1px solid #e5e7eb; border-radius: 12px; box-shadow: 0 10px 30px rgba(0,0,0,0.22); display: grid; place-items: center; opacity: 0; visibility: hidden; transform: translateY(10px) scale(0.96); transition: opacity .35s ease, transform .35s ease, visibility 0s linear .35s; z-index: 9999; }
.qr-float.show { opacity: 1; visibility: visible; transform: translateY(0) scale(1); transition: opacity .35s ease, transform .35s ease, visibility 0s; }
.qr-float img { max-width: 88%; max-height: 88%; object-fit: contain; display: block; }
`;
const OverlaySponsorsPage: React.FC = () => {
const bg = useColorModeValue('transparent', 'transparent');
const { data, isLoading } = useQuery<string[]>({
const { data: sponsors, isLoading } = useQuery<string[]>({
queryKey: ['public-sponsors-list'],
queryFn: listSponsorsPublic,
refetchInterval: 10000,
staleTime: 5000,
refetchInterval: 60000,
staleTime: 30000,
});
const list = useMemo(() => Array.isArray(sponsors) ? sponsors.slice(0, 80) : [], [sponsors]);
const trackRef = useRef<HTMLDivElement | null>(null);
const containerRef = useRef<HTMLDivElement | null>(null);
const [duration, setDuration] = useState<number>(40);
const [qrUrl, setQrUrl] = useState<string>('');
const [qrVisible, setQrVisible] = useState<boolean>(false);
const scheduleRef = useRef<{ intId?: any } | null>(null);
useEffect(() => {
if (!trackRef.current) return;
// After images load, compute total width and set duration
const imgs = Array.from(trackRef.current.querySelectorAll('img')) as HTMLImageElement[];
if (imgs.length === 0) {
setDuration(40);
return;
}
let completed = 0;
const check = () => {
completed++;
if (completed >= imgs.length) {
// compute combined width of first half (since content duplicated)
const children = Array.from(trackRef.current!.children) as HTMLElement[];
const half = Math.floor(children.length / 2);
let total = 0; const gap = 48;
for (let i = 0; i < half; i++) {
const el = children[i];
if (el && (el as HTMLElement).style.display !== 'none') total += el.getBoundingClientRect().width;
}
const visible = half; // approximate
if (visible > 0) total += (visible - 1) * gap;
const halfWidth = total; // track anim scrolls by half
const pps = 60; // pixels per second
const d = halfWidth > 0 ? Math.max(15, halfWidth / pps) : 40;
setDuration(Math.round(d));
}
};
imgs.forEach((img) => {
if (img.complete && img.naturalWidth > 0) check();
else {
img.addEventListener('load', check, { once: true });
img.addEventListener('error', () => {
const parent = img.parentElement as HTMLElement | null;
if (parent) parent.style.display = 'none';
check();
}, { once: true });
}
});
return () => {
imgs.forEach((img) => {
img.onload = null; img.onerror = null;
});
};
}, [list]);
useEffect(() => {
let mounted = true;
(async () => {
try {
const qr = await getQr();
if (mounted && qr) setQrUrl(qr);
} catch {}
try {
const st = await getPublicScoreboard();
const everyMin = Math.max(1, Number(st.qrEvery || st.qrEvery === 0 ? st.qrEvery : (st as any).QRShowEveryMinutes || 5));
const durSec = Math.max(5, Number(st.qrDuration || st.qrDuration === 0 ? st.qrDuration : (st as any).QRShowDurationSeconds || 60));
// First show shortly after load, then on schedule
const show = () => {
setQrVisible(true);
window.setTimeout(() => setQrVisible(false), durSec * 1000);
};
window.setTimeout(show, 2500);
scheduleRef.current = { intId: window.setInterval(show, everyMin * 60 * 1000) };
} catch {}
})();
return () => {
mounted = false;
if (scheduleRef.current?.intId) window.clearInterval(scheduleRef.current.intId);
};
}, []);
return (
<Box minH="100vh" bg={bg} display="flex" alignItems="center" justifyContent="center" p={4}>
<Box minH="100vh" bg={bg}>
<style>{css}</style>
{isLoading ? (
<Center><Spinner /></Center>
<Center minH="100vh"><Spinner /></Center>
) : (
<SimpleGrid columns={{ base: 2, sm: 3, md: 4, lg: 5 }} spacing={{ base: 6, md: 10 }}>
{(data || []).map((src, i) => (
<Box key={`${src}-${i}`} p={3} bg="rgba(255,255,255,0.0)" borderRadius="md">
<Image src={src} alt={`sponsor-${i}`} maxH="64px" mx="auto" objectFit="contain"/>
</Box>
))}
</SimpleGrid>
<>
<div className="bar" ref={containerRef}>
<div className="scroller">
<div className="track" ref={trackRef} style={{ ['--scroll-duration' as any]: `${duration}s` }}>
{/* duplicate content for seamless loop */}
{list.concat(list).map((src, i) => (
<div className="item" key={`${src}-${i}`}>
<img src={src} alt="" loading="eager" decoding="async" onError={(e)=>{ (e.currentTarget.parentElement as HTMLElement).style.display='none'; }} />
</div>
))}
</div>
</div>
</div>
{qrUrl ? (
<div className={`qr-float${qrVisible ? ' show' : ''}`} aria-hidden={!qrVisible}>
<img src={qrUrl} alt="QR" />
</div>
) : null}
</>
)}
</Box>
);
@@ -59,7 +59,6 @@ import ContactMap from '../../components/home/ContactMap';
import RichTextEditor from '../../components/common/RichTextEditor';
import { getCachedYouTube, YouTubeVideo } from '../../services/youtube';
import SaveStatusIndicator from '../../components/common/SaveStatusIndicator';
import DraftRecoveryModal from '../../components/common/DraftRecoveryModal';
import { useAutoSave, loadDraft, getDraftMetadata } from '../../hooks/useAutoSave';
import { FiVideo, FiYoutube } from 'react-icons/fi';
import ThumbnailPreview from '../../components/common/ThumbnailPreview';
@@ -84,8 +83,8 @@ const AdminActivitiesPage: React.FC = () => {
const qc = useQueryClient();
const { isOpen, onOpen, onClose } = useDisclosure();
const [editing, setEditing] = useState<Partial<Event> | null>(null);
const [showDraftRecovery, setShowDraftRecovery] = useState(false);
const [draftKey, setDraftKey] = useState<string>('');
const [localDraft, setLocalDraft] = useState<Partial<Event> | null>(null);
const [aiPrompt, setAiPrompt] = useState<string>('');
const [aiLoading, setAiLoading] = useState<boolean>(false);
const [aiTone, setAiTone] = useState<'informative'|'friendly'|'formal'>('informative');
@@ -141,6 +140,28 @@ const AdminActivitiesPage: React.FC = () => {
enabled: isOpen && editing !== null,
});
// Load local new-draft and expose in list (no popup)
const refreshLocalDraft = React.useCallback(() => {
try {
const key = 'draft-activity-new';
const metadata = getDraftMetadata(key);
if (metadata && metadata.age < 1440) {
const d = loadDraft<Partial<Event>>(key);
if (d) {
const restored: any = { ...d };
if (restored.id) delete restored.id;
setLocalDraft(restored);
return;
}
}
} catch {}
setLocalDraft(null);
}, []);
React.useEffect(() => {
refreshLocalDraft();
}, [refreshLocalDraft]);
const { data, isLoading } = useQuery({
queryKey: ['admin-events'],
queryFn: () => getEvents(),
@@ -236,20 +257,16 @@ const AdminActivitiesPage: React.FC = () => {
};
const openCreate = () => {
// Check for existing draft
const key = 'draft-activity-new';
setDraftKey(key);
const metadata = getDraftMetadata(key);
if (metadata && metadata.age < 1440) {
// Show recovery modal
setShowDraftRecovery(true);
if (localDraft) {
setEditing(localDraft);
} else {
// No draft, start fresh
setEditing({ title: '', description: '', type: 'other', is_public: true } as any);
setLocationLat(undefined);
setLocationLng(undefined);
onOpen();
setEditing({ title: '', description: '', type: 'other', is_public: false } as any);
}
setLocationLat(undefined);
setLocationLng(undefined);
onOpen();
};
const openEdit = (ev: Event) => {
// Set unique draft key for this event
@@ -272,42 +289,9 @@ const AdminActivitiesPage: React.FC = () => {
setLocationLat(undefined);
setLocationLng(undefined);
onClose();
refreshLocalDraft();
};
// Draft recovery handlers
const handleRecoverDraft = () => {
const draft = loadDraft<Partial<Event>>(draftKey);
if (draft) {
const isNewDraft = draftKey === 'draft-activity-new';
const restored: any = { ...draft };
if (isNewDraft && restored.id) {
delete restored.id;
}
setEditing(restored);
// Restore location if present
if ((restored as any)?.latitude && (restored as any)?.longitude) {
setLocationLat((restored as any).latitude);
setLocationLng((restored as any).longitude);
}
onOpen();
}
setShowDraftRecovery(false);
};
const handleDiscardDraft = () => {
clearDraft();
setEditing({ title: '', description: '', type: 'other', is_public: true } as any);
setLocationLat(undefined);
setLocationLng(undefined);
setShowDraftRecovery(false);
onOpen();
};
const handleDeleteOnly = () => {
clearDraft();
setShowDraftRecovery(false);
// Don't open the modal - just delete and close
};
const createMut = useMutation({
mutationFn: (payload: Partial<Event>) => createEvent(payload),
@@ -562,6 +546,53 @@ const AdminActivitiesPage: React.FC = () => {
{isLoading && (
<Tr><Td colSpan={8}>Načítání</Td></Tr>
)}
{!isLoading && localDraft && (
<Tr key="local-draft" opacity={0.6}>
<Td>
{(localDraft as any).image_url ? (
<ThumbnailPreview
src={assetUrl((localDraft as any).image_url) || (localDraft as any).image_url}
alt={(localDraft as any).title || 'Koncept'}
size="48px"
previewSize="350px"
/>
) : (
<ChakraImage
src={assetUrl(settingsQ.data?.club_logo_url) || assetUrl('/dist/img/logo-club-empty.svg') || '/dist/img/logo-club-empty.svg'}
alt="No image"
boxSize="48px"
objectFit="contain"
opacity={0.3}
/>
)}
</Td>
<Td>
<VStack align="start" spacing={0}>
<Text fontWeight="medium">{(localDraft as any).title || 'Bez názvu (koncept)'}</Text>
<Text fontSize="xs" color="gray.500">Koncept (lokálně uložený)</Text>
</VStack>
</Td>
<Td>
<Badge colorScheme="gray" fontSize="xs">{(localDraft as any).type ? typeLabel((localDraft as any).type as any) : '—'}</Badge>
</Td>
<Td></Td>
<Td></Td>
<Td>{(localDraft as any).location || '—'}</Td>
<Td><Badge colorScheme="gray">Koncept</Badge></Td>
<Td isNumeric>
<HStack spacing={1} justify="flex-end">
<IconButton aria-label="Upravit koncept" size="sm" icon={<FiEdit2 />} onClick={openCreate} />
<IconButton
aria-label="Smazat koncept"
size="sm"
colorScheme="red"
icon={<FiTrash2 />}
onClick={() => { try { localStorage.removeItem('draft-activity-new'); } catch {} setLocalDraft(null); toast({ title: 'Koncept odstraněn', status: 'success', duration: 2000 }); }}
/>
</HStack>
</Td>
</Tr>
)}
{!isLoading && events.map(ev => (
<Tr key={ev.id} opacity={ev.is_public ? 1 : 0.6}>
<Td>
@@ -582,7 +613,12 @@ const AdminActivitiesPage: React.FC = () => {
/>
)}
</Td>
<Td>{ev.title}</Td>
<Td>
<VStack align="start" spacing={0}>
<Text fontWeight="medium">{ev.title}</Text>
<Text fontSize="xs" color="gray.500">{ev.is_public ? '✓ Veřejná' : '○ Koncept'}</Text>
</VStack>
</Td>
<Td>{typeLabel(ev.type as any)}</Td>
<Td>{new Date(ev.start_time).toLocaleString()}</Td>
<Td>{ev.end_time ? new Date(ev.end_time).toLocaleString() : '-'}</Td>
@@ -1163,16 +1199,7 @@ const AdminActivitiesPage: React.FC = () => {
</ModalContent>
</Modal>
{/* Draft Recovery Modal */}
<DraftRecoveryModal
isOpen={showDraftRecovery}
onClose={() => setShowDraftRecovery(false)}
onRecover={handleRecoverDraft}
onDiscard={handleDiscardDraft}
onDeleteOnly={handleDeleteOnly}
draftAge={getDraftMetadata(draftKey)?.age || null}
entityType="aktivitu"
/>
</Box>
</AdminLayout>
);
@@ -130,6 +130,51 @@ const getEventTranslation = (eventName: string): { name: string; source: string;
name: 'Kliknutí na externí odkaz',
source: 'Různé stránky',
description: 'Uživatel klikl na odkaz vedoucí mimo web'
},
'Button Click': {
name: 'Kliknutí na tlačítko',
source: 'Různé stránky',
description: 'Uživatel klikl na tlačítko'
},
'Navigation': {
name: 'Navigace',
source: 'Menu / odkazy',
description: 'Uživatel přešel na jinou stránku'
},
'Search': {
name: 'Vyhledávání',
source: 'Vyhledávání',
description: 'Uživatel vyhledával na webu'
},
'Email Open': {
name: 'Otevření emailu',
source: 'Email',
description: 'Příjemce otevřel email'
},
'Email Click': {
name: 'Kliknutí v emailu',
source: 'Email',
description: 'Příjemce klikl na odkaz v emailu'
},
'Email Spam': {
name: 'Označeno jako spam',
source: 'Email',
description: 'Příjemce označil zprávu jako spam'
},
'Email Unsubscribe': {
name: 'Odhlášení z emailu',
source: 'Email',
description: 'Příjemce se odhlásil z odběru'
},
'ShortLink Click': {
name: 'Kliknutí na zkrácený odkaz',
source: 'Zkrácené odkazy',
description: 'Uživatel klikl na zkrácený odkaz'
},
'Link Redirect': {
name: 'Přesměrování odkazu',
source: 'Sledování odkazů',
description: 'Zaznamenané přesměrování sledovaného odkazu'
}
};
@@ -161,11 +206,11 @@ const AdminDashboardPage = () => {
staleTime: 10 * 60 * 1000,
});
// Fetch top events from Umami
// Fetch top events (adblock-safe alias)
const { data: topEvents } = useQuery<Array<{ x: string; y: number }>>({
queryKey: ['admin', 'analytics', 'umami-events'],
queryFn: async () => {
const response = await api.get('/admin/umami/metrics/event?days=7');
const response = await api.get('/admin/insights/breakdown/event?days=7');
return response.data || [];
},
staleTime: 10 * 60 * 1000,
+60 -15
View File
@@ -194,6 +194,51 @@ const getEventTranslation = (eventName: string): { name: string; source: string;
name: 'Hlasování v anketě',
source: 'Ankety',
description: 'Uživatel hlasoval v anketě'
},
'Button Click': {
name: 'Kliknutí na tlačítko',
source: 'Různé stránky',
description: 'Uživatel klikl na tlačítko'
},
'Navigation': {
name: 'Navigace',
source: 'Menu / odkazy',
description: 'Uživatel přešel na jinou stránku'
},
'Search': {
name: 'Vyhledávání',
source: 'Vyhledávání',
description: 'Uživatel vyhledával na webu'
},
'Email Open': {
name: 'Otevření emailu',
source: 'Email',
description: 'Příjemce otevřel email'
},
'Email Click': {
name: 'Kliknutí v emailu',
source: 'Email',
description: 'Příjemce klikl na odkaz v emailu'
},
'Email Spam': {
name: 'Označeno jako spam',
source: 'Email',
description: 'Příjemce označil zprávu jako spam'
},
'Email Unsubscribe': {
name: 'Odhlášení z emailu',
source: 'Email',
description: 'Příjemce se odhlásil z odběru'
},
'ShortLink Click': {
name: 'Kliknutí na zkrácený odkaz',
source: 'Zkrácené odkazy',
description: 'Uživatel klikl na zkrácený odkaz'
},
'Link Redirect': {
name: 'Přesměrování odkazu',
source: 'Sledování odkazů',
description: 'Zaznamenané přesměrování sledovaného odkazu'
}
};
@@ -242,7 +287,7 @@ const AnalyticsAdminPage: React.FC = () => {
const daysNum = parseInt(days);
// Fetch stats with calculated time range
const statsResponse = await api.get(`/admin/umami/stats?days=${days}`);
const statsResponse = await api.get(`/admin/insights/summary?days=${days}`);
setStats(statsResponse.data);
// Check if we have any data
@@ -253,14 +298,14 @@ const AnalyticsAdminPage: React.FC = () => {
// Fetch metrics
const [pages, browsers, os, countries, devices, events, queries, pageviews] = await Promise.all([
api.get(`/admin/umami/metrics/url?days=${days}`),
api.get(`/admin/umami/metrics/browser?days=${days}`),
api.get(`/admin/umami/metrics/os?days=${days}`),
api.get(`/admin/umami/metrics/country?days=${days}`),
api.get(`/admin/umami/metrics/device?days=${days}`),
api.get(`/admin/umami/metrics/event?days=${days}`),
api.get(`/admin/umami/metrics/query?days=${days}`).catch(() => ({ data: [] })),
api.get(`/admin/umami/pageviews?days=${days}`),
api.get(`/admin/insights/breakdown/url?days=${days}`),
api.get(`/admin/insights/breakdown/browser?days=${days}`),
api.get(`/admin/insights/breakdown/os?days=${days}`),
api.get(`/admin/insights/breakdown/country?days=${days}`),
api.get(`/admin/insights/breakdown/device?days=${days}`),
api.get(`/admin/insights/breakdown/event?days=${days}`),
api.get(`/admin/insights/breakdown/query?days=${days}`).catch(() => ({ data: [] })),
api.get(`/admin/insights/pageviews?days=${days}`),
]);
setPageMetrics(pages.data || []);
@@ -330,7 +375,7 @@ const AnalyticsAdminPage: React.FC = () => {
const fetchUmamiConfig = async () => {
try {
const response = await api.get('/umami/config');
const response = await api.get('/insights/config');
setUmamiConfig(response.data);
} catch (error) {
console.error('Failed to fetch Umami config:', error);
@@ -345,11 +390,11 @@ const AnalyticsAdminPage: React.FC = () => {
try {
// Fetch detailed analytics for the selected country
const [pages, browsers, os, devices, events] = await Promise.all([
api.get(`/admin/umami/metrics/url?days=${timeRange}&country=${countryCode}`),
api.get(`/admin/umami/metrics/browser?days=${timeRange}&country=${countryCode}`),
api.get(`/admin/umami/metrics/os?days=${timeRange}&country=${countryCode}`),
api.get(`/admin/umami/metrics/device?days=${timeRange}&country=${countryCode}`),
api.get(`/admin/umami/metrics/event?days=${timeRange}&country=${countryCode}`),
api.get(`/admin/insights/breakdown/url?days=${timeRange}&country=${countryCode}`),
api.get(`/admin/insights/breakdown/browser?days=${timeRange}&country=${countryCode}`),
api.get(`/admin/insights/breakdown/os?days=${timeRange}&country=${countryCode}`),
api.get(`/admin/insights/breakdown/device?days=${timeRange}&country=${countryCode}`),
api.get(`/admin/insights/breakdown/event?days=${timeRange}&country=${countryCode}`),
]);
setCountryDetails({
+39 -11
View File
@@ -249,6 +249,7 @@ const ArticlesAdminPage = () => {
const [aiPrompt, setAiPrompt] = useState('');
const [aiAudience, setAiAudience] = useState('Fanoušci klubu');
const [aiMinWords, setAiMinWords] = useState<number>(500);
const [aiMinWordsInput, setAiMinWordsInput] = useState<string>('500');
const [featSwitchLoading, setFeatSwitchLoading] = useState<boolean>(false);
const [competitions, setCompetitions] = useState<Array<{ code?: string; name: string }>>([]);
const [aliasesMap, setAliasesMap] = useState<Record<string, string>>({});
@@ -755,7 +756,11 @@ const ArticlesAdminPage = () => {
}, []);
const aiMut = useMutation({
mutationFn: () => generateBlogAI({ prompt: aiPrompt, audience: aiAudience, min_words: aiMinWords }),
mutationFn: () => {
const parsed = parseInt(String(aiMinWordsInput || '').trim(), 10);
const effective = Number.isFinite(parsed) && !isNaN(parsed) && parsed > 0 ? parsed : aiMinWords;
return generateBlogAI({ prompt: aiPrompt, audience: aiAudience, min_words: effective });
},
onSuccess: (res) => {
console.log('AI blog response:', res);
@@ -1517,7 +1522,7 @@ const ArticlesAdminPage = () => {
</ModalHeader>
<ModalCloseButton />
<ModalBody maxH="calc(90vh - 120px)" overflowY="auto">
<Tabs variant="enclosed" colorScheme="blue" isFitted index={activeTabIndex} onChange={(index) => setActiveTabIndex(index)} isLazy lazyBehavior="unmount">
<Tabs variant="enclosed" colorScheme="blue" isFitted index={activeTabIndex} onChange={(index) => setActiveTabIndex(index)} isLazy lazyBehavior="keepMounted">
<TabList>
<Tab>AI</Tab>
<Tab>Základní</Tab>
@@ -1558,7 +1563,28 @@ const ArticlesAdminPage = () => {
</FormControl>
<FormControl w="180px">
<FormLabel>Min. slov</FormLabel>
<Input type="number" value={aiMinWords} onChange={(e) => setAiMinWords(Math.max(200, Number(e.target.value || 0)))} bg={inputBg} />
<Input
type="text"
inputMode="numeric"
placeholder="500"
value={aiMinWordsInput}
onChange={(e) => {
const v = e.target.value;
const digits = v.replace(/[^0-9]/g, '');
setAiMinWordsInput(digits);
}}
onBlur={() => {
const n = parseInt(String(aiMinWordsInput || '').trim(), 10);
if (!isNaN(n) && Number.isFinite(n) && n > 0) {
setAiMinWords(n);
setAiMinWordsInput(String(n));
} else {
// Reset to last valid numeric value
setAiMinWordsInput(String(aiMinWords || 500));
}
}}
bg={inputBg}
/>
</FormControl>
</HStack>
<HStack>
@@ -1806,14 +1832,16 @@ const ArticlesAdminPage = () => {
Vložit fotografie z alba
</Button>
</HStack>
<RichTextEditor
value={editing?.content || ''}
onChange={(val: string) => setEditing((prev) => ({ ...(prev as any), content: val }))}
placeholder="Začněte psát obsah článku..."
height="60vh"
onImageUpload={uploadFile}
toolbar="full"
/>
{activeTabIndex === 2 && (
<RichTextEditor
value={editing?.content || ''}
onChange={(val: string) => setEditing((prev) => ({ ...(prev as any), content: val }))}
placeholder="Začněte psát obsah článku..."
height="60vh"
onImageUpload={uploadFile}
toolbar="full"
/>
)}
</FormControl>
</TabPanel>
+20 -10
View File
@@ -1,10 +1,10 @@
import React from 'react';
import AdminLayout from '../../layouts/AdminLayout';
import { Box, Heading, HStack, VStack, Button, Select, Input, Table, Thead, Tbody, Tr, Th, Td, Text, Badge, IconButton, useToast, Modal, ModalOverlay, ModalContent, ModalHeader, ModalBody, ModalFooter, ModalCloseButton, useDisclosure, FormControl, FormLabel, NumberInput, NumberInputField, Switch } from '@chakra-ui/react';
import { Box, Heading, HStack, VStack, Button, Select, Input, Table, Thead, Tbody, Tr, Th, Td, Text, Badge, IconButton, useToast, Modal, ModalOverlay, ModalContent, ModalHeader, ModalBody, ModalFooter, ModalCloseButton, useDisclosure, FormControl, FormLabel, NumberInput, NumberInputField, Switch, Tooltip } from '@chakra-ui/react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { adminListComments, adminUpdateCommentStatus, adminBanUser, adminListUnbanRequests, adminResolveUnban, adminListBans, adminLiftBan } from '../../services/admin/comments';
import { deleteComment } from '../../services/comments';
import { FiTrash2 } from 'react-icons/fi';
import { FiTrash2, FiEye, FiEyeOff } from 'react-icons/fi';
import { getArticles } from '../../services/articles';
import { getEvents } from '../../services/eventService';
import { getCachedYouTube } from '../../services/youtube';
@@ -132,7 +132,7 @@ const CommentsAdminPage: React.FC = () => {
<Heading size="md" mb={4}>Komentáře (moderace)</Heading>
<VStack align="stretch" spacing={3} mb={4}>
<HStack>
<Select placeholder="Status" value={status} onChange={(e) => { setStatus(e.target.value); setPage(1); }} maxW="200px">
<Select placeholder="Stav" value={status} onChange={(e) => { setStatus(e.target.value); setPage(1); }} maxW="200px">
<option value="visible">Viditelné</option>
<option value="hidden">Skryté</option>
</Select>
@@ -172,7 +172,7 @@ const CommentsAdminPage: React.FC = () => {
<Th>Obsah</Th>
<Th>Spam</Th>
<Th>Hlášení</Th>
<Th>Status</Th>
<Th>Stav</Th>
<Th>Akce</Th>
</Tr>
</Thead>
@@ -189,10 +189,16 @@ const CommentsAdminPage: React.FC = () => {
<Td>{(c as any).spam_score ? <Badge colorScheme={(c as any).spam_score > 0.5 ? 'orange' : 'green'}>{(c as any).spam_score.toFixed(2)}</Badge> : '-'}</Td>
<Td>{(c as any).reports ? <Badge colorScheme={(c as any).reports > 2 ? 'red' : 'yellow'}>{(c as any).reports}</Badge> : '-'}</Td>
<Td>
<HStack>
<Button size="xs" variant={c.status === 'visible' ? 'solid' : 'outline'} onClick={() => updateStatusMut.mutate({ id: c.id, s: 'visible' })}>Viditelné</Button>
<Button size="xs" variant={c.status === 'hidden' ? 'solid' : 'outline'} onClick={() => updateStatusMut.mutate({ id: c.id, s: 'hidden' })}>Skryté</Button>
</HStack>
<Tooltip label={c.status === 'visible' ? 'Viditelné' : 'Skryté'}>
<IconButton
aria-label={c.status === 'visible' ? 'Viditelné' : 'Skryté'}
size="xs"
variant="ghost"
colorScheme={c.status === 'visible' ? 'green' : 'gray'}
icon={c.status === 'visible' ? <FiEye /> : <FiEyeOff />}
onClick={() => updateStatusMut.mutate({ id: c.id, s: (c.status === 'visible' ? 'hidden' : 'visible') as any })}
/>
</Tooltip>
</Td>
<Td>
<HStack>
@@ -222,7 +228,7 @@ const CommentsAdminPage: React.FC = () => {
<Th>ID</Th>
<Th>Uživatel</Th>
<Th>Text</Th>
<Th>Status</Th>
<Th>Stav</Th>
<Th>Akce</Th>
</Tr>
</Thead>
@@ -232,7 +238,11 @@ const CommentsAdminPage: React.FC = () => {
<Td>#{r.id}</Td>
<Td>#{r.user?.id} {r.user?.first_name} {r.user?.last_name} <Text as="span" color="gray.500" fontSize="sm">{r.user?.email}</Text></Td>
<Td maxW="480px"><Text noOfLines={2}>{r.message}</Text></Td>
<Td><Badge>{r.status}</Badge></Td>
<Td>
{r.status === 'pending' && <Badge colorScheme="yellow">Čeká na vyřízení</Badge>}
{r.status === 'approved' && <Badge colorScheme="green">Schváleno</Badge>}
{r.status === 'rejected' && <Badge colorScheme="red">Zamítnuto</Badge>}
</Td>
<Td>
<HStack>
<Button size="xs" colorScheme="green" variant="outline" onClick={() => resolveUnbanMut.mutate({ id: r.id, action: 'approve' })}>Povolit</Button>
+51 -25
View File
@@ -34,7 +34,7 @@ import {
} from '@chakra-ui/react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import AdminLayout from '../../layouts/AdminLayout';
import { putMatchOverride, fetchAdminMatches } from '../../services/adminMatches';
import { patchMatchOverride, fetchAdminMatches } from '../../services/adminMatches';
import { getPublicSettings } from '../../services/settings';
import { useEffect, useMemo, useRef, useState } from 'react';
@@ -52,7 +52,9 @@ const MatchesAdminPage = () => {
const [form, setForm] = useState({
venue_override: '',
date_time_edit: '',
score_override: '',
});
const [origDateLocal, setOrigDateLocal] = useState<string>('');
const normalizeName = (s: string) => {
let out = String(s || '');
@@ -88,7 +90,7 @@ const MatchesAdminPage = () => {
const items = await fetchAdminMatches();
const FACR_DATE_FMT = 'dd.MM.yyyy HH:mm';
const parseTs = (obj: any): number => {
const s = String(obj?.date_time || obj?.date || '').trim();
const s = String(obj?.date_time || (obj?.date && obj?.time ? `${obj.date} ${obj.time}` : obj?.date) || '').trim();
if (!s) return Number.MAX_SAFE_INTEGER;
try {
const dt = parse(s, FACR_DATE_FMT, new Date());
@@ -112,8 +114,9 @@ const MatchesAdminPage = () => {
const [sideFilter, setSideFilter] = useState<'home' | 'away' | ''>('');
const normalizedTeam = teamFilter.trim().toLowerCase();
const FACR_DATE_FMT = 'dd.MM.yyyy HH:mm';
const formatDisplayDate = (s: string): string => {
const str = String(s || '').trim();
const formatDisplayDate = (s: string, t?: string): string => {
// Prefer full date-time string; if only date and time parts exist, combine them
const str = String((s && t ? `${s} ${t}` : s) || '').trim();
if (!str) return '';
try {
const dt = parse(str, FACR_DATE_FMT, new Date());
@@ -216,7 +219,7 @@ const MatchesAdminPage = () => {
}
// date parse
const dtStr = String(m.date_time || m.date || '');
const dtStr = String(m.date_time || (m.date && m.time ? `${m.date} ${m.time}` : m.date) || '');
let ts = NaN;
try {
ts = parse(dtStr, FACR_DATE_FMT, new Date()).getTime();
@@ -346,14 +349,24 @@ const MatchesAdminPage = () => {
mutationFn: async () => {
const externalMatchId: string = String((selected?.match_id ?? selected?.id ?? '')).trim();
if (!externalMatchId) throw new Error('Chybí match_id');
const payload: any = {
venue_override: form.venue_override,
date_time_override: form.date_time_edit,
};
Object.keys(payload).forEach((k) => {
if (payload[k as keyof typeof payload] === '') payload[k as keyof typeof payload] = null;
});
await putMatchOverride(externalMatchId, payload);
const body: any = {};
// Venue: send only if changed
const currentVenue = String(selected?.venue || '');
if (form.venue_override.trim() !== currentVenue) {
body.venue_override = form.venue_override.trim() === '' ? null : form.venue_override.trim();
}
// Score: send only if changed
const currentScore = String(selected?.score || (selected?.result_home != null && selected?.result_away != null ? `${selected.result_home}:${selected.result_away}` : '') || '');
if (form.score_override.trim() !== currentScore) {
body.score_override = form.score_override.trim() === '' ? null : form.score_override.trim();
}
// Datetime: send only if changed
if (form.date_time_edit !== origDateLocal) {
body.date_time_override = form.date_time_edit.trim() === '' ? null : form.date_time_edit;
}
// If nothing changed, do nothing
if (Object.keys(body).length === 0) return { ok: true };
await patchMatchOverride(externalMatchId, body);
return { ok: true };
},
onSuccess: () => {
@@ -369,7 +382,7 @@ const MatchesAdminPage = () => {
const openEdit = (m: any) => {
setSelected(m);
const facrStr: string = m.date_time || m.date || '';
const facrStr: string = m.date_time || (m.date && m.time ? `${m.date} ${m.time}` : m.date) || '';
let localStr = '';
if (facrStr) {
try {
@@ -386,9 +399,11 @@ const MatchesAdminPage = () => {
}
}
}
setOrigDateLocal(localStr);
setForm({
venue_override: m.venue || '',
date_time_edit: localStr,
score_override: String(m.score || (m.result_home != null && m.result_away != null ? `${m.result_home}:${m.result_away}` : '') || ''),
});
setIsOpen(true);
};
@@ -554,17 +569,19 @@ const MatchesAdminPage = () => {
// Utility to check if match is in the past
const isMatchPast = (dateTimeStr: string): boolean => {
if (!dateTimeStr) return false;
// Try full date+time first: dd.MM.yyyy HH:mm
try {
const dt = parse(dateTimeStr, FACR_DATE_FMT, new Date());
if (!isNaN(dt.getTime())) {
return dt.getTime() < Date.now();
}
} catch (_) {
const d = new Date(dateTimeStr);
if (!isNaN(d.getTime())) {
return d.getTime() < Date.now();
}
}
if (!isNaN(dt.getTime())) return dt.getTime() < Date.now();
} catch {}
// If only date is present: dd.MM.yyyy
try {
const dOnly = parse(dateTimeStr, 'dd.MM.yyyy', new Date());
if (!isNaN(dOnly.getTime())) return dOnly.getTime() < Date.now();
} catch {}
// Fallback to Date constructor (RFC3339 etc.)
const d2 = new Date(dateTimeStr);
if (!isNaN(d2.getTime())) return d2.getTime() < Date.now();
return false;
};
@@ -785,7 +802,7 @@ const MatchesAdminPage = () => {
</Tr>
) : (
visibleMatches.map((m: any, idx: number) => {
const isPast = isMatchPast(m.date_time || m.date || '');
const isPast = isMatchPast(m.date_time || (m.date && m.time ? `${m.date} ${m.time}` : m.date) || '');
const hasScore = m.score || (m.result_home != null && m.result_away != null);
return (
<Tr
@@ -797,7 +814,7 @@ const MatchesAdminPage = () => {
>
<Td>
<HStack spacing={2}>
<Text>{formatDisplayDate(String(m.date_time || m.date || ''))}</Text>
<Text>{formatDisplayDate(String(m.date || m.date_time || ''), String(m.time || ''))}</Text>
{isPast && <Badge colorScheme="gray" fontSize="xs">Odehráno</Badge>}
{!isPast && <Badge colorScheme="green" fontSize="xs">Nadcházející</Badge>}
</HStack>
@@ -899,6 +916,15 @@ const MatchesAdminPage = () => {
/>
</FormControl>
<FormControl>
<FormLabel>Skóre</FormLabel>
<Input
placeholder="např. 2:1"
value={form.score_override}
onChange={(e) => setForm((f) => ({ ...f, score_override: e.target.value }))}
/>
</FormControl>
{/* Team name/logo editing removed */}
@@ -1,8 +1,8 @@
import React, { useEffect, useMemo, useRef, useState } from 'react';
import { Box, Button, Center, HStack, Heading, Image, SimpleGrid, Text, useColorModeValue, useToast, VStack } from '@chakra-ui/react';
import { Box, Button, Center, HStack, Heading, Image, SimpleGrid, Text, useColorModeValue, useToast, VStack, Badge } from '@chakra-ui/react';
import AdminLayout from '@/layouts/AdminLayout';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { getAdminScoreboard, updateAdminScoreboard, ScoreboardState, startTimer, pauseTimer, resetTimer } from '@/services/scoreboard';
import { getAdminScoreboard, updateAdminScoreboard, ScoreboardState, startTimer, pauseTimer, resetTimer, swapSides, startSecondHalf } from '@/services/scoreboard';
const MobileScoreboardControlPage: React.FC = () => {
const toast = useToast();
@@ -81,6 +81,13 @@ const MobileScoreboardControlPage: React.FC = () => {
<Button variant="outline" onClick={handleResetTimer}>Reset</Button>
</HStack>
<Text fontSize="2xl" fontFamily="mono">{mmss}</Text>
<HStack>
<Badge colorScheme="purple">Poločas: {state.half || 1}</Badge>
</HStack>
<HStack>
<Button size="sm" variant="outline" onClick={async ()=>{ try { await swapSides(); await qc.invalidateQueries({ queryKey: ['admin-scoreboard-mobile'] }); toast({ title: 'Strany prohozeny', status: 'success' }); } catch { toast({ title: 'Prohození selhalo', status: 'error' }); } }}>Prohodit strany</Button>
<Button size="sm" colorScheme="purple" onClick={async ()=>{ try { await startSecondHalf(); await qc.invalidateQueries({ queryKey: ['admin-scoreboard-mobile'] }); toast({ title: 'Začal 2. poločas', status: 'success' }); } catch { toast({ title: 'Akce selhala', status: 'error' }); } }}>Začít 2. poločas</Button>
</HStack>
</VStack>
<VStack spacing={2}>
{state.awayLogo ? <Image src={state.awayLogo} alt="HOS" boxSize="64px" objectFit="contain" /> : null}
@@ -381,7 +381,7 @@ const PlayersAdminPage: React.FC = () => {
<Tr key={p.id} opacity={p.is_active ? 1 : 0.6}>
<Td>
<ThumbnailPreview
src={assetUrl(p.image_url) || '/logo192.png'}
src={assetUrl(p.image_url) || '/dist/img/player-placeholder.svg'}
alt={`${p.first_name} ${p.last_name}`}
size="48px"
previewSize="300px"
@@ -72,7 +72,7 @@ const SettingsAdminPage: React.FC = () => {
const fetchUmamiConfig = async () => {
try {
const response = await api.get('/umami/config');
const response = await api.get('/insights/config');
setUmamiConfig(response.data);
if (response.data?.website_id) {
setUmamiWebsiteId(response.data.website_id);
@@ -618,7 +618,7 @@ const SettingsAdminPage: React.FC = () => {
}
setUmamiInitializing(true);
try {
const response = await api.post('/admin/umami/initialize', {
const response = await api.post('/admin/insights/initialize', {
name: umamiName,
domain: umamiDomain,
});
+1
View File
@@ -21,6 +21,7 @@ export type MatchOverrideInput = {
away_name_override?: string | null;
venue_override?: string | null;
date_time_override?: string | null; // ISO string
score_override?: string | null;
home_logo_url?: string | null;
away_logo_url?: string | null;
notes?: string | null;
+1
View File
@@ -9,6 +9,7 @@ export type CommentItem = {
target_label?: string;
parent_id?: number | null;
content: string;
content_html?: string;
status?: 'visible' | 'hidden';
is_edited?: boolean;
edited_at?: string | null;
+7 -6
View File
@@ -1,6 +1,7 @@
// Translate country codes and names to Czech nationality names
export function translateNationality(code?: string): string {
if (!code) return '';
const val = String(code ?? '');
if (!val) return '';
// Country code translations (2-letter codes)
const codeTranslations: Record<string, string> = {
@@ -102,13 +103,13 @@ export function translateNationality(code?: string): string {
};
// Try code translation first (for 2-letter codes)
const upperCode = code.toUpperCase();
const upperCode = val.toUpperCase();
if (codeTranslations[upperCode]) {
return codeTranslations[upperCode];
}
// Try full name translation (case-insensitive)
const lowerName = code.toLowerCase();
const lowerName = val.toLowerCase();
for (const [englishName, czechName] of Object.entries(nameTranslations)) {
if (englishName.toLowerCase() === lowerName) {
return czechName;
@@ -116,13 +117,13 @@ export function translateNationality(code?: string): string {
}
// Return original if no translation found
return code;
return val;
}
// Convert a 2-letter ISO country code to a flag emoji
export function countryCodeToEmoji(cc?: string): string {
if (!cc) return '';
const v = cc.trim().toUpperCase();
const v = String(cc).trim().toUpperCase();
if (!/^[A-Z]{2}$/.test(v)) return '';
return v.replace(/./g, (ch) => String.fromCodePoint(127397 + ch.charCodeAt(0)));
}
@@ -130,7 +131,7 @@ export function countryCodeToEmoji(cc?: string): string {
// Get an emoji flag for a given nationality string (code like "CZ" or English name like "Czechia")
export function getCountryFlag(nationality?: string): string {
if (!nationality) return '';
const n = nationality.trim();
const n = String(nationality).trim();
if (!n) return '';
// If already a 2-letter code
if (/^[A-Za-z]{2}$/.test(n)) {