From ddad77eb875614149aa27bb5495a667d473cb063 Mon Sep 17 00:00:00 2001 From: cemreinanc Date: Sun, 15 Mar 2026 21:42:21 +0100 Subject: [PATCH 01/27] wip --- .../src/app/(main)/questions/components/sidebar.tsx | 10 +++++----- front_end/src/app/(main)/questions/page.tsx | 6 +++--- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/front_end/src/app/(main)/questions/components/sidebar.tsx b/front_end/src/app/(main)/questions/components/sidebar.tsx index 435a365ecc..732f46c34d 100644 --- a/front_end/src/app/(main)/questions/components/sidebar.tsx +++ b/front_end/src/app/(main)/questions/components/sidebar.tsx @@ -46,6 +46,7 @@ const FeedSidebar: FC = ({ items }) => { const fullPathname = `${pathname}${params.toString() ? `?${params.toString()}` : ""}`; const sidebarSections: SidebarSection[] = useMemo(() => { + console.log("api result", items); const menuItems: SidebarMenuItem[] = [ { name: t("feedHome"), @@ -140,17 +141,16 @@ const FeedSidebar: FC = ({ items }) => { useContentTranslatedBannerContext(); const topPositionClasses = isTranslationBannerVisible - ? "top-24 lg:top-20" - : "top-12 lg:top-20"; + ? "top-24 lg:top-header" + : "top-header"; return (
-
+
-
+
-
+
-
+
{isCommunityFeed ? ( Date: Mon, 23 Mar 2026 10:56:53 +0100 Subject: [PATCH 02/27] fix: sticky with portals and overlays --- front_end/src/app/globals.css | 5 +++-- front_end/src/components/base_modal.tsx | 4 ++-- front_end/src/components/popover_filter/index.tsx | 4 ++-- 3 files changed, 7 insertions(+), 6 deletions(-) diff --git a/front_end/src/app/globals.css b/front_end/src/app/globals.css index 2fa2aebbda..9a0d798226 100644 --- a/front_end/src/app/globals.css +++ b/front_end/src/app/globals.css @@ -9,9 +9,10 @@ box-sizing: border-box; border: 0 solid #e5e7eb; } - /* required to fix a open dialog bug + /* required to fix HeadlessUI scroll lock breaking sticky elements https://github.com/tailwindlabs/headlessui/discussions/2181 */ - html:has(#headlessui-portal-root) { + html:has(#headlessui-portal-root), + html[style*="overflow"] { @apply !overflow-visible !p-0; } } diff --git a/front_end/src/components/base_modal.tsx b/front_end/src/components/base_modal.tsx index 9cd45b09af..2daf350367 100644 --- a/front_end/src/components/base_modal.tsx +++ b/front_end/src/components/base_modal.tsx @@ -33,7 +33,7 @@ const BaseModal: FC> = ({ }) => { useEffect(() => { if (isOpen && isImmersive) { - document.body.style.overflow = "hidden"; + document.body.style.overflow = "clip"; } else { document.body.style.overflow = "unset"; } @@ -65,7 +65,7 @@ const BaseModal: FC> = ({ )} />
> = ({ if (!fullScreenEnabled || isLargeScreen) return; if (open) { - document.body.style.overflow = "hidden"; + document.body.style.overflow = "clip"; } else { document.body.style.overflow = "auto"; } @@ -44,7 +44,7 @@ const Panel: FC> = ({ className={cn( "absolute right-0 top-10 z-[100] box-border flex flex-col items-start overflow-hidden overflow-y-auto rounded border border-gray-300 bg-gray-0 p-5 shadow-lg shadow-[#0003] dark:border-gray-300-dark dark:bg-gray-0-dark", { - "max-sm:fixed max-sm:top-0 max-sm:z-[1300] max-sm:h-dvh max-sm:w-screen max-sm:overflow-y-auto max-sm:px-5 max-sm:pb-0 max-sm:pt-5": + "max-sm:fixed max-sm:top-0 max-sm:z-[1300] max-sm:h-dvh max-sm:w-screen max-sm:overflow-y-auto max-sm:overscroll-contain max-sm:px-5 max-sm:pb-0 max-sm:pt-5": fullScreenEnabled, }, className From 38714f01e8f724e5166651a76ea71ab091a71c75 Mon Sep 17 00:00:00 2001 From: cemreinanc Date: Mon, 23 Mar 2026 18:00:58 +0100 Subject: [PATCH 03/27] filters bar --- front_end/messages/cs.json | 1 + front_end/messages/en.json | 1 + front_end/messages/es.json | 1 + front_end/messages/pt.json | 1 + front_end/messages/zh-TW.json | 1 + front_end/messages/zh.json | 1 + .../app/(main)/components/global_search.tsx | 2 - .../components/feed_filters/main.tsx | 80 +++++++---- .../feed_filters/my_predictions.tsx | 53 ++++--- .../feed_filters/my_questions_and_posts.tsx | 50 ++++--- .../(main)/questions/components/sidebar.tsx | 33 +++-- .../components/sticky_filter_bar.tsx | 62 ++++++++ front_end/src/app/(main)/questions/page.tsx | 28 ++-- .../src/components/popover_filter/index.tsx | 50 +++++-- front_end/src/components/posts_filters.tsx | 136 ++++++++++-------- front_end/src/components/random_button.tsx | 24 +++- front_end/src/components/search_input.tsx | 71 ++++++++- .../src/components/tournament_filters.tsx | 48 ++++--- front_end/src/components/ui/listbox.tsx | 13 +- 19 files changed, 467 insertions(+), 189 deletions(-) create mode 100644 front_end/src/app/(main)/questions/components/sticky_filter_bar.tsx diff --git a/front_end/messages/cs.json b/front_end/messages/cs.json index b890493692..c24e49a347 100644 --- a/front_end/messages/cs.json +++ b/front_end/messages/cs.json @@ -379,6 +379,7 @@ "allCategories": "Všechny kategorie", "toggleAllTopics": "Přepnout všechna témata", "Filter": "Filtr", + "sort": "Řadit", "Done": "Hotovo", "Clear": "Vymazat", "predicted": "Předpovězeno", diff --git a/front_end/messages/en.json b/front_end/messages/en.json index 8c167b41a2..6990b5091e 100644 --- a/front_end/messages/en.json +++ b/front_end/messages/en.json @@ -577,6 +577,7 @@ "seeAllCategories": "See all categories", "toggleAllTopics": "Toggle all topics", "Filter": "Filter", + "sort": "Sort", "done": "done", "clear": "clear", "predicted": "Predicted", diff --git a/front_end/messages/es.json b/front_end/messages/es.json index 781d7c3a65..6b9730d29e 100644 --- a/front_end/messages/es.json +++ b/front_end/messages/es.json @@ -386,6 +386,7 @@ "allCategories": "Todas las categorías", "toggleAllTopics": "Alternar todos los temas", "Filter": "Filtrar", + "sort": "Ordenar", "Done": "Hecho", "Clear": "Limpiar", "predicted": "Predicho", diff --git a/front_end/messages/pt.json b/front_end/messages/pt.json index 7d46728750..730a2a151c 100644 --- a/front_end/messages/pt.json +++ b/front_end/messages/pt.json @@ -410,6 +410,7 @@ "allCategories": "Todas as Categorias", "toggleAllTopics": "Alternar todos os tópicos", "Filter": "Filtro", + "sort": "Ordenar", "done": "concluído", "clear": "limpar", "predicted": "Previsto", diff --git a/front_end/messages/zh-TW.json b/front_end/messages/zh-TW.json index 2132a14e32..2296d4646f 100644 --- a/front_end/messages/zh-TW.json +++ b/front_end/messages/zh-TW.json @@ -438,6 +438,7 @@ "allCategories": "所有類別", "toggleAllTopics": "切換所有話題", "Filter": "篩選", + "sort": "排序", "done": "完成", "clear": "清除", "predicted": "已預測", diff --git a/front_end/messages/zh.json b/front_end/messages/zh.json index cd2c8290fe..cca1a8b412 100644 --- a/front_end/messages/zh.json +++ b/front_end/messages/zh.json @@ -383,6 +383,7 @@ "allCategories": "所有类别", "toggleAllTopics": "切換所有主題", "Filter": "篩選", + "sort": "排序", "Done": "完成", "Clear": "清除", "predicted": "已預測", diff --git a/front_end/src/app/(main)/components/global_search.tsx b/front_end/src/app/(main)/components/global_search.tsx index 7006f78360..c77af009e3 100644 --- a/front_end/src/app/(main)/components/global_search.tsx +++ b/front_end/src/app/(main)/components/global_search.tsx @@ -5,7 +5,6 @@ import { useRouter } from "next/navigation"; import { useTranslations } from "next-intl"; import React, { useState, useEffect, useCallback } from "react"; -import RandomButton from "@/components/random_button"; import SearchInput from "@/components/search_input"; import { POST_ORDER_BY_FILTER, @@ -107,7 +106,6 @@ const GlobalSearch: React.FC = ({ submitButtonClassName="hidden md:block" submitIconClassName="text-blue-500 dark:text-blue-500" /> -
); }; diff --git a/front_end/src/app/(main)/questions/components/feed_filters/main.tsx b/front_end/src/app/(main)/questions/components/feed_filters/main.tsx index d2daa5650c..a233f3bf6c 100644 --- a/front_end/src/app/(main)/questions/components/feed_filters/main.tsx +++ b/front_end/src/app/(main)/questions/components/feed_filters/main.tsx @@ -41,7 +41,8 @@ const MainFeedFilters: FC = ({ const { user } = useAuth(); const { PUBLIC_MINIMAL_UI } = usePublicSettings(); - const isLargeScreen = useBreakpoint("md"); + const isMediumScreen = useBreakpoint("md"); + const isLargeScreen = useBreakpoint("lg"); const [projectFilters, setProjectFilters] = useState< TournamentPreview[] | undefined @@ -95,38 +96,59 @@ const MainFeedFilters: FC = ({ const mainSortNewVisible = isLargeScreen || !isNil(user); const mainSortOptions: GroupButton[] = useMemo( - () => [ - { - value: QuestionOrder.HotDesc, - label: t("hot"), - }, - { - value: QuestionOrder.WeeklyMovementDesc, - label: t("movers"), - }, - ...(mainSortNewVisible + () => + isMediumScreen ? [ { - value: QuestionOrder.OpenTimeDesc, - label: t("new"), + value: QuestionOrder.HotDesc, + label: t("hot"), }, - ] - : []), - ...(mainSortNewsVisible - ? [ { - value: QuestionOrder.NewsHotness, - label: t("inTheNews"), + value: QuestionOrder.WeeklyMovementDesc, + label: t("movers"), }, + ...(mainSortNewVisible + ? [ + { + value: QuestionOrder.OpenTimeDesc, + label: t("new"), + }, + ] + : []), + ...(mainSortNewsVisible + ? [ + { + value: QuestionOrder.NewsHotness, + label: t("inTheNews"), + }, + ] + : []), ] - : []), - ], + : [], // eslint-disable-next-line react-hooks/exhaustive-deps - [t, isLargeScreen] + [t, isLargeScreen, isMediumScreen] ); const sortOptions = useMemo( () => [ + ...(!isMediumScreen + ? [ + { + value: QuestionOrder.HotDesc, + label: t("hot"), + }, + { + value: QuestionOrder.WeeklyMovementDesc, + label: t("movers"), + }, + ] + : []), + ...(!isMediumScreen || !mainSortNewVisible + ? [{ value: QuestionOrder.OpenTimeDesc, label: t("new") }] + : []), + ...((!isMediumScreen || !mainSortNewsVisible) && !PUBLIC_MINIMAL_UI + ? [{ value: QuestionOrder.NewsHotness, label: t("inTheNews") }] + : []), { value: QuestionOrder.VotesDesc, label: t("mostUpvotes") }, { value: QuestionOrder.CommentCountDesc, label: t("mostComments") }, { @@ -144,14 +166,14 @@ const MainFeedFilters: FC = ({ { value: QuestionOrder.CloseTimeAsc, label: t("closingSoon") }, { value: QuestionOrder.ResolveTimeAsc, label: t("resolvingSoon") }, { value: QuestionOrder.CpRevealTimeDesc, label: t("recentlyRevealed") }, - ...(!mainSortNewVisible - ? [{ value: QuestionOrder.OpenTimeDesc, label: t("new") }] - : []), - ...(!mainSortNewsVisible && !PUBLIC_MINIMAL_UI - ? [{ value: QuestionOrder.NewsHotness, label: t("inTheNews") }] - : []), ], - [mainSortNewVisible, mainSortNewsVisible, PUBLIC_MINIMAL_UI, t] + [ + mainSortNewVisible, + mainSortNewsVisible, + PUBLIC_MINIMAL_UI, + t, + isMediumScreen, + ] ); const onOrderChange = ( diff --git a/front_end/src/app/(main)/questions/components/feed_filters/my_predictions.tsx b/front_end/src/app/(main)/questions/components/feed_filters/my_predictions.tsx index 802fece4a2..ee5e95f275 100644 --- a/front_end/src/app/(main)/questions/components/feed_filters/my_predictions.tsx +++ b/front_end/src/app/(main)/questions/components/feed_filters/my_predictions.tsx @@ -16,6 +16,7 @@ import { POST_STATUS_FILTER, } from "@/constants/posts_feed"; import { useAuth } from "@/contexts/auth_context"; +import { useBreakpoint } from "@/hooks/tailwind"; import useSearchParams from "@/hooks/use_search_params"; import { PostStatus } from "@/types/post"; import { QuestionOrder } from "@/types/question"; @@ -26,6 +27,7 @@ const MyPredictionsFilters: FC = ({ panelClassname }) => { const { params } = useSearchParams(); const t = useTranslations(); const { user } = useAuth(); + const isMediumScreen = useBreakpoint("lg"); const filters = useMemo(() => { const filters = [ @@ -43,25 +45,44 @@ const MyPredictionsFilters: FC = ({ panelClassname }) => { }, [params, t, user]); const mainSortOptions: GroupButton[] = useMemo( - () => [ - { - value: QuestionOrder.WeeklyMovementDesc, - label: t("movers"), - }, - { - value: QuestionOrder.UserNextWithdrawTimeAsc, - label: t("withdrawingSoon"), - }, - { - value: QuestionOrder.UnreadCommentCountDesc, - label: t("newComments"), - }, - ], - [t] + () => + isMediumScreen + ? [ + { + value: QuestionOrder.WeeklyMovementDesc, + label: t("movers"), + }, + { + value: QuestionOrder.UserNextWithdrawTimeAsc, + label: t("withdrawingSoon"), + }, + { + value: QuestionOrder.UnreadCommentCountDesc, + label: t("newComments"), + }, + ] + : [], + [t, isMediumScreen] ); const sortOptions = useMemo( () => [ + ...(!isMediumScreen + ? [ + { + value: QuestionOrder.WeeklyMovementDesc, + label: t("movers"), + }, + { + value: QuestionOrder.UserNextWithdrawTimeAsc, + label: t("withdrawingSoon"), + }, + { + value: QuestionOrder.UnreadCommentCountDesc, + label: t("newComments"), + }, + ] + : []), { value: QuestionOrder.LastPredictionTimeDesc, label: t("recentPredictions"), @@ -77,7 +98,7 @@ const MyPredictionsFilters: FC = ({ panelClassname }) => { { value: QuestionOrder.LastPredictionTimeAsc, label: t("stale") }, { value: QuestionOrder.NewsHotness, label: t("inTheNews") }, ], - [t] + [t, isMediumScreen] ); const handleFilterChange = ( diff --git a/front_end/src/app/(main)/questions/components/feed_filters/my_questions_and_posts.tsx b/front_end/src/app/(main)/questions/components/feed_filters/my_questions_and_posts.tsx index 843438e209..98d29f4166 100644 --- a/front_end/src/app/(main)/questions/components/feed_filters/my_questions_and_posts.tsx +++ b/front_end/src/app/(main)/questions/components/feed_filters/my_questions_and_posts.tsx @@ -12,6 +12,7 @@ import PostsFilters from "@/components/posts_filters"; import { GroupButton } from "@/components/ui/button_group"; import { POST_ACCESS_FILTER } from "@/constants/posts_feed"; import { useAuth } from "@/contexts/auth_context"; +import { useBreakpoint } from "@/hooks/tailwind"; import useSearchParams from "@/hooks/use_search_params"; import { PostStatus } from "@/types/post"; import { QuestionOrder } from "@/types/question"; @@ -22,6 +23,7 @@ const MyQuestionsAndPostsFilters: FC = ({ panelClassname }) => { const { params } = useSearchParams(); const t = useTranslations(); const { user } = useAuth(); + const isMediumScreen = useBreakpoint("lg"); const filters = useMemo(() => { const filters = [ @@ -62,25 +64,41 @@ const MyQuestionsAndPostsFilters: FC = ({ panelClassname }) => { }, [params, t, user]); const mainSortOptions: GroupButton[] = useMemo( - () => [ - { - value: QuestionOrder.HotDesc, - label: t("hot"), - }, - { - value: QuestionOrder.WeeklyMovementDesc, - label: t("movers"), - }, - { - value: QuestionOrder.PredictionCountDesc, - label: t("mostPredictions"), - }, - ], - [t] + () => + isMediumScreen + ? [ + { + value: QuestionOrder.HotDesc, + label: t("hot"), + }, + { + value: QuestionOrder.WeeklyMovementDesc, + label: t("movers"), + }, + { + value: QuestionOrder.PredictionCountDesc, + label: t("mostPredictions"), + }, + ] + : [], + [t, isMediumScreen] ); const sortOptions = useMemo( () => [ + ...(!isMediumScreen + ? [ + { value: QuestionOrder.HotDesc, label: t("hot") }, + { + value: QuestionOrder.WeeklyMovementDesc, + label: t("movers"), + }, + { + value: QuestionOrder.PredictionCountDesc, + label: t("mostPredictions"), + }, + ] + : []), { value: QuestionOrder.UnreadCommentCountDesc, label: t("unreadComments"), @@ -94,7 +112,7 @@ const MyQuestionsAndPostsFilters: FC = ({ panelClassname }) => { { value: QuestionOrder.CpRevealTimeDesc, label: t("recentlyRevealed") }, { value: QuestionOrder.NewsHotness, label: t("inTheNews") }, ], - [t] + [t, isMediumScreen] ); return ( diff --git a/front_end/src/app/(main)/questions/components/sidebar.tsx b/front_end/src/app/(main)/questions/components/sidebar.tsx index 732f46c34d..c90f4102a8 100644 --- a/front_end/src/app/(main)/questions/components/sidebar.tsx +++ b/front_end/src/app/(main)/questions/components/sidebar.tsx @@ -7,7 +7,7 @@ import { import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { usePathname } from "next/navigation"; import { useTranslations } from "next-intl"; -import { FC, Fragment, useMemo, useState } from "react"; +import { FC, Fragment, useEffect, useRef, useMemo, useState } from "react"; import TopicItem from "@/app/(main)/questions/components/topic_item"; import useFeed from "@/app/(main)/questions/hooks/use_feed"; @@ -15,7 +15,6 @@ import Button from "@/components/ui/button"; import { FeedType } from "@/constants/posts_feed"; import { useAuth } from "@/contexts/auth_context"; import { usePublicSettings } from "@/contexts/public_settings_context"; -import { useContentTranslatedBannerContext } from "@/contexts/translations_banner_context"; import useSearchParams from "@/hooks/use_search_params"; import { SidebarItem, @@ -136,21 +135,37 @@ const FeedSidebar: FC = ({ items }) => { ]); const [isMobileExpanded, setIsMobileExpanded] = useState(false); + const outerRef = useRef(null); - const { bannerIsVisible: isTranslationBannerVisible } = - useContentTranslatedBannerContext(); + useEffect(() => { + const el = outerRef.current; + if (!el) return; - const topPositionClasses = isTranslationBannerVisible - ? "top-24 lg:top-header" - : "top-header"; + const obs = new ResizeObserver(([entry]) => { + const h = entry?.borderBoxSize?.[0]?.blockSize ?? el.offsetHeight; + document.documentElement.style.setProperty( + "--feed-sidebar-mobile-height", + `${h}px` + ); + }); + + obs.observe(el); + return () => { + obs.disconnect(); + document.documentElement.style.removeProperty( + "--feed-sidebar-mobile-height" + ); + }; + }, []); return (
-
+
= ({ + children, +}) => { + const sentinelRef = useRef(null); + const [isStuck, setIsStuck] = useState(false); + + const { bannerIsVisible: isTranslationBannerVisible } = + useContentTranslatedBannerContext(); + + useEffect(() => { + const el = sentinelRef.current; + if (!el) return; + + const obs = new IntersectionObserver( + ([entry]) => setIsStuck(!entry?.isIntersecting), + { + root: null, + threshold: 0, + rootMargin: `-${STICKY_TOP}px 0px 0px 0px`, + } + ); + + obs.observe(el); + return () => obs.disconnect(); + }, []); + + return ( + <> +
+ +
+
{children}
+
+ + ); +}; + +export default StickyFilterBar; diff --git a/front_end/src/app/(main)/questions/page.tsx b/front_end/src/app/(main)/questions/page.tsx index 0224591212..df46a0254c 100644 --- a/front_end/src/app/(main)/questions/page.tsx +++ b/front_end/src/app/(main)/questions/page.tsx @@ -19,6 +19,7 @@ import { QuestionOrder } from "@/types/question"; import { InterfaceType } from "@/types/users"; import FeedFilters from "./components/feed_filters"; +import StickyFilterBar from "./components/sticky_filter_bar"; import { generateFiltersFromSearchParams } from "./helpers/filters"; export const metadata = { @@ -50,8 +51,8 @@ export default async function Questions(props: {
-
- {isCommunityFeed ? ( + {isCommunityFeed ? ( +
- ) : isWeeklyTopCommentsFeed ? ( +
+ ) : isWeeklyTopCommentsFeed ? ( +
- ) : ( - <> - +
+ ) : ( +
+ + + +
key === POST_PAGE_FILTER )} /> - - )} -
+
+
+ )}
diff --git a/front_end/src/components/popover_filter/index.tsx b/front_end/src/components/popover_filter/index.tsx index da996d167b..8a6f2a6862 100644 --- a/front_end/src/components/popover_filter/index.tsx +++ b/front_end/src/components/popover_filter/index.tsx @@ -67,6 +67,7 @@ type Props = { ) => void; onClear: () => void; fullScreenEnabled?: boolean; + hasActiveFilters?: boolean; }; const PopoverFilter: FC = ({ @@ -76,6 +77,7 @@ const PopoverFilter: FC = ({ onChange, onClear, fullScreenEnabled, + hasActiveFilters, }) => { const t = useTranslations(); @@ -83,21 +85,39 @@ const PopoverFilter: FC = ({ {({ open, close }) => ( <> - - sendAnalyticsEvent("feedFilterClick", { - event_category: new URLSearchParams( - window.location.search - ).toString(), - }) - } - > - {buttonLabel || t("Filter")} - +
+ + sendAnalyticsEvent("feedFilterClick", { + event_category: new URLSearchParams( + window.location.search + ).toString(), + }) + } + > + {buttonLabel || t("Filter")} + + {hasActiveFilters && ( + + )} +
= ({ @@ -76,6 +77,7 @@ const PostsFilters: FC = ({ onOrderChange, showRandomButton, panelClassname, + className, }) => { const t = useTranslations(); const { @@ -88,12 +90,8 @@ const PostsFilters: FC = ({ } = useSearchParams(); defaultOrder = defaultOrder ?? QuestionOrder.ActivityDesc; - const { - globalSearch, - updateGlobalSearch, - setIsVisible, - setModifySearchParams, - } = useGlobalSearchContext(); + const { globalSearch, updateGlobalSearch, setModifySearchParams } = + useGlobalSearchContext(); // eslint-disable-next-line react-hooks/exhaustive-deps const debouncedAnalyticsEvent = useCallback( @@ -227,70 +225,84 @@ const PostsFilters: FC = ({ }; return ( -
-
- { - setIsVisible(v); - }} - > -
- { - debouncedAnalyticsEvent(); - deleteParam(POST_PAGE_FILTER, true); - updateGlobalSearch(e.target.value); - }} - onErase={eraseSearch} - placeholder={t("questionSearchPlaceholder")} - /> - {showRandomButton && } -
-
-
- +
+
+ {mainSortOptions.map((button) => ( + + ))} +
+ {dropdownSortOptions && ( + o.value === order) + ? "secondary" + : "tertiary" + } + className="rounded-full" + onChange={handleOrderChange} + onClick={(value) => + sendAnalyticsEvent("feedSortClick", { + event_category: value, + }) + } + options={dropdownSortOptions} + value={order || defaultOrder} + menuPosition="left" + label={ + dropdownSortOptions.find((o) => o.value === order) + ? `${t("sort")}: ${dropdownSortOptions.find((o) => o.value === order)?.label}` + : t("sort") + } + /> + )} + {mainSortOptions.length === 0 ?
: null} + 0} + /> + { + debouncedAnalyticsEvent(); + deleteParam(POST_PAGE_FILTER, true); + updateGlobalSearch(e.target.value); + }} + onErase={eraseSearch} + placeholder={t("questionSearchPlaceholder")} + collapsible /> -
- {dropdownSortOptions && ( - - sendAnalyticsEvent("feedSortClick", { - event_category: value, - }) - } - options={dropdownSortOptions} - value={order || defaultOrder} - label="More" - /> - )} - -
+ )}
{!!activeFilters.length && ( -
+
{activeFilters.map(({ id, label, value }) => ( removeFilter(id, value)} > diff --git a/front_end/src/components/random_button.tsx b/front_end/src/components/random_button.tsx index cecb1bc80e..9762d4f72b 100644 --- a/front_end/src/components/random_button.tsx +++ b/front_end/src/components/random_button.tsx @@ -1,14 +1,18 @@ "use client"; -import { Button } from "@headlessui/react"; +import { faDice } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { useRouter } from "next/navigation"; -import { FC, useState } from "react"; +import { ComponentProps, FC, useState } from "react"; +import Button from "@/components/ui/button"; import ClientPostsApi from "@/services/api/posts/posts.client"; +import cn from "@/utils/core/cn"; -import { Die } from "./icons/die"; - -const RandomButton: FC = () => { +const RandomButton: FC> = ({ + className, + ...props +}) => { const [isLoading, setIsLoading] = useState(false); const router = useRouter(); @@ -32,9 +36,15 @@ const RandomButton: FC = () => { onClick={handleRandomClick} disabled={isLoading} aria-label="Random Question" - className="flex w-[48px] cursor-pointer items-center justify-center rounded-none border-0 bg-transparent text-xl transition-transform hover:animate-spin" + size="md" + presentationType="icon" + className={cn( + "shrink-0 transition-transform hover:animate-spin", + className + )} + {...props} > - + ); }; diff --git a/front_end/src/components/search_input.tsx b/front_end/src/components/search_input.tsx index b1129689a0..a7085efe97 100644 --- a/front_end/src/components/search_input.tsx +++ b/front_end/src/components/search_input.tsx @@ -1,7 +1,14 @@ import { faMagnifyingGlass, faXmark } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { Field, Input } from "@headlessui/react"; -import React, { ChangeEventHandler, FC, FormEvent } from "react"; +import React, { + ChangeEventHandler, + FC, + FormEvent, + useCallback, + useRef, + useState, +} from "react"; import Button from "@/components/ui/button"; import cn from "@/utils/core/cn"; @@ -25,6 +32,7 @@ type Props = { rightControlsClassName?: string; rightButtonClassName?: string; inputRef?: React.Ref; + collapsible?: boolean; }; const SearchInput: FC = ({ @@ -43,10 +51,62 @@ const SearchInput: FC = ({ rightControlsClassName, rightButtonClassName, inputRef, + collapsible, }) => { const isForm = !!onSubmit; const isLeft = iconPosition === "left"; + const [isExpanded, setIsExpanded] = useState(!collapsible || !!value); + const internalRef = useRef(null); + + const expand = useCallback(() => { + setIsExpanded(true); + requestAnimationFrame(() => { + if (typeof inputRef === "object" && inputRef?.current) { + inputRef.current.focus(); + } else { + internalRef.current?.focus(); + } + }); + }, [inputRef]); + + const collapse = useCallback(() => { + if (collapsible && !value) { + setIsExpanded(false); + } + }, [collapsible, value]); + + const handleErase = useCallback(() => { + onErase(); + if (collapsible) { + setIsExpanded(false); + } + }, [onErase, collapsible]); + + if (collapsible && !isExpanded) { + return ( + + ); + } + return ( = ({ e.preventDefault(); onSubmit?.(value); }} + onBlur={(e: React.FocusEvent) => { + if (!e.currentTarget.contains(e.relatedTarget)) { + collapse(); + } + }} > {isLeft && ( @@ -74,7 +139,7 @@ const SearchInput: FC = ({ )} = ({ {!!value && (
diff --git a/front_end/src/app/(main)/c/[slug]/settings/page.tsx b/front_end/src/app/(main)/c/[slug]/settings/page.tsx index 90a8d2ed7c..67f5809b54 100644 --- a/front_end/src/app/(main)/c/[slug]/settings/page.tsx +++ b/front_end/src/app/(main)/c/[slug]/settings/page.tsx @@ -60,7 +60,11 @@ export default async function CommunityManagementSettings(props: Props) { } > - +
diff --git a/front_end/src/app/(main)/news/page.tsx b/front_end/src/app/(main)/news/page.tsx index 9298167c56..68f2f14cd6 100644 --- a/front_end/src/app/(main)/news/page.tsx +++ b/front_end/src/app/(main)/news/page.tsx @@ -60,7 +60,7 @@ export default async function NewsFeed(props: { } > - +
diff --git a/front_end/src/app/(main)/questions/components/feed_filters/main.tsx b/front_end/src/app/(main)/questions/components/feed_filters/main.tsx index da1e2e1ac8..3f51ccb2d2 100644 --- a/front_end/src/app/(main)/questions/components/feed_filters/main.tsx +++ b/front_end/src/app/(main)/questions/components/feed_filters/main.tsx @@ -1,5 +1,4 @@ "use client"; -import { isNil } from "lodash"; import { useTranslations } from "next-intl"; import { FC, useCallback, useEffect, useMemo, useState } from "react"; diff --git a/front_end/src/app/(main)/questions/components/sidebar.tsx b/front_end/src/app/(main)/questions/components/sidebar.tsx index 79a336a4a8..bfc532d4d1 100644 --- a/front_end/src/app/(main)/questions/components/sidebar.tsx +++ b/front_end/src/app/(main)/questions/components/sidebar.tsx @@ -48,7 +48,6 @@ const FeedSidebar: FC = ({ items }) => { useContentTranslatedBannerContext(); const sidebarSections: SidebarSection[] = useMemo(() => { - console.log("api result", items); const menuItems: SidebarMenuItem[] = [ { name: t("feedHome"), diff --git a/front_end/src/app/layout.tsx b/front_end/src/app/layout.tsx index 0b7cbdc174..d8f5e3f096 100644 --- a/front_end/src/app/layout.tsx +++ b/front_end/src/app/layout.tsx @@ -16,9 +16,15 @@ import PublicSettingsScript from "@/components/public_settings_script"; import QueryClientProviderWrapper from "@/components/query_client_provider"; import SimplifiedSignupModal from "@/components/simplified_signup_modal"; import AppThemeProvider from "@/components/theme_provider"; +import { FeedLayout } from "@/components/ui/layout_switcher"; import { TailwindIndicator } from "@/components/ui/tailwind-indicator"; import { METAC_COLORS } from "@/constants/colors"; +import { + FEED_LAYOUT_COOKIE, + FEED_LAYOUT_DEFAULT, +} from "@/constants/posts_feed"; import AuthProvider from "@/contexts/auth_context"; +import FeedLayoutProvider from "@/contexts/feed_layout_context"; import { GlobalSearchProvider } from "@/contexts/global_search_context"; import ModalProvider from "@/contexts/modal_context"; import NavigationProvider from "@/contexts/navigation_context"; @@ -89,6 +95,8 @@ export default async function RootLayout({ const cookieStore = await cookies(); const csrfToken = cookieStore.get(CSRF_COOKIE_NAME)?.value || null; + const feedLayout = (cookieStore.get(FEED_LAYOUT_COOKIE)?.value || + FEED_LAYOUT_DEFAULT) as FeedLayout; return ( - - - {children} - - - - + + + + {children} + + + + + diff --git a/front_end/src/components/communities_feed/paginated_communities_feed.tsx b/front_end/src/components/communities_feed/paginated_communities_feed.tsx index cebd4cac08..54db5f4ab5 100644 --- a/front_end/src/components/communities_feed/paginated_communities_feed.tsx +++ b/front_end/src/components/communities_feed/paginated_communities_feed.tsx @@ -66,7 +66,7 @@ const PaginatedCommunitiesFeed: FC = ({ }; return ( -
+
{t.rich("introducingCommunities", { bold: (chunks) => ( diff --git a/front_end/src/components/posts_feed/build_feed_items.ts b/front_end/src/components/posts_feed/build_feed_items.ts index b2e5cfd607..4d786c99d2 100644 --- a/front_end/src/components/posts_feed/build_feed_items.ts +++ b/front_end/src/components/posts_feed/build_feed_items.ts @@ -6,6 +6,27 @@ export type FeedItem = | { type: "post"; post: PostWithForecasts } | { type: "project"; tile: FeedProjectTile }; +const postItemCache = new WeakMap(); +const tileItemCache = new WeakMap(); + +function getPostItem(post: PostWithForecasts): FeedItem { + let item = postItemCache.get(post); + if (!item) { + item = { type: "post", post }; + postItemCache.set(post, item); + } + return item; +} + +function getTileItem(tile: FeedProjectTile): FeedItem { + let item = tileItemCache.get(tile); + if (!item) { + item = { type: "project", tile }; + tileItemCache.set(tile, item); + } + return item; +} + function seededRandom(seed: number): () => number { let s = seed | 0 || 1; return () => { @@ -19,7 +40,7 @@ export function buildFeedItems( tiles: FeedProjectTile[] ): FeedItem[] { if (!tiles.length || !posts.length) { - return posts.map((post) => ({ type: "post", post })); + return posts.map((post) => getPostItem(post)); } const seed = @@ -59,10 +80,10 @@ export function buildFeedItems( const items: FeedItem[] = []; posts.forEach((post, i) => { - items.push({ type: "post", post }); + items.push(getPostItem(post)); const tile = tileAtIndex.get(i); if (tile) { - items.push({ type: "project", tile }); + items.push(getTileItem(tile)); } }); diff --git a/front_end/src/components/posts_feed/index.tsx b/front_end/src/components/posts_feed/index.tsx index 720382c99c..0d8aacce0f 100644 --- a/front_end/src/components/posts_feed/index.tsx +++ b/front_end/src/components/posts_feed/index.tsx @@ -4,6 +4,7 @@ import PaginatedPostsFeed, { PostsFeedType, } from "@/components/posts_feed/paginated_feed"; import WithServerComponentErrorBoundary from "@/components/server_component_error_boundary"; +import { FeedLayout } from "@/components/ui/layout_switcher"; import { POSTS_PER_PAGE } from "@/constants/posts_feed"; import ServerPostsApi from "@/services/api/posts/posts.server"; import { PostsParams } from "@/services/api/posts/posts.shared"; @@ -16,6 +17,7 @@ type Props = { type?: PostsFeedType; isCommunity?: boolean; showProjectTiles?: boolean; + forceLayout?: FeedLayout; }; const AwaitedPostsFeed: FC = async ({ @@ -23,6 +25,7 @@ const AwaitedPostsFeed: FC = async ({ type, isCommunity, showProjectTiles, + forceLayout, }) => { const { PUBLIC_MINIMAL_UI } = getPublicSettings(); const skipTiles = !showProjectTiles || isCommunity || PUBLIC_MINIMAL_UI; @@ -49,6 +52,7 @@ const AwaitedPostsFeed: FC = async ({ initialProjectTiles={projectTiles} type={type} isCommunity={isCommunity} + forceLayout={forceLayout} /> ); }; diff --git a/front_end/src/components/posts_feed/paginated_feed.tsx b/front_end/src/components/posts_feed/paginated_feed.tsx index 825bece590..5e5be4830f 100644 --- a/front_end/src/components/posts_feed/paginated_feed.tsx +++ b/front_end/src/components/posts_feed/paginated_feed.tsx @@ -1,15 +1,18 @@ "use client"; import { isNil } from "lodash"; import { useTranslations } from "next-intl"; -import { FC, Fragment, useEffect, useMemo, useState } from "react"; +import { FC, useEffect, useMemo, useState } from "react"; import { QuestionVariantComposer } from "@/app/(main)/questions/[id]/components/question_variant_composer"; import ConsumerPostCard from "@/components/consumer_post_card"; import NewsCard from "@/components/news_card"; import PostCard from "@/components/post_card"; import Button from "@/components/ui/button"; +import { type FeedLayout } from "@/components/ui/layout_switcher"; import LoadingIndicator from "@/components/ui/loading_indicator"; -import { POSTS_PER_PAGE, POST_PAGE_FILTER } from "@/constants/posts_feed"; +import { Masonry } from "@/components/ui/masonry"; +import { POST_PAGE_FILTER, POSTS_PER_PAGE } from "@/constants/posts_feed"; +import { useFeedLayout } from "@/contexts/feed_layout_context"; import { usePublicSettings } from "@/contexts/public_settings_context"; import { useContentTranslatedBannerContext } from "@/contexts/translations_banner_context"; import useSearchParams from "@/hooks/use_search_params"; @@ -21,7 +24,7 @@ import { sendAnalyticsEvent } from "@/utils/analytics"; import { logError } from "@/utils/core/errors"; import { isNotebookPost } from "@/utils/questions/helpers"; -import { buildFeedItems } from "./build_feed_items"; +import { FeedItem, buildFeedItems } from "./build_feed_items"; import EmptyCommunityFeed from "./empty_community_feed"; import PostsFeedScrollRestoration from "./feed_scroll_restoration"; import FeedTournamentTile from "./feed_tournament_tile"; @@ -37,6 +40,7 @@ type Props = { type?: PostsFeedType; isCommunity?: boolean; indexWeights?: Record; + forceLayout?: FeedLayout; }; const PaginatedPostsFeed: FC = ({ @@ -46,6 +50,7 @@ const PaginatedPostsFeed: FC = ({ type = "posts", isCommunity, indexWeights = {}, + forceLayout, }) => { const t = useTranslations(); const { params, setParam, replaceUrlWithoutNavigation } = useSearchParams(); @@ -141,31 +146,8 @@ const PaginatedPostsFeed: FC = ({ [paginatedPosts, initialProjectTiles] ); - const renderPost = (post: PostWithForecasts) => { - const indexWeight = weightByPostId.get(post.id); - if (isNotebookPost(post) && type === "news") { - return ; - } - - return ( - - } - forecaster={ - - } - /> - ); - }; + const { layout: contextLayout } = useFeedLayout(); + const layout = forceLayout ?? contextLayout; return ( <> @@ -185,19 +167,14 @@ const PaginatedPostsFeed: FC = ({ )} )} - {feedItems.map((item) => - item.type === "project" ? ( - - ) : ( - - {renderPost(item.post)} - - ) - )} + = ({ ); }; +const FeedLayoutView: FC<{ + items: FeedItem[]; + feedPage: number; + type: PostsFeedType; + isCommunity?: boolean; + weightByPostId: Map; + layout: FeedLayout; +}> = ({ items, feedPage, type, isCommunity, weightByPostId, layout }) => { + return ( + ( + + )} + /> + ); +}; + +const FeedItemCard: FC<{ + item: FeedItem; + feedPage: number; + type: PostsFeedType; + isCommunity?: boolean; + weightByPostId: Map; + forceConsumer?: boolean; +}> = ({ item, feedPage, type, isCommunity, weightByPostId, forceConsumer }) => { + if (item.type === "project") { + return ; + } + + const { post } = item; + + if (isNotebookPost(post) && type === "news") { + return ; + } + + const indexWeight = weightByPostId.get(post.id); + + return ( + + } + forecaster={ + forceConsumer ? ( + + ) : ( + + ) + } + /> + ); +}; + export default PaginatedPostsFeed; diff --git a/front_end/src/components/posts_filters.tsx b/front_end/src/components/posts_filters.tsx index cb0dc6af18..f5a9e1e167 100644 --- a/front_end/src/components/posts_filters.tsx +++ b/front_end/src/components/posts_filters.tsx @@ -16,6 +16,7 @@ import SearchInput from "@/components/search_input"; import Button from "@/components/ui/button"; import { GroupButton } from "@/components/ui/button_group"; import Chip from "@/components/ui/chip"; +import LayoutSwitcher, { FeedLayout } from "@/components/ui/layout_switcher"; import Listbox, { SelectOption } from "@/components/ui/listbox"; import { POST_ORDER_BY_FILTER, @@ -23,6 +24,7 @@ import { POST_STATUS_FILTER, POST_WITHDRAWN_FILTER, } from "@/constants/posts_feed"; +import { useFeedLayout } from "@/contexts/feed_layout_context"; import { useGlobalSearchContext } from "@/contexts/global_search_context"; import useSearchParams from "@/hooks/use_search_params"; import { QuestionOrder } from "@/types/question"; @@ -66,6 +68,7 @@ type Props = { showRandomButton?: boolean; panelClassname?: string; className?: string; + forceLayout?: FeedLayout; }; const PostsFilters: FC = ({ @@ -78,8 +81,10 @@ const PostsFilters: FC = ({ showRandomButton, panelClassname, className, + forceLayout, }) => { const t = useTranslations(); + const { layout, setLayout } = useFeedLayout(); const { params, setParam, @@ -294,6 +299,13 @@ const PostsFilters: FC = ({ className="text-purple-700 dark:text-purple-700-dark" /> )} + {!forceLayout && ( + + )}
{!!activeFilters.length && ( diff --git a/front_end/src/components/tournament_filters.tsx b/front_end/src/components/tournament_filters.tsx index ab49af839a..3c3632a83d 100644 --- a/front_end/src/components/tournament_filters.tsx +++ b/front_end/src/components/tournament_filters.tsx @@ -162,6 +162,7 @@ const TournamentFilters: FC = () => { onOrderChange={handleOrderChange} inputConfig={{ debounceTime: 500, mode: "client" }} className="mb-3" + forceLayout="list" /> ); }; diff --git a/front_end/src/components/ui/layout_switcher.tsx b/front_end/src/components/ui/layout_switcher.tsx new file mode 100644 index 0000000000..80bc6b91fa --- /dev/null +++ b/front_end/src/components/ui/layout_switcher.tsx @@ -0,0 +1,55 @@ +"use client"; + +import { faList, faTableCells } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { FC } from "react"; + +import cn from "@/utils/core/cn"; + +export type FeedLayout = "list" | "grid"; + +type Props = { + value: FeedLayout; + onChange: (layout: FeedLayout) => void; + className?: string; +}; + +const LayoutSwitcher: FC = ({ value, onChange, className }) => { + return ( +
+ + +
+ ); +}; + +export default LayoutSwitcher; diff --git a/front_end/src/components/ui/masonry.tsx b/front_end/src/components/ui/masonry.tsx new file mode 100644 index 0000000000..30e98bd85c --- /dev/null +++ b/front_end/src/components/ui/masonry.tsx @@ -0,0 +1,364 @@ +import React, { + useState, + useMemo, + useEffect, + useRef, + useCallback, +} from "react"; + +export function useGridStyles(columns: number, gap: number) { + return useMemo( + () => ({ + display: "grid", + alignItems: "start", + gridColumnGap: gap, + gridTemplateColumns: `repeat(${columns}, minmax(0, 1fr))`, + }), + [columns, gap] + ); +} + +export function useMediaValues( + medias: number[] | undefined, + columns: number[], + gap: number[] +) { + const [values, setValues] = useState({ columns: 0, gap: 1 }); + + useEffect(() => { + if (!medias) { + setValues({ columns: columns[0] ?? 0, gap: gap[0] ?? 0 }); + return; + } + + const mediaQueries = medias.map((media) => + window.matchMedia(`(min-width: ${media}px)`) + ); + + const onSizeChange = () => { + let matches = 0; + + mediaQueries.forEach((mediaQuery) => { + if (mediaQuery.matches) { + matches++; + } + }); + + // Update Values + const idx = Math.min(mediaQueries.length - 1, Math.max(0, matches)); + setValues({ columns: columns[idx] ?? 0, gap: gap[idx] ?? 0 }); + }; + + // Initial Call + onSizeChange(); + + // Apply Listeners + for (const mediaQuery of mediaQueries) { + mediaQuery.addEventListener("change", onSizeChange); + } + + return () => { + for (const mediaQuery of mediaQueries) { + mediaQuery.removeEventListener("change", onSizeChange); + } + }; + }, [medias, columns, gap]); + + return values; +} + +export function asList(data: number | number[]) { + return Array.isArray(data) ? data : [data]; +} + +export type MasonryProps = React.ComponentPropsWithoutRef<"div"> & { + items: T[]; + render: (item: T, idx: number) => React.ReactNode; + config: { + columns: number | number[]; + gap: number | number[]; + media?: number[]; + useBalancedLayout?: boolean; + }; + as?: React.ElementType; +}; + +export function Masonry({ + items = [], + render, + config, + as: Component = "div", + ...rest +}: MasonryProps) { + const _columns = useMemo(() => asList(config.columns), [config.columns]); + const _gaps = useMemo(() => asList(config.gap), [config.gap]); + const { columns, gap } = useMediaValues(config.media, _columns, _gaps); + const styles = useGridStyles(columns, gap); + + if (!columns) return null; + + if (config.useBalancedLayout) { + return ( + + ); + } + + const dataColumns = createDataColumns(createChunks(items, columns), columns); + + return ( + + {dataColumns.map((column, columnIdx) => ( + + {column.map((item, idx) => ( +
{render(item, idx)}
+ ))} +
+ ))} +
+ ); +} + +function BalancedMasonry({ + items, + render, + columns, + gap, + styles, + as: Component = "div", + ...rest +}: { + items: T[]; + render: (item: T, idx: number) => React.ReactNode; + columns: number; + gap: number; + styles: React.CSSProperties; + as?: React.ElementType; +} & React.ComponentPropsWithoutRef<"div">) { + const columnAssignments = useRef>(new Map()); + const measuredHeights = useRef>(new Map()); + const prevColumnCount = useRef(columns); + const [placementGeneration, setPlacementGeneration] = useState(0); + + // Breakpoint change: clear everything for full redistribution + if (columns !== prevColumnCount.current) { + columnAssignments.current.clear(); + measuredHeights.current.clear(); + prevColumnCount.current = columns; + } + + // Stale cleanup: remove items no longer in the array + const itemSet = new Set(items); + for (const key of columnAssignments.current.keys()) { + if (!itemSet.has(key)) { + columnAssignments.current.delete(key); + measuredHeights.current.delete(key); + } + } + for (const key of measuredHeights.current.keys()) { + if (!itemSet.has(key)) { + measuredHeights.current.delete(key); + } + } + + const placedItems: T[] = []; + const pendingItems: T[] = []; + for (const item of items) { + if (columnAssignments.current.has(item)) { + placedItems.push(item); + } else { + pendingItems.push(item); + } + } + + // Build columns for placed items + const placedColumns: T[][] = Array.from({ length: columns }, () => []); + for (const item of placedItems) { + const col = columnAssignments.current.get(item) ?? 0; + if (placedColumns[col]) { + placedColumns[col].push(item); + } + } + + const pendingMeasureRef = useCallback( + (node: HTMLDivElement | null, item: T) => { + if (node) { + const height = node.getBoundingClientRect().height; + measuredHeights.current.set(item, height); + } + }, + [] + ); + + // Place pending items once all are measured + useEffect(() => { + if (pendingItems.length === 0) return; + + const allMeasured = pendingItems.every((item) => + measuredHeights.current.has(item) + ); + if (!allMeasured) return; + + // Compute current column heights from placed items + const currentColumnHeights = new Array(columns).fill(0); + for (const item of placedItems) { + const col = columnAssignments.current.get(item) ?? 0; + currentColumnHeights[col] += measuredHeights.current.get(item) ?? 0; + } + + // Distribute pending items to shortest columns + const newAssignments = createBalancedColumns( + pendingItems, + columns, + (item) => measuredHeights.current.get(item) ?? 0, + currentColumnHeights + ); + + for (let colIdx = 0; colIdx < newAssignments.length; colIdx++) { + const col = newAssignments[colIdx]; + if (col) { + for (const item of col) { + columnAssignments.current.set(item, colIdx); + } + } + } + + setPlacementGeneration((g) => g + 1); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [items, columns, pendingItems.length]); + + // Re-measure placed items to keep heights up-to-date (for future placements) + const placedMeasureRef = useCallback( + (node: HTMLDivElement | null, item: T) => { + if (node) { + const height = node.getBoundingClientRect().height; + measuredHeights.current.set(item, height); + } + }, + [] + ); + + return ( + + {placedColumns.map((column, columnIdx) => ( + + {column.map((item, idx) => ( +
placedMeasureRef(node, item)}> + {render(item, items.indexOf(item))} +
+ ))} +
+ ))} + {/* Hidden measurement area for pending items */} + {pendingItems.length > 0 && ( +
+ {/* Render one pending item per column slot to get correct width */} + {pendingItems.map((item, idx) => ( +
pendingMeasureRef(node, item)} + > + {render(item, items.indexOf(item))} +
+ ))} +
+ )} +
+ ); +} + +export function MasonryRow({ + children, + gap, +}: { + children: React.ReactNode; + gap: number; +}) { + return ( +
+ {children} +
+ ); +} + +export function createChunks(data: T[] = [], columns = 3) { + const result = []; + + for (let idx = 0; idx < data.length; idx += columns) { + const slice = data.slice(idx, idx + columns); + result.push(slice); + } + + return result; +} + +export function createDataColumns(data: T[][] = [], columns = 3) { + const result = Array.from({ length: columns }, () => []); + + for (let idx = 0; idx < columns; idx++) { + for (let jdx = 0; jdx < data.length; jdx += 1) { + const item = data[jdx]?.[idx]; + if (item) { + result[idx]?.push(item); + } + } + } + + return result; +} + +export function createBalancedColumns( + items: T[], + columns: number, + getHeight: (item: T) => number, + initialColumnHeights?: number[] +): T[][] { + const result = Array.from({ length: columns }, () => []); + const columnHeights = initialColumnHeights + ? [...initialColumnHeights] + : new Array(columns).fill(0); + + // Maintain original order, but distribute to shortest column + for (const item of items) { + let shortestColumnIndex = 0; + let minHeight = columnHeights[0]; + + for (let i = 1; i < columns; i++) { + if (columnHeights[i] < minHeight) { + minHeight = columnHeights[i]; + shortestColumnIndex = i; + } + } + + result[shortestColumnIndex]?.push(item); + columnHeights[shortestColumnIndex] += getHeight(item); + } + + return result; +} diff --git a/front_end/src/constants/posts_feed.ts b/front_end/src/constants/posts_feed.ts index 6dfe9b5ede..75ba47e7a5 100644 --- a/front_end/src/constants/posts_feed.ts +++ b/front_end/src/constants/posts_feed.ts @@ -31,4 +31,7 @@ export const POST_COMMUNITIES_FILTER = "communities"; export const POST_WEEKLY_TOP_COMMENTS_FILTER = "weekly_top_comments"; export const POST_PROJECT_FILTER = "default_project_id"; +export const FEED_LAYOUT_COOKIE = "feed_layout"; +export const FEED_LAYOUT_DEFAULT: "list" | "grid" = "list"; + export const POSTS_PER_PAGE = 10; diff --git a/front_end/src/contexts/feed_layout_context.tsx b/front_end/src/contexts/feed_layout_context.tsx new file mode 100644 index 0000000000..99365ffe3f --- /dev/null +++ b/front_end/src/contexts/feed_layout_context.tsx @@ -0,0 +1,45 @@ +"use client"; + +import { + createContext, + FC, + PropsWithChildren, + useContext, + useState, +} from "react"; + +import { FeedLayout } from "@/components/ui/layout_switcher"; +import { + FEED_LAYOUT_COOKIE, + FEED_LAYOUT_DEFAULT, +} from "@/constants/posts_feed"; + +type FeedLayoutContextType = { + layout: FeedLayout; + setLayout: (layout: FeedLayout) => void; +}; + +const FeedLayoutContext = createContext({ + layout: FEED_LAYOUT_DEFAULT, + setLayout: () => {}, +}); + +const FeedLayoutProvider: FC< + PropsWithChildren<{ initialLayout: FeedLayout }> +> = ({ initialLayout, children }) => { + const [layout, setLayoutState] = useState(initialLayout); + + const setLayout = (newLayout: FeedLayout) => { + setLayoutState(newLayout); + document.cookie = `${FEED_LAYOUT_COOKIE}=${newLayout};path=/;max-age=${60 * 60 * 24 * 365};samesite=lax`; + }; + + return ( + + {children} + + ); +}; + +export default FeedLayoutProvider; +export const useFeedLayout = () => useContext(FeedLayoutContext); From 475ab4caf0cbacc1986cd92f01b4337503390686 Mon Sep 17 00:00:00 2001 From: cemreinanc Date: Sat, 28 Mar 2026 15:25:10 +0100 Subject: [PATCH 08/27] refactor: update layout and styling across feed tile components --- Claude.md | 2 +- .../src/components/popover_filter/index.tsx | 1 + .../post_card/basic_post_card/index.tsx | 8 +- .../basic_post_card/post_controls.tsx | 18 +- .../post_card/multiple_choice_tile/index.tsx | 262 +++++++++--------- .../prediction_continuous_info.tsx | 10 +- .../question_continuous_tile.tsx | 18 +- .../components/posts_feed/paginated_feed.tsx | 22 +- front_end/src/components/posts_filters.tsx | 12 +- 9 files changed, 175 insertions(+), 178 deletions(-) diff --git a/Claude.md b/Claude.md index ed22f55abc..edb292cdb0 100644 --- a/Claude.md +++ b/Claude.md @@ -16,7 +16,7 @@ This is a Django + Next.js monorepo. Python/Django backend lives in the root dir - For any frontend content visible to the user, use the translation mechanism used across the whole frontend. `const t = useTranslations()` and then `t("stringKey")` while adding the "stringKey" to all the corresponding language files in `front_end/messages/`: `en.json`, `es.json`, `cs.json`, `pt.json`, `zh.json`, `zh-TW.json`. # Workflow -- When connected to an IDE, check terminal outputs first. If a dev server is already running, do not run a build. Instead, read the dev server terminal output for any latest errors and use those for feedback. +- When connected to an IDE, or have access to a tmux session running node/uv, check terminal outputs first. If a dev server is already running for the project, do not run a build. Instead, read the dev server terminal output for any latest errors and use those for feedback. - When done making code changes, run the relevant linters and formatters based on which files you edited: - Python files: run `uv run ruff format .` and `uv run ruff check .` - Frontend (JS/TS) files: run `npm run -C ./front_end lint` and `npm run -C ./front_end format`, and try to build with `npm run -C ./front_end build` if there is no running dev server in IDE. \ No newline at end of file diff --git a/front_end/src/components/popover_filter/index.tsx b/front_end/src/components/popover_filter/index.tsx index 8a6f2a6862..bdc4a001ba 100644 --- a/front_end/src/components/popover_filter/index.tsx +++ b/front_end/src/components/popover_filter/index.tsx @@ -88,6 +88,7 @@ const PopoverFilter: FC = ({
> = ({ )}
> = ({ > {!hideTitle && ( -
+

{title}

{typeof indexWeight === "number" && ( -
+
)} diff --git a/front_end/src/components/post_card/basic_post_card/post_controls.tsx b/front_end/src/components/post_card/basic_post_card/post_controls.tsx index 4681c85308..22e466f027 100644 --- a/front_end/src/components/post_card/basic_post_card/post_controls.tsx +++ b/front_end/src/components/post_card/basic_post_card/post_controls.tsx @@ -34,8 +34,8 @@ const BasicPostControls: FC> = ({ minimalistic; return ( -
-
+
+
{!minimalistic && } {/* CommentStatus - compact on small screens, full on large screens */} @@ -43,14 +43,14 @@ const BasicPostControls: FC> = ({ totalCount={post.comment_count ?? 0} unreadCount={post.unread_comment_count ?? 0} url={getPostLink(post)} - className="bg-gray-200 dark:bg-gray-200-dark md:hidden" + className="bg-gray-200 @[480px]:hidden dark:bg-gray-200-dark" compact={true} /> @@ -59,28 +59,28 @@ const BasicPostControls: FC> = ({ post={post} resolution={resolutionData} compact={true} - className="md:hidden" + className="@[480px]:hidden" /> {/* ForecastersCounter - compact on small screens, full on large screens */}
-
+
{!minimalistic && defaultProject && ( )} diff --git a/front_end/src/components/post_card/multiple_choice_tile/index.tsx b/front_end/src/components/post_card/multiple_choice_tile/index.tsx index 864a930497..6bc887f474 100644 --- a/front_end/src/components/post_card/multiple_choice_tile/index.tsx +++ b/front_end/src/components/post_card/multiple_choice_tile/index.tsx @@ -163,107 +163,111 @@ export const MultipleChoiceTile: FC = ({ ); return ( -
+
- {isResolvedView ? ( - - ) : ( - !minimalistic && ( - - ) + ref={tileRef} + className={cn( + "MultipleChoiceTile ml-0 w-full items-start", + { + "flex flex-col": isEmbed && isCompactEmbed, + "grid grid-cols-2": isEmbed && !isCompactEmbed, + "flex grid-cols-5 flex-col @[550px]:grid": !isEmbed && showChart, + }, + { + "gap-3": isEmbed && isCompactEmbed && !minimalistic, + "gap-5": isEmbed && !isCompactEmbed && !minimalistic, + "gap-5 @[550px]:gap-8": !isEmbed && !minimalistic, + } )} -
- {showChart && !isCompactEmbed && !isResolvedView && ( + >
-
- {isNil(group) ? ( - - ) : ( - + ) : ( + !minimalistic && ( + - )} -
+ ) + )}
- )} + {showChart && !isCompactEmbed && !isResolvedView && ( +
+
+ {isNil(group) ? ( + + ) : ( + + )} +
+
+ )} +
); }; @@ -302,50 +306,52 @@ export const FanGraphTile: FC = ({ }, [canReaffirm, forecast, onReaffirm]); return ( -
+
- {!minimalistic && ( - + className={cn( + "MultipleChoiceTile ml-0 flex w-full flex-col items-start", + { + "@[550px]:grid @[550px]:grid-cols-5": showChart, + "gap-8": !minimalistic, + } )} -
- {showChart && ( + >
- + {!minimalistic && ( + + )}
- )} + {showChart && ( +
+ +
+ )} +
); }; diff --git a/front_end/src/components/post_card/question_tile/prediction_continuous_info.tsx b/front_end/src/components/post_card/question_tile/prediction_continuous_info.tsx index 409564549c..310637e864 100644 --- a/front_end/src/components/post_card/question_tile/prediction_continuous_info.tsx +++ b/front_end/src/components/post_card/question_tile/prediction_continuous_info.tsx @@ -63,17 +63,17 @@ const PredictionContinuousInfo: FC = ({ return (
-
+
{!hideCP && ( <> @@ -85,12 +85,12 @@ const PredictionContinuousInfo: FC = ({ question.aggregations[question.default_aggregation_method]?.latest ?.forecaster_count ?? undefined } - className="mx-auto md:mx-0" + className="mx-auto @[550px]:mx-0" /> )}
{showMyPrediction && question.my_forecasts?.latest && ( -
+
= ({ // Binary questions use original side-by-side layout if (question.type === QuestionType.Binary) { return ( -
+
= ({ {/* Mobile: Overlay layout */}
{/* CP values container - positioned first */} -
+
@@ -222,7 +222,7 @@ const QuestionContinuousTile: FC = ({ />
)} @@ -231,8 +231,8 @@ const QuestionContinuousTile: FC = ({ {/* Large screens: Side-by-side layout (like binary questions) */}
@@ -255,7 +255,7 @@ const QuestionContinuousTile: FC = ({ />
)} diff --git a/front_end/src/components/posts_feed/paginated_feed.tsx b/front_end/src/components/posts_feed/paginated_feed.tsx index 5e5be4830f..1068286d69 100644 --- a/front_end/src/components/posts_feed/paginated_feed.tsx +++ b/front_end/src/components/posts_feed/paginated_feed.tsx @@ -231,7 +231,6 @@ const FeedLayoutView: FC<{ type={type} isCommunity={isCommunity} weightByPostId={weightByPostId} - forceConsumer={layout === "grid"} /> )} /> @@ -244,8 +243,7 @@ const FeedItemCard: FC<{ type: PostsFeedType; isCommunity?: boolean; weightByPostId: Map; - forceConsumer?: boolean; -}> = ({ item, feedPage, type, isCommunity, weightByPostId, forceConsumer }) => { +}> = ({ item, feedPage, type, isCommunity, weightByPostId }) => { if (item.type === "project") { return ; } @@ -268,19 +266,11 @@ const FeedItemCard: FC<{ /> } forecaster={ - forceConsumer ? ( - - ) : ( - - ) + } /> ); diff --git a/front_end/src/components/posts_filters.tsx b/front_end/src/components/posts_filters.tsx index f5a9e1e167..209bc0b56b 100644 --- a/front_end/src/components/posts_filters.tsx +++ b/front_end/src/components/posts_filters.tsx @@ -293,12 +293,6 @@ const PostsFilters: FC = ({ placeholder={t("questionSearchPlaceholder")} collapsible /> - {showRandomButton && ( - - )} {!forceLayout && ( = ({ className="hidden lg:flex" /> )} + {showRandomButton && ( + + )}
{!!activeFilters.length && ( From 1f9a1a8062340ee43f740082afa2e9ccfe524326 Mon Sep 17 00:00:00 2001 From: cemreinanc Date: Sat, 28 Mar 2026 15:33:59 +0100 Subject: [PATCH 09/27] review fixes --- front_end/messages/cs.json | 3 +++ front_end/messages/en.json | 3 +++ front_end/messages/es.json | 3 +++ front_end/messages/pt.json | 3 +++ front_end/messages/zh-TW.json | 3 +++ front_end/messages/zh.json | 3 +++ .../(main)/components/content_translated_banner/index.tsx | 4 ++-- front_end/src/app/layout.tsx | 7 +++++-- front_end/src/components/random_button.tsx | 4 +++- front_end/src/components/ui/layout_switcher.tsx | 6 ++++-- 10 files changed, 32 insertions(+), 7 deletions(-) diff --git a/front_end/messages/cs.json b/front_end/messages/cs.json index c24e49a347..41484a1894 100644 --- a/front_end/messages/cs.json +++ b/front_end/messages/cs.json @@ -1016,6 +1016,9 @@ "communitySlugDescription": "Vaše komunita je přístupná přes následující URL:", "communityDescription": "Popis komunity", "contentTranslatedHeaderText": "Některý obsah na této stránce je automaticky přeložen a může být nepřesný.", + "translatedBy": "přeloženo pomocí", + "listLayout": "Zobrazení seznamu", + "gridLayout": "Zobrazení mřížky", "showOriginalContent": "Zobrazit originál", "nextQuestion": "Další otázka", "fullName": "Celé jméno", diff --git a/front_end/messages/en.json b/front_end/messages/en.json index 6990b5091e..58af313a1b 100644 --- a/front_end/messages/en.json +++ b/front_end/messages/en.json @@ -1252,6 +1252,9 @@ "youArePostingAPrivateComment": "You are posting a private comment", "unread": "unread", "contentTranslatedHeaderText": "Some content on this page is automatically translated, and may be inaccurate.", + "translatedBy": "translated by", + "listLayout": "List layout", + "gridLayout": "Grid layout", "showOriginalContent": "Show original", "nextQuestion": "Next Question", "next": "Next", diff --git a/front_end/messages/es.json b/front_end/messages/es.json index 6b9730d29e..6a554543af 100644 --- a/front_end/messages/es.json +++ b/front_end/messages/es.json @@ -1015,6 +1015,9 @@ "communitySlugDescription": "Tu comunidad puede ser accedida a través de la siguiente URL:", "communityDescription": "Descripción de la Comunidad", "contentTranslatedHeaderText": "Parte del contenido en esta página se traduce automáticamente y puede ser inexacto.", + "translatedBy": "traducido por", + "listLayout": "Vista de lista", + "gridLayout": "Vista de cuadrícula", "showOriginalContent": "Mostrar original", "nextQuestion": "Siguiente Pregunta", "fullName": "Nombre Completo", diff --git a/front_end/messages/pt.json b/front_end/messages/pt.json index 730a2a151c..9c3ced4c20 100644 --- a/front_end/messages/pt.json +++ b/front_end/messages/pt.json @@ -969,6 +969,9 @@ "youArePostingAPrivateComment": "Você está postando um comentário privado", "unread": "não lido", "contentTranslatedHeaderText": "Algum conteúdo nesta página foi traduzido automaticamente e pode estar incorreto.", + "translatedBy": "traduzido por", + "listLayout": "Layout de lista", + "gridLayout": "Layout de grade", "showOriginalContent": "Mostrar original", "discard": "Descartar", "onboardingStep4AlmostDone": "Você está quase terminando este tutorial!", diff --git a/front_end/messages/zh-TW.json b/front_end/messages/zh-TW.json index 2296d4646f..1d29499478 100644 --- a/front_end/messages/zh-TW.json +++ b/front_end/messages/zh-TW.json @@ -1042,6 +1042,9 @@ "youArePostingAPrivateComment": "您正在發佈一則私人評論", "unread": "未讀", "contentTranslatedHeaderText": "此頁面上的部分內容是自動翻譯的,可能不準確。", + "translatedBy": "翻譯提供", + "listLayout": "列表佈局", + "gridLayout": "網格佈局", "showOriginalContent": "顯示原文", "nextQuestion": "下一個問題", "next": "下一步", diff --git a/front_end/messages/zh.json b/front_end/messages/zh.json index cca1a8b412..6fdc3cca2f 100644 --- a/front_end/messages/zh.json +++ b/front_end/messages/zh.json @@ -1017,6 +1017,9 @@ "communitySlugDescription": "您可以通过以下网址访问您的社区:", "communityDescription": "社区描述", "contentTranslatedHeaderText": "此页面上的一些内容是自动翻译的,可能不准确。", + "translatedBy": "翻译提供", + "listLayout": "列表布局", + "gridLayout": "网格布局", "showOriginalContent": "显示原文", "nextQuestion": "下一问题", "fullName": "全名", diff --git a/front_end/src/app/(main)/components/content_translated_banner/index.tsx b/front_end/src/app/(main)/components/content_translated_banner/index.tsx index a9f1dc1bd4..c0834ead72 100644 --- a/front_end/src/app/(main)/components/content_translated_banner/index.tsx +++ b/front_end/src/app/(main)/components/content_translated_banner/index.tsx @@ -48,8 +48,8 @@ const ContentTranslatedBanner: FC<{ forceVisible?: boolean }> = ({
- - translated by + + {t("translatedBy")}
diff --git a/front_end/src/app/layout.tsx b/front_end/src/app/layout.tsx index d8f5e3f096..d9babc6771 100644 --- a/front_end/src/app/layout.tsx +++ b/front_end/src/app/layout.tsx @@ -95,8 +95,11 @@ export default async function RootLayout({ const cookieStore = await cookies(); const csrfToken = cookieStore.get(CSRF_COOKIE_NAME)?.value || null; - const feedLayout = (cookieStore.get(FEED_LAYOUT_COOKIE)?.value || - FEED_LAYOUT_DEFAULT) as FeedLayout; + const rawFeedLayout = cookieStore.get(FEED_LAYOUT_COOKIE)?.value; + const feedLayout: FeedLayout = + rawFeedLayout === "list" || rawFeedLayout === "grid" + ? rawFeedLayout + : FEED_LAYOUT_DEFAULT; return ( > = ({ className, ...props }) => { + const t = useTranslations(); const [isLoading, setIsLoading] = useState(false); const router = useRouter(); @@ -35,7 +37,7 @@ const RandomButton: FC> = ({
diff --git a/front_end/src/components/visibility_observer.tsx b/front_end/src/components/visibility_observer.tsx deleted file mode 100644 index 7c1742c2f5..0000000000 --- a/front_end/src/components/visibility_observer.tsx +++ /dev/null @@ -1,35 +0,0 @@ -import React, { useEffect, useRef, PropsWithChildren } from "react"; - -type VisibilityObserverProps = { - onVisibilityChange: (isVisible: boolean) => void; -}; - -const VisibilityObserver: React.FC< - PropsWithChildren -> = ({ children, onVisibilityChange }) => { - const wrapperRef = useRef(null); - - useEffect(() => { - const observer = new IntersectionObserver( - ([entry]) => { - onVisibilityChange(entry?.isIntersecting ?? false); - }, - { threshold: 0.1 } // Adjust threshold as needed - ); - - const currentElement = wrapperRef.current; - if (currentElement) { - observer.observe(currentElement); - } - - return () => { - if (currentElement) { - observer.unobserve(currentElement); - } - }; - }, [onVisibilityChange]); - - return
{children}
; -}; - -export default VisibilityObserver; diff --git a/front_end/src/constants/posts_feed.ts b/front_end/src/constants/posts_feed.ts index 85e7229ce4..3ad7a96d45 100644 --- a/front_end/src/constants/posts_feed.ts +++ b/front_end/src/constants/posts_feed.ts @@ -32,6 +32,5 @@ export const POST_WEEKLY_TOP_COMMENTS_FILTER = "weekly_top_comments"; export const POST_PROJECT_FILTER = "default_project_id"; export const FEED_LAYOUT_COOKIE = "feed_layout"; -export const FEED_LAYOUT_DEFAULT: "list" | "grid" = "grid"; export const POSTS_PER_PAGE = 10; diff --git a/front_end/src/contexts/feed_layout_context.tsx b/front_end/src/contexts/feed_layout_context.tsx index 99365ffe3f..0843206637 100644 --- a/front_end/src/contexts/feed_layout_context.tsx +++ b/front_end/src/contexts/feed_layout_context.tsx @@ -9,10 +9,9 @@ import { } from "react"; import { FeedLayout } from "@/components/ui/layout_switcher"; -import { - FEED_LAYOUT_COOKIE, - FEED_LAYOUT_DEFAULT, -} from "@/constants/posts_feed"; +import { FEED_LAYOUT_COOKIE } from "@/constants/posts_feed"; +import { useAuth } from "@/contexts/auth_context"; +import { InterfaceType } from "@/types/users"; type FeedLayoutContextType = { layout: FeedLayout; @@ -20,14 +19,28 @@ type FeedLayoutContextType = { }; const FeedLayoutContext = createContext({ - layout: FEED_LAYOUT_DEFAULT, + layout: "grid", setLayout: () => {}, }); -const FeedLayoutProvider: FC< - PropsWithChildren<{ initialLayout: FeedLayout }> -> = ({ initialLayout, children }) => { - const [layout, setLayoutState] = useState(initialLayout); +function getInitialLayout( + cookieLayout: string | undefined, + interfaceType: InterfaceType | undefined +): FeedLayout { + if (cookieLayout === "list" || cookieLayout === "grid") { + return cookieLayout; + } + return interfaceType === InterfaceType.ForecasterView ? "list" : "grid"; +} + +const FeedLayoutProvider: FC> = ({ + cookieLayout, + children, +}) => { + const { user } = useAuth(); + const [layout, setLayoutState] = useState(() => + getInitialLayout(cookieLayout, user?.interface_type) + ); const setLayout = (newLayout: FeedLayout) => { setLayoutState(newLayout); diff --git a/front_end/src/contexts/global_search_context.tsx b/front_end/src/contexts/global_search_context.tsx index 8ff0dfe430..9bae82a1c3 100644 --- a/front_end/src/contexts/global_search_context.tsx +++ b/front_end/src/contexts/global_search_context.tsx @@ -5,7 +5,6 @@ import { useState, FC, createContext, - useEffect, Dispatch, SetStateAction, useCallback, @@ -14,11 +13,7 @@ import { import { POST_TEXT_SEARCH_FILTER } from "@/constants/posts_feed"; import useSearchInputState from "@/hooks/use_search_input_state"; -import { useNavigation } from "./navigation_context"; - interface GlobalSearchContextProps { - isVisible: boolean; - setIsVisible: (a: boolean) => void; globalSearch: string; updateGlobalSearch: (search: string) => void; setModifySearchParams: Dispatch>; @@ -26,8 +21,6 @@ interface GlobalSearchContextProps { setIsSearched: Dispatch>; } const GlobalSearchContext = createContext({ - isVisible: false, - setIsVisible: () => {}, globalSearch: "", updateGlobalSearch: () => {}, setModifySearchParams: () => {}, @@ -36,7 +29,6 @@ const GlobalSearchContext = createContext({ }); export const GlobalSearchProvider: FC = ({ children }) => { - const [isVisible, setIsVisible] = useState(false); const [modifySearchParams, setModifySearchParams] = useState(false); const [globalSearch, setGlobalSearch] = useSearchInputState( POST_TEXT_SEARCH_FILTER, @@ -48,20 +40,6 @@ export const GlobalSearchProvider: FC = ({ children }) => { ); const [isSearched, setIsSearched] = useState(false); - const { previousPath, currentPath } = useNavigation(); - useEffect(() => { - if (previousPath !== currentPath && previousPath !== null) { - setIsVisible(false); - } - }, [previousPath, currentPath]); - const [delayedIsVisible, setDelayedIsVisible] = useState(false); - - useEffect(() => { - setTimeout(() => { - setDelayedIsVisible(isVisible); - }, 100); - }, [isVisible]); - const updateGlobalSearch = useCallback( (search: string) => { setGlobalSearch(search); @@ -76,8 +54,6 @@ export const GlobalSearchProvider: FC = ({ children }) => { return ( Date: Mon, 30 Mar 2026 12:54:12 +0200 Subject: [PATCH 12/27] collapsible sidebar titles --- .../(main)/questions/components/sidebar.tsx | 75 +++++++++++++------ .../questions/components/topic_item.tsx | 13 +++- 2 files changed, 64 insertions(+), 24 deletions(-) diff --git a/front_end/src/app/(main)/questions/components/sidebar.tsx b/front_end/src/app/(main)/questions/components/sidebar.tsx index bfc532d4d1..df0ccdb204 100644 --- a/front_end/src/app/(main)/questions/components/sidebar.tsx +++ b/front_end/src/app/(main)/questions/components/sidebar.tsx @@ -1,6 +1,7 @@ "use client"; import { faArrowUp, + faChevronUp, faEllipsis, faHome, } from "@fortawesome/free-solid-svg-icons"; @@ -137,6 +138,21 @@ const FeedSidebar: FC = ({ items }) => { ]); const [isMobileExpanded, setIsMobileExpanded] = useState(false); + const [collapsedSections, setCollapsedSections] = useState< + Set + >(new Set()); + + const toggleSection = (sectionType: SidebarSectionType) => { + setCollapsedSections((prev) => { + const next = new Set(prev); + if (next.has(sectionType)) { + next.delete(sectionType); + } else { + next.add(sectionType); + } + return next; + }); + }; const outerRef = useRef(null); useEffect(() => { @@ -207,28 +223,43 @@ const FeedSidebar: FC = ({ items }) => { > {sidebarSections .filter(({ items }) => items.length > 0) - .map(({ type: sectionType, title, items }) => ( - - {title && ( -
- {title} -
- )} - {items.map(({ name, emoji, onClick, url, isActive }, idx) => ( - { - setIsMobileExpanded(false); - onClick && onClick(); - }} - isActive={isActive ?? false} - /> - ))} -
- ))} + .map(({ type: sectionType, title, items }) => { + const isCollapsed = + sectionType !== null && collapsedSections.has(sectionType); + return ( + + {title && sectionType !== null && ( + + )} + {items.map(({ name, emoji, onClick, url, isActive }, idx) => ( + { + setIsMobileExpanded(false); + onClick && onClick(); + }} + isActive={isActive ?? false} + className={cn(isCollapsed && "sm:hidden")} + /> + ))} + + ); + })} void; href?: string; + className?: string; }; -const TopicItem: FC = ({ isActive, onClick, text, emoji, href }) => { +const TopicItem: FC = ({ + isActive, + onClick, + text, + emoji, + href, + className, +}) => { return (
From 50666dbe721b558317f78a0244d6d729fab7c8fd Mon Sep 17 00:00:00 2001 From: cemreinanc Date: Wed, 29 Apr 2026 17:59:32 +0200 Subject: [PATCH 15/27] qa fixes --- .../components/feed_filters/main.tsx | 1 - .../(main)/questions/components/sidebar.tsx | 2 +- .../app/(main)/questions/hooks/use_feed.tsx | 5 +++- .../src/components/ui/layout_switcher.tsx | 27 ++++++++++--------- front_end/src/utils/sidebar.ts | 26 +++++++++++++++++- 5 files changed, 44 insertions(+), 17 deletions(-) diff --git a/front_end/src/app/(main)/questions/components/feed_filters/main.tsx b/front_end/src/app/(main)/questions/components/feed_filters/main.tsx index 3f51ccb2d2..48de1b1586 100644 --- a/front_end/src/app/(main)/questions/components/feed_filters/main.tsx +++ b/front_end/src/app/(main)/questions/components/feed_filters/main.tsx @@ -205,7 +205,6 @@ const MainFeedFilters: FC = ({ sortOptions={sortOptions} onOrderChange={onOrderChange} defaultOrder={QuestionOrder.HotDesc} - showRandomButton panelClassname={panelClassname} /> ); diff --git a/front_end/src/app/(main)/questions/components/sidebar.tsx b/front_end/src/app/(main)/questions/components/sidebar.tsx index df0ccdb204..d58dbf14fb 100644 --- a/front_end/src/app/(main)/questions/components/sidebar.tsx +++ b/front_end/src/app/(main)/questions/components/sidebar.tsx @@ -180,7 +180,7 @@ const FeedSidebar: FC = ({ items }) => {
diff --git a/front_end/src/app/(main)/questions/hooks/use_feed.tsx b/front_end/src/app/(main)/questions/hooks/use_feed.tsx index 7e6e5e454f..07d9d54e90 100644 --- a/front_end/src/app/(main)/questions/hooks/use_feed.tsx +++ b/front_end/src/app/(main)/questions/hooks/use_feed.tsx @@ -3,6 +3,7 @@ import { useCallback, useMemo } from "react"; import { FeedType, + POST_CATEGORIES_FILTER, POST_COMMUNITIES_FILTER, POST_FOLLOWING_FILTER, POST_FOR_MAIN_FEED, @@ -23,6 +24,7 @@ const useFeed = () => { const pathname = usePathname(); const selectedTopic = params.get(POST_TOPIC_FILTER); + const selectedCategory = params.get(POST_CATEGORIES_FILTER); const guessedById = params.get(POST_FORECASTER_ID_FILTER); const authorUsernames = params.getAll(POST_USERNAMES_FILTER); const following = params.get(POST_FOLLOWING_FILTER); @@ -32,7 +34,7 @@ const useFeed = () => { const commentsFeed = params.get(POST_COMMENTS_FEED_FILTER); const currentFeed = useMemo(() => { - if (selectedTopic) return null; + if (selectedTopic || selectedCategory) return null; if (guessedById) return FeedType.MY_PREDICTIONS; if (following) return FeedType.FOLLOWING; @@ -53,6 +55,7 @@ const useFeed = () => { return FeedType.HOME; }, [ selectedTopic, + selectedCategory, guessedById, following, authorUsernames, diff --git a/front_end/src/components/ui/layout_switcher.tsx b/front_end/src/components/ui/layout_switcher.tsx index 800fbf0585..bfd00c2d99 100644 --- a/front_end/src/components/ui/layout_switcher.tsx +++ b/front_end/src/components/ui/layout_switcher.tsx @@ -17,17 +17,20 @@ type Props = { const LayoutSwitcher: FC = ({ value, onChange, className }) => { const t = useTranslations(); + const nextValue = value === "list" ? "grid" : "list"; + return ( -
onChange(nextValue)} className={cn( - "flex items-center gap-1.5 rounded-full border border-blue-400 bg-gray-0 p-1 dark:border-blue-400-dark dark:bg-gray-0-dark", + "flex cursor-pointer items-center gap-1.5 rounded-full border border-blue-400 bg-gray-0 p-1 dark:border-blue-400-dark dark:bg-gray-0-dark", className )} > - - -
+ + ); }; diff --git a/front_end/src/utils/sidebar.ts b/front_end/src/utils/sidebar.ts index 7eaaa262b1..c2645d8572 100644 --- a/front_end/src/utils/sidebar.ts +++ b/front_end/src/utils/sidebar.ts @@ -2,6 +2,28 @@ import { SidebarItem, SidebarMenuItem } from "@/types/sidebar"; import { sendAnalyticsEvent } from "@/utils/analytics"; import { getPostLink, getProjectLink } from "@/utils/navigation"; +const normalizePathname = (pathname: string) => + pathname.length > 1 ? pathname.replace(/\/$/, "") : pathname; + +const isSidebarItemActive = (currentUrl: string, itemUrl: string): boolean => { + // Parse-only base URL. This is not used for navigation; it just lets URL handle relative URLs. + const current = new URL(currentUrl, "https://metaculus.local"); + const item = new URL(itemUrl, "https://metaculus.local"); + const currentPathname = normalizePathname(current.pathname); + const itemPathname = normalizePathname(item.pathname); + + if (item.searchParams.size > 0) { + return ( + currentPathname === itemPathname && + Array.from(item.searchParams).every( + ([key, value]) => current.searchParams.get(key) === value + ) + ); + } + + return currentPathname.startsWith(itemPathname); +}; + export const convertSidebarItem = ( { name, post, project, section, url, emoji }: SidebarItem, fullPathname?: string @@ -19,7 +41,9 @@ export const convertSidebarItem = ( emoji: emoji || project?.emoji, section, url: itemUrl, - isActive: fullPathname ? fullPathname.startsWith(itemUrl) : undefined, + isActive: fullPathname + ? isSidebarItemActive(fullPathname, itemUrl) + : undefined, onClick: () => { sendAnalyticsEvent("sidebarClick", { event_category: itemName, From 71463347422a2f2590c2a62307feef593c8f3bf7 Mon Sep 17 00:00:00 2001 From: cemreinanc Date: Thu, 30 Apr 2026 16:08:55 +0200 Subject: [PATCH 16/27] feed: sort filter bar updates --- .../components/feed_filters/main.tsx | 79 +++----- .../feed_filters/my_predictions.tsx | 53 ++---- .../feed_filters/my_questions_and_posts.tsx | 50 ++---- .../components/sticky_filter_bar.tsx | 4 +- .../src/components/popover_filter/index.tsx | 33 +++- front_end/src/components/posts_filters.tsx | 169 +++++++++++++----- .../src/components/tournament_filters.tsx | 47 ++--- 7 files changed, 224 insertions(+), 211 deletions(-) diff --git a/front_end/src/app/(main)/questions/components/feed_filters/main.tsx b/front_end/src/app/(main)/questions/components/feed_filters/main.tsx index 48de1b1586..af1432bad8 100644 --- a/front_end/src/app/(main)/questions/components/feed_filters/main.tsx +++ b/front_end/src/app/(main)/questions/components/feed_filters/main.tsx @@ -17,7 +17,6 @@ import { } from "@/constants/posts_feed"; import { useAuth } from "@/contexts/auth_context"; import { usePublicSettings } from "@/contexts/public_settings_context"; -import { useBreakpoint } from "@/hooks/tailwind"; import useSearchParams from "@/hooks/use_search_params"; import ClientProjectsApi from "@/services/api/projects/projects.client"; import { PostStatus } from "@/types/post"; @@ -40,9 +39,6 @@ const MainFeedFilters: FC = ({ const { user } = useAuth(); const { PUBLIC_MINIMAL_UI } = usePublicSettings(); - const isMediumScreen = useBreakpoint("md"); - const isLargeScreen = useBreakpoint("lg"); - const [projectFilters, setProjectFilters] = useState< TournamentPreview[] | undefined >(); @@ -90,63 +86,34 @@ const MainFeedFilters: FC = ({ return filters; }, [params, t, user, projectFilters]); - const mainSortNewsVisible = !PUBLIC_MINIMAL_UI && isLargeScreen; - const mainSortNewVisible = isLargeScreen; - const mainSortOptions: GroupButton[] = useMemo( - () => - isMediumScreen + () => [ + { + value: QuestionOrder.HotDesc, + label: t("hot"), + }, + { + value: QuestionOrder.WeeklyMovementDesc, + label: t("movers"), + }, + { + value: QuestionOrder.OpenTimeDesc, + label: t("new"), + }, + ...(!PUBLIC_MINIMAL_UI ? [ { - value: QuestionOrder.HotDesc, - label: t("hot"), - }, - { - value: QuestionOrder.WeeklyMovementDesc, - label: t("movers"), + value: QuestionOrder.NewsHotness, + label: t("inTheNews"), }, - ...(mainSortNewVisible - ? [ - { - value: QuestionOrder.OpenTimeDesc, - label: t("new"), - }, - ] - : []), - ...(mainSortNewsVisible - ? [ - { - value: QuestionOrder.NewsHotness, - label: t("inTheNews"), - }, - ] - : []), ] - : [], - // eslint-disable-next-line react-hooks/exhaustive-deps - [t, isLargeScreen, isMediumScreen] + : []), + ], + [t, PUBLIC_MINIMAL_UI] ); const sortOptions = useMemo( () => [ - ...(!isMediumScreen - ? [ - { - value: QuestionOrder.HotDesc, - label: t("hot"), - }, - { - value: QuestionOrder.WeeklyMovementDesc, - label: t("movers"), - }, - ] - : []), - ...(!isMediumScreen || !mainSortNewVisible - ? [{ value: QuestionOrder.OpenTimeDesc, label: t("new") }] - : []), - ...((!isMediumScreen || !mainSortNewsVisible) && !PUBLIC_MINIMAL_UI - ? [{ value: QuestionOrder.NewsHotness, label: t("inTheNews") }] - : []), { value: QuestionOrder.VotesDesc, label: t("mostUpvotes") }, { value: QuestionOrder.CommentCountDesc, label: t("mostComments") }, { @@ -165,13 +132,7 @@ const MainFeedFilters: FC = ({ { value: QuestionOrder.ResolveTimeAsc, label: t("resolvingSoon") }, { value: QuestionOrder.CpRevealTimeDesc, label: t("recentlyRevealed") }, ], - [ - mainSortNewVisible, - mainSortNewsVisible, - PUBLIC_MINIMAL_UI, - t, - isMediumScreen, - ] + [t] ); const onOrderChange = ( diff --git a/front_end/src/app/(main)/questions/components/feed_filters/my_predictions.tsx b/front_end/src/app/(main)/questions/components/feed_filters/my_predictions.tsx index bd9dff6157..174395af1e 100644 --- a/front_end/src/app/(main)/questions/components/feed_filters/my_predictions.tsx +++ b/front_end/src/app/(main)/questions/components/feed_filters/my_predictions.tsx @@ -16,7 +16,6 @@ import { POST_STATUS_FILTER, } from "@/constants/posts_feed"; import { useAuth } from "@/contexts/auth_context"; -import { useBreakpoint } from "@/hooks/tailwind"; import useSearchParams from "@/hooks/use_search_params"; import { PostStatus } from "@/types/post"; import { QuestionOrder } from "@/types/question"; @@ -27,7 +26,6 @@ const MyPredictionsFilters: FC = ({ panelClassname }) => { const { params } = useSearchParams(); const t = useTranslations(); const { user } = useAuth(); - const isMediumScreen = useBreakpoint("lg"); const filters = useMemo(() => { const filters = [ @@ -45,44 +43,25 @@ const MyPredictionsFilters: FC = ({ panelClassname }) => { }, [params, t, user]); const mainSortOptions: GroupButton[] = useMemo( - () => - isMediumScreen - ? [ - { - value: QuestionOrder.WeeklyMovementDesc, - label: t("movers"), - }, - { - value: QuestionOrder.UserNextWithdrawTimeAsc, - label: t("withdrawingSoon"), - }, - { - value: QuestionOrder.UnreadCommentCountDesc, - label: t("newComments"), - }, - ] - : [], - [t, isMediumScreen] + () => [ + { + value: QuestionOrder.WeeklyMovementDesc, + label: t("movers"), + }, + { + value: QuestionOrder.UserNextWithdrawTimeAsc, + label: t("withdrawingSoon"), + }, + { + value: QuestionOrder.UnreadCommentCountDesc, + label: t("newComments"), + }, + ], + [t] ); const sortOptions = useMemo( () => [ - ...(!isMediumScreen - ? [ - { - value: QuestionOrder.WeeklyMovementDesc, - label: t("movers"), - }, - { - value: QuestionOrder.UserNextWithdrawTimeAsc, - label: t("withdrawingSoon"), - }, - { - value: QuestionOrder.UnreadCommentCountDesc, - label: t("newComments"), - }, - ] - : []), { value: QuestionOrder.LastPredictionTimeDesc, label: t("recentPredictions"), @@ -98,7 +77,7 @@ const MyPredictionsFilters: FC = ({ panelClassname }) => { { value: QuestionOrder.LastPredictionTimeAsc, label: t("stale") }, { value: QuestionOrder.NewsHotness, label: t("inTheNews") }, ], - [t, isMediumScreen] + [t] ); const handleFilterChange = ( diff --git a/front_end/src/app/(main)/questions/components/feed_filters/my_questions_and_posts.tsx b/front_end/src/app/(main)/questions/components/feed_filters/my_questions_and_posts.tsx index 98d29f4166..843438e209 100644 --- a/front_end/src/app/(main)/questions/components/feed_filters/my_questions_and_posts.tsx +++ b/front_end/src/app/(main)/questions/components/feed_filters/my_questions_and_posts.tsx @@ -12,7 +12,6 @@ import PostsFilters from "@/components/posts_filters"; import { GroupButton } from "@/components/ui/button_group"; import { POST_ACCESS_FILTER } from "@/constants/posts_feed"; import { useAuth } from "@/contexts/auth_context"; -import { useBreakpoint } from "@/hooks/tailwind"; import useSearchParams from "@/hooks/use_search_params"; import { PostStatus } from "@/types/post"; import { QuestionOrder } from "@/types/question"; @@ -23,7 +22,6 @@ const MyQuestionsAndPostsFilters: FC = ({ panelClassname }) => { const { params } = useSearchParams(); const t = useTranslations(); const { user } = useAuth(); - const isMediumScreen = useBreakpoint("lg"); const filters = useMemo(() => { const filters = [ @@ -64,41 +62,25 @@ const MyQuestionsAndPostsFilters: FC = ({ panelClassname }) => { }, [params, t, user]); const mainSortOptions: GroupButton[] = useMemo( - () => - isMediumScreen - ? [ - { - value: QuestionOrder.HotDesc, - label: t("hot"), - }, - { - value: QuestionOrder.WeeklyMovementDesc, - label: t("movers"), - }, - { - value: QuestionOrder.PredictionCountDesc, - label: t("mostPredictions"), - }, - ] - : [], - [t, isMediumScreen] + () => [ + { + value: QuestionOrder.HotDesc, + label: t("hot"), + }, + { + value: QuestionOrder.WeeklyMovementDesc, + label: t("movers"), + }, + { + value: QuestionOrder.PredictionCountDesc, + label: t("mostPredictions"), + }, + ], + [t] ); const sortOptions = useMemo( () => [ - ...(!isMediumScreen - ? [ - { value: QuestionOrder.HotDesc, label: t("hot") }, - { - value: QuestionOrder.WeeklyMovementDesc, - label: t("movers"), - }, - { - value: QuestionOrder.PredictionCountDesc, - label: t("mostPredictions"), - }, - ] - : []), { value: QuestionOrder.UnreadCommentCountDesc, label: t("unreadComments"), @@ -112,7 +94,7 @@ const MyQuestionsAndPostsFilters: FC = ({ panelClassname }) => { { value: QuestionOrder.CpRevealTimeDesc, label: t("recentlyRevealed") }, { value: QuestionOrder.NewsHotness, label: t("inTheNews") }, ], - [t, isMediumScreen] + [t] ); return ( diff --git a/front_end/src/app/(main)/questions/components/sticky_filter_bar.tsx b/front_end/src/app/(main)/questions/components/sticky_filter_bar.tsx index 64eb4b7595..25b5084f95 100644 --- a/front_end/src/app/(main)/questions/components/sticky_filter_bar.tsx +++ b/front_end/src/app/(main)/questions/components/sticky_filter_bar.tsx @@ -80,7 +80,9 @@ const StickyFilterBar: React.FC<{ children: React.ReactNode }> = ({ isStuck ? glassClasses : "border-b border-transparent bg-transparent" )} > -
{children}
+
+ {children} +
); diff --git a/front_end/src/components/popover_filter/index.tsx b/front_end/src/components/popover_filter/index.tsx index df6279efd1..fbe9829b46 100644 --- a/front_end/src/components/popover_filter/index.tsx +++ b/front_end/src/components/popover_filter/index.tsx @@ -1,4 +1,4 @@ -import { faXmark } from "@fortawesome/free-solid-svg-icons"; +import { faFilter, faXmark } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { Popover, PopoverButton, PopoverPanel } from "@headlessui/react"; import { useTranslations } from "next-intl"; @@ -61,6 +61,9 @@ const Panel: FC> = ({ type Props = { filters: FilterSection[]; buttonLabel?: string; + buttonClassName?: string; + clearButtonClassName?: string; + iconOnlyBelowMd?: boolean; panelClassName?: string; onChange: ( filterId: string, @@ -76,6 +79,9 @@ type Props = { const PopoverFilter: FC = ({ filters, buttonLabel, + buttonClassName, + clearButtonClassName, + iconOnlyBelowMd, panelClassName, onChange, onClear, @@ -83,6 +89,7 @@ const PopoverFilter: FC = ({ hasActiveFilters, }) => { const t = useTranslations(); + const resolvedButtonLabel = buttonLabel || t("Filter"); return ( @@ -93,12 +100,14 @@ const PopoverFilter: FC = ({ as={Button} size="sm" variant={hasActiveFilters ? "secondary" : "tertiary"} + aria-label={resolvedButtonLabel} className={cn( hasActiveFilters && "rounded-r-none border-r-0 pr-1.5", { "border-blue-600 bg-blue-100 dark:border-blue-600-dark dark:bg-blue-100-dark": open && !hasActiveFilters, - } + }, + buttonClassName )} onClick={() => sendAnalyticsEvent("feedFilterClick", { @@ -108,17 +117,31 @@ const PopoverFilter: FC = ({ }) } > - {buttonLabel || t("Filter")} + {iconOnlyBelowMd && ( + + )} + + {resolvedButtonLabel} + {hasActiveFilters && ( )}
diff --git a/front_end/src/components/posts_filters.tsx b/front_end/src/components/posts_filters.tsx index c48f66130d..c681d0e4d7 100644 --- a/front_end/src/components/posts_filters.tsx +++ b/front_end/src/components/posts_filters.tsx @@ -1,10 +1,19 @@ "use client"; import { faCircleXmark } from "@fortawesome/free-regular-svg-icons"; +import { faXmark } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { debounce } from "lodash"; import { useTranslations } from "next-intl"; -import { FC, useCallback, useEffect, useMemo } from "react"; +import { + CSSProperties, + FC, + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from "react"; import { getFilterChipColor } from "@/app/(main)/questions/helpers/filters"; import PopoverFilter from "@/components/popover_filter"; @@ -88,6 +97,8 @@ const PostsFilters: FC = ({ }) => { const t = useTranslations(); const { layout, setLayout } = useFeedLayout(); + const actionRailRef = useRef(null); + const [actionRailWidth, setActionRailWidth] = useState(0); const { params, setParam, @@ -121,12 +132,35 @@ const PostsFilters: FC = ({ // eslint-disable-next-line react-hooks/exhaustive-deps }, []); + useEffect(() => { + const actionRail = actionRailRef.current; + + if (!actionRail) { + return; + } + + const updateActionRailWidth = () => { + setActionRailWidth(actionRail.offsetWidth); + }; + + updateActionRailWidth(); + + const resizeObserver = new ResizeObserver(updateActionRailWidth); + resizeObserver.observe(actionRail); + + return () => { + resizeObserver.disconnect(); + }; + }, []); + const eraseSearch = () => { updateGlobalSearch(""); }; const order = (params.get(POST_ORDER_BY_FILTER) ?? defaultOrder) as QuestionOrder; + const hasActiveDropdownSort = + dropdownSortOptions?.some((o) => o.value === order) ?? false; const [popoverFilters, activeFilters] = useMemo(() => { const activeFilters: ActiveFilter[] = filters.flatMap((filterSection) => @@ -232,52 +266,93 @@ const PostsFilters: FC = ({ const removeFilter = (filterId: string, filterValue: string) => { deleteParam(filterId, true, filterValue); }; + const railFadeWidth = actionRailWidth ? 48 : 0; return (
-
- {mainSortOptions.map((button) => ( - - ))} -
+ {mainSortOptions.map((button) => ( + + ))} +
+
+
{dropdownSortOptions && ( - o.value === order) - ? "secondary" - : "tertiary" - } - className="rounded-full" - onChange={handleOrderChange} - onClick={(value) => - sendAnalyticsEvent("feedSortClick", { - event_category: value, - }) - } - options={dropdownSortOptions} - value={order || defaultOrder} - menuPosition="left" - label={ - dropdownSortOptions.find((o) => o.value === order) - ? `${t("sort")}: ${dropdownSortOptions.find((o) => o.value === order)?.label}` - : t("sort") - } - /> +
+ + sendAnalyticsEvent("feedSortClick", { + event_category: value, + }) + } + options={dropdownSortOptions} + value={order || defaultOrder} + menuPosition="right" + label={ + dropdownSortOptions.find((o) => o.value === order) + ? `${t("sort")}: ${dropdownSortOptions.find((o) => o.value === order)?.label}` + : t("sort") + } + /> + {hasActiveDropdownSort && ( + + )} +
)} - {mainSortOptions.length === 0 ?
: null} = ({ onClear={clearPopupFilters} fullScreenEnabled hasActiveFilters={activeFilters.length > 0} + iconOnlyBelowMd + buttonClassName="max-md:shrink-0 max-md:p-0 max-md:[&.rounded-r-none]:rounded-l-full max-md:[&:not(.rounded-r-none)]:rounded-full max-sm:size-[26px] sm:max-md:size-8" + clearButtonClassName="max-sm:px-1.5 max-sm:py-1" /> = ({
{!!activeFilters.length && ( -
+
{activeFilters.map(({ id, label, value }) => ( removeFilter(id, value)} > {label} - + ))}
diff --git a/front_end/src/components/tournament_filters.tsx b/front_end/src/components/tournament_filters.tsx index 3c3632a83d..497bbb5930 100644 --- a/front_end/src/components/tournament_filters.tsx +++ b/front_end/src/components/tournament_filters.tsx @@ -21,7 +21,6 @@ import { POST_WITHDRAWN_FILTER, } from "@/constants/posts_feed"; import { useAuth } from "@/contexts/auth_context"; -import { useBreakpoint } from "@/hooks/tailwind"; import useSearchParams from "@/hooks/use_search_params"; import { PostStatus } from "@/types/post"; import { QuestionOrder } from "@/types/question"; @@ -53,45 +52,31 @@ const TournamentFilters: FC = () => { const { user } = useAuth(); const { params } = useSearchParams(); const t = useTranslations(); - const isMediumScreen = useBreakpoint("md"); const filters = useMemo(() => { return getTournamentPostsFilters({ user, t, params }); }, [params, t, user]); const mainSortOptions = useMemo( - () => - isMediumScreen - ? [ - { - value: QuestionOrder.ActivityDesc, - label: t("hot"), - }, - { - value: QuestionOrder.WeeklyMovementDesc, - label: t("movers"), - }, - { - value: QuestionOrder.OpenTimeDesc, - label: t("new"), - }, - ] - : [], - [t, isMediumScreen] + () => [ + { + value: QuestionOrder.ActivityDesc, + label: t("hot"), + }, + { + value: QuestionOrder.WeeklyMovementDesc, + label: t("movers"), + }, + { + value: QuestionOrder.OpenTimeDesc, + label: t("new"), + }, + ], + [t] ); const sortOptions = useMemo( () => [ - ...(!isMediumScreen - ? [ - { value: QuestionOrder.ActivityDesc, label: t("hot") }, - { - value: QuestionOrder.WeeklyMovementDesc, - label: t("movers"), - }, - { value: QuestionOrder.OpenTimeDesc, label: t("new") }, - ] - : []), { value: QuestionOrder.VotesDesc, label: t("mostUpvotes") }, { value: QuestionOrder.CommentCountDesc, label: t("mostComments") }, { @@ -128,7 +113,7 @@ const TournamentFilters: FC = () => { : []), { value: QuestionOrder.NewsHotness, label: t("inTheNews") }, ], - [t, user, isMediumScreen] + [t, user] ); const handleOrderChange = ( From 10e9fa5de29e25ca990a44f21dd143cefb84f761 Mon Sep 17 00:00:00 2001 From: cemreinanc Date: Thu, 30 Apr 2026 16:09:34 +0200 Subject: [PATCH 17/27] add footer top border --- front_end/src/app/(main)/components/footer.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/front_end/src/app/(main)/components/footer.tsx b/front_end/src/app/(main)/components/footer.tsx index 61b6c824ca..8bd06f9ac6 100644 --- a/front_end/src/app/(main)/components/footer.tsx +++ b/front_end/src/app/(main)/components/footer.tsx @@ -244,7 +244,7 @@ const Footer: FC<{ hideSelectors?: boolean }> = ({ hideSelectors }) => { const handleContactClick = () => setCurrentModal({ type: "contactUs" }); return ( -