diff --git a/front_end/bun.lock b/front_end/bun.lock index 33f7062c71..b5435bb087 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": "^30.0.0", + "@types/sanitize-html": "2.16.1", "eslint": "^9.0.0", "eslint-config-next": "^16.2.6", "eslint-config-prettier": "^10.1.0", @@ -1386,6 +1388,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=="], @@ -1926,7 +1930,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=="], @@ -2182,7 +2186,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=="], @@ -3646,8 +3650,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=="], @@ -3676,6 +3678,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=="], @@ -3706,6 +3710,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=="], @@ -3900,8 +3906,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/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/package.json b/front_end/package.json index e742a7cfb7..6a6149cf23 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", @@ -122,6 +123,7 @@ }, "devDependencies": { "@eslint/eslintrc": "^3.3.0", + "@types/sanitize-html": "2.16.1", "@types/jest": "^30.0.0", "eslint": "^9.0.0", "eslint-config-next": "^16.2.6", 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 ( <> - -
+
+ )} +
); }; diff --git a/front_end/src/app/(main)/components/bulletins.tsx b/front_end/src/app/(main)/components/bulletins.tsx index 22add18a31..4b6f4c8b40 100644 --- a/front_end/src/app/(main)/components/bulletins.tsx +++ b/front_end/src/app/(main)/components/bulletins.tsx @@ -1,74 +1,67 @@ -"use client"; -import dynamic from "next/dynamic"; -import { usePathname } from "next/navigation"; -import { FC, useCallback, useEffect, useMemo, useState } from "react"; +import { cookies } from "next/headers"; -import ClientMiscApi from "@/services/api/misc/misc.client"; +import ServerMiscApi from "@/services/api/misc/misc.server"; +import type { BulletinItem } from "@/services/api/misc/misc.shared"; +import { AuthCookieReader } from "@/services/auth_tokens"; import { logError } from "@/utils/core/errors"; -import { getBulletinParamsFromPathname } from "@/utils/navigation"; -import Bulletin from "./bulletin"; +import BulletinsClient from "./bulletins_client"; +import { + DISMISSED_BULLETINS_COOKIE, + parseDismissedBulletinIds, +} from "./bulletins_shared"; -const HIDE_PREFIXES = [ - "/about", - "/services", - "/help", - "/faq", - "/press", - "/privacy-policy", - "/terms-of-use", - "/futureeval", -] as const; - -const Bulletins: FC = () => { - const [bulletins, setBulletins] = useState< - { - text: string; - id: number; - }[] - >([]); +const getInitialBulletins = async (): Promise => { + try { + return await ServerMiscApi.getBulletins(); + } catch (error) { + logError(error); + return []; + } +}; - const pathname = usePathname(); +const getInitialDismissedBulletinIds = async (): Promise => { + try { + return await ServerMiscApi.getDismissedBulletinIds(); + } catch (error) { + logError(error); + return []; + } +}; - const shouldHide = useMemo(() => { - return ( - HIDE_PREFIXES.some((p) => pathname === p || pathname.startsWith(p)) || - pathname === "/" - ); - }, [pathname]); +const mergeBulletinIds = (...idGroups: number[][]) => [ + ...new Set(idGroups.flat()), +]; - const bulletinParams = useMemo( - () => getBulletinParamsFromPathname(pathname), - [pathname] +const Bulletins = async () => { + const initialBulletinsPromise = getInitialBulletins(); + const cookieStore = await cookies(); + const cookieDismissedBulletinIds = parseDismissedBulletinIds( + cookieStore.get(DISMISSED_BULLETINS_COOKIE)?.value ); + const authenticatedDismissedBulletinIdsPromise = new AuthCookieReader( + cookieStore + ).hasAuthSession() + ? getInitialDismissedBulletinIds() + : Promise.resolve([]); - const fetchBulletins = useCallback(async () => { - try { - const bulletins = await ClientMiscApi.getBulletins(bulletinParams); - setBulletins(bulletins ?? []); - } catch (error) { - logError(error); - } - }, [bulletinParams]); - - useEffect(() => { - if (!shouldHide) { - void fetchBulletins(); - } else { - setBulletins([]); - } - }, [shouldHide, fetchBulletins]); + const [initialBulletins, authenticatedDismissedBulletinIds] = + await Promise.all([ + initialBulletinsPromise, + authenticatedDismissedBulletinIdsPromise, + ]); + const initialDismissedBulletinIds = mergeBulletinIds( + authenticatedDismissedBulletinIds, + cookieDismissedBulletinIds + ); return ( -
- {!shouldHide && - bulletins.map((bulletin) => ( - - ))} -
+ ); }; -export default dynamic(() => Promise.resolve(Bulletins), { - ssr: false, -}); +export default Bulletins; diff --git a/front_end/src/app/(main)/components/bulletins_client.tsx b/front_end/src/app/(main)/components/bulletins_client.tsx new file mode 100644 index 0000000000..a8e111a047 --- /dev/null +++ b/front_end/src/app/(main)/components/bulletins_client.tsx @@ -0,0 +1,129 @@ +"use client"; +import { useQuery } from "@tanstack/react-query"; +import { FC, useEffect, useMemo, useRef, useState } from "react"; + +import { useAuth } from "@/contexts/auth_context"; +import ClientMiscApi from "@/services/api/misc/misc.client"; +import type { BulletinItem } from "@/services/api/misc/misc.shared"; +import { logError } from "@/utils/core/errors"; + +import { dismissBulletin } from "../actions"; +import Bulletin from "./bulletin"; +import { + BULLETINS_QUERY_KEY, + BULLETINS_STALE_TIME, + DISMISSED_BULLETINS_COOKIE, + serializeDismissedBulletinIds, +} from "./bulletins_shared"; + +type Props = { + initialBulletins: BulletinItem[]; + initialDismissedBulletinIds: number[]; + initialSyncedDismissedBulletinIds: number[]; +}; + +const writeDismissedBulletinIdsToCookie = (ids: Iterable) => { + const serializedIds = serializeDismissedBulletinIds(ids); + + document.cookie = `${DISMISSED_BULLETINS_COOKIE}=${serializedIds}; path=/; max-age=31536000; samesite=lax`; +}; + +const BulletinsClient: FC = ({ + initialBulletins, + initialDismissedBulletinIds, + initialSyncedDismissedBulletinIds, +}) => { + const { user } = useAuth(); + const [dismissedBulletinIds, setDismissedBulletinIds] = useState( + () => new Set(initialDismissedBulletinIds) + ); + 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, + initialData: initialBulletins, + staleTime: BULLETINS_STALE_TIME, + queryFn: async () => { + try { + return await ClientMiscApi.getBulletins(); + } catch (error) { + logError(error); + throw error; + } + }, + }); + + useEffect(() => { + if (!user) { + return; + } + + // Cookie dismissals are browser-local. For authenticated users, sync only + // active bulletin ids that the DB did not already report as dismissed. + const activeBulletinIds = new Set(bulletins.map((bulletin) => bulletin.id)); + + dismissedBulletinIds.forEach((bulletinId) => { + if (!activeBulletinIds.has(bulletinId)) { + return; + } + + if (syncedDismissedBulletinIdsRef.current.has(bulletinId)) { + return; + } + + 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]); + + const visibleBulletin = useMemo( + () => bulletins.find((bulletin) => !dismissedBulletinIds.has(bulletin.id)), + [bulletins, dismissedBulletinIds] + ); + + if (!visibleBulletin) { + return null; + } + + return ( + { + setDismissedBulletinIds((currentDismissedBulletinIds) => { + const nextDismissedBulletinIds = new Set(currentDismissedBulletinIds); + nextDismissedBulletinIds.add(visibleBulletin.id); + writeDismissedBulletinIdsToCookie(nextDismissedBulletinIds); + return nextDismissedBulletinIds; + }); + }} + /> + ); +}; + +export default BulletinsClient; diff --git a/front_end/src/app/(main)/components/bulletins_shared.ts b/front_end/src/app/(main)/components/bulletins_shared.ts new file mode 100644 index 0000000000..e6601740a2 --- /dev/null +++ b/front_end/src/app/(main)/components/bulletins_shared.ts @@ -0,0 +1,30 @@ +export const BULLETINS_STALE_TIME = 60 * 1000; +export const BULLETINS_QUERY_KEY = ["bulletins"] as const; + +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 []; + } + + return [ + ...new Set( + value + .split(",") + .filter((id) => /^\d+$/.test(id)) + .map((id) => Number(id)) + .filter(isPositiveInteger) + ), + ]; +}; + +export const serializeDismissedBulletinIds = (ids: Iterable) => + [...new Set(ids)] + .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 36af7f6f87..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 @@ -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")}{" "} + +

+ +
+
+ + {t("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..0048698af3 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; + className?: string; }; -const CommunityHeader: FC = ({ community, alwaysShowName = true }) => { +const CommunityHeader: FC = ({ + community, + alwaysShowName = true, + className, +}) => { const { showActiveCommunity } = useShowActiveCommunityContext(); const [localShowName, setLocalShowName] = useState(alwaysShowName); const { navbarLinks } = useNavbarLinks({ community }); return ( -
+
= ({ forceDefault = false }) => { - const pathname = usePathname(); - const withDefaultHeader = forceDefault || getWithDefaultHeader(pathname); - - if (withDefaultHeader) { - return
; - } - - return null; -}; - -export default GlobalHeader; diff --git a/front_end/src/app/(main)/components/headers/header.tsx b/front_end/src/app/(main)/components/headers/header.tsx index 2a76ae740b..18c9e6124f 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 = { + className?: string; +}; + +const Header: FC = ({ className }) => { 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..63e6231cb8 --- /dev/null +++ b/front_end/src/app/(main)/components/top_chrome.tsx @@ -0,0 +1,77 @@ +import { logError } from "@/utils/core/errors"; + +import Bulletins from "./bulletins"; +import ContentTranslatedBanner from "./content_translated_banner"; +import { ImpersonationBanner } from "./impersonation_banner_server"; +import { TopChromeClient } from "./top_chrome_client"; +import { + TopChromeFallbackHeader, + TopChromePartErrorBoundary, +} from "./top_chrome_error_boundary"; +import { TopChromeHeaderSlot } from "./top_chrome_header_slot"; + +type Props = { + hideBulletins?: boolean; + hideHeader?: boolean; + hideImpersonationBanner?: boolean; + hideTranslationBanner?: boolean; + defaultHeader?: React.ReactNode; +}; + +const SafeBulletins = async () => { + try { + return await Bulletins(); + } catch (error) { + logError(error, { + message: "Failed to render top chrome bulletins", + }); + return null; + } +}; + +const SafeImpersonationBanner = async () => { + try { + return await ImpersonationBanner(); + } catch (error) { + logError(error, { + message: "Failed to render top chrome impersonation banner", + }); + return null; + } +}; + +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..5d6e2aab06 --- /dev/null +++ b/front_end/src/app/(main)/components/top_chrome_client.tsx @@ -0,0 +1,92 @@ +"use client"; + +import { FC, ReactNode, useEffect, useRef } from "react"; + +import { logError } from "@/utils/core/errors"; + +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 = () => { + try { + document.documentElement.style.setProperty( + TOP_CHROME_HEIGHT_CSS_VAR, + `${topChromeEl.getBoundingClientRect().height}px` + ); + } catch (error) { + logError(error, { + message: "Failed to measure top chrome height", + }); + } + }; + + updateTopChromeHeight(); + + if (typeof ResizeObserver === "undefined") { + if (typeof MutationObserver === "undefined") { + return () => { + document.documentElement.style.removeProperty( + TOP_CHROME_HEIGHT_CSS_VAR + ); + }; + } + + const observer = new MutationObserver(updateTopChromeHeight); + 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 () => { + if (isObserving) { + observer.disconnect(); + } + window.removeEventListener("resize", updateTopChromeHeight); + document.documentElement.style.removeProperty( + TOP_CHROME_HEIGHT_CSS_VAR + ); + }; + } + + const observer = new ResizeObserver(updateTopChromeHeight); + try { + observer.observe(topChromeEl); + } catch (error) { + logError(error, { + message: "Failed to observe top chrome height", + }); + } + + 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_error_boundary.tsx b/front_end/src/app/(main)/components/top_chrome_error_boundary.tsx new file mode 100644 index 0000000000..0eb95bf301 --- /dev/null +++ b/front_end/src/app/(main)/components/top_chrome_error_boundary.tsx @@ -0,0 +1,46 @@ +"use client"; + +import Link from "next/link"; +import { ErrorBoundary } from "react-error-boundary"; + +import { logError } from "@/utils/core/errors"; + +type Props = { + children: React.ReactNode; + fallback?: React.ReactNode; + name: string; +}; + +export const TopChromePartErrorBoundary = ({ + children, + fallback = null, + name, +}: Props) => { + return ( + { + logError(error, { + message: `Failed to render top chrome ${name}`, + }); + }} + > + {children} + + ); +}; + +export const TopChromeFallbackHeader = () => { + return ( +
+ +

+ M +

+ +
+ ); +}; 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..8380f0d3de --- /dev/null +++ b/front_end/src/app/(main)/components/top_chrome_header_context.tsx @@ -0,0 +1,107 @@ +"use client"; + +import { usePathname } from "next/navigation"; +import { + createContext, + FC, + PropsWithChildren, + useCallback, + useContext, + useLayoutEffect, + useMemo, + useState, +} from "react"; + +import { + normalizeTopChromeRouteKey, + type TopChromeHeaderConfig, + type TopChromeHeaderState, +} from "./top_chrome_header_shared"; + +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< + PropsWithChildren<{ + initialHeaderState?: TopChromeHeaderState | null; + }> +> = ({ children, initialHeaderState }) => { + const routeKey = normalizeTopChromeRouteKey(usePathname()); + const [headerState, setHeaderState] = useState( + () => + initialHeaderState + ? { + ...initialHeaderState, + routeKey: normalizeTopChromeRouteKey(initialHeaderState.routeKey), + } + : null + ); + + const activeHeader = + headerState?.routeKey === routeKey ? headerState.header : null; + + const setHeaderForRoute = useCallback( + (nextRouteKey: string, header: TopChromeHeaderConfig) => { + const normalizedRouteKey = normalizeTopChromeRouteKey(nextRouteKey); + + setHeaderState((previousHeaderState) => { + if ( + previousHeaderState?.routeKey === normalizedRouteKey && + getHeaderIdentity(previousHeaderState.header) === + getHeaderIdentity(header) + ) { + return previousHeaderState; + } + + return { routeKey: normalizedRouteKey, 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(); + + useLayoutEffect(() => { + setHeaderForRoute(routeKey, header); + }, [header, routeKey, setHeaderForRoute]); + + return null; +}; diff --git a/front_end/src/app/(main)/components/top_chrome_header_server.ts b/front_end/src/app/(main)/components/top_chrome_header_server.ts new file mode 100644 index 0000000000..63affac6ce --- /dev/null +++ b/front_end/src/app/(main)/components/top_chrome_header_server.ts @@ -0,0 +1,166 @@ +import "server-only"; + +import { headers } from "next/headers"; +import { cache } from "react"; + +import ServerPostsApi from "@/services/api/posts/posts.server"; +import ServerProjectsApi from "@/services/api/projects/projects.server"; +import { TournamentType } from "@/types/projects"; + +import { + normalizeTopChromeRouteKey, + type TopChromeHeaderConfig, + type TopChromeHeaderState, +} from "./top_chrome_header_shared"; + +// IMPORTANT: this file only seeds the top chrome header for the initial SSR +// request. If a page needs a special header, it must also render +// TopChromeHeaderSetter so client-side navigation into that page updates the +// header after the parent layout has already mounted. + +const getPost = cache((postId: number) => ServerPostsApi.getPost(postId)); + +const getCommunityBySlug = cache((slug: string) => + ServerProjectsApi.getCommunity(slug) +); + +const getCommunityById = cache(async (communityId: number) => { + const communitiesResponse = await ServerProjectsApi.getCommunities({ + ids: [communityId], + }); + + return communitiesResponse.results[0] ?? null; +}); + +const parsePositiveInteger = (value: string | null | undefined) => { + if (!value) { + return null; + } + + const parsedValue = Number(value); + + return Number.isInteger(parsedValue) && parsedValue > 0 ? parsedValue : null; +}; + +const getCommunityHeaderFromPost = async ( + postId: number +): Promise => { + try { + const post = await getPost(postId); + const defaultProject = post.projects?.default_project; + + if ( + defaultProject?.type !== TournamentType.Community || + !defaultProject.slug + ) { + return null; + } + + const community = await getCommunityBySlug(defaultProject.slug); + + return { type: "community", community }; + } catch { + return null; + } +}; + +const getCommunityHeaderFromSlug = async ( + slug: string, + alwaysShowName?: boolean +): Promise => { + try { + const community = await getCommunityBySlug(slug); + + return { type: "community", community, alwaysShowName }; + } catch { + return null; + } +}; + +const getCommunityHeaderFromCreateUrl = async ( + url: URL +): Promise => { + const communityId = parsePositiveInteger( + url.searchParams.get("community_id") + ); + + if (!communityId) { + return null; + } + + try { + const community = await getCommunityById(communityId); + + return community ? { type: "community", community } : null; + } catch { + return null; + } +}; + +const resolveTopChromeHeaderConfig = async ( + url: URL +): Promise => { + const pathname = normalizeTopChromeRouteKey(url.pathname); + const segments = pathname.split("/").filter(Boolean); + const [rootSegment, secondSegment, thirdSegment] = segments; + + // Community URLs: + // - /c/:slug should show the community header without forcing the name. + // - /c/:slug/settings should show the community header with the name. + // - /c/:slug/:postId/... resolves from the post so mismatched slugs do not + // seed the wrong header. + if (rootSegment === "c" && secondSegment) { + const postId = parsePositiveInteger(thirdSegment); + + if (postId) { + return getCommunityHeaderFromPost(postId); + } + + return getCommunityHeaderFromSlug(secondSegment, segments.length !== 2); + } + + // Creation URLs can be community-scoped by query string: + // /questions/create?community_id=:id and + // /questions/create/:content_type?community_id=:id + if (rootSegment === "questions" && secondSegment === "create") { + return getCommunityHeaderFromCreateUrl(url); + } + + // Canonical post URLs seed a community header only when the post's default + // project is a community. Normal tournament/site-main posts keep the default + // header by returning null. + if (rootSegment === "questions" || rootSegment === "notebooks") { + const postId = parsePositiveInteger(secondSegment); + + return postId ? getCommunityHeaderFromPost(postId) : null; + } + + return null; +}; + +export const resolveInitialTopChromeHeaderState = + async (): Promise => { + const headersList = await headers(); + const requestUrl = headersList.get("x-url"); + + if (!requestUrl) { + return null; + } + + try { + const url = new URL(requestUrl); + const header = await resolveTopChromeHeaderConfig(url); + + // Null intentionally means "use the normal header". The client provider + // still scopes any seeded special header to this exact pathname, so + // client navigation away from it naturally reverts to the default header. + return header + ? { + routeKey: normalizeTopChromeRouteKey(url.pathname), + header, + } + : null; + } catch { + return null; + } + }; diff --git a/front_end/src/app/(main)/components/top_chrome_header_shared.ts b/front_end/src/app/(main)/components/top_chrome_header_shared.ts new file mode 100644 index 0000000000..761f8e09b9 --- /dev/null +++ b/front_end/src/app/(main)/components/top_chrome_header_shared.ts @@ -0,0 +1,22 @@ +import type { Community } from "@/types/projects"; + +export type TopChromeHeaderConfig = + | { + type: "community"; + community: Community | null; + alwaysShowName?: boolean; + } + | { + type: "default"; + }; + +export type TopChromeHeaderState = { + routeKey: string; + header: TopChromeHeaderConfig; +}; + +export const normalizeTopChromeRouteKey = (pathname: string) => { + const normalizedPathname = pathname.replace(/\/+$/, ""); + + return normalizedPathname || "/"; +}; 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..4eb9f75384 --- /dev/null +++ b/front_end/src/app/(main)/components/top_chrome_header_slot.tsx @@ -0,0 +1,24 @@ +"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)/error.tsx b/front_end/src/app/(main)/error.tsx index 0421141bb3..79a11a9ab7 100644 --- a/front_end/src/app/(main)/error.tsx +++ b/front_end/src/app/(main)/error.tsx @@ -1,24 +1,10 @@ "use client"; -import { usePathname } from "next/navigation"; - -import Header from "@/app/(main)/components/headers/header"; import GlobalErrorBoundary from "@/components/global_error_boundary"; -import { getWithDefaultHeader } from "@/utils/navigation"; export default function RootError(props: { error: Error & { digest?: string }; reset: () => void; }) { - const pathname = usePathname(); - const withDefaultHeader = getWithDefaultHeader(pathname); - - return ( - <> - {/* Ensure header is always visible in error view */} - {/* Even if it is dynamically defined on the route */} - {!withDefaultHeader &&
} - - - ); + return ; } diff --git a/front_end/src/app/(main)/labor-hub/components/labor_hub_navigation.tsx b/front_end/src/app/(main)/labor-hub/components/labor_hub_navigation.tsx index 11b110d7cb..f234047a86 100644 --- a/front_end/src/app/(main)/labor-hub/components/labor_hub_navigation.tsx +++ b/front_end/src/app/(main)/labor-hub/components/labor_hub_navigation.tsx @@ -20,6 +20,7 @@ import toast from "react-hot-toast"; import Button from "@/components/ui/button"; import LoadingSpinner from "@/components/ui/loading_spiner"; +import { useTopChromeHeightPx } from "@/hooks/use_top_chrome_height"; import cn from "@/utils/core/cn"; import { NewsletterSubscribePopover } from "./newsletter_subscribe_popover"; @@ -36,6 +37,7 @@ export default function LaborHubNavigation({ const [isNewsletterOpen, setIsNewsletterOpen] = useState(false); const [isDownloadingPdf, setIsDownloadingPdf] = useState(false); const sentinelRef = useRef(null); + const topChromeHeight = useTopChromeHeightPx(); const { refs, floatingStyles, context, isPositioned } = useFloating({ open: isNewsletterOpen, @@ -136,7 +138,7 @@ export default function LaborHubNavigation({ }, { threshold: [0], - rootMargin: "-48px 0px 0px 0px", // Account for top-12 offset (48px) + rootMargin: `-${topChromeHeight}px 0px 0px 0px`, } ); @@ -145,7 +147,7 @@ export default function LaborHubNavigation({ return () => { observer.disconnect(); }; - }, []); + }, [topChromeHeight]); const surfaceClassName = "bg-gray-0 dark:bg-gray-0-dark"; const fadeToSurfaceClassName = "to-gray-0 dark:to-gray-0-dark"; @@ -159,7 +161,7 @@ export default function LaborHubNavigation({ return ( <>
-
+
) { - const authManager = await getAuthCookieManager(); - const isImpersonating = authManager.isImpersonating(); + const initialHeaderState = await resolveInitialTopChromeHeaderState(); return ( -
- - - {isImpersonating && } - - -
{children}
- {!PUBLIC_MINIMAL_UI && ( - <> - -
- - )} - - -
+ + +
+ +
{children}
+ {!PUBLIC_MINIMAL_UI && ( + <> + +
+ + )} + + +
+
+
); } diff --git a/front_end/src/app/(main)/not-found.tsx b/front_end/src/app/(main)/not-found.tsx index 112929601f..c6caa6a5c9 100644 --- a/front_end/src/app/(main)/not-found.tsx +++ b/front_end/src/app/(main)/not-found.tsx @@ -1,12 +1,5 @@ import NotFoundSection from "@/components/not_found_section"; -import GlobalHeader from "./components/headers/global_header"; - export default async function NotFound() { - return ( - <> - - - - ); + return ; } 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)/notebooks/components/notebook_content_sections.tsx b/front_end/src/app/(main)/notebooks/components/notebook_content_sections.tsx index 46c72c0c05..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 @@ -14,6 +14,7 @@ import { import { useBreakpoint } from "@/hooks/tailwind"; import useHash from "@/hooks/use_hash"; import useSectionHeadings from "@/hooks/use_section_headings"; +import { useTopChromeHeightPx } from "@/hooks/use_top_chrome_height"; import cn from "@/utils/core/cn"; type Props = { @@ -21,7 +22,6 @@ type Props = { unreadComments?: number; }; -const MOBILE_SCROLL_OFFSET = 66 + 48 + 16; const DESKTOP_SCROLL_OFFSET = 66; const NotebookContentSections: FC = ({ @@ -37,6 +37,7 @@ const NotebookContentSections: FC = ({ const [isNotebookTitleVisible, setIsNotebookTitleVisible] = useState(true); const isLargeScreen = useBreakpoint("md"); + const topChromeHeight = useTopChromeHeightPx(); useEffect(() => { for (const h of headings) { const el = document.getElementById(h.id); @@ -48,10 +49,14 @@ const NotebookContentSections: FC = ({ }, [headings]); useEffect(() => { - const notebookTitleElement = document.querySelector( - `#${NOTEBOOK_TITLE}` - ) as HTMLElement | null; - setNotebookTitle(notebookTitleElement?.textContent ?? null); + const frame = requestAnimationFrame(() => { + const notebookTitleElement = document.querySelector( + `#${NOTEBOOK_TITLE}` + ) as HTMLElement | null; + setNotebookTitle(notebookTitleElement?.textContent ?? null); + }); + + return () => cancelAnimationFrame(frame); }, []); useEffect(() => { @@ -77,8 +82,8 @@ const NotebookContentSections: FC = ({ ids.push(NOTEBOOK_COMMENTS_TITLE); const offset = isLargeScreen - ? DESKTOP_SCROLL_OFFSET - : MOBILE_SCROLL_OFFSET; + ? DESKTOP_SCROLL_OFFSET + topChromeHeight + : DESKTOP_SCROLL_OFFSET + topChromeHeight + 16; const activeId = [...ids].reverse().find((id) => { const el = document.getElementById(id); @@ -100,7 +105,7 @@ const NotebookContentSections: FC = ({ window.removeEventListener("scroll", handleOnScroll); window.removeEventListener("resize", handleOnScroll); }; - }, [headings, isLargeScreen]); + }, [headings, isLargeScreen, topChromeHeight]); const commentsTitle = useMemo(() => { const commentCount = t("commentsWithCount", { count: commentsCount }); @@ -141,7 +146,7 @@ const NotebookContentSections: FC = ({ return ( {({ open, close }) => ( <> 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 ? ( - - ) : ( -
- )}
= ({ items }) => { const [isMobileExpanded, setIsMobileExpanded] = useState(false); - const { bannerIsVisible: isTranslationBannerVisible } = - useContentTranslatedBannerContext(); - - const topPositionClasses = isTranslationBannerVisible - ? "top-24 lg:top-20" - : "top-12 lg:top-20"; - return ( -
-
+
+
- {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/(main)/questions/page.tsx b/front_end/src/app/(main)/questions/page.tsx index e36c6f0877..1009813852 100644 --- a/front_end/src/app/(main)/questions/page.tsx +++ b/front_end/src/app/(main)/questions/page.tsx @@ -55,7 +55,7 @@ export default async function Questions(props: { return ( <> -
+
diff --git a/front_end/src/app/(prediction-flow)/components/header.tsx b/front_end/src/app/(prediction-flow)/components/header.tsx index 7224b8455e..7f5162a519 100644 --- a/front_end/src/app/(prediction-flow)/components/header.tsx +++ b/front_end/src/app/(prediction-flow)/components/header.tsx @@ -21,11 +21,13 @@ import ExitFlowModal from "./exit_flow_modal"; import { usePredictionFlow } from "./prediction_flow_provider"; type Props = { + className?: string; tournamentSlug: string; tournamentName: string; }; const PredictionFlowHeader: FC = ({ + className, tournamentName, tournamentSlug, }) => { @@ -40,7 +42,7 @@ const PredictionFlowHeader: FC = ({ return ( <> - + - {/* Header */} -
-
-

- M -

-
-

- {""} -

- - -
+ + +

+ M +

+
+ + + + +
+ } + />
{/* Progress section skeleton */} 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/(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..a1e5fb19ff --- /dev/null +++ b/front_end/src/app/(services-quiz)/components/services_quiz_flow_content.tsx @@ -0,0 +1,36 @@ +"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]; + + 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/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/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/components/markdown_editor/editor.css b/front_end/src/components/markdown_editor/editor.css index 9b8d3df012..f806973980 100644 --- a/front_end/src/components/markdown_editor/editor.css +++ b/front_end/src/components/markdown_editor/editor.css @@ -60,7 +60,7 @@ } .mdxeditor-toolbar { - @apply sticky top-12 flex-wrap; + @apply sticky top-header flex-wrap; container-type: inline-size; & [class*="toolbarTitleMode"] { display: none; /* We render our custom source mode title instead */ diff --git a/front_end/src/components/ui/tabs/index.tsx b/front_end/src/components/ui/tabs/index.tsx index 2dfafbe611..c268e15d70 100644 --- a/front_end/src/components/ui/tabs/index.tsx +++ b/front_end/src/components/ui/tabs/index.tsx @@ -96,7 +96,7 @@ export const TabsList = ({ ? "" // no negative margins, overflow, or background for contained variant : "-mx-3 overflow-x-auto bg-blue-200 px-3 py-3 dark:bg-blue-200-dark sm:-mx-4 sm:px-4", ctx.variant === "separated" && !contained - ? "sticky top-12 gap-2" + ? "sticky top-header gap-2" : "gap-2", className )} diff --git a/front_end/src/hooks/use_top_chrome_height.ts b/front_end/src/hooks/use_top_chrome_height.ts new file mode 100644 index 0000000000..6f5ad885db --- /dev/null +++ b/front_end/src/hooks/use_top_chrome_height.ts @@ -0,0 +1,64 @@ +"use client"; + +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; + +export const getTopChromeHeightPx = () => { + if (typeof window === "undefined") { + return DEFAULT_TOP_CHROME_HEIGHT_PX; + } + + const rawValue = getComputedStyle(document.documentElement) + .getPropertyValue(TOP_CHROME_HEIGHT_CSS_VAR) + .trim(); + const parsedValue = Number.parseFloat(rawValue); + + return Number.isFinite(parsedValue) + ? parsedValue + : DEFAULT_TOP_CHROME_HEIGHT_PX; +}; + +export const useTopChromeHeightPx = () => { + const [topChromeHeight, setTopChromeHeight] = useState( + DEFAULT_TOP_CHROME_HEIGHT_PX + ); + + useEffect(() => { + const updateTopChromeHeight = () => { + setTopChromeHeight(getTopChromeHeightPx()); + }; + + updateTopChromeHeight(); + + 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 () => { + if (isObserving) { + observer?.disconnect(); + } + window.removeEventListener("resize", updateTopChromeHeight); + }; + }, []); + + return topChromeHeight; +}; diff --git a/front_end/src/services/api/misc/misc.shared.ts b/front_end/src/services/api/misc/misc.shared.ts index 35f8f52891..4953b03523 100644 --- a/front_end/src/services/api/misc/misc.shared.ts +++ b/front_end/src/services/api/misc/misc.shared.ts @@ -1,5 +1,4 @@ import { ApiService } from "@/services/api/api_service"; -import { encodeQueryParams } from "@/utils/navigation"; export type ContactForm = { email: string; @@ -22,21 +21,26 @@ export interface SiteStats { years_of_predictions: number; } -type BulletinParams = { - post_id?: number; - project_slug?: string; +export type BulletinItem = { + text: string; + id: number; }; class MiscApi extends ApiService { - async getBulletins(params?: BulletinParams) { - const queryParams = encodeQueryParams(params ?? {}); + async getBulletins(): Promise { const resp = await this.get<{ - bulletins: { - text: string; - id: number; - }[]; - }>(`/get-bulletins/${queryParams}`); - return resp?.bulletins; + bulletins: BulletinItem[]; + }>("/get-bulletins/", undefined, { + passAuthHeader: false, + }); + return resp.bulletins; + } + + async getDismissedBulletinIds() { + const resp = await this.get<{ + dismissed_bulletin_ids: number[]; + }>("/get-dismissed-bulletin-ids/"); + return resp.dismissed_bulletin_ids; } async getSiteStats() { diff --git a/front_end/src/utils/navigation.ts b/front_end/src/utils/navigation.ts index 4945ff3713..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 * @@ -222,17 +215,3 @@ export function ensureRelativeRedirect(input: string): string { // Normalize slashes return "/" + url; } - -export function getBulletinParamsFromPathname(pathname: string) { - const questionMatch = pathname.match(/^\/questions\/(\d+)(?:\/|$)/); - if (questionMatch) { - return { post_id: Number(questionMatch[1]) }; - } - - const projectMatch = pathname.match(/^\/tournament\/([^/]+)(?:\/|$)/); - if (projectMatch) { - return { project_slug: projectMatch[1] }; - } - - return undefined; -} 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", diff --git a/misc/admin.py b/misc/admin.py index 1cb3ecd1fe..057f08b0f3 100644 --- a/misc/admin.py +++ b/misc/admin.py @@ -1,4 +1,3 @@ -from admin_auto_filters.filters import AutocompleteFilterFactory from django import forms from django.contrib import admin from django.core.exceptions import ValidationError @@ -9,12 +8,7 @@ @admin.register(Bulletin) class BulletinAdmin(admin.ModelAdmin): list_display = ["__str__", "bulletin_start", "bulletin_end"] - search_fields = ["post", "project", "bulletin_start", "bulletin_end", "text"] - list_filter = [ - AutocompleteFilterFactory("Post", "post"), - AutocompleteFilterFactory("Project", "project"), - ] - autocomplete_fields = ["post", "project"] + search_fields = ["bulletin_start", "bulletin_end", "text"] class SidebarItemAdminForm(forms.ModelForm): diff --git a/misc/migrations/0009_remove_bulletin_post_project.py b/misc/migrations/0009_remove_bulletin_post_project.py new file mode 100644 index 0000000000..b1ad115df3 --- /dev/null +++ b/misc/migrations/0009_remove_bulletin_post_project.py @@ -0,0 +1,20 @@ +# Generated by Django 5.2 on 2026-05-09 + +from django.db import migrations + + +class Migration(migrations.Migration): + dependencies = [ + ("misc", "0008_replace_whitelistuser_with_userdataaccess"), + ] + + operations = [ + migrations.RemoveField( + model_name="bulletin", + name="post", + ), + migrations.RemoveField( + model_name="bulletin", + name="project", + ), + ] diff --git a/misc/models.py b/misc/models.py index bf15e319de..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 @@ -44,30 +45,9 @@ class Bulletin(TimeStampedModel): bulletin_end = models.DateTimeField() text = models.TextField() - post = models.ForeignKey( - Post, - null=True, - blank=True, - db_index=True, - on_delete=models.CASCADE, - help_text="""Optional. If set, places this Bulletin only on this post's page.""", - ) - project = models.ForeignKey( - Project, - null=True, - blank=True, - db_index=True, - on_delete=models.CASCADE, - help_text="""Optional. If set, places this Bulletin only on this project's page.""", - ) - def __str__(self): - text = self.text - if self.post: - text = (self.post.short_title or self.post.title)[:50] + "... " + text - elif self.project: - text = self.project.name[:50] + "... " + text - return text[:150] + "..." if len(text) > 150 else 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/urls.py b/misc/urls.py index 217a6ccd17..bdbb3d3bd9 100644 --- a/misc/urls.py +++ b/misc/urls.py @@ -7,6 +7,7 @@ path("contact-form/", views.contact_api_view), path("contact-form/services/", views.contact_service_api_view), path("get-bulletins/", views.get_bulletins), + path("get-dismissed-bulletin-ids/", views.get_dismissed_bulletin_ids), path("get-site-stats/", views.get_site_stats), path("cancel-bulletin//", views.cancel_bulletin), path( diff --git a/misc/views.py b/misc/views.py index ee04b69389..4fc386a97d 100644 --- a/misc/views.py +++ b/misc/views.py @@ -1,6 +1,5 @@ from datetime import datetime -from django.db.models import Q from django.conf import settings from django.core.mail import EmailMessage from django.http import JsonResponse @@ -82,38 +81,15 @@ def remove_article_api_view(request, pk): return Response(status=status.HTTP_204_NO_CONTENT) +@cache_page(60) @api_view(["GET"]) @permission_classes([AllowAny]) def get_bulletins(request): - user = request.user - data = request.query_params - post_id = data.get("post_id") - project_slug = data.get("project_slug") # maybe needs to be slug for simplicity - bulletins = Bulletin.objects.filter( bulletin_start__lte=timezone.now(), bulletin_end__gte=timezone.now(), - ) - - if post_id: - bulletins = bulletins.filter(Q(post_id__isnull=True) | Q(post_id=post_id)) - else: - bulletins = bulletins.filter(post_id__isnull=True) - - if project_slug: - bulletins = bulletins.filter( - Q(project_id__isnull=True) | Q(project__slug=project_slug) - ) - else: - bulletins = bulletins.filter(project_id__isnull=True) - - bulletins_viewed_by_user = [] - if user and user.is_authenticated: - bulletins_viewed_by_user = [ - x.bulletin.pk for x in BulletinViewedBy.objects.filter(user=user).all() - ] + ).order_by("-bulletin_start", "-created_at", "-pk") - bulletins = [x for x in bulletins if x.pk not in bulletins_viewed_by_user] bulletins_ser = { "bulletins": [ {"text": bulletin.text, "id": bulletin.pk} for bulletin in bulletins @@ -122,6 +98,19 @@ def get_bulletins(request): return Response(bulletins_ser) +@api_view(["GET"]) +def get_dismissed_bulletin_ids(request): + user = request.user + dismissed_bulletin_ids = list( + BulletinViewedBy.objects.filter( + user=user, + bulletin__bulletin_start__lte=timezone.now(), + bulletin__bulletin_end__gte=timezone.now(), + ).values_list("bulletin_id", flat=True) + ) + return Response({"dismissed_bulletin_ids": dismissed_bulletin_ids}) + + @cache_page(60 * 60 * 24) @api_view(["GET"]) @permission_classes([AllowAny]) @@ -147,10 +136,8 @@ def cancel_bulletin(request, pk): user = request.user if not user or not user.is_authenticated: return Response(status=status.HTTP_200_OK) - bulletin_viewed_by = BulletinViewedBy( - bulletin=Bulletin.objects.get(pk=pk), user=user - ) - bulletin_viewed_by.save() + bulletin = get_object_or_404(Bulletin, pk=pk) + BulletinViewedBy.objects.get_or_create(bulletin=bulletin, user=user) return Response(status=status.HTTP_201_CREATED)