Files
MyClub/frontend/src/pages/OverlaySponsorsPage.tsx
T
Tomas Dvorak c941313fd5 dev day #92
2025-11-14 15:53:12 +01:00

139 lines
6.4 KiB
TypeScript

import React, { useEffect, useMemo, useRef, useState } from 'react';
import { Box, Center, Spinner, useColorModeValue } from '@chakra-ui/react';
import { useQuery } from '@tanstack/react-query';
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: sponsors, isLoading } = useQuery<string[]>({
queryKey: ['public-sponsors-list'],
queryFn: listSponsorsPublic,
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}>
<style>{css}</style>
{isLoading ? (
<Center minH="100vh"><Spinner /></Center>
) : (
<>
<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>
);
};
export default OverlaySponsorsPage;