From df5466eaad258a37584d16b91194e94f1a293d44 Mon Sep 17 00:00:00 2001 From: cemreinanc Date: Fri, 8 May 2026 15:03:11 +0200 Subject: [PATCH 1/8] wip --- .../components/futureeval-navbar.tsx | 2 +- front_end/src/app/(main)/c/[slug]/page.tsx | 15 +- .../src/app/(main)/c/[slug]/settings/page.tsx | 4 +- .../src/app/(main)/components/bulletin.tsx | 55 +++--- .../src/app/(main)/components/bulletins.tsx | 48 ++---- .../content_translated_banner/index.tsx | 58 ++++--- .../components/headers/community_header.tsx | 15 +- .../components/headers/global_header.tsx | 5 +- .../app/(main)/components/headers/header.tsx | 163 +++++++++--------- ...er.tsx => impersonation_banner_client.tsx} | 6 +- .../impersonation_banner_server.tsx | 14 ++ .../src/app/(main)/components/top_chrome.tsx | 30 ++++ .../(main)/components/top_chrome_client.tsx | 42 +++++ .../components/top_chrome_header_context.tsx | 108 ++++++++++++ .../components/top_chrome_header_slot.tsx | 25 +++ front_end/src/app/(main)/layout.tsx | 44 +++-- .../[id]/[[...slug]]/page_compotent.tsx | 31 ++-- .../[id]/[[...slug]]/page_component.tsx | 26 +-- .../questions/create/[content_type]/page.tsx | 13 +- .../src/app/(main)/questions/create/page.tsx | 13 +- front_end/src/app/not-found.tsx | 4 +- front_end/tailwind.config.ts | 2 +- 22 files changed, 480 insertions(+), 243 deletions(-) rename front_end/src/app/(main)/components/{impersonation_banner.tsx => impersonation_banner_client.tsx} (90%) create mode 100644 front_end/src/app/(main)/components/impersonation_banner_server.tsx create mode 100644 front_end/src/app/(main)/components/top_chrome.tsx create mode 100644 front_end/src/app/(main)/components/top_chrome_client.tsx create mode 100644 front_end/src/app/(main)/components/top_chrome_header_context.tsx create mode 100644 front_end/src/app/(main)/components/top_chrome_header_slot.tsx diff --git a/front_end/src/app/(futureeval)/futureeval/components/futureeval-navbar.tsx b/front_end/src/app/(futureeval)/futureeval/components/futureeval-navbar.tsx index 853fe5e4c4..b978a3054a 100644 --- a/front_end/src/app/(futureeval)/futureeval/components/futureeval-navbar.tsx +++ b/front_end/src/app/(futureeval)/futureeval/components/futureeval-navbar.tsx @@ -14,7 +14,7 @@ const FutureEvalNavbar: React.FC = () => { return (
- + <> +
@@ -99,6 +104,6 @@ export default async function IndividualCommunity(props: Props) {
- + ); } 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..c24aa4f716 100644 --- a/front_end/src/app/(main)/c/[slug]/settings/page.tsx +++ b/front_end/src/app/(main)/c/[slug]/settings/page.tsx @@ -3,7 +3,7 @@ import { getTranslations } from "next-intl/server"; import React, { Suspense } from "react"; import CommunitySettings from "@/app/(main)/c/[slug]/settings/components/settings"; -import CommunityHeader from "@/app/(main)/components/headers/community_header"; +import { TopChromeHeaderSetter } from "@/app/(main)/components/top_chrome_header_context"; import { generateFiltersFromSearchParams } from "@/app/(main)/questions/helpers/filters"; import AwaitedPostsFeed from "@/components/posts_feed"; import LoadingIndicator from "@/components/ui/loading_indicator"; @@ -44,7 +44,7 @@ export default async function CommunityManagementSettings(props: Props) { CommunitySettingsMode.Questions) as CommunitySettingsMode; return ( <> - +
{mode === CommunitySettingsMode.Questions && ( diff --git a/front_end/src/app/(main)/components/bulletin.tsx b/front_end/src/app/(main)/components/bulletin.tsx index c1efb4910b..279805b87c 100644 --- a/front_end/src/app/(main)/components/bulletin.tsx +++ b/front_end/src/app/(main)/components/bulletin.tsx @@ -13,45 +13,42 @@ const Bulletin: FC<{ text: string | React.ReactNode; id?: number; className?: string; -}> = ({ text, id, className }) => { + onHidden?: () => void; +}> = ({ text, id, className, onHidden }) => { const [hidden, setHidden] = useState(false); + if (hidden) { + return null; + } + return (
-
- { - if (id) { - await cancelBulletin(id); - } - setHidden(true); + { + if (id) { + await cancelBulletin(id); + } + setHidden(true); + onHidden?.(); + }} + /> + {typeof text === "string" ? ( +
-
- {typeof text === "string" - ? text.split("\n").map((line, lineIdx) => ( -
- )) - : text} -
-
+ ) : ( + text + )}
); }; diff --git a/front_end/src/app/(main)/components/bulletins.tsx b/front_end/src/app/(main)/components/bulletins.tsx index 22add18a31..b7b66c55fb 100644 --- a/front_end/src/app/(main)/components/bulletins.tsx +++ b/front_end/src/app/(main)/components/bulletins.tsx @@ -9,17 +9,6 @@ import { getBulletinParamsFromPathname } from "@/utils/navigation"; import Bulletin from "./bulletin"; -const HIDE_PREFIXES = [ - "/about", - "/services", - "/help", - "/faq", - "/press", - "/privacy-policy", - "/terms-of-use", - "/futureeval", -] as const; - const Bulletins: FC = () => { const [bulletins, setBulletins] = useState< { @@ -30,13 +19,6 @@ const Bulletins: FC = () => { const pathname = usePathname(); - const shouldHide = useMemo(() => { - return ( - HIDE_PREFIXES.some((p) => pathname === p || pathname.startsWith(p)) || - pathname === "/" - ); - }, [pathname]); - const bulletinParams = useMemo( () => getBulletinParamsFromPathname(pathname), [pathname] @@ -52,19 +34,27 @@ const Bulletins: FC = () => { }, [bulletinParams]); useEffect(() => { - if (!shouldHide) { - void fetchBulletins(); - } else { - setBulletins([]); - } - }, [shouldHide, fetchBulletins]); + void fetchBulletins(); + }, [fetchBulletins]); + + if (bulletins.length === 0) { + return null; + } return ( -
- {!shouldHide && - bulletins.map((bulletin) => ( - - ))} +
+ {bulletins.map((bulletin) => ( + + setBulletins((currentBulletins) => + currentBulletins.filter(({ id }) => id !== bulletin.id) + ) + } + /> + ))}
); }; 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 36af7f6f87..7d0875dab6 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 @@ -8,12 +8,14 @@ import { SetOriginalLanguage as setOriginalLanguage } from "@/components/languag import Button from "@/components/ui/button"; import { useContentTranslatedBannerContext } from "@/contexts/translations_banner_context"; import useSearchParams from "@/hooks/use_search_params"; +import cn from "@/utils/core/cn"; import GoogleTranslateAttribution from "./google_translate_attribution"; -const ContentTranslatedBanner: FC<{ forceVisible?: boolean }> = ({ - forceVisible = false, -}) => { +const ContentTranslatedBanner: FC<{ + forceVisible?: boolean; + className?: string; +}> = ({ forceVisible = false, className }) => { const t = useTranslations(); const { setBannerIsVisible, bannerIsVisible } = useContentTranslatedBannerContext(); @@ -30,33 +32,35 @@ const ContentTranslatedBanner: FC<{ forceVisible?: boolean }> = ({ } return ( - <> -
-
-
-

- {t("contentTranslatedHeaderText")}{" "} - -

- -
-
- - translated by - - -
+
+
+

+ {t("contentTranslatedHeaderText")}{" "} + +

+ +
+
+ + translated by + +
- +
); }; diff --git a/front_end/src/app/(main)/components/headers/community_header.tsx b/front_end/src/app/(main)/components/headers/community_header.tsx index 12cd5bb820..6713821052 100644 --- a/front_end/src/app/(main)/components/headers/community_header.tsx +++ b/front_end/src/app/(main)/components/headers/community_header.tsx @@ -8,6 +8,7 @@ import LanguageMenu from "@/components/language_menu"; import NavLink from "@/components/nav_link"; import ThemeToggle from "@/components/theme_toggle"; import { Community } from "@/types/projects"; +import cn from "@/utils/core/cn"; import { useShowActiveCommunityContext } from "../../c/components/community_context"; import CommunitiesDropdown from "../communities_dropdown"; @@ -16,15 +17,25 @@ import useNavbarLinks from "./hooks/useNavbarLinks"; type Props = { community: Community | null; alwaysShowName?: boolean; + fixed?: boolean; }; -const CommunityHeader: FC = ({ community, alwaysShowName = true }) => { +const CommunityHeader: FC = ({ + community, + alwaysShowName = true, + fixed = true, +}) => { const { showActiveCommunity } = useShowActiveCommunityContext(); const [localShowName, setLocalShowName] = useState(alwaysShowName); const { navbarLinks } = useNavbarLinks({ community }); return ( -
+
= ({ forceDefault = false }) => { +const GlobalHeader: FC = ({ forceDefault = false, fixed = true }) => { const pathname = usePathname(); const withDefaultHeader = forceDefault || getWithDefaultHeader(pathname); if (withDefaultHeader) { - return
; + return
; } return null; diff --git a/front_end/src/app/(main)/components/headers/header.tsx b/front_end/src/app/(main)/components/headers/header.tsx index 2a76ae740b..6c22046079 100644 --- a/front_end/src/app/(main)/components/headers/header.tsx +++ b/front_end/src/app/(main)/components/headers/header.tsx @@ -14,14 +14,17 @@ import { useAuth } from "@/contexts/auth_context"; import cn from "@/utils/core/cn"; import { isPathEqual } from "@/utils/navigation"; -import ContentTranslatedBanner from "../content_translated_banner"; import GlobalSearch from "../global_search"; import MobileMenu from "./components/mobile_menu"; import NavbarLinks from "./components/navbar_links"; import NavbarLogo from "./components/navbar_logo"; import useNavbarLinks from "./hooks/useNavbarLinks"; -const Header: FC = () => { +type Props = { + fixed?: boolean; +}; + +const Header: FC = ({ fixed = true }) => { const t = useTranslations(); const { user } = useAuth(); const pathname = usePathname(); @@ -29,91 +32,93 @@ const Header: FC = () => { const { lgLinks, smLinks, xsLinks, xxsLinks } = navbarLinks; return ( - <> -
-
- +
+
+ - {/* Regular links */} - - - - + {/* Regular links */} + + + + - {/* The More menu */} -
- - - isPathEqual(pathname, link.href ?? "") - ), - } - )} - > - {t("more")} - - - - - {menuLinks.map((link) => ( - - ))} - - -
+ {/* The More menu */} +
+ + + isPathEqual(pathname, link.href ?? "") + ), + } + )} + > + {t("more")} + + + + + {menuLinks.map((link) => ( + + ))} + +
+
- {/* Right-side items wrapper */} -
- {/* Global Search */} - + {/* Right-side items wrapper */} +
+ {/* Global Search */} + -
    - {!!user && ( -
  • - - {LINKS.createQuestion.label} - -
  • - )} -
  • - +
      + {!!user && ( +
    • + + {LINKS.createQuestion.label} +
    • -
    - - {!user && ( -
    - -
    )} +
  • + +
  • +
- -
-
- - + {!user && ( +
+ +
+ )} + + +
+
); }; diff --git a/front_end/src/app/(main)/components/impersonation_banner.tsx b/front_end/src/app/(main)/components/impersonation_banner_client.tsx similarity index 90% rename from front_end/src/app/(main)/components/impersonation_banner.tsx rename to front_end/src/app/(main)/components/impersonation_banner_client.tsx index 5c7aafadc2..0233f4a4e8 100644 --- a/front_end/src/app/(main)/components/impersonation_banner.tsx +++ b/front_end/src/app/(main)/components/impersonation_banner_client.tsx @@ -7,7 +7,7 @@ import { stopImpersonatingAction } from "@/app/(main)/accounts/settings/actions" import Button from "@/components/ui/button"; import { useAuth } from "@/contexts/auth_context"; -const ImpersonationBanner: FC = () => { +export const ImpersonationBannerClient: FC = () => { const t = useTranslations(); const [isLoading, setIsLoading] = useState(false); const { user } = useAuth(); @@ -17,7 +17,7 @@ const ImpersonationBanner: FC = () => { stopImpersonatingAction().finally(() => setIsLoading(false)); }; - if (!user?.is_bot) return; + if (!user?.is_bot) return null; return (
@@ -33,5 +33,3 @@ const ImpersonationBanner: FC = () => {
); }; - -export default ImpersonationBanner; diff --git a/front_end/src/app/(main)/components/impersonation_banner_server.tsx b/front_end/src/app/(main)/components/impersonation_banner_server.tsx new file mode 100644 index 0000000000..c4003e6475 --- /dev/null +++ b/front_end/src/app/(main)/components/impersonation_banner_server.tsx @@ -0,0 +1,14 @@ +import { getAuthCookieManager } from "@/services/auth_tokens"; + +import { ImpersonationBannerClient } from "./impersonation_banner_client"; + +export const ImpersonationBanner = async () => { + const authManager = await getAuthCookieManager(); + const isImpersonating = authManager.isImpersonating(); + + if (!isImpersonating) { + return null; + } + + return ; +}; diff --git a/front_end/src/app/(main)/components/top_chrome.tsx b/front_end/src/app/(main)/components/top_chrome.tsx new file mode 100644 index 0000000000..93d9a43d6c --- /dev/null +++ b/front_end/src/app/(main)/components/top_chrome.tsx @@ -0,0 +1,30 @@ +import Bulletins from "./bulletins"; +import ContentTranslatedBanner from "./content_translated_banner"; +import { ImpersonationBanner } from "./impersonation_banner_server"; +import { TopChromeClient } from "./top_chrome_client"; +import { TopChromeHeaderSlot } from "./top_chrome_header_slot"; + +type Props = { + hideBulletins?: boolean; + hideHeader?: boolean; + hideImpersonationBanner?: boolean; + hideTranslationBanner?: boolean; + defaultHeader?: React.ReactNode; +}; + +export const TopChrome = ({ + hideBulletins = false, + hideHeader = false, + hideImpersonationBanner = false, + hideTranslationBanner = false, + defaultHeader, +}: Props) => { + return ( + + {!hideBulletins && } + {!hideHeader && } + {!hideImpersonationBanner && } + {!hideTranslationBanner && } + + ); +}; diff --git a/front_end/src/app/(main)/components/top_chrome_client.tsx b/front_end/src/app/(main)/components/top_chrome_client.tsx new file mode 100644 index 0000000000..decaf4549f --- /dev/null +++ b/front_end/src/app/(main)/components/top_chrome_client.tsx @@ -0,0 +1,42 @@ +"use client"; + +import { FC, ReactNode, useEffect, useRef } from "react"; + +const TOP_CHROME_HEIGHT_CSS_VAR = "--top-chrome-height"; + +export const TopChromeClient: FC<{ children: ReactNode }> = ({ children }) => { + const topChromeRef = useRef(null); + + useEffect(() => { + const topChromeEl = topChromeRef.current; + if (!topChromeEl) { + return; + } + + const updateTopChromeHeight = () => { + document.documentElement.style.setProperty( + TOP_CHROME_HEIGHT_CSS_VAR, + `${topChromeEl.getBoundingClientRect().height}px` + ); + }; + + updateTopChromeHeight(); + + const observer = new ResizeObserver(updateTopChromeHeight); + observer.observe(topChromeEl); + + return () => { + observer.disconnect(); + document.documentElement.style.removeProperty(TOP_CHROME_HEIGHT_CSS_VAR); + }; + }, []); + + return ( +
+ {children} +
+ ); +}; diff --git a/front_end/src/app/(main)/components/top_chrome_header_context.tsx b/front_end/src/app/(main)/components/top_chrome_header_context.tsx new file mode 100644 index 0000000000..7613f42dd9 --- /dev/null +++ b/front_end/src/app/(main)/components/top_chrome_header_context.tsx @@ -0,0 +1,108 @@ +"use client"; + +import { usePathname } from "next/navigation"; +import { + createContext, + FC, + PropsWithChildren, + useCallback, + useContext, + useEffect, + useMemo, + useState, +} from "react"; + +import { Community } from "@/types/projects"; + +export type TopChromeHeaderConfig = + | { + type: "community"; + community: Community | null; + alwaysShowName?: boolean; + } + | { + type: "default"; + }; + +type TopChromeHeaderState = { + routeKey: string; + header: TopChromeHeaderConfig; +}; + +type TopChromeHeaderContextType = { + activeHeader: TopChromeHeaderConfig | null; + routeKey: string; + setHeaderForRoute: (routeKey: string, header: TopChromeHeaderConfig) => void; +}; + +const TopChromeHeaderContext = createContext({ + activeHeader: null, + routeKey: "", + setHeaderForRoute: () => {}, +}); + +const getHeaderIdentity = (header: TopChromeHeaderConfig) => { + if (header.type === "default") { + return "default"; + } + + return `community:${header.community?.id ?? "none"}:${header.alwaysShowName ?? true}`; +}; + +export const TopChromeHeaderProvider: FC = ({ + children, +}) => { + const routeKey = usePathname(); + const [headerState, setHeaderState] = useState( + null + ); + + const activeHeader = + headerState?.routeKey === routeKey ? headerState.header : null; + + const setHeaderForRoute = useCallback( + (nextRouteKey: string, header: TopChromeHeaderConfig) => { + setHeaderState((previousHeaderState) => { + if ( + previousHeaderState?.routeKey === nextRouteKey && + getHeaderIdentity(previousHeaderState.header) === + getHeaderIdentity(header) + ) { + return previousHeaderState; + } + + return { routeKey: nextRouteKey, header }; + }); + }, + [] + ); + + const value = useMemo( + () => ({ + activeHeader, + routeKey, + setHeaderForRoute, + }), + [activeHeader, routeKey, setHeaderForRoute] + ); + + return ( + + {children} + + ); +}; + +export const useTopChromeHeader = () => useContext(TopChromeHeaderContext); + +export const TopChromeHeaderSetter: FC<{ + header: TopChromeHeaderConfig; +}> = ({ header }) => { + const { routeKey, setHeaderForRoute } = useTopChromeHeader(); + + useEffect(() => { + setHeaderForRoute(routeKey, header); + }, [header, routeKey, setHeaderForRoute]); + + return null; +}; diff --git a/front_end/src/app/(main)/components/top_chrome_header_slot.tsx b/front_end/src/app/(main)/components/top_chrome_header_slot.tsx new file mode 100644 index 0000000000..acef679d73 --- /dev/null +++ b/front_end/src/app/(main)/components/top_chrome_header_slot.tsx @@ -0,0 +1,25 @@ +"use client"; + +import CommunityHeader from "./headers/community_header"; +import Header from "./headers/header"; +import { useTopChromeHeader } from "./top_chrome_header_context"; + +type Props = { + defaultHeader?: React.ReactNode; +}; + +export const TopChromeHeaderSlot = ({ defaultHeader }: Props) => { + const { activeHeader } = useTopChromeHeader(); + + if (activeHeader?.type === "community") { + return ( + + ); + } + + return defaultHeader ||
; +}; diff --git a/front_end/src/app/(main)/layout.tsx b/front_end/src/app/(main)/layout.tsx index 63440b91e9..c9a1732cac 100644 --- a/front_end/src/app/(main)/layout.tsx +++ b/front_end/src/app/(main)/layout.tsx @@ -2,17 +2,16 @@ import { config } from "@fortawesome/fontawesome-svg-core"; import "@fortawesome/fontawesome-svg-core/styles.css"; import type { Metadata } from "next"; +import ShowActiveCommunityProvider from "@/app/(main)/c/components/community_context"; import { defaultDescription } from "@/constants/metadata"; import { PrintOverrideProvider } from "@/contexts/theme_override_context"; -import { getAuthCookieManager } from "@/services/auth_tokens"; import { getPublicSettings } from "@/utils/public_settings.server"; import FeedbackFloat from "./(home)/components/feedback_float"; -import Bulletins from "./components/bulletins"; import CookiesBanner from "./components/cookies_banner"; import Footer from "./components/footer"; -import GlobalHeader from "./components/headers/global_header"; -import ImpersonationBanner from "./components/impersonation_banner"; +import { TopChrome } from "./components/top_chrome"; +import { TopChromeHeaderProvider } from "./components/top_chrome_header_context"; import VersionChecker from "./components/version_checker"; config.autoAddCss = false; @@ -24,32 +23,29 @@ export const metadata: Metadata = { const { PUBLIC_MINIMAL_UI } = getPublicSettings(); -export default async function RootLayout({ +export default function RootLayout({ children, }: Readonly<{ children: React.ReactNode; }>) { - const authManager = await getAuthCookieManager(); - const isImpersonating = authManager.isImpersonating(); - return ( -
- - - {isImpersonating && } - - -
{children}
- {!PUBLIC_MINIMAL_UI && ( - <> - -
- - )} - - -
+ + +
+ +
{children}
+ {!PUBLIC_MINIMAL_UI && ( + <> + +
+ + )} + + +
+
+
); } diff --git a/front_end/src/app/(main)/notebooks/[id]/[[...slug]]/page_compotent.tsx b/front_end/src/app/(main)/notebooks/[id]/[[...slug]]/page_compotent.tsx index f95f998d38..ce0a766948 100644 --- a/front_end/src/app/(main)/notebooks/[id]/[[...slug]]/page_compotent.tsx +++ b/front_end/src/app/(main)/notebooks/[id]/[[...slug]]/page_compotent.tsx @@ -5,8 +5,7 @@ import { getLocale, getTranslations } from "next-intl/server"; import { FC } from "react"; import CommentsFeedProvider from "@/app/(main)/components/comments_feed_provider"; -import CommunityHeader from "@/app/(main)/components/headers/community_header"; -import Header from "@/app/(main)/components/headers/header"; +import { TopChromeHeaderSetter } from "@/app/(main)/components/top_chrome_header_context"; import NotebookContentSections from "@/app/(main)/notebooks/components/notebook_content_sections"; import NotebookEditor from "@/app/(main)/notebooks/components/notebook_editor"; import { @@ -39,28 +38,26 @@ const IndividualNotebookPage: FC<{ return notFound(); } - const isCommunityQuestion = defaultProject?.type === TournamentType.Community; - let currentCommunity = null; - if (isCommunityQuestion) { - currentCommunity = await ServerProjectsApi.getCommunity( - defaultProject.slug as string - ); - } + const isCommunityNotebook = defaultProject?.type === TournamentType.Community; + const community = + isCommunityNotebook && defaultProject?.slug + ? await ServerProjectsApi.getCommunity(defaultProject.slug) + : null; const locale = await getLocale(); const t = await getTranslations(); const questionTitle = getPostTitle(postData); - const HeaderElement = isCommunityQuestion ? ( - - ) : ( -
- ); - return ( <> - {HeaderElement} - + {community && ( + + )}
{postData.notebook.image_url && postData.notebook.image_url.startsWith("https:") && ( diff --git a/front_end/src/app/(main)/questions/[id]/[[...slug]]/page_component.tsx b/front_end/src/app/(main)/questions/[id]/[[...slug]]/page_component.tsx index 463ae9286e..cf5184b60c 100644 --- a/front_end/src/app/(main)/questions/[id]/[[...slug]]/page_component.tsx +++ b/front_end/src/app/(main)/questions/[id]/[[...slug]]/page_component.tsx @@ -3,8 +3,7 @@ import { FC } from "react"; import { CoherenceLinksProvider } from "@/app/(main)/components/coherence_links_provider"; import CommentsFeedProvider from "@/app/(main)/components/comments_feed_provider"; -import CommunityHeader from "@/app/(main)/components/headers/community_header"; -import Header from "@/app/(main)/components/headers/header"; +import { TopChromeHeaderSetter } from "@/app/(main)/components/top_chrome_header_context"; import HideCPProvider from "@/contexts/cp_context"; import { EmbedModalContextProvider } from "@/contexts/embed_modal_context"; import { PostSubscriptionProvider } from "@/contexts/post_subscription_context"; @@ -43,12 +42,10 @@ const IndividualQuestionPage: FC<{ } const isCommunityQuestion = defaultProject?.type === TournamentType.Community; - let currentCommunity = null; - if (isCommunityQuestion) { - currentCommunity = await ServerProjectsApi.getCommunity( - defaultProject.slug as string - ); - } + const community = + isCommunityQuestion && defaultProject?.slug + ? await ServerProjectsApi.getCommunity(defaultProject.slug) + : null; const preselectedGroupQuestionId = extractPreselectedGroupQuestionId(searchParams); @@ -61,15 +58,18 @@ const IndividualQuestionPage: FC<{ return ( + {community && ( + + )} - {isCommunityQuestion ? ( - - ) : ( -
- )}
- {community ? :
} + {component} diff --git a/front_end/src/app/(main)/questions/create/page.tsx b/front_end/src/app/(main)/questions/create/page.tsx index b6f84d1d2b..cbbfafb7c2 100644 --- a/front_end/src/app/(main)/questions/create/page.tsx +++ b/front_end/src/app/(main)/questions/create/page.tsx @@ -2,8 +2,7 @@ import Link from "next/link"; import { getTranslations } from "next-intl/server"; import React from "react"; -import CommunityHeader from "@/app/(main)/components/headers/community_header"; -import Header from "@/app/(main)/components/headers/header"; +import { TopChromeHeaderSetter } from "@/app/(main)/components/top_chrome_header_context"; import { EXPRESSION_OF_INTEREST_FORM_URL } from "@/app/(main)/pro-forecasters/constants/expression_of_interest_form"; import QuestionRepost from "@/app/(main)/questions/components/question_repost"; import ServerProjectsApi from "@/services/api/projects/projects.server"; @@ -55,8 +54,16 @@ const Creator: React.FC<{ searchParams: Promise }> = async ( return ( <> + - {community ? :
}

diff --git a/front_end/src/app/not-found.tsx b/front_end/src/app/not-found.tsx index 4ec74031b5..9b2b74cb91 100644 --- a/front_end/src/app/not-found.tsx +++ b/front_end/src/app/not-found.tsx @@ -3,14 +3,14 @@ import { getPublicSettings } from "@/utils/public_settings.server"; import FeedbackFloat from "./(main)/(home)/components/feedback_float"; import Footer from "./(main)/components/footer"; -import GlobalHeader from "./(main)/components/headers/global_header"; +import { TopChrome } from "./(main)/components/top_chrome"; const { PUBLIC_MINIMAL_UI } = getPublicSettings(); export default async function NotFound() { return (
- + {!PUBLIC_MINIMAL_UI && ( diff --git a/front_end/tailwind.config.ts b/front_end/tailwind.config.ts index 42a7389b3f..261486f6f4 100644 --- a/front_end/tailwind.config.ts +++ b/front_end/tailwind.config.ts @@ -84,7 +84,7 @@ const config: Config = { "url(\"data:image/svg+xml,%3csvg width='100%25' height='100%25' xmlns='http://www.w3.org/2000/svg'%3e%3crect width='100%25' height='100%25' fill='none' rx='4' ry='4' stroke='%236387A8FF' stroke-width='1' stroke-dasharray='4%2c 6' stroke-dashoffset='0' stroke-linecap='square'/%3e%3c/svg%3e\")", }, spacing: { - header: "3rem", + header: "var(--top-chrome-height,3rem)", }, borderRadius: { xs: "2px", From 2697d6722f5f4420fb800cb864fd04059adb3766 Mon Sep 17 00:00:00 2001 From: cemreinanc Date: Sat, 9 May 2026 16:44:44 +0200 Subject: [PATCH 2/8] fix layouts using header and header height --- .../bridgewater-2025/contest-rules/page.tsx | 4 +- .../bridgewater-2025/how-it-works/page.tsx | 4 +- .../(bridgewater)/bridgewater-2025/layout.tsx | 5 +- .../bridgewater-2025/leaderboards/page.tsx | 4 +- .../notice-at-collection/page.tsx | 4 +- .../(bridgewater)/bridgewater-2025/page.tsx | 5 +- .../bridgewater-2025/q1/page.tsx | 4 +- .../(bridgewater)/bridgewater-reg/layout.tsx | 5 +- .../(bridgewater)/bridgewater-reg/page.tsx | 3 -- .../bridgewater/contest-rules/page.tsx | 4 +- .../bridgewater/how-it-works/page.tsx | 4 +- .../(bridgewater)/bridgewater/layout.tsx | 5 +- .../bridgewater/leaderboards/page.tsx | 4 +- .../bridgewater/notice-at-collection/page.tsx | 4 +- .../(bridgewater)/bridgewater/page.tsx | 4 +- .../(bridgewater)/bridgewater/q1/page.tsx | 4 +- .../id-verification/page.tsx | 6 +-- .../rand/confirm/page.tsx | 3 -- .../rand/contest-rules/page.tsx | 5 +- .../(campaigns-registration)/rand/layout.tsx | 5 +- .../(campaigns-registration)/rand/page.tsx | 3 -- .../components/tournaments_header.tsx | 12 ++--- .../components/aggregation_graph_panel.tsx | 25 ++++++---- .../components/headers/global_header.tsx | 25 ---------- .../components/labor_hub_navigation.tsx | 8 +-- front_end/src/app/(main)/not-found.tsx | 9 +--- .../components/notebook_content_sections.tsx | 21 +++++--- .../(main)/questions/components/sidebar.tsx | 17 +------ front_end/src/app/(main)/questions/page.tsx | 2 +- .../src/components/markdown_editor/editor.css | 2 +- front_end/src/components/ui/tabs/index.tsx | 2 +- front_end/src/hooks/use_top_chrome_height.ts | 49 +++++++++++++++++++ 32 files changed, 126 insertions(+), 135 deletions(-) delete mode 100644 front_end/src/app/(main)/components/headers/global_header.tsx create mode 100644 front_end/src/hooks/use_top_chrome_height.ts diff --git a/front_end/src/app/(campaigns-registration)/(bridgewater)/bridgewater-2025/contest-rules/page.tsx b/front_end/src/app/(campaigns-registration)/(bridgewater)/bridgewater-2025/contest-rules/page.tsx index 05a4158ff7..aa6ade6af4 100644 --- a/front_end/src/app/(campaigns-registration)/(bridgewater)/bridgewater-2025/contest-rules/page.tsx +++ b/front_end/src/app/(campaigns-registration)/(bridgewater)/bridgewater-2025/contest-rules/page.tsx @@ -2,7 +2,6 @@ import Link from "next/link"; import Button from "@/components/ui/button"; -import GlobalHeader from "../../../../(main)/components/headers/global_header"; import PageWrapper from "../../../../(main)/components/pagewrapper"; export const metadata = { @@ -14,8 +13,7 @@ export const metadata = { export default function ContestRules() { return ( <> - -
+
- -

+ + +

+ M +

+
+ + + + + + + + + } + />
{/* Progress section skeleton */} diff --git a/front_end/src/app/(prediction-flow)/tournament/[slug]/prediction-flow/page.tsx b/front_end/src/app/(prediction-flow)/tournament/[slug]/prediction-flow/page.tsx index 769d5a8fd9..6c4351f849 100644 --- a/front_end/src/app/(prediction-flow)/tournament/[slug]/prediction-flow/page.tsx +++ b/front_end/src/app/(prediction-flow)/tournament/[slug]/prediction-flow/page.tsx @@ -2,6 +2,7 @@ import { Metadata } from "next"; import { notFound, redirect } from "next/navigation"; import { getTranslations } from "next-intl/server"; +import { TopChrome } from "@/app/(main)/components/top_chrome"; import PredictionFlowHeader from "@/app/(prediction-flow)/components/header"; import PredictionFlowPost from "@/app/(prediction-flow)/components/prediction_flow_post"; import PredictionFlowProvider, { @@ -63,9 +64,13 @@ export default async function PredictionFlow(props: Props) { flowType={flowType} initialPosts={forecastFlowPosts} > - + } />
diff --git a/front_end/src/app/(services-quiz)/components/services_quiz_flow_content.tsx b/front_end/src/app/(services-quiz)/components/services_quiz_flow_content.tsx new file mode 100644 index 0000000000..870d08eb22 --- /dev/null +++ b/front_end/src/app/(services-quiz)/components/services_quiz_flow_content.tsx @@ -0,0 +1,37 @@ +"use client"; + +import { FC } from "react"; + +import { ServicesQuizStepId } from "./quiz_state/services_quiz_answers_provider"; +import { useServicesQuizFlow } from "./quiz_state/services_quiz_flow_provider"; +import ServicesQuizStepper from "./services_quiz_stepper"; +import ServicesQuizFinal from "./steps/services_quiz_final"; +import ServicesQuizStep1 from "./steps/services_quiz_step_1"; +import ServicesQuizStep2 from "./steps/services_quiz_step_2"; +import ServicesQuizStep3 from "./steps/services_quiz_step_3"; +import ServicesQuizStep4 from "./steps/services_quiz_step_4"; +import ServicesQuizStep5 from "./steps/services_quiz_step_5"; + +const STEP_COMPONENTS: Record = { + 1: ServicesQuizStep1, + 2: ServicesQuizStep2, + 3: ServicesQuizStep3, + 4: ServicesQuizStep4, + 5: ServicesQuizStep5, + 6: ServicesQuizFinal, +}; + +const ServicesQuizFlowContent: FC = () => { + const { step } = useServicesQuizFlow(); + const ActiveStep = + STEP_COMPONENTS[step as ServicesQuizStepId] ?? STEP_COMPONENTS[1]; + + return ( +
+ + +
+ ); +}; + +export default ServicesQuizFlowContent; diff --git a/front_end/src/app/(services-quiz)/components/services_quiz_header.tsx b/front_end/src/app/(services-quiz)/components/services_quiz_header.tsx index 8d89746fa2..73f222605c 100644 --- a/front_end/src/app/(services-quiz)/components/services_quiz_header.tsx +++ b/front_end/src/app/(services-quiz)/components/services_quiz_header.tsx @@ -17,12 +17,16 @@ import cn from "@/utils/core/cn"; import { useServicesQuizExitGuard } from "./quiz_state/services_quiz_exit_guard_provider"; -const ServicesQuizHeader: FC = () => { +type Props = { + className?: string; +}; + +const ServicesQuizHeader: FC = ({ className }) => { const t = useTranslations(); const { requestExit } = useServicesQuizExitGuard(); return ( - + = ({ initialCategory }) => { - const onSubmit = useCallback(async (payload: ServicesQuizSubmitPayload) => { - await appendServicesQuizRow(payload); - }, []); - return ( - - - ); -}; - -const STEP_COMPONENTS: Record = { - 1: ServicesQuizStep1, - 2: ServicesQuizStep2, - 3: ServicesQuizStep3, - 4: ServicesQuizStep4, - 5: ServicesQuizStep5, - 6: ServicesQuizFinal, -}; - -const ServicesQuizScreenInner: FC = () => { - const { step } = useServicesQuizFlow(); - const ActiveStep = - STEP_COMPONENTS[step as ServicesQuizStepId] ?? STEP_COMPONENTS[1]; - - return ( - <> - -
- - -
+ } /> + - + ); }; diff --git a/front_end/src/app/(storefront)/layout.tsx b/front_end/src/app/(storefront)/layout.tsx index d98010a076..103ca3510b 100644 --- a/front_end/src/app/(storefront)/layout.tsx +++ b/front_end/src/app/(storefront)/layout.tsx @@ -8,6 +8,7 @@ import { ThemeOverrideContainer } from "@/contexts/theme_override_context"; import StorefrontFooter from "./components/storefront_footer"; import FeedbackFloat from "../(main)/(home)/components/feedback_float"; import CookiesBanner from "../(main)/components/cookies_banner"; +import { TopChrome } from "../(main)/components/top_chrome"; import VersionChecker from "../(main)/components/version_checker"; config.autoAddCss = false; @@ -25,8 +26,9 @@ export default function StorefrontLayout({ return ( +
{children}
diff --git a/front_end/src/components/flow/flow_header.tsx b/front_end/src/components/flow/flow_header.tsx index 1ed4d9d211..07776da927 100644 --- a/front_end/src/components/flow/flow_header.tsx +++ b/front_end/src/components/flow/flow_header.tsx @@ -9,6 +9,8 @@ import React, { useMemo, } from "react"; +import cn from "@/utils/core/cn"; + type Ctx = { title: ReactNode; }; @@ -24,15 +26,25 @@ const useFlowHeader = () => { }; type RootProps = PropsWithChildren<{ + className?: string; title: ReactNode; }>; -export const FlowHeaderRoot: FC = ({ title, children }) => { +export const FlowHeaderRoot: FC = ({ + className, + title, + children, +}) => { const value = useMemo(() => ({ title }), [title]); return ( -
+
{children}
diff --git a/front_end/src/utils/navigation.ts b/front_end/src/utils/navigation.ts index 37f1176ddf..2bfc27c43a 100644 --- a/front_end/src/utils/navigation.ts +++ b/front_end/src/utils/navigation.ts @@ -167,13 +167,6 @@ export const getProjectSlug = (project: Pick) => { return project.slug ?? project.id; }; -export const getWithDefaultHeader = (pathname: string): boolean => - !pathname.match(/^\/questions\/(\d+)(\/.*)?$/) && - !pathname.match(/^\/notebooks\/(\d+)(\/.*)?$/) && - !pathname.startsWith("/c/") && - !pathname.startsWith("/questions/create") && - !pathname.startsWith("/futureeval"); - /** * Ensures trailing slash is handled properly, e.g. when link is defined manually in code * From efa912a6c6061ba032b61f5bcc83dc0668d8be03 Mon Sep 17 00:00:00 2001 From: cemreinanc Date: Sat, 9 May 2026 23:26:32 +0200 Subject: [PATCH 6/8] fix style and ssr of bulletin --- front_end/bun.lock | 22 ++++++----- front_end/package.json | 2 + .../src/app/(main)/components/bulletin.tsx | 38 +++++++++++++------ 3 files changed, 41 insertions(+), 21 deletions(-) diff --git a/front_end/bun.lock b/front_end/bun.lock index 7804b0922c..41e04b83f8 100644 --- a/front_end/bun.lock +++ b/front_end/bun.lock @@ -87,6 +87,7 @@ "react-merge-refs": "^2.1.1", "react-tweet": "^3.3.0", "remark": "^15.0.1", + "sanitize-html": "2.17.3", "sass": "^1.99.0", "sharp": "^0.34.5", "storybook": "^9.1.20", @@ -102,6 +103,7 @@ "devDependencies": { "@eslint/eslintrc": "^3.3.0", "@types/jest": "^29.5.14", + "@types/sanitize-html": "2.16.1", "eslint": "^9.0.0", "eslint-config-next": "^16.2.4", "eslint-config-prettier": "^10.1.0", @@ -1380,6 +1382,8 @@ "@types/resolve": ["@types/resolve@1.20.6", "", {}, "sha512-A4STmOXPhMUtHH+S6ymgE2GiBSMqf4oTvcQZMcHzokuTLVYzXTB8ttjcgxOVaAp2lGwEdzZ0J+cRbbeevQj1UQ=="], + "@types/sanitize-html": ["@types/sanitize-html@2.16.1", "", { "dependencies": { "htmlparser2": "^10.1" } }, "sha512-n9wjs8bCOTyN/ynwD8s/nTcTreIHB1vf31vhLMGqUPNHaweKC4/fAl4Dj+hUlCTKYgm4P3k83fmiFfzkZ6sgMA=="], + "@types/stack-utils": ["@types/stack-utils@2.0.3", "", {}, "sha512-9aEbYZ3TbYMznPdcdr3SmIrLXwC/AKZXQeCf9Pgao5CKb8CyHuEX5jzWPTkvregvhRJHcpRO6BFoGW9ycaOkYw=="], "@types/tedious": ["@types/tedious@4.0.14", "", { "dependencies": { "@types/node": "*" } }, "sha512-KHPsfX/FoVbUGbyYvk1q9MMQHLPeRZhRJZdO45Q4YjvFkv4hMNghCWTvy7rdKessBsmtz4euWCWAB6/tVpI1Iw=="], @@ -1884,7 +1888,7 @@ "enquirer": ["enquirer@2.3.6", "", { "dependencies": { "ansi-colors": "^4.1.1" } }, "sha512-yjNnPr315/FjS4zIsUxYguYUPP2e1NK4d7E7ZOLiyYCcbFBiTMyID+2wvm2w6+pZ/odMA7cRkjhsPbltwBOrLg=="], - "entities": ["entities@6.0.1", "", {}, "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g=="], + "entities": ["entities@7.0.1", "", {}, "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA=="], "error-ex": ["error-ex@1.3.2", "", { "dependencies": { "is-arrayish": "^0.2.1" } }, "sha512-7dFHNmqeFSEt2ZBsCriorKnn3Z2pj+fd9kmI6QoWw4//DL+icEBfc0U7qJCisqrTsKTjw4fNFy2pW9OqStD84g=="], @@ -2140,7 +2144,7 @@ "html-to-image": ["html-to-image@1.11.13", "", {}, "sha512-cuOPoI7WApyhBElTTb9oqsawRvZ0rHhaHwghRLlTuffoD1B2aDemlCruLeZrUIIdvG7gs9xeELEPm6PhuASqrg=="], - "htmlparser2": ["htmlparser2@10.0.0", "", { "dependencies": { "domelementtype": "^2.3.0", "domhandler": "^5.0.3", "domutils": "^3.2.1", "entities": "^6.0.0" } }, "sha512-TwAZM+zE5Tq3lrEHvOlvwgj1XLWQCtaaibSN11Q+gGBAS7Y1uZSWwXXRe4iF6OXnaq1riyQAPFOBtYc77Mxq0g=="], + "htmlparser2": ["htmlparser2@10.1.0", "", { "dependencies": { "domelementtype": "^2.3.0", "domhandler": "^5.0.3", "domutils": "^3.2.2", "entities": "^7.0.1" } }, "sha512-VTZkM9GWRAtEpveh7MSF6SjjrpNVNNVJfFup7xTY3UpFtm67foy9HDVXneLtFVt4pMz5kZtgNcvCniNFb1hlEQ=="], "http-proxy-agent": ["http-proxy-agent@7.0.2", "", { "dependencies": { "agent-base": "^7.1.0", "debug": "^4.3.4" } }, "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig=="], @@ -3644,8 +3648,6 @@ "glob/minimatch": ["minimatch@9.0.9", "", { "dependencies": { "brace-expansion": "^2.0.2" } }, "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg=="], - "html-dom-parser/htmlparser2": ["htmlparser2@10.1.0", "", { "dependencies": { "domelementtype": "^2.3.0", "domhandler": "^5.0.3", "domutils": "^3.2.2", "entities": "^7.0.1" } }, "sha512-VTZkM9GWRAtEpveh7MSF6SjjrpNVNNVJfFup7xTY3UpFtm67foy9HDVXneLtFVt4pMz5kZtgNcvCniNFb1hlEQ=="], - "ip-address/sprintf-js": ["sprintf-js@1.1.3", "", {}, "sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA=="], "istanbul-lib-instrument/semver": ["semver@7.7.4", "", { "bin": "bin/semver.js" }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="], @@ -3712,6 +3714,8 @@ "jws/safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="], + "linkedom/htmlparser2": ["htmlparser2@10.0.0", "", { "dependencies": { "domelementtype": "^2.3.0", "domhandler": "^5.0.3", "domutils": "^3.2.1", "entities": "^6.0.0" } }, "sha512-TwAZM+zE5Tq3lrEHvOlvwgj1XLWQCtaaibSN11Q+gGBAS7Y1uZSWwXXRe4iF6OXnaq1riyQAPFOBtYc77Mxq0g=="], + "make-dir/semver": ["semver@7.7.4", "", { "bin": "bin/semver.js" }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="], "mdast-util-frontmatter/escape-string-regexp": ["escape-string-regexp@5.0.0", "", {}, "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw=="], @@ -3742,6 +3746,8 @@ "parse-entities/@types/unist": ["@types/unist@2.0.11", "", {}, "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA=="], + "parse5/entities": ["entities@6.0.1", "", {}, "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g=="], + "path-scurry/lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="], "pidusage/safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="], @@ -3780,8 +3786,6 @@ "safe-push-apply/isarray": ["isarray@2.0.5", "", {}, "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw=="], - "sanitize-html/htmlparser2": ["htmlparser2@10.1.0", "", { "dependencies": { "domelementtype": "^2.3.0", "domhandler": "^5.0.3", "domutils": "^3.2.2", "entities": "^7.0.1" } }, "sha512-VTZkM9GWRAtEpveh7MSF6SjjrpNVNNVJfFup7xTY3UpFtm67foy9HDVXneLtFVt4pMz5kZtgNcvCniNFb1hlEQ=="], - "sass/chokidar": ["chokidar@4.0.3", "", { "dependencies": { "readdirp": "^4.0.1" } }, "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA=="], "schema-utils/ajv": ["ajv@8.18.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A=="], @@ -3952,8 +3956,6 @@ "glob/minimatch/brace-expansion": ["brace-expansion@2.0.3", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-MCV/fYJEbqx68aE58kv2cA/kiky1G8vux3OR6/jbS+jIMe/6fJWa0DTzJU7dqijOWYwHi1t29FlfYI9uytqlpA=="], - "html-dom-parser/htmlparser2/entities": ["entities@7.0.1", "", {}, "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA=="], - "jest-circus/@jest/environment/@jest/fake-timers": ["@jest/fake-timers@29.7.0", "", { "dependencies": { "@jest/types": "^29.6.3", "@sinonjs/fake-timers": "^10.0.2", "@types/node": "*", "jest-message-util": "^29.7.0", "jest-mock": "^29.7.0", "jest-util": "^29.7.0" } }, "sha512-q4DH1Ha4TTFPdxLsqDXK1d3+ioSL7yL5oCMJZgDYm6i+6CygW5E5xVr/D1HdsGxjt1ZWSfUAs9OxSB/BNelWrQ=="], "jest-circus/@jest/environment/jest-mock": ["jest-mock@29.7.0", "", { "dependencies": { "@jest/types": "^29.6.3", "@types/node": "*", "jest-util": "^29.7.0" } }, "sha512-ITOMZn+UkYS4ZFh83xYAOzWStloNzJFO2s8DWrE4lhtGD+AorgnbkiKERe4wQVBydIGPx059g6riW5Btp6Llnw=="], @@ -3990,6 +3992,8 @@ "jest-worker/@types/node/undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], + "linkedom/htmlparser2/entities": ["entities@6.0.1", "", {}, "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g=="], + "node-fetch/whatwg-url/tr46": ["tr46@0.0.3", "", {}, "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw=="], "node-fetch/whatwg-url/webidl-conversions": ["webidl-conversions@3.0.1", "", {}, "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ=="], @@ -4014,8 +4018,6 @@ "protobufjs/@types/node/undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], - "sanitize-html/htmlparser2/entities": ["entities@7.0.1", "", {}, "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA=="], - "sass/chokidar/readdirp": ["readdirp@4.1.2", "", {}, "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg=="], "schema-utils/ajv/json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="], diff --git a/front_end/package.json b/front_end/package.json index 1670c518a2..c30e0cac9a 100644 --- a/front_end/package.json +++ b/front_end/package.json @@ -108,6 +108,7 @@ "react-merge-refs": "^2.1.1", "react-tweet": "^3.3.0", "remark": "^15.0.1", + "sanitize-html": "2.17.3", "sass": "^1.99.0", "sharp": "^0.34.5", "storybook": "^9.1.20", @@ -123,6 +124,7 @@ "devDependencies": { "@eslint/eslintrc": "^3.3.0", "@types/jest": "^29.5.14", + "@types/sanitize-html": "2.16.1", "eslint": "^9.0.0", "eslint-config-next": "^16.2.4", "eslint-config-prettier": "^10.1.0", diff --git a/front_end/src/app/(main)/components/bulletin.tsx b/front_end/src/app/(main)/components/bulletin.tsx index 82750204e7..176650d349 100644 --- a/front_end/src/app/(main)/components/bulletin.tsx +++ b/front_end/src/app/(main)/components/bulletin.tsx @@ -2,25 +2,40 @@ import { faClose } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; -import { FC, useEffect, useState } from "react"; +import { FC, useMemo } from "react"; +import sanitizeHtml from "sanitize-html"; import cn from "@/utils/core/cn"; -import { sanitizeHtmlContent } from "@/utils/markdown"; + +const sanitizeBulletinHtml = (content: string): string => + sanitizeHtml(content, { + allowedTags: ["a", "b", "br", "del", "em", "i", "s", "strong", "u"], + allowedAttributes: { + a: ["href", "target", "rel"], + }, + transformTags: { + a: (tagName, attribs) => { + if (attribs.target === "_blank" && !attribs.rel) { + return { + tagName, + attribs: { + ...attribs, + rel: "noopener noreferrer", + }, + }; + } + + return { tagName, attribs }; + }, + }, + }); const Bulletin: FC<{ text: string; className?: string; onHidden?: () => void; }> = ({ text, className, onHidden }) => { - const [sanitizedText, setSanitizedText] = useState(null); - - useEffect(() => { - setSanitizedText(sanitizeHtmlContent(text)); - }, [text]); - - if (sanitizedText === null) { - return null; - } + const sanitizedText = useMemo(() => sanitizeBulletinHtml(text), [text]); return (
Date: Mon, 11 May 2026 13:47:29 +0200 Subject: [PATCH 7/8] fix review comments --- 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 + .../src/app/(main)/components/bulletin.tsx | 21 +++++++----- .../(main)/components/bulletins_client.tsx | 31 ++++++++++++++--- .../app/(main)/components/bulletins_shared.ts | 9 +++-- .../content_translated_banner/index.tsx | 2 +- .../(main)/components/top_chrome_client.tsx | 22 ++++++++---- .../components/notebook_content_sections.tsx | 2 +- .../[slug]/prediction-flow/loading.tsx | 26 +++----------- .../loading_header_actions.tsx | 34 +++++++++++++++++++ .../components/services_quiz_flow_content.tsx | 3 +- front_end/src/hooks/use_top_chrome_height.ts | 27 +++++++++++---- misc/models.py | 4 ++- misc/views.py | 5 ++- 18 files changed, 134 insertions(+), 58 deletions(-) create mode 100644 front_end/src/app/(prediction-flow)/tournament/[slug]/prediction-flow/loading_header_actions.tsx diff --git a/front_end/messages/cs.json b/front_end/messages/cs.json index c86efb1ac0..6c37045acb 100644 --- a/front_end/messages/cs.json +++ b/front_end/messages/cs.json @@ -1038,6 +1038,7 @@ "communityDescription": "Popis komunity", "contentTranslatedHeaderText": "Některý obsah na této stránce je automaticky přeložen a může být nepřesný.", "showOriginalContent": "Zobrazit originál", + "translated_by": "přeloženo pomocí", "nextQuestion": "Další otázka", "fullName": "Celé jméno", "country": "Země pobytu", diff --git a/front_end/messages/en.json b/front_end/messages/en.json index ef20906158..0799dc5b1b 100644 --- a/front_end/messages/en.json +++ b/front_end/messages/en.json @@ -1302,6 +1302,7 @@ "unread": "unread", "contentTranslatedHeaderText": "Some content on this page is automatically translated, and may be inaccurate.", "showOriginalContent": "Show original", + "translated_by": "translated by", "nextQuestion": "Next Question", "next": "Next", "fullName": "Full Name", diff --git a/front_end/messages/es.json b/front_end/messages/es.json index ab962f5916..9c78a9486d 100644 --- a/front_end/messages/es.json +++ b/front_end/messages/es.json @@ -1037,6 +1037,7 @@ "communityDescription": "Descripción de la Comunidad", "contentTranslatedHeaderText": "Parte del contenido en esta página se traduce automáticamente y puede ser inexacto.", "showOriginalContent": "Mostrar original", + "translated_by": "traducido por", "nextQuestion": "Siguiente Pregunta", "fullName": "Nombre Completo", "country": "País de Residencia", diff --git a/front_end/messages/pt.json b/front_end/messages/pt.json index 70a1bbc454..611e97ee13 100644 --- a/front_end/messages/pt.json +++ b/front_end/messages/pt.json @@ -991,6 +991,7 @@ "unread": "não lido", "contentTranslatedHeaderText": "Algum conteúdo nesta página foi traduzido automaticamente e pode estar incorreto.", "showOriginalContent": "Mostrar original", + "translated_by": "traduzido por", "discard": "Descartar", "onboardingStep4AlmostDone": "Você está quase terminando este tutorial!", "onboardingStep5WellDone": "Muito bem!", diff --git a/front_end/messages/zh-TW.json b/front_end/messages/zh-TW.json index 32b99d0e02..45ce78a15b 100644 --- a/front_end/messages/zh-TW.json +++ b/front_end/messages/zh-TW.json @@ -1064,6 +1064,7 @@ "unread": "未讀", "contentTranslatedHeaderText": "此頁面上的部分內容是自動翻譯的,可能不準確。", "showOriginalContent": "顯示原文", + "translated_by": "翻譯方", "nextQuestion": "下一個問題", "next": "下一步", "fullName": "全名", diff --git a/front_end/messages/zh.json b/front_end/messages/zh.json index e589cc6f8f..3af775b0c3 100644 --- a/front_end/messages/zh.json +++ b/front_end/messages/zh.json @@ -1039,6 +1039,7 @@ "communityDescription": "社区描述", "contentTranslatedHeaderText": "此页面上的一些内容是自动翻译的,可能不准确。", "showOriginalContent": "显示原文", + "translated_by": "翻译方", "nextQuestion": "下一问题", "fullName": "全名", "country": "居住国家", diff --git a/front_end/src/app/(main)/components/bulletin.tsx b/front_end/src/app/(main)/components/bulletin.tsx index 176650d349..960ff477af 100644 --- a/front_end/src/app/(main)/components/bulletin.tsx +++ b/front_end/src/app/(main)/components/bulletin.tsx @@ -2,6 +2,7 @@ import { faClose } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { useTranslations } from "next-intl"; import { FC, useMemo } from "react"; import sanitizeHtml from "sanitize-html"; @@ -35,22 +36,26 @@ const Bulletin: FC<{ className?: string; onHidden?: () => void; }> = ({ text, className, onHidden }) => { + const t = useTranslations(); const sanitizedText = useMemo(() => sanitizeBulletinHtml(text), [text]); return (
- { - onHidden?.(); - }} - /> + {onHidden && ( + + )}
= ({ const syncedDismissedBulletinIdsRef = useRef( new Set(initialSyncedDismissedBulletinIds) ); + const pendingDismissedBulletinIdsRef = useRef(new Set()); + + useEffect(() => { + // eslint-disable-next-line react-hooks/set-state-in-effect + setDismissedBulletinIds(() => new Set(initialDismissedBulletinIds)); + syncedDismissedBulletinIdsRef.current = new Set( + initialSyncedDismissedBulletinIds + ); + pendingDismissedBulletinIdsRef.current = new Set(); + }, [initialDismissedBulletinIds, initialSyncedDismissedBulletinIds]); const { data: bulletins = [] } = useQuery({ queryKey: BULLETINS_QUERY_KEY, @@ -50,7 +60,7 @@ const BulletinsClient: FC = ({ return await ClientMiscApi.getBulletins(); } catch (error) { logError(error); - return []; + throw error; } }, }); @@ -73,10 +83,21 @@ const BulletinsClient: FC = ({ return; } - syncedDismissedBulletinIdsRef.current.add(bulletinId); - void dismissBulletin(bulletinId).catch((error) => { - logError(error); - }); + if (pendingDismissedBulletinIdsRef.current.has(bulletinId)) { + return; + } + + pendingDismissedBulletinIdsRef.current.add(bulletinId); + void dismissBulletin(bulletinId) + .then(() => { + syncedDismissedBulletinIdsRef.current.add(bulletinId); + }) + .catch((error) => { + logError(error); + }) + .finally(() => { + pendingDismissedBulletinIdsRef.current.delete(bulletinId); + }); }); }, [bulletins, dismissedBulletinIds, user]); diff --git a/front_end/src/app/(main)/components/bulletins_shared.ts b/front_end/src/app/(main)/components/bulletins_shared.ts index 245863c668..e6601740a2 100644 --- a/front_end/src/app/(main)/components/bulletins_shared.ts +++ b/front_end/src/app/(main)/components/bulletins_shared.ts @@ -5,6 +5,8 @@ export const DISMISSED_BULLETINS_COOKIE = "dismissed_bulletins"; const MAX_DISMISSED_BULLETIN_IDS = 10; +const isPositiveInteger = (id: number) => Number.isInteger(id) && id > 0; + export const parseDismissedBulletinIds = (value?: string | null): number[] => { if (!value) { return []; @@ -14,14 +16,15 @@ export const parseDismissedBulletinIds = (value?: string | null): number[] => { ...new Set( value .split(",") - .map((id) => Number.parseInt(id, 10)) - .filter(Number.isFinite) + .filter((id) => /^\d+$/.test(id)) + .map((id) => Number(id)) + .filter(isPositiveInteger) ), ]; }; export const serializeDismissedBulletinIds = (ids: Iterable) => [...new Set(ids)] - .filter(Number.isFinite) + .filter(isPositiveInteger) .slice(-MAX_DISMISSED_BULLETIN_IDS) .join(","); 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 7d0875dab6..695b213427 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 @@ -54,7 +54,7 @@ const ContentTranslatedBanner: FC<{
- translated by + {t("translated_by")}
diff --git a/front_end/src/app/(main)/components/top_chrome_client.tsx b/front_end/src/app/(main)/components/top_chrome_client.tsx index 7bf4547867..5d6e2aab06 100644 --- a/front_end/src/app/(main)/components/top_chrome_client.tsx +++ b/front_end/src/app/(main)/components/top_chrome_client.tsx @@ -40,15 +40,25 @@ export const TopChromeClient: FC<{ children: ReactNode }> = ({ children }) => { } const observer = new MutationObserver(updateTopChromeHeight); - observer.observe(topChromeEl, { - attributes: true, - childList: true, - subtree: true, - }); + let isObserving = false; + try { + observer.observe(topChromeEl, { + attributes: true, + childList: true, + subtree: true, + }); + isObserving = true; + } catch (error) { + logError(error, { + message: "Failed to observe top chrome height", + }); + } window.addEventListener("resize", updateTopChromeHeight); return () => { - observer.disconnect(); + if (isObserving) { + observer.disconnect(); + } window.removeEventListener("resize", updateTopChromeHeight); document.documentElement.style.removeProperty( TOP_CHROME_HEIGHT_CSS_VAR diff --git a/front_end/src/app/(main)/notebooks/components/notebook_content_sections.tsx b/front_end/src/app/(main)/notebooks/components/notebook_content_sections.tsx index 584a67c20d..325da7afa1 100644 --- a/front_end/src/app/(main)/notebooks/components/notebook_content_sections.tsx +++ b/front_end/src/app/(main)/notebooks/components/notebook_content_sections.tsx @@ -82,7 +82,7 @@ const NotebookContentSections: FC = ({ ids.push(NOTEBOOK_COMMENTS_TITLE); const offset = isLargeScreen - ? DESKTOP_SCROLL_OFFSET + ? DESKTOP_SCROLL_OFFSET + topChromeHeight : DESKTOP_SCROLL_OFFSET + topChromeHeight + 16; const activeId = [...ids].reverse().find((id) => { diff --git a/front_end/src/app/(prediction-flow)/tournament/[slug]/prediction-flow/loading.tsx b/front_end/src/app/(prediction-flow)/tournament/[slug]/prediction-flow/loading.tsx index c42cb0de6f..2082280f8b 100644 --- a/front_end/src/app/(prediction-flow)/tournament/[slug]/prediction-flow/loading.tsx +++ b/front_end/src/app/(prediction-flow)/tournament/[slug]/prediction-flow/loading.tsx @@ -1,18 +1,13 @@ -import { faRightFromBracket } from "@fortawesome/free-solid-svg-icons"; -import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; -import { getTranslations } from "next-intl/server"; - import { TopChrome } from "@/app/(main)/components/top_chrome"; import { - FlowHeaderActions, FlowHeaderBrand, FlowHeaderRoot, FlowHeaderTitle, } from "@/components/flow/flow_header"; -import Button from "@/components/ui/button"; -export default async function Loading() { - const t = await getTranslations(); +import LoadingHeaderActions from "./loading_header_actions"; + +export default function Loading() { return ( <> - - - - + } /> diff --git a/front_end/src/app/(prediction-flow)/tournament/[slug]/prediction-flow/loading_header_actions.tsx b/front_end/src/app/(prediction-flow)/tournament/[slug]/prediction-flow/loading_header_actions.tsx new file mode 100644 index 0000000000..56abdd9a02 --- /dev/null +++ b/front_end/src/app/(prediction-flow)/tournament/[slug]/prediction-flow/loading_header_actions.tsx @@ -0,0 +1,34 @@ +"use client"; + +import { faRightFromBracket } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { useParams } from "next/navigation"; +import { useTranslations } from "next-intl"; + +import { FlowHeaderActions } from "@/components/flow/flow_header"; +import Button from "@/components/ui/button"; + +const LoadingHeaderActions = () => { + const t = useTranslations(); + const params = useParams<{ slug?: string | string[] }>(); + const slug = Array.isArray(params.slug) ? params.slug[0] : params.slug; + const exitHref = slug ? `/tournament/${slug}` : "/tournaments"; + + return ( + + + + + ); +}; + +export default LoadingHeaderActions; diff --git a/front_end/src/app/(services-quiz)/components/services_quiz_flow_content.tsx b/front_end/src/app/(services-quiz)/components/services_quiz_flow_content.tsx index 870d08eb22..a1e5fb19ff 100644 --- a/front_end/src/app/(services-quiz)/components/services_quiz_flow_content.tsx +++ b/front_end/src/app/(services-quiz)/components/services_quiz_flow_content.tsx @@ -23,8 +23,7 @@ const STEP_COMPONENTS: Record = { const ServicesQuizFlowContent: FC = () => { const { step } = useServicesQuizFlow(); - const ActiveStep = - STEP_COMPONENTS[step as ServicesQuizStepId] ?? STEP_COMPONENTS[1]; + const ActiveStep = STEP_COMPONENTS[step]; return (
diff --git a/front_end/src/hooks/use_top_chrome_height.ts b/front_end/src/hooks/use_top_chrome_height.ts index 5f0b9a7126..6f5ad885db 100644 --- a/front_end/src/hooks/use_top_chrome_height.ts +++ b/front_end/src/hooks/use_top_chrome_height.ts @@ -2,6 +2,8 @@ import { useEffect, useState } from "react"; +import { logError } from "@/utils/core/errors"; + const TOP_CHROME_HEIGHT_CSS_VAR = "--top-chrome-height"; const DEFAULT_TOP_CHROME_HEIGHT_PX = 48; @@ -32,15 +34,28 @@ export const useTopChromeHeightPx = () => { updateTopChromeHeight(); - const observer = new MutationObserver(updateTopChromeHeight); - observer.observe(document.documentElement, { - attributes: true, - attributeFilter: ["style"], - }); + const observer = + typeof MutationObserver === "undefined" + ? null + : new MutationObserver(updateTopChromeHeight); + let isObserving = false; + try { + observer?.observe(document.documentElement, { + attributes: true, + attributeFilter: ["style"], + }); + isObserving = !!observer; + } catch (error) { + logError(error, { + message: "Failed to observe top chrome height", + }); + } window.addEventListener("resize", updateTopChromeHeight); return () => { - observer.disconnect(); + if (isObserving) { + observer?.disconnect(); + } window.removeEventListener("resize", updateTopChromeHeight); }; }, []); diff --git a/misc/models.py b/misc/models.py index 32c70c4acf..6ebc2ce6b2 100644 --- a/misc/models.py +++ b/misc/models.py @@ -1,4 +1,5 @@ from django.db import models +from django.utils.html import strip_tags from pgvector.django import VectorField from posts.models import Post @@ -45,7 +46,8 @@ class Bulletin(TimeStampedModel): text = models.TextField() def __str__(self): - return self.text[:150] + "..." if len(self.text) > 150 else self.text + plain_text = strip_tags(self.text) + return plain_text[:150] + "..." if len(plain_text) > 150 else plain_text class BulletinViewedBy(TimeStampedModel): diff --git a/misc/views.py b/misc/views.py index 2fa29e00c7..6bf6df0433 100644 --- a/misc/views.py +++ b/misc/views.py @@ -140,9 +140,8 @@ def cancel_bulletin(request, pk): user = request.user if not user or not user.is_authenticated: return Response(status=status.HTTP_200_OK) - BulletinViewedBy.objects.get_or_create( - bulletin=Bulletin.objects.get(pk=pk), user=user - ) + bulletin = get_object_or_404(Bulletin, pk=pk) + BulletinViewedBy.objects.get_or_create(bulletin=bulletin, user=user) return Response(status=status.HTTP_201_CREATED) From 2521370e435eca41deb54257f27fbb965b021b04 Mon Sep 17 00:00:00 2001 From: cemreinanc Date: Tue, 12 May 2026 15:12:58 +0200 Subject: [PATCH 8/8] small fix --- misc/views.py | 4 ---- 1 file changed, 4 deletions(-) diff --git a/misc/views.py b/misc/views.py index 6bf6df0433..4fc386a97d 100644 --- a/misc/views.py +++ b/misc/views.py @@ -99,12 +99,8 @@ def get_bulletins(request): @api_view(["GET"]) -@permission_classes([AllowAny]) def get_dismissed_bulletin_ids(request): user = request.user - if not user or not user.is_authenticated: - return Response({"dismissed_bulletin_ids": []}) - dismissed_bulletin_ids = list( BulletinViewedBy.objects.filter( user=user,