diff --git a/client/src/pages/Home/components/TestArea/TestAreaComponent.jsx b/client/src/pages/Home/components/TestArea/TestAreaComponent.jsx index 73f9eb54..ed7191e3 100644 --- a/client/src/pages/Home/components/TestArea/TestAreaComponent.jsx +++ b/client/src/pages/Home/components/TestArea/TestAreaComponent.jsx @@ -1,4 +1,5 @@ -import {useContext} from "react"; +import {useContext, useEffect, useRef, useState, useCallback} from "react"; +import {createPortal} from "react-dom"; import {ConfigContext} from "@/common/contexts/Config"; import {SpeedtestContext} from "@/common/contexts/Speedtests"; import Speedtest from "../Speedtest"; @@ -6,41 +7,162 @@ import {getIconBySpeed} from "@/common/utils/TestUtil"; import "./styles.sass"; import {t} from "i18next"; -function TestArea() { +const TestArea = () => { const config = useContext(ConfigContext)[0]; - const [speedtests] = useContext(SpeedtestContext); + const {speedtests, loadMoreTests, loading, hasMore} = useContext(SpeedtestContext); + const [stickyDate, setStickyDate] = useState(null); + const [showStickyDate, setShowStickyDate] = useState(false); + const [initialLoadComplete, setInitialLoadComplete] = useState(false); + const containerRef = useRef(); + const lastElementRef = useRef(); + + useEffect(() => { + if (!loading && !initialLoadComplete) { + setInitialLoadComplete(true); + } + }, [loading, initialLoadComplete]); + + useEffect(() => { + if (speedtests.length > 0) { + const initialDate = getDateFromTest(speedtests[0]); + setStickyDate(initialDate); + } + }, [speedtests]); + + const getDateFromTest = (test) => { + const date = new Date(Date.parse(test.created)); + return date.toLocaleDateString("default", {weekday: 'long', year: 'numeric', month: 'long', day: 'numeric'}); + }; + + const handleScroll = useCallback(() => { + const scrollTop = Math.max(window.scrollY, document.documentElement.scrollTop, document.body.scrollTop); + + const shouldShow = scrollTop > 50; + setShowStickyDate(shouldShow); + + const windowHeight = window.innerHeight; + const documentHeight = Math.max(document.body.scrollHeight, document.body.offsetHeight, + document.documentElement.clientHeight, document.documentElement.scrollHeight, + document.documentElement.offsetHeight); + + const nearBottom = scrollTop + windowHeight >= documentHeight - 500; + const atBottom = scrollTop + windowHeight >= documentHeight - 50; + + if ((nearBottom || atBottom) && hasMore && !loading && speedtests.length > 0) { + loadMoreTests(); + } + + if (shouldShow && speedtests.length > 0) { + const testElements = document.querySelectorAll('.speedtest'); + if (testElements.length > 0) { + for (let i = 0; i < testElements.length; i++) { + const element = testElements[i]; + const elementRect = element.getBoundingClientRect(); + + if (elementRect.top <= 200 && elementRect.bottom > 50) { + if (speedtests[i]) { + const newDate = getDateFromTest(speedtests[i]); + setStickyDate(newDate); + } + break; + } + } + } + } + }, [speedtests, stickyDate, hasMore, loading, loadMoreTests]); + + useEffect(() => { + let ticking = false; + + const throttledScrollHandler = () => { + if (!ticking) { + requestAnimationFrame(() => { + handleScroll(); + ticking = false; + }); + ticking = true; + } + }; + + window.addEventListener('scroll', throttledScrollHandler, {passive: true}); + document.body.addEventListener('scroll', throttledScrollHandler, {passive: true}); + + setTimeout(handleScroll, 100); + + return () => { + window.removeEventListener('scroll', throttledScrollHandler); + document.body.removeEventListener('scroll', throttledScrollHandler); + }; + }, [handleScroll]); + + useEffect(() => { + if (!lastElementRef.current || !hasMore || loading) return; + + const observer = new IntersectionObserver((entries) => { + if (entries[0].isIntersecting && hasMore && !loading) loadMoreTests(); + }, {threshold: 0.01, rootMargin: '200px 0px'}); + + observer.observe(lastElementRef.current); + + return () => { + observer.disconnect(); + }; + }, [hasMore, loading, loadMoreTests, speedtests.length]); if (Object.entries(config).length === 0) return (<>); - if (speedtests.length === 0) return

{t("test.not_available")}

+ if (!initialLoadComplete || (speedtests.length === 0 && loading)) return <>; + + if (speedtests.length === 0 && initialLoadComplete) + return

{t("test.not_available")}

; return ( -
- {speedtests.map ? speedtests.map(test => { - const dateArray = test.created.split("-").map((value) => parseInt(value)); - const date = test.type === "average" ? new Date(dateArray[0], dateArray[1] - 1, dateArray[2]) - : new Date(Date.parse(test.created)); + <> + {showStickyDate && stickyDate && createPortal( +
+ {stickyDate} +
, document.body)} - let item = localStorage.getItem("testTime"); - if ((item === "3" || item === "4") && test.type !== "average") return; +
+ {speedtests.map((test, index) => { + const date = new Date(Date.parse(test.created)); + const isLast = index === speedtests.length - 1; - let id = (test.type === "average") ? date.getDate() + "-" + date.getMonth() : test.id; + return ( +
+ +
+ ); + })} - return - }) : ""} -
+ {loading && ( +
+

{t("test.loading_more")}

+
+ )} + + {!hasMore && speedtests.length > 0 && ( +
+

{t("test.no_more_tests")}

+
+ )} +
+ ); } diff --git a/client/src/pages/Home/components/TestArea/styles.sass b/client/src/pages/Home/components/TestArea/styles.sass index 43c3c0b7..7f7bf644 100644 --- a/client/src/pages/Home/components/TestArea/styles.sass +++ b/client/src/pages/Home/components/TestArea/styles.sass @@ -1,5 +1,42 @@ @use "@/common/styles/colors" as * +.floating-date-indicator + position: fixed + user-select: none + top: 30px + display: flex + transform: translateX(-50%) + left: 50% + z-index: 50 + background: $dark-gray + backdrop-filter: blur(10px) + border: $light-gray 2px solid + border-radius: 12px + padding: 12px 24px + text-align: center + font-weight: 600 + color: $white + box-shadow: 0 0 15px rgba($light-gray, 0.5) + animation: slideInDown 0.3s ease-out + + span + font-size: 22px + letter-spacing: 0.5px + +@keyframes slideInDown + from + opacity: 0 + transform: translateX(-50%) translateY(-20px) + to + opacity: 1 + transform: translateX(-50%) translateY(0) + +.loading-more, .end-of-list + text-align: center + padding: 20px + color: $subtext + font-style: italic + .error-text margin: 0 font-size: 26pt @@ -10,6 +47,13 @@ @media (max-width: 605px) .error-text width: 28rem + + .floating-date-indicator + top: 15px + padding: 10px 20px + + span + font-size: 13px @media (max-width: 475px) .error-text