diff --git a/package-lock.json b/package-lock.json index 14b33560..69be5cb8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -36,6 +36,7 @@ "react-async-loader": "^0.1.2", "react-dom": "^17.0.2", "react-i18next": "^14.1.1", + "react-intersection-observer": "^9.13.1", "react-leaflet": "3.2.5", "react-leaflet-google-layer": "^2.2.0", "react-router-dom": "^6.23.0", @@ -24664,6 +24665,20 @@ } } }, + "node_modules/react-intersection-observer": { + "version": "9.13.1", + "resolved": "https://registry.npmjs.org/react-intersection-observer/-/react-intersection-observer-9.13.1.tgz", + "integrity": "sha512-tSzDaTy0qwNPLJHg8XZhlyHTgGW6drFKTtvjdL+p6um12rcnp8Z5XstE+QNBJ7c64n5o0Lj4ilUleA41bmDoMw==", + "peerDependencies": { + "react": "^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + } + } + }, "node_modules/react-is": { "version": "17.0.2", "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", diff --git a/package.json b/package.json index 72dff625..3bf04554 100644 --- a/package.json +++ b/package.json @@ -30,6 +30,7 @@ "react-async-loader": "^0.1.2", "react-dom": "^17.0.2", "react-i18next": "^14.1.1", + "react-intersection-observer": "^9.13.1", "react-leaflet": "3.2.5", "react-leaflet-google-layer": "^2.2.0", "react-router-dom": "^6.23.0", @@ -134,7 +135,7 @@ "prettier --write" ] }, -"overrides": { - "react-refresh": "0.11.0" -} + "overrides": { + "react-refresh": "0.11.0" + } } diff --git a/src/App.tsx b/src/App.tsx index 67979abb..832a84f8 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -11,7 +11,7 @@ import { useTranslation } from 'react-i18next'; import { useTheme } from '@material-ui/core/styles'; import PopUpRedirect from './components/atoms/PopUpRedirect'; import WidgetsTemplate from './components/organisms/WidgetsTemplate'; -import {observer} from "mobx-react-lite"; +import { observer } from 'mobx-react-lite'; // main components height - must add up to 100 const headerHeight = '5vh'; @@ -31,7 +31,6 @@ const App: FC = () => { const classes = useStyles(); const store = useStore(); const theme = useTheme(); - const appDir = i18n.dir(); useEffect(() => { diff --git a/src/components/atoms/InfiniteScroll.tsx b/src/components/atoms/InfiniteScroll.tsx deleted file mode 100644 index 290adcf5..00000000 --- a/src/components/atoms/InfiniteScroll.tsx +++ /dev/null @@ -1,49 +0,0 @@ -import React, { FC, useEffect, useCallback, useRef } from 'react'; -import { makeStyles } from '@material-ui/core'; -import { useStore } from 'store/storeConfig'; - -const INFINITE_SCROLLING_OFFSET: number = 30; - -function isScrollEnd(element: HTMLDivElement) { - return element.scrollTop + element.clientHeight > element.scrollHeight - INFINITE_SCROLLING_OFFSET; -} - -const useStyles = makeStyles({ - root: { - overflow: 'auto', - position: 'relative', - }, -}); - -interface IProps { - onScrollEnd: () => any; -} - -const InfiniteScroll: FC = ({ children, onScrollEnd }) => { - const classes = useStyles(); - const scrollList = useRef(null); - const store = useStore(); - const { newsFlashStore } = store; - - const handleScroll = useCallback(() => { - if (scrollList.current !== null && !newsFlashStore.newsFlashLoading) { - if (isScrollEnd(scrollList.current)) { - onScrollEnd(); - } - } - }, [scrollList, onScrollEnd, newsFlashStore.newsFlashLoading]); - - useEffect(() => { - if (scrollList.current) { - scrollList.current.addEventListener('scroll', handleScroll); - } - }, [handleScroll]); - - return ( -
- {children} -
- ); -}; - -export default InfiniteScroll; diff --git a/src/components/atoms/index.ts b/src/components/atoms/index.ts index 5b14a8e4..266238e9 100644 --- a/src/components/atoms/index.ts +++ b/src/components/atoms/index.ts @@ -7,7 +7,6 @@ export { default as Textbox } from './Textbox'; export { default as ErrorBoundary } from './ErrorBoundary'; export { default as MetaTag } from './MetaTag'; export { default as Loader } from './Loader'; -export { default as InfiniteScroll } from './InfiniteScroll'; export { default as Dialog } from './Dialog'; export { default as AppBar } from './AppBar'; export { default as Logo } from './Logo'; diff --git a/src/components/molecules/NewsFlashComp.tsx b/src/components/molecules/NewsFlashComp.tsx index 5b766834..4f1526af 100644 --- a/src/components/molecules/NewsFlashComp.tsx +++ b/src/components/molecules/NewsFlashComp.tsx @@ -57,7 +57,7 @@ const NewsFlashComp: FC = ({ news }) => { const verificationIcon = getVerificationIcon(news.newsflash_location_qualification); const criticalIcon = news.critical && ; const {newsId} = useParams() - const newsID = newsId ? parseInt(newsId) : '' + const newsID = newsId ? parseInt(newsId) : ''; const className = news.id === newsID ? classes.activeNewsFlash : ''; const date = news.date == null ? '' : dateFormat(new Date(news.date.replace(/-/g, '/')), locale); const handleLocationEditorOpen = () => setOpen(true); diff --git a/src/components/organisms/News.tsx b/src/components/organisms/News.tsx index 2774e2b1..b5a05456 100644 --- a/src/components/organisms/News.tsx +++ b/src/components/organisms/News.tsx @@ -1,4 +1,4 @@ -import React, { FC } from 'react'; +import { FC, useRef } from 'react'; import { Typography } from 'components/atoms'; import { Box, makeStyles } from '@material-ui/core'; import { useStore } from 'store/storeConfig'; @@ -7,41 +7,65 @@ import RootStore from 'store/root.store'; import { observer } from 'mobx-react-lite'; import LocationSearchIndicator from 'components/molecules/LocationSearchIndicator'; import { IRouteProps } from 'models/Route'; -import NewsFlashComp from "components/molecules/NewsFlashComp"; - +import NewsFlashComp from 'components/molecules/NewsFlashComp'; +import { useScrollObserver } from 'hooks/useScrollObserver.hooks'; +import { Direction } from 'models/ScrollObserver.model'; +import { combineRefs } from 'utils/element.util'; const useStyles = makeStyles({ container: {}, newsFeed: { + display: 'flex', + flexDirection: 'column', + flexGrow: 1, overflow: 'auto', }, }); - -const News: FC = () => { +interface InfiniteScrollProps { + onScroll: (direction: Direction) => void; +} + +const News: FC = ({ onScroll }) => { const store: RootStore = useStore(); const classes = useStyles(); - const { gpsId, street, city } = useParams(); + const { gpsId, street, city, newsId = '' } = useParams(); const { newsFlashStore } = store; + const containerRef = useRef(null); + + const { firstItemRef, lastItemRef, selectedItemRef } = useScrollObserver({ + newsId, + onScroll, + containerRef, + newsData: newsFlashStore.newsFlashCollection.data, + newsLoading: newsFlashStore.newsFlashLoading, + }); return ( - - - - {gpsId && } - {street && city && } - {newsFlashStore.newsFlashCollection.length > 0 ? ( - newsFlashStore.newsFlashCollection.map((news) => - - ) - ) : ( - - לא נמצאו תוצאות מהמקור המבוקש - - )} +
+ {gpsId && } + {street && city && } + {newsFlashStore.newsFlashCollection.data.length > 0 ? ( + newsFlashStore.newsFlashCollection.data.map((news, index) => { + const isFirst = index === 0; + const isLast = index === newsFlashStore.newsFlashCollection.data.length - 1; + const selectedItem = news.id === +newsId ? selectedItemRef : undefined; + + return ( +
+ +
+ ); + }) + ) : ( + + לא נמצאו תוצאות מהמקור המבוקש - - + )} +
); }; diff --git a/src/components/organisms/SideBar.tsx b/src/components/organisms/SideBar.tsx index 0cf70696..8a71b83b 100644 --- a/src/components/organisms/SideBar.tsx +++ b/src/components/organisms/SideBar.tsx @@ -8,11 +8,9 @@ import { Typography, ErrorBoundary } from 'components/atoms'; import { observer } from 'mobx-react-lite'; import { useStore } from 'store/storeConfig'; import RootStore from 'store/root.store'; -import { InfiniteScroll } from 'components/atoms'; import SideBarMap from 'components/molecules/SideBarMap'; import { useTranslation } from 'react-i18next'; - -const INFINITE_SCROLL_FETCH_SIZE = 100; +import { Direction } from 'models/ScrollObserver.model'; interface IProps {} @@ -37,10 +35,23 @@ const SideBar: FC = () => { const mapTitle = `${t('sideBar')}`; const location = newsFlashStore.activeNewsFlashLocation; const loading = newsFlashStore.newsFlashLoading; + const currentPageNumber = newsFlashStore.newsFlashPageNumber; + const lastPrevPage = newsFlashStore.newsFlashLastPrevPage; + const totalPages = newsFlashStore.newsFlashCollection.pagination.totalPages; - const fetchMoreNewsItems = useCallback(() => { - newsFlashStore.infiniteFetchLimit(INFINITE_SCROLL_FETCH_SIZE); - }, [newsFlashStore]); + const fetchMoreNewsItems = useCallback( + (direction: Direction) => { + if (loading) return; + if (direction === Direction.PREV && currentPageNumber > 1 && lastPrevPage > 1) { + newsFlashStore.filterNewsFlashCollection(direction); + return; + } + if (direction === Direction.NEXT && totalPages > currentPageNumber) { + newsFlashStore.filterNewsFlashCollection(direction); + } + }, + [currentPageNumber, lastPrevPage, loading, newsFlashStore, totalPages], + ); return ( @@ -51,9 +62,7 @@ const SideBar: FC = () => { - - - +
@@ -62,7 +71,7 @@ const SideBar: FC = () => { {location && ( - + )}
diff --git a/src/hooks/useScrollObserver.hooks.ts b/src/hooks/useScrollObserver.hooks.ts new file mode 100644 index 00000000..479c35ef --- /dev/null +++ b/src/hooks/useScrollObserver.hooks.ts @@ -0,0 +1,87 @@ +import { Direction } from 'models/ScrollObserver.model'; +import { useEffect, useRef } from 'react'; +import { useInView } from 'react-intersection-observer'; + +interface IProps { + newsId: string; + containerRef: React.RefObject; + newsData: any[]; + newsLoading: boolean; + onScroll: (direction: Direction) => void; +} + +export const useScrollObserver = ({ newsId, onScroll, containerRef, newsData, newsLoading }: IProps) => { + const selectedItemRef = useRef(null); + const isFirstRender = useRef(true); + const isInViewFirstRender = useRef(true); + + useEffect(() => { + if (isFirstRender.current && newsId && newsData.length > 0 && !newsLoading) { + const itemIndex = newsData.findIndex((item) => item.id.toString() === newsId); + + if (itemIndex !== -1) { + requestAnimationFrame(() => { + selectedItemRef.current?.scrollIntoView({ + behavior: 'smooth', + block: 'center', + }); + }); + } + isFirstRender.current = false; + } + }, [newsId, newsData, newsLoading]); + + const [firstItemRef] = useInView({ + threshold: 0.1, + delay: 100, + onChange: (inView) => { + if (isInViewFirstRender.current) { + isInViewFirstRender.current = false; + return; + } + if (inView && !newsLoading) { + const container = containerRef.current; + if (!container) return; + + const oldScrollHeight = container.scrollHeight; + const oldScrollTop = container.scrollTop; + + // Create mutation observer before fetching + const observer = new MutationObserver(() => { + const newScrollHeight = container.scrollHeight; + const heightDiff = newScrollHeight - oldScrollHeight; + + // Adjust scroll position to prevent jump + container.scrollTop = oldScrollTop + heightDiff; + observer.disconnect(); + }); + // Start observing before fetch + observer.observe(container, { childList: true, subtree: true }); + + onScroll(Direction.PREV); + isInViewFirstRender.current = true; + } + }, + }); + + const [lastItemRef] = useInView({ + threshold: 0.1, + delay: 100, + onChange: (inView) => { + if (isInViewFirstRender.current) { + isInViewFirstRender.current = false; + return; + } + if (inView && !newsLoading) { + onScroll(Direction.NEXT); + isInViewFirstRender.current = true; + } + }, + }); + + return { + firstItemRef, + lastItemRef, + selectedItemRef, + }; +}; diff --git a/src/models/NewFlash.ts b/src/models/NewFlash.ts index cced7cc2..c90dbb30 100644 --- a/src/models/NewFlash.ts +++ b/src/models/NewFlash.ts @@ -17,3 +17,15 @@ export interface INewsFlash { source: string; critical?: boolean; } + +export interface IPagination { + pageNumber: number; + pageSize: number; + totalRecords: number; + totalPages: number; +} + +export interface INewsFlashCollection { + data: INewsFlash[]; + pagination: IPagination; +} diff --git a/src/models/ScrollObserver.model.ts b/src/models/ScrollObserver.model.ts new file mode 100644 index 00000000..e3dd4cf4 --- /dev/null +++ b/src/models/ScrollObserver.model.ts @@ -0,0 +1,4 @@ +export enum Direction { + PREV = 'PREV', + NEXT = 'NEXT', +} diff --git a/src/pages/HomePageRedirect.tsx b/src/pages/HomePageRedirect.tsx index 188fc738..82cc127d 100644 --- a/src/pages/HomePageRedirect.tsx +++ b/src/pages/HomePageRedirect.tsx @@ -10,7 +10,7 @@ interface IProps {} const HomePageRedirect: FC = () => { const store: RootStore = useStore(); const { newsFlashStore } = store; - const defaultId = newsFlashStore?.newsFlashCollection[0]?.id; + const defaultId = newsFlashStore?.newsFlashCollection.data[0]?.id; return defaultId ? : null; }; diff --git a/src/services/init.service.ts b/src/services/init.service.ts index eca51d48..514a3b33 100644 --- a/src/services/init.service.ts +++ b/src/services/init.service.ts @@ -2,12 +2,14 @@ import axios from 'axios'; import { fetchNews } from './news.data.service'; import L from 'leaflet'; import { serverUrl } from 'const/generalConst'; +import { getNewsIdFromUrl } from 'utils/url.utils'; export function initService(): Promise { setAxiosDefaults(); setLocationMapDefaults(); - const promiseArray = [fetchNews()]; + const queryParams = getNewsIdFromUrl(); + const promiseArray = [fetchNews(queryParams)]; return Promise.all(promiseArray).then((promiseCollection) => ({ newsFlashCollection: promiseCollection[0], diff --git a/src/services/news.data.service.ts b/src/services/news.data.service.ts index 317bcbab..401de65c 100644 --- a/src/services/news.data.service.ts +++ b/src/services/news.data.service.ts @@ -22,35 +22,44 @@ const errorNews: INewsFlash = { const NEWS_FLASH_API: string = '/api/news-flash'; export interface IFetchNewsQueryParams { source?: string; - offSet?: number; - limit?: number; critical?: boolean | null; -}; + newsFlashId?: number; + pageNumber?: number; + pageSize?: number; +} -export function fetchNews({source = '', offSet = 0, limit = 100, critical = null}: IFetchNewsQueryParams = {}): Promise { +export function fetchNews({ + source = '', + pageNumber, + pageSize = 100, + critical = null, + newsFlashId, +}: IFetchNewsQueryParams): Promise { const query = []; if (source) { query.push(`source=${source}`); } - if (limit) { - query.push(`limit=${limit}`); + if (pageSize) { + query.push(`pageSize=${pageSize}`); } if (critical !== null) { query.push(`critical=${critical}`); } - query.push(`offset=${offSet}`); - + if (newsFlashId) { + query.push(`newsFlashId=${newsFlashId}`); + } + if (pageNumber) { + query.push(`pageNumber=${pageNumber}`); + } query.push('resolution=suburban_road'); query.push('resolution=street'); const url = `${NEWS_FLASH_API}?${query.join('&')}`; - return ( - axios - .get(url) - .then((res) => res.data) - .catch(onErrorFetchNewsFlash) - ); + return axios + .get(url) + .then((res) => res.data) + .catch(onErrorFetchNewsFlash); } function onErrorFetchNewsFlash() { diff --git a/src/store/news-flash-store.ts b/src/store/news-flash-store.ts index 86e0d62e..7a547593 100644 --- a/src/store/news-flash-store.ts +++ b/src/store/news-flash-store.ts @@ -1,9 +1,10 @@ import { runInAction, makeAutoObservable } from 'mobx'; import { SourceFilterEnum } from 'models/SourceFilter'; import { fetchNews, IFetchNewsQueryParams } from 'services/news.data.service'; -import { INewsFlash } from 'models/NewFlash'; +import { INewsFlash, INewsFlashCollection } from 'models/NewFlash'; import { IPoint } from 'models/Point'; import RootStore from './root.store'; +import { Direction } from 'models/ScrollObserver.model'; const DEFAULT_TIME_FILTER = 5; const DEFAULT_LOCATION = { latitude: 32.0853, longitude: 34.7818 }; @@ -11,9 +12,14 @@ const LOCAL_FILTERS: { [key in SourceFilterEnum]?: (newsFlashCollection: Array = []; + newsFlashCollection: INewsFlashCollection = { + data: [] as INewsFlash[], + pagination: { pageNumber: 1, pageSize: 100, totalRecords: 0, totalPages: 0 }, + }; activeNewsFlashId: number = 0; // active newsflash id - newsFlashFetchOffSet = 0; + newsFlashPageNumber = 1; + newsFlashLastNextPage = 1; + newsFlashLastPrevPage = 1; newsFlashActiveFilter: SourceFilterEnum = SourceFilterEnum.all; newsFlashLoading: boolean = false; newsFlashWidgetsTimerFilter = DEFAULT_TIME_FILTER; // newsflash time filter (in years ago, 5 is the default) @@ -24,7 +30,7 @@ export default class NewsFlashStore { } get activeNewsFlash(): INewsFlash | undefined { - return this.newsFlashCollection.find((item) => item.id === this.activeNewsFlashId); + return this.newsFlashCollection?.data.find((item) => item.id === this.activeNewsFlashId); } selectNewsFlash(id: number): void { @@ -89,49 +95,65 @@ export default class NewsFlashStore { if (filter !== this.newsFlashActiveFilter) { runInAction(() => { this.newsFlashActiveFilter = filter; - this.newsFlashFetchOffSet = 0; + this.newsFlashPageNumber = 1; }); if (!(filter in LOCAL_FILTERS)) { runInAction(() => { - this.newsFlashCollection = []; + this.newsFlashCollection.data = []; }); } this.filterNewsFlashCollection(); } } - filterNewsFlashCollection(): void { + async filterNewsFlashCollection(direction: Direction = Direction.NEXT) { runInAction(() => (this.newsFlashLoading = true)); + if (this.newsFlashActiveFilter in LOCAL_FILTERS) { const filterMethod = LOCAL_FILTERS[this.newsFlashActiveFilter]; - const filtered = filterMethod && filterMethod(this.newsFlashCollection); - runInAction(() => (this.newsFlashCollection = [...(filtered || [])])); - runInAction(() => (this.newsFlashLoading = false)); + const filtered = filterMethod && filterMethod(this.newsFlashCollection.data); + runInAction(() => { + this.newsFlashCollection.data = [...(filtered || [])]; + this.newsFlashLoading = false; + }); } else { const queryParams: IFetchNewsQueryParams = { - offSet: this.newsFlashFetchOffSet + pageNumber: + direction === Direction.NEXT ? this.newsFlashLastNextPage + 1 : Math.max(this.newsFlashLastPrevPage - 1, 1), }; - if (this.newsFlashActiveFilter === "critical") { - queryParams["critical"] = true; + + if (this.newsFlashActiveFilter === 'critical') { + queryParams['critical'] = true; } else { - queryParams["source"] = this.newsFlashActiveFilter; + queryParams['source'] = this.newsFlashActiveFilter; } - fetchNews(queryParams).then((data: any) => { + + fetchNews(queryParams).then((res: any) => { runInAction(() => (this.newsFlashLoading = false)); - if (data) { - runInAction(() => (this.newsFlashCollection = [...this.newsFlashCollection, ...data])); + if (res) { + runInAction(() => { + this.newsFlashCollection = { + data: + direction === Direction.NEXT + ? [...this.newsFlashCollection.data, ...res.data] + : [...res.data, ...this.newsFlashCollection.data], + pagination: res.pagination, + }; + if (direction === Direction.NEXT) { + this.newsFlashLastNextPage = res.pagination.pageNumber; + } else { + this.newsFlashLastPrevPage = res.pagination.pageNumber; + } + this.newsFlashPageNumber = res.pagination.pageNumber; + this.newsFlashLoading = false; + }); } else { - console.error(`filterNewsFlashCollection(filter:${this.newsFlashActiveFilter}) invalid data:`, data); + console.error(`filterNewsFlashCollection(filter:${this.newsFlashActiveFilter}) invalid data:`, res); + runInAction(() => (this.newsFlashLoading = false)); } }); } } - infiniteFetchLimit(fetchSize: number): void { - runInAction(() => (this.newsFlashFetchOffSet += fetchSize)); - if (this.newsFlashCollection.length >= this.newsFlashFetchOffSet - fetchSize) { - this.filterNewsFlashCollection(); - } - } get activeNewsFlashLocation() { let location: IPoint = DEFAULT_LOCATION; // default location diff --git a/src/store/root.store.ts b/src/store/root.store.ts index 02f71d9c..0af907b3 100644 --- a/src/store/root.store.ts +++ b/src/store/root.store.ts @@ -42,6 +42,9 @@ export default class RootStore { runInAction(() => { if (initData.newsFlashCollection) { this.newsFlashStore.newsFlashCollection = initData.newsFlashCollection; + this.newsFlashStore.newsFlashLastNextPage = initData.newsFlashCollection.pagination.pageNumber; + this.newsFlashStore.newsFlashLastPrevPage = initData.newsFlashCollection.pagination.pageNumber; + this.newsFlashStore.newsFlashPageNumber = initData.newsFlashCollection.pagination.pageNumber; } if (initData.newsFlashWidgetsData) { this.widgetsStore.newsFlashWidgetsData = initData.newsFlashWidgetsData.widgets; diff --git a/src/utils/element.util.ts b/src/utils/element.util.ts new file mode 100644 index 00000000..6cd49716 --- /dev/null +++ b/src/utils/element.util.ts @@ -0,0 +1,12 @@ +export const combineRefs = + (...refs: any[]) => + (element: HTMLElement | null) => { + refs.forEach((ref) => { + if (!ref) return; + if (typeof ref === 'function') { + ref(element); + } else { + ref.current = element; + } + }); + }; diff --git a/src/utils/url.utils.ts b/src/utils/url.utils.ts new file mode 100644 index 00000000..974efc01 --- /dev/null +++ b/src/utils/url.utils.ts @@ -0,0 +1,6 @@ +export const getNewsIdFromUrl = () => { + const pathname = window.location.pathname; + const [, , newsId] = pathname.split('/'); + + return newsId ? { newsFlashId: +newsId } : { pageNumber: 1 }; +};