mirror of
https://github.com/Dvorinka/MyClubServer.git
synced 2026-06-05 03:02:56 +00:00
dev day #92
This commit is contained in:
@@ -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>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user