mirror of
https://github.com/Dvorinka/MyClubServer.git
synced 2026-06-04 02:32:57 +00:00
dev day #92
This commit is contained in:
@@ -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;
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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í e‑mailu',
|
||||
source: 'E‑mail',
|
||||
description: 'Příjemce otevřel e‑mail'
|
||||
},
|
||||
'Email Click': {
|
||||
name: 'Kliknutí v e‑mailu',
|
||||
source: 'E‑mail',
|
||||
description: 'Příjemce klikl na odkaz v e‑mailu'
|
||||
},
|
||||
'Email Spam': {
|
||||
name: 'Označeno jako spam',
|
||||
source: 'E‑mail',
|
||||
description: 'Příjemce označil zprávu jako spam'
|
||||
},
|
||||
'Email Unsubscribe': {
|
||||
name: 'Odhlášení z e‑mailu',
|
||||
source: 'E‑mail',
|
||||
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,
|
||||
|
||||
@@ -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í e‑mailu',
|
||||
source: 'E‑mail',
|
||||
description: 'Příjemce otevřel e‑mail'
|
||||
},
|
||||
'Email Click': {
|
||||
name: 'Kliknutí v e‑mailu',
|
||||
source: 'E‑mail',
|
||||
description: 'Příjemce klikl na odkaz v e‑mailu'
|
||||
},
|
||||
'Email Spam': {
|
||||
name: 'Označeno jako spam',
|
||||
source: 'E‑mail',
|
||||
description: 'Příjemce označil zprávu jako spam'
|
||||
},
|
||||
'Email Unsubscribe': {
|
||||
name: 'Odhlášení z e‑mailu',
|
||||
source: 'E‑mail',
|
||||
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({
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
Reference in New Issue
Block a user