diff --git a/front_end/src/app/(main)/(tournaments)/tournament/components/tournament_feed.tsx b/front_end/src/app/(main)/(tournaments)/tournament/components/tournament_feed.tsx index b753e0df9f..7e8a16be2f 100644 --- a/front_end/src/app/(main)/(tournaments)/tournament/components/tournament_feed.tsx +++ b/front_end/src/app/(main)/(tournaments)/tournament/components/tournament_feed.tsx @@ -23,7 +23,8 @@ type Props = { const TournamentFeed: FC = ({ tournament }) => { const searchParams = useSearchParams(); const questionFilters = generateFiltersFromSearchParams( - Object.fromEntries(searchParams) + Object.fromEntries(searchParams), + { withoutPageParam: true } ); const pageFilters: PostsParams = { statuses: PostStatus.APPROVED, @@ -46,7 +47,8 @@ const TournamentFeed: FC = ({ tournament }) => { setBannerisVisible(true); } }, [questions, setBannerisVisible, tournament]); - + const relevantParams = Object.fromEntries(searchParams); + const { page, ...otherParams } = relevantParams; useEffect(() => { const fetchData = async () => { setIsLoading(true); @@ -58,7 +60,7 @@ const TournamentFeed: FC = ({ tournament }) => { const { questions } = (await fetchPosts( pageFilters, 0, - POSTS_PER_PAGE + (!isNaN(Number(page)) ? Number(page) : 1) * POSTS_PER_PAGE )) as { questions: PostWithForecasts[]; count: number }; setQuestions(questions); @@ -70,9 +72,10 @@ const TournamentFeed: FC = ({ tournament }) => { setIsLoading(false); } }; + fetchData(); // eslint-disable-next-line react-hooks/exhaustive-deps - }, [searchParams]); + }, [JSON.stringify(otherParams)]); return isLoading ? ( diff --git a/front_end/src/app/(main)/questions/helpers/filters.ts b/front_end/src/app/(main)/questions/helpers/filters.ts index dc13876255..03a04a6517 100644 --- a/front_end/src/app/(main)/questions/helpers/filters.ts +++ b/front_end/src/app/(main)/questions/helpers/filters.ts @@ -19,6 +19,7 @@ import { POST_NOT_FORECASTER_ID_FILTER, POST_ORDER_BY_FILTER, POST_STATUS_FILTER, + POST_PAGE_FILTER, POST_TAGS_FILTER, POST_TEXT_SEARCH_FILTER, POST_TOPIC_FILTER, @@ -34,7 +35,6 @@ import { PostForecastType, PostStatus, } from "@/types/post"; -import { Category, Tag } from "@/types/projects"; import { QuestionOrder, QuestionType } from "@/types/question"; import { CurrentUser } from "@/types/users"; @@ -65,15 +65,20 @@ export const POST_STATUS_LABEL_MAP = { type FiltersFromSearchParamsOptions = { defaultOrderBy?: string; defaultForMainFeed?: boolean; + withoutPageParam?: boolean; }; export function generateFiltersFromSearchParams( searchParams: SearchParams, options: FiltersFromSearchParamsOptions = {} ): PostsParams { - const { defaultOrderBy, defaultForMainFeed } = options; + const { defaultOrderBy, defaultForMainFeed, withoutPageParam } = options; const filters: PostsParams = {}; + if (!withoutPageParam && typeof searchParams[POST_PAGE_FILTER] === "string") { + filters.page = Number(searchParams[POST_PAGE_FILTER]); + } + if (typeof searchParams[POST_TEXT_SEARCH_FILTER] === "string") { filters.search = searchParams[POST_TEXT_SEARCH_FILTER]; } diff --git a/front_end/src/components/posts_feed/feed_scroll_restoration.tsx b/front_end/src/components/posts_feed/feed_scroll_restoration.tsx new file mode 100644 index 0000000000..ababe8fe53 --- /dev/null +++ b/front_end/src/components/posts_feed/feed_scroll_restoration.tsx @@ -0,0 +1,66 @@ +import { usePathname } from "next/navigation"; +import { FC, useEffect } from "react"; + +import useSearchParams from "@/hooks/use_search_params"; +import { PostWithForecasts } from "@/types/post"; + +type Props = { + initialQuestions: PostWithForecasts[]; + pageNumber: number; +}; +const PostsFeedScrollRestoration: FC = ({ + initialQuestions, + pageNumber, +}) => { + const pathname = usePathname(); + const { params } = useSearchParams(); + const fullPathname = `${pathname}${params.toString() ? `?${params.toString()}` : ""}`; + + useEffect(() => { + const cacheKey = `feed-scroll-restoration`; + let timeoutId = undefined; + const saveScrollPosition = () => { + const currentScroll = window.scrollY; + if (currentScroll > 0) { + sessionStorage.setItem( + cacheKey, + JSON.stringify({ + scrollPathName: fullPathname, + scrollPosition: currentScroll.toString(), + }) + ); + } + }; + + const savedScrollData = sessionStorage.getItem(cacheKey); + const parsedScrollData = savedScrollData ? JSON.parse(savedScrollData) : {}; + const { scrollPathName, scrollPosition } = parsedScrollData; + if ( + scrollPosition && + initialQuestions.length > 0 && + !!pageNumber && + scrollPathName === fullPathname + ) { + timeoutId = setTimeout(() => { + window.scrollTo({ + top: parseInt(scrollPosition), + behavior: "smooth", + }); + + sessionStorage.removeItem(cacheKey); + window.addEventListener("scrollend", saveScrollPosition); + }, 1000); + } else { + window.addEventListener("scrollend", saveScrollPosition); + } + + return () => { + window.removeEventListener("scrollend", saveScrollPosition); + timeoutId && clearTimeout(timeoutId); + }; + }, [fullPathname, initialQuestions.length, pageNumber]); + + return null; +}; + +export default PostsFeedScrollRestoration; diff --git a/front_end/src/components/posts_feed/index.tsx b/front_end/src/components/posts_feed/index.tsx index 354de01099..c25727b69b 100644 --- a/front_end/src/components/posts_feed/index.tsx +++ b/front_end/src/components/posts_feed/index.tsx @@ -35,7 +35,9 @@ const AwaitedPostsFeed: FC = async ({ const { results: questions, count } = await PostsApi.getPostsWithCP({ ...filters, - limit: POSTS_PER_PAGE, + limit: + (!isNaN(Number(filters.page)) ? Number(filters.page) : 1) * + POSTS_PER_PAGE, }); return ( diff --git a/front_end/src/components/posts_feed/paginated_feed.tsx b/front_end/src/components/posts_feed/paginated_feed.tsx index a4ff2a1102..bea53daa23 100644 --- a/front_end/src/components/posts_feed/paginated_feed.tsx +++ b/front_end/src/components/posts_feed/paginated_feed.tsx @@ -9,12 +9,14 @@ import NewsCard from "@/components/news_card"; import PostCard from "@/components/post_card"; import Button from "@/components/ui/button"; import LoadingIndicator from "@/components/ui/loading_indicator"; -import { POSTS_PER_PAGE } from "@/constants/posts_feed"; +import { POSTS_PER_PAGE, POST_PAGE_FILTER } from "@/constants/posts_feed"; +import useSearchParams from "@/hooks/use_search_params"; import { PostsParams } from "@/services/posts"; import { PostWithForecasts, PostWithNotebook } from "@/types/post"; import { logError } from "@/utils/errors"; import EmptyCommunityFeed from "./empty_community_feed"; +import PostsFeedScrollRestoration from "./feed_scroll_restoration"; import InReviewBox from "./in_review_box"; import { FormErrorMessage } from "../ui/form_field"; @@ -34,10 +36,15 @@ const PaginatedPostsFeed: FC = ({ isCommunity, }) => { const t = useTranslations(); - + const { params, setParam, shallowNavigateToSearchParams } = useSearchParams(); + const pageNumber = Number(params.get(POST_PAGE_FILTER)); const [paginatedPosts, setPaginatedPosts] = useState(initialQuestions); - const [offset, setOffset] = useState(POSTS_PER_PAGE); + const [offset, setOffset] = useState( + !isNaN(pageNumber) && pageNumber > 0 + ? pageNumber * POSTS_PER_PAGE + : POSTS_PER_PAGE + ); const [hasMoreData, setHasMoreData] = useState( initialQuestions.length >= POSTS_PER_PAGE ); @@ -83,8 +90,12 @@ const PaginatedPostsFeed: FC = ({ } if (!hasNextPage) setHasMoreData(false); - setPaginatedPosts((prevPosts) => [...prevPosts, ...newPosts]); - setOffset((prevOffset) => prevOffset + POSTS_PER_PAGE); + if (!!newPosts.length) { + setPaginatedPosts((prevPosts) => [...prevPosts, ...newPosts]); + setParam(POST_PAGE_FILTER, `${offset / POSTS_PER_PAGE + 1}`, false); + setOffset((prevOffset) => prevOffset + POSTS_PER_PAGE); + shallowNavigateToSearchParams(); + } } catch (err) { logError(err); const error = err as Error & { digest?: string }; @@ -92,7 +103,6 @@ const PaginatedPostsFeed: FC = ({ } finally { setIsLoading(false); } - setIsLoading(false); } }; @@ -124,6 +134,10 @@ const PaginatedPostsFeed: FC = ({ {paginatedPosts.map((p) => ( {renderPost(p)} ))} + {hasMoreData ? ( diff --git a/front_end/src/components/posts_filters.tsx b/front_end/src/components/posts_filters.tsx index cb58f80ad2..7a2d863ffc 100644 --- a/front_end/src/components/posts_filters.tsx +++ b/front_end/src/components/posts_filters.tsx @@ -4,7 +4,7 @@ import { faCircleXmark } from "@fortawesome/free-regular-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { sendGAEvent } from "@next/third-parties/google"; import { useTranslations } from "next-intl"; -import { FC, useMemo } from "react"; +import { FC, useEffect, useMemo } from "react"; import { getFilterChipColor } from "@/app/(main)/questions/helpers/filters"; import PopoverFilter from "@/components/popover_filter"; @@ -18,6 +18,7 @@ import Chip from "@/components/ui/chip"; import Listbox, { SelectOption } from "@/components/ui/listbox"; import { POST_ORDER_BY_FILTER, + POST_PAGE_FILTER, POST_TEXT_SEARCH_FILTER, } from "@/constants/posts_feed"; import useSearchInputState from "@/hooks/use_search_input_state"; @@ -102,6 +103,12 @@ const PostsFilters: FC = ({ return [filters, activeFilters]; }, [filters]); + + // reset page param after applying new filters + useEffect(() => { + deleteParam(POST_PAGE_FILTER, false); + }, [filters, deleteParam]); + const handleOrderChange = (order: QuestionOrder) => { const withNavigation = false; @@ -178,7 +185,10 @@ const PostsFilters: FC = ({
setSearch(e.target.value)} + onChange={(e) => { + deleteParam(POST_PAGE_FILTER, true); + setSearch(e.target.value); + }} onErase={eraseSearch} placeholder={t("questionSearchPlaceholder")} /> diff --git a/front_end/src/constants/posts_feed.ts b/front_end/src/constants/posts_feed.ts index 1314fe3401..6378e67183 100644 --- a/front_end/src/constants/posts_feed.ts +++ b/front_end/src/constants/posts_feed.ts @@ -6,6 +6,7 @@ export enum FeedType { COMMUNITIES = "communities", } +export const POST_PAGE_FILTER = "page"; export const POST_TOPIC_FILTER = "topic"; export const POST_TEXT_SEARCH_FILTER = "search"; export const POST_TYPE_FILTER = "forecast_type"; diff --git a/front_end/src/hooks/use_search_input_state.ts b/front_end/src/hooks/use_search_input_state.ts index a2852570fd..d1fbb94092 100644 --- a/front_end/src/hooks/use_search_input_state.ts +++ b/front_end/src/hooks/use_search_input_state.ts @@ -1,6 +1,6 @@ import { useEffect, useState } from "react"; -import { POST_ORDER_BY_FILTER } from "@/constants/posts_feed"; +import { POST_ORDER_BY_FILTER, POST_PAGE_FILTER } from "@/constants/posts_feed"; import useDebounce from "@/hooks/use_debounce"; import useSearchParams from "@/hooks/use_search_params"; import { QuestionOrder } from "@/types/question"; @@ -30,7 +30,8 @@ const useSearchInputState = (paramName: string, config?: Config) => { if (withNavigation && !params.get(POST_ORDER_BY_FILTER)) { setParam(POST_ORDER_BY_FILTER, QuestionOrder.RankDesc, withNavigation); } - + // Auto-remove page filter on input change + deleteParam(POST_PAGE_FILTER, false); setParam(paramName, debouncedSearch, withNavigation); } else { // Auto-remove -rank ordering for server search @@ -40,7 +41,8 @@ const useSearchInputState = (paramName: string, config?: Config) => { ) { deleteParam(POST_ORDER_BY_FILTER, withNavigation); } - + // Auto-remove page filter on input change + deleteParam(POST_PAGE_FILTER, false); deleteParam(paramName, withNavigation); } diff --git a/front_end/src/services/posts.ts b/front_end/src/services/posts.ts index f76e89d296..bd77448e5d 100644 --- a/front_end/src/services/posts.ts +++ b/front_end/src/services/posts.ts @@ -19,6 +19,7 @@ import { get, post, put } from "@/utils/fetch"; import { encodeQueryParams } from "@/utils/navigation"; export type PostsParams = PaginationParams & { + page?: number; topic?: string; answered_by_me?: boolean; search?: string;