diff --git a/front_end/messages/cs.json b/front_end/messages/cs.json index 4e194cbbe8..ec789e9761 100644 --- a/front_end/messages/cs.json +++ b/front_end/messages/cs.json @@ -700,6 +700,7 @@ "resolutionCriteriaExplanation": "Dobrá otázka se téměř vždy vyřeší jednoznačně. Jaká je konkrétní událost, která by mohla nastat? Do jakého data by musela nastat, aby se otázka vyřešila jako Ano? Pokud máte zdroj dat, podle kterého se otázka vyřeší, přidejte jej sem.", "choicesSeparatedBy": "Možnosti (oddělené čárkou)", "projects": "projekty", + "projectsTags": "Projekty / Štítky", "FABPrizePool": "CENOVÝ FOND", "FABPrizeValue": "$30,000", "FABStartDate": "STARTOVACÍ DATUM", @@ -1819,6 +1820,7 @@ "loadMore": "Načíst více", "noPrivateNotes": "Žádné soukromé poznámky", "privateNotes": "Soukromé poznámky", + "questionLinks": "Odkazy na otázku", "justNow": "právě teď", "cmmButtonShort": "Mysl", "FABRegisterBot": "Zaregistrujte se, abyste přihlásili svého robota do turnaje", @@ -2166,5 +2168,6 @@ "whyTrustMetaculusLessNoiseTitle": "Méně šumu, více pravdy", "whyTrustMetaculusLessNoise": "Předpovědi Metaculus, poháněné davem, prořezávají šum tím, že zakládají každou předpověď na transparentních důkazech, odpovědném skóre a desetiletí doložené přesnosti. Metaculus vybavuje politiky, výzkumníky, novináře a korporátní organizace předpověďmi založenými na důkazech, které poskytují jasný, kvantifikovatelný vhled do nejkritičtějších nejistot světa. Prozkoumejte naši nabídku Business řešení a zjistěte, jak může Metaculus zlepšit rozhodování vaší organizace.", "publishTimeLockedDescription": "Čas publikace nelze po vytvoření změnit.", + "nMore": "{count} více...", "thousandsOfOpenQuestions": "20 000+ otevřených otázek" } diff --git a/front_end/messages/en.json b/front_end/messages/en.json index bbd0aa0cc4..d4b729c939 100644 --- a/front_end/messages/en.json +++ b/front_end/messages/en.json @@ -767,6 +767,7 @@ "aboutMetaculusTitle": "About Metaculus", "aboutMetaculusDescription": "Metaculus is an online forecasting platform and aggregation engine working to improve human reasoning and coordination on topics of global importance.", "more": "More", + "nMore": "{count} more...", "moreLikely": "percentage points higher", "afterElection": "under {winner}", "ifCandidateElected": "if {candidate} elected", @@ -1066,6 +1067,7 @@ "choicesSeparatedBy": "Choices (separated by ,)", "choicesLockedHelp": "Options can only be changed through the admin panel once forecasting has started.", "projects": "projects", + "projectsTags": "Projects / Tags", "FABPrizePool": "PRIZE POOL", "FABPrizeValue": "$30,000", "FABStartDate": "START DATE", @@ -1942,6 +1944,7 @@ "loadMore": "Load More", "noPrivateNotes": "No private notes yet", "privateNotes": "Private Notes", + "questionLinks": "Question Links", "justNow": "just now", "cmmButtonShort": "Mind", "myForecastingBots": "My Forecasting Bots", diff --git a/front_end/messages/es.json b/front_end/messages/es.json index 1298c75d95..dd79b0d44b 100644 --- a/front_end/messages/es.json +++ b/front_end/messages/es.json @@ -702,6 +702,7 @@ "resolutionCriteriaExplanation": "Una buena pregunta casi siempre se resolverá de manera inequívoca. ¿Cuál es el evento específico que podría ocurrir? ¿Antes de qué fecha tendría que ocurrir para que la pregunta se resuelva como Sí? Si tienes una fuente de datos con la que se resolverá la pregunta, enlázala aquí.", "choicesSeparatedBy": "Opciones (separadas por ,)", "projects": "proyectos", + "projectsTags": "Proyectos / Etiquetas", "FABPrizePool": "FONDO DE PREMIOS", "FABPrizeValue": "$30,000", "FABStartDate": "FECHA DE INICIO", @@ -1818,6 +1819,7 @@ "loadMore": "Cargar más", "noPrivateNotes": "Aún no hay notas privadas", "privateNotes": "Notas privadas", + "questionLinks": "Enlaces de preguntas", "justNow": "justo ahora", "cmmButtonShort": "Mente", "FABRegisterBot": "Regístrate para inscribir tu bot en el torneo", @@ -2166,5 +2168,6 @@ "whyTrustMetaculusLessNoiseTitle": "Menos ruido, más verdad", "whyTrustMetaculusLessNoise": "Los pronósticos impulsados por la multitud de Metaculus cortan el ruido al basar cada predicción en evidencia transparente, puntuaciones responsables y una década de precisión demostrada. Metaculus equipa a los responsables de políticas, investigadores, periodistas y organizaciones corporativas con pronósticos basados en evidencia que ofrecen una visión clara y cuantificable de las incertidumbres más críticas del mundo. Explora nuestra suite de Soluciones Empresariales para aprender cómo Metaculus puede mejorar la toma de decisiones de tu organización.", "publishTimeLockedDescription": "La hora de publicación no puede cambiarse después de la creación.", + "nMore": "{count} más...", "thousandsOfOpenQuestions": "20,000+ preguntas abiertas" } diff --git a/front_end/messages/pt.json b/front_end/messages/pt.json index ff9daf06cc..b6f289b566 100644 --- a/front_end/messages/pt.json +++ b/front_end/messages/pt.json @@ -791,6 +791,7 @@ "choices": "Escolhas", "choicesSeparatedBy": "Escolhas (separadas por ,)", "projects": "projetos", + "projectsTags": "Projetos / Tags", "FABPrizePool": "PRÊMIO TOTAL", "FABPrizeValue": "$30,000", "FABStartDate": "DATA DE INÍCIO", @@ -1816,6 +1817,7 @@ "loadMore": "Carregar Mais", "noPrivateNotes": "Ainda não há notas privadas", "privateNotes": "Notas Privadas", + "questionLinks": "Links da pergunta", "justNow": "agora mesmo", "cmmButtonShort": "Mente", "FABRegisterBot": "Inscreva-se para registrar seu bot no torneio", @@ -2164,5 +2166,6 @@ "whyTrustMetaculusLessNoiseTitle": "Menos ruído, mais verdade", "whyTrustMetaculusLessNoise": "As previsões baseadas na multidão do Metaculus cortam o ruído ao basear cada previsão em evidências transparentes, pontuações responsáveis e uma década de precisão demonstrada. O Metaculus equipa formuladores de políticas, pesquisadores, jornalistas e organizações corporativas com previsões baseadas em evidências que oferecem insights claros e quantificáveis sobre as incertezas mais críticas do mundo. Explore nosso conjunto de Soluções Empresariais para saber mais sobre como o Metaculus pode melhorar a tomada de decisões da sua organização.", "publishTimeLockedDescription": "O horário de publicação não pode ser alterado após a criação.", + "nMore": "{count} a mais...", "thousandsOfOpenQuestions": "20.000+ perguntas abertas" } diff --git a/front_end/messages/zh-TW.json b/front_end/messages/zh-TW.json index 8e691fd1e1..b16ee57d94 100644 --- a/front_end/messages/zh-TW.json +++ b/front_end/messages/zh-TW.json @@ -853,6 +853,7 @@ "choices": "選擇", "choicesSeparatedBy": "選擇(以,分隔)", "projects": "專案", + "projectsTags": "專案 / 標籤", "FABPrizePool": "獎金池", "FABPrizeValue": "$30,000", "FABStartDate": "開始日期", @@ -1815,6 +1816,7 @@ "loadMore": "載入更多", "noPrivateNotes": "尚無私人筆記", "privateNotes": "私人筆記", + "questionLinks": "問題連結", "justNow": "剛剛", "cmmButtonShort": "心智", "FABRegisterBot": "註冊以參加賽事", @@ -2163,5 +2165,6 @@ "whyTrustMetaculusLessNoiseTitle": "更少的噪音,更多的真相", "whyTrustMetaculusLessNoise": "Metaculus 的群眾驅動預測通過將每一個預測植根於透明的證據、負責的計分和十年的已證準確性,抑制了噪音。Metaculus 為政策制定者、研究人員、記者和企業組織提供基於證據的預測,為全球最重要的不確定性提供清晰、可量化的洞察。探索我們的 企業解決方案,了解 Metaculus 如何改善貴組織的決策。", "publishTimeLockedDescription": "建立後將無法更改發佈時間。", + "nMore": "還有 {count} 個...", "thousandsOfOpenQuestions": "20,000+ 開放問題" } diff --git a/front_end/messages/zh.json b/front_end/messages/zh.json index 91db90e7ac..6862ba5ce3 100644 --- a/front_end/messages/zh.json +++ b/front_end/messages/zh.json @@ -691,6 +691,7 @@ "resolutionCriteriaExplanation": "一個好的問題幾乎總是能夠明確解決。可能發生的具體事件是什麼?在什麼日期之前需要發生才能使問題解決為「是」?如果你有用於解決問題的數據源,請在此處連結。", "choicesSeparatedBy": "選項(以逗號分隔)", "projects": "專案", + "projectsTags": "项目 / 标签", "FABPrizePool": "獎金池", "FABPrizeValue": "30,000美元", "FABStartDate": "開始日期", @@ -1820,6 +1821,7 @@ "loadMore": "加載更多", "noPrivateNotes": "尚無私人筆記", "privateNotes": "私人筆記", + "questionLinks": "问题链接", "justNow": "刚刚", "cmmButtonShort": "心情", "FABRegisterBot": "注册您的机器人以参加锦标赛", @@ -2168,5 +2170,6 @@ "whyTrustMetaculusLessNoiseTitle": "少噪音,多真相", "whyTrustMetaculusLessNoise": "Metaculus 的众包预测通过以透明证据、可追溯的评分以及十年证明的准确性为基础来削减预测中的噪音。Metaculus 为政策制定者、研究人员、记者和企业组织提供基于证据的预测,为世界上最重要的不确定性提供清晰、可量化的洞察。了解我们的企业解决方案,了解 Metaculus 如何改善贵组织的决策。", "publishTimeLockedDescription": "创建后将无法更改发布时间。", + "nMore": "{count} 更多...", "thousandsOfOpenQuestions": "20,000+ 开放问题" } diff --git a/front_end/src/app/(main)/components/comments_feed_provider.tsx b/front_end/src/app/(main)/components/comments_feed_provider.tsx index 3433b5112f..ca3a3fae06 100644 --- a/front_end/src/app/(main)/components/comments_feed_provider.tsx +++ b/front_end/src/app/(main)/components/comments_feed_provider.tsx @@ -199,6 +199,9 @@ const CommentsFeedProvider: FC< try { setIsLoading(true); setError(undefined); + if (!keepComments) { + setOffset(0); + } const response = await ClientCommentsApi.getComments({ post: postData?.id, author: profileId, 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..2cbf6e18ec 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 @@ -13,15 +13,12 @@ import { SearchParams } from "@/types/navigation"; import { GroupOfQuestionsGraphType } from "@/types/post"; import { TournamentType } from "@/types/projects"; import cn from "@/utils/core/cn"; -import { - getPostTitle, - isGroupOfQuestionsPost, -} from "@/utils/questions/helpers"; +import { isGroupOfQuestionsPost } from "@/utils/questions/helpers"; import NotebookRedirect from "../components/notebook_redirect"; import QuestionEmbedModal from "../components/question_embed_modal"; -import QuestionLayout from "../components/question_layout"; -import QuestionView from "../components/question_view"; +import QuestionPageShell from "../components/question_page_shell"; +import { QuestionVariantComposer } from "../components/question_variant_composer"; import Sidebar from "../components/sidebar"; import { SLUG_POST_SUB_QUESTION_ID } from "../search_params"; import { cachedGetPost } from "./utils/get_post"; @@ -53,7 +50,6 @@ const IndividualQuestionPage: FC<{ const preselectedGroupQuestionId = extractPreselectedGroupQuestionId(searchParams); - const questionTitle = getPostTitle(postData); const isFanChart = isGroupOfQuestionsPost(postData) && postData.group_of_questions?.graph_type === @@ -88,24 +84,22 @@ const IndividualQuestionPage: FC<{ /> )} - - {isCommunityQuestion && defaultProject && ( - - )} - - + mobileSidebar={ + + } + /> - + + } + consumer={ + + } + /> diff --git a/front_end/src/app/(main)/questions/[id]/components/key_factors/item_view/key_factor_header.tsx b/front_end/src/app/(main)/questions/[id]/components/key_factors/item_view/key_factor_header.tsx index 5c12043006..d007e21fdb 100644 --- a/front_end/src/app/(main)/questions/[id]/components/key_factors/item_view/key_factor_header.tsx +++ b/front_end/src/app/(main)/questions/[id]/components/key_factors/item_view/key_factor_header.tsx @@ -18,7 +18,7 @@ const KeyFactorHeader: FC = ({ label, username, linkAnchor }) => { const questionLayout = useQuestionLayoutSafe(); const handleActivate = () => { - questionLayout?.setMobileActiveTab("comments"); + questionLayout?.setActiveTab("comments"); const target = document.getElementById(linkAnchor.replace("#", "")); if (target) { diff --git a/front_end/src/app/(main)/questions/[id]/components/key_factors/item_view/more_panel.tsx b/front_end/src/app/(main)/questions/[id]/components/key_factors/item_view/more_panel.tsx index 5c0ba978d8..92bbc55adf 100644 --- a/front_end/src/app/(main)/questions/[id]/components/key_factors/item_view/more_panel.tsx +++ b/front_end/src/app/(main)/questions/[id]/components/key_factors/item_view/more_panel.tsx @@ -123,12 +123,10 @@ const MorePanel: FC = ({ const handleViewComment = () => { onClose(); questionLayout?.closeKeyFactorOverlay(); + questionLayout?.setActiveTab("comments"); setTimeout(() => { - const el = document.getElementById(`comment-${keyFactor.comment_id}`); - if (el) { - el.scrollIntoView({ behavior: "smooth", block: "center" }); - } - }, 100); + questionLayout?.requestScrollToComment(keyFactor.comment_id); + }, 50); }; const actions: ActionItem[] = [ diff --git a/front_end/src/app/(main)/questions/[id]/components/key_factors/key_factor_detail_overlay.tsx b/front_end/src/app/(main)/questions/[id]/components/key_factors/key_factor_detail_overlay.tsx index 1cc64c1fbd..59c0ebad40 100644 --- a/front_end/src/app/(main)/questions/[id]/components/key_factors/key_factor_detail_overlay.tsx +++ b/front_end/src/app/(main)/questions/[id]/components/key_factors/key_factor_detail_overlay.tsx @@ -121,19 +121,20 @@ const KeyFactorDetailOverlay: FC = (props) => { if (!keyFactor) return; await ensureCommentLoaded(keyFactor.comment_id); onClose(); + questionLayout?.setActiveTab("comments"); setTimeout(() => { - const el = document.getElementById(`comment-${keyFactor.comment_id}`); - el?.scrollIntoView({ behavior: "smooth", block: "center" }); - }, 200); + questionLayout?.requestScrollToComment(keyFactor.comment_id); + }, 50); }; const handleReplyToComment = async () => { if (!keyFactor) return; await ensureCommentLoaded(keyFactor.comment_id); onClose(); + questionLayout?.setActiveTab("comments"); setTimeout(() => { questionLayout?.requestReplyToComment(keyFactor.comment_id); - }, 500); + }, 50); }; const hasComment = !!(keyFactor && (comment?.text?.trim() || !comment)); diff --git a/front_end/src/app/(main)/questions/[id]/components/key_factors/key_factors_question_consumer_section.tsx b/front_end/src/app/(main)/questions/[id]/components/key_factors/key_factors_question_consumer_section.tsx index 0117fa6dbf..0c29532cd1 100644 --- a/front_end/src/app/(main)/questions/[id]/components/key_factors/key_factors_question_consumer_section.tsx +++ b/front_end/src/app/(main)/questions/[id]/components/key_factors/key_factors_question_consumer_section.tsx @@ -8,14 +8,14 @@ import { openKeyFactorsSectionAndScrollTo } from "@/app/(main)/questions/[id]/co import { PostStatus, PostWithForecasts } from "@/types/post"; import { sendAnalyticsEvent } from "@/utils/analytics"; -import KeyFactorDetailOverlay from "./key_factor_detail_overlay"; -import KeyFactorsConsumerCarousel from "./key_factors_consumer_carousel"; -import { useShouldHideKeyFactors } from "./use_should_hide_key_factors"; -import { useQuestionLayout } from "../question_layout/question_layout_context"; import { MAX_TOP_KEY_FACTORS, useTopKeyFactorsCarouselItems, } from "./hooks/use_top_key_factors_carousel_items"; +import KeyFactorDetailOverlay from "./key_factor_detail_overlay"; +import KeyFactorsConsumerCarousel from "./key_factors_consumer_carousel"; +import { useShouldHideKeyFactors } from "./use_should_hide_key_factors"; +import { useQuestionLayout } from "../question_layout/question_layout_context"; type Props = { post: PostWithForecasts; @@ -51,7 +51,7 @@ const KeyFactorsQuestionConsumerSection: FC = ({ post }) => { return (
@@ -63,7 +63,7 @@ const KeyFactorsQuestionConsumerSection: FC = ({ post }) => { openKeyFactorsElement("[id='key-factors']"); sendAnalyticsEvent("KeyFactorViewAllClick"); }} - className="text-sm text-blue-700 hover:text-blue-800 dark:text-blue-700-dark dark:hover:text-blue-600-dark" + className="text-center text-sm font-normal leading-5 text-blue-600 hover:text-blue-700 dark:text-blue-600-dark dark:hover:text-blue-700-dark" > {t("viewAll", { count: totalCount })} diff --git a/front_end/src/app/(main)/questions/[id]/components/post_score_data/single_question_score_data.tsx b/front_end/src/app/(main)/questions/[id]/components/post_score_data/single_question_score_data.tsx index 60f18bd637..1eaee76b6b 100644 --- a/front_end/src/app/(main)/questions/[id]/components/post_score_data/single_question_score_data.tsx +++ b/front_end/src/app/(main)/questions/[id]/components/post_score_data/single_question_score_data.tsx @@ -1,6 +1,8 @@ import React, { FC } from "react"; import ForecastMaker from "@/components/forecast_maker"; +import BackgroundInfo from "@/components/question/background_info"; +import ResolutionCriteria from "@/components/question/resolution_criteria"; import { PostWithForecasts } from "@/types/post"; import { QuestionType } from "@/types/question"; import { isContinuousQuestion } from "@/utils/questions/helpers"; @@ -27,11 +29,20 @@ const SingleQuestionScoreData: FC = ({ if (isConsumerView) { return ( - +
+ {question.type !== QuestionType.MultipleChoice && ( + + )} + +
); } @@ -46,6 +57,8 @@ const SingleQuestionScoreData: FC = ({ )} {isContinuousQuestion(question) && } + +
); }; diff --git a/front_end/src/app/(main)/questions/[id]/components/post_score_data/utils.ts b/front_end/src/app/(main)/questions/[id]/components/post_score_data/utils.ts index 3ae61719cd..e583c4a96a 100644 --- a/front_end/src/app/(main)/questions/[id]/components/post_score_data/utils.ts +++ b/front_end/src/app/(main)/questions/[id]/components/post_score_data/utils.ts @@ -20,22 +20,38 @@ export const shouldQuestionShowScores = (question: QuestionWithForecasts) => { ); }; -export function shouldPostShowScores(post: PostWithForecasts): boolean { +export const shouldQuestionShowUserScores = ( + question: QuestionWithForecasts +) => { + const userScores = question.my_forecasts?.score_data; + return ( + !isNil(userScores) && + Object.keys(userScores).length > 0 && + !isUnsuccessfullyResolved(question.resolution) + ); +}; + +function someQuestionIn( + post: PostWithForecasts, + predicate: (q: QuestionWithForecasts) => boolean +): boolean { if (isGroupOfQuestionsPost(post)) { - return post.group_of_questions.questions.some(shouldQuestionShowScores); + return post.group_of_questions.questions.some(predicate); } if (isConditionalPost(post)) { const { condition, question_yes, question_no } = post.conditional; - - if (condition.resolution === "yes") { - return shouldQuestionShowScores(question_yes); - } else if (condition.resolution === "no") { - return shouldQuestionShowScores(question_no); - } + if (condition.resolution === "yes") return predicate(question_yes); + if (condition.resolution === "no") return predicate(question_no); } - if (isQuestionPost(post)) return shouldQuestionShowScores(post.question); + if (isQuestionPost(post)) return predicate(post.question); return false; } + +export const shouldPostShowScores = (post: PostWithForecasts) => + someQuestionIn(post, shouldQuestionShowScores); + +export const shouldPostShowUserScores = (post: PostWithForecasts) => + someQuestionIn(post, shouldQuestionShowUserScores); diff --git a/front_end/src/app/(main)/questions/[id]/components/question_layout/consumer_question_layout/consumer_tabs.tsx b/front_end/src/app/(main)/questions/[id]/components/question_layout/consumer_question_layout/consumer_tabs.tsx deleted file mode 100644 index ec36fe6f3c..0000000000 --- a/front_end/src/app/(main)/questions/[id]/components/question_layout/consumer_question_layout/consumer_tabs.tsx +++ /dev/null @@ -1,24 +0,0 @@ -"use client"; - -import { PropsWithChildren } from "react"; - -import { Tabs } from "@/components/ui/tabs/index"; - -import { useQuestionLayout } from "../question_layout_context"; - -const ConsumerTabs: React.FC = ({ children }) => { - const { mobileActiveTab, setMobileActiveTab } = useQuestionLayout(); - - return ( - - {children} - - ); -}; - -export default ConsumerTabs; diff --git a/front_end/src/app/(main)/questions/[id]/components/question_layout/consumer_question_layout/index.tsx b/front_end/src/app/(main)/questions/[id]/components/question_layout/consumer_question_layout/index.tsx deleted file mode 100644 index f1bbf44597..0000000000 --- a/front_end/src/app/(main)/questions/[id]/components/question_layout/consumer_question_layout/index.tsx +++ /dev/null @@ -1,120 +0,0 @@ -import { useTranslations } from "next-intl"; -import { PropsWithChildren } from "react"; - -import KeyFactorsFeed from "@/app/(main)/questions/[id]/components/key_factors/key_factors_feed"; -import PostScoreData from "@/app/(main)/questions/[id]/components/post_score_data"; -import { shouldPostShowScores } from "@/app/(main)/questions/[id]/components/post_score_data/utils"; -import ConsumerTabs from "@/app/(main)/questions/[id]/components/question_layout/consumer_question_layout/consumer_tabs"; -import DetailedGroupCard from "@/components/detailed_question_card/detailed_group_card"; -import { TabsList, TabsSection, TabsTab } from "@/components/ui/tabs"; -import { GroupOfQuestionsGraphType, PostWithForecasts } from "@/types/post"; -import { isGroupOfQuestionsPost } from "@/utils/questions/helpers"; - -import QuestionTimeline, { - hasTimeline as hasTimelineFn, -} from "../../question_view/consumer_question_view/timeline"; -import QuestionInfo from "../question_info"; -import QuestionSection from "../question_section"; -import ResponsiveCommentFeed from "./responsive_comment_feed"; - -type Props = { - postData: PostWithForecasts; - preselectedGroupQuestionId: number | undefined; -}; -const ConsumerQuestionLayout: React.FC> = ({ - children, - preselectedGroupQuestionId, - postData, -}) => { - const t = useTranslations(); - const hasTimeline = hasTimelineFn(postData); - - const isFanGraph = - postData.group_of_questions?.graph_type === - GroupOfQuestionsGraphType.FanGraph; - - const showScores = shouldPostShowScores(postData); - - return ( -
- - {children} -
- - - {t("comments")} - {hasTimeline && ( - {t("timeline")} - )} - {showScores && {t("scores")}} - {t("keyFactors")} - {t("info")} - - - - - - {hasTimeline && ( - - {isGroupOfQuestionsPost(postData) && ( - - )} - - - )} - {showScores && ( - - - - )} - -
- -
-
- - - -
-
-
- -
-
-
- -
-
- ); -}; - -export default ConsumerQuestionLayout; diff --git a/front_end/src/app/(main)/questions/[id]/components/question_layout/consumer_question_layout/responsive_comment_feed.tsx b/front_end/src/app/(main)/questions/[id]/components/question_layout/consumer_question_layout/responsive_comment_feed.tsx index e6cf0075ab..5fde43565b 100644 --- a/front_end/src/app/(main)/questions/[id]/components/question_layout/consumer_question_layout/responsive_comment_feed.tsx +++ b/front_end/src/app/(main)/questions/[id]/components/question_layout/consumer_question_layout/responsive_comment_feed.tsx @@ -9,6 +9,7 @@ import { PostWithForecasts } from "@/types/post"; type Props = { postData: PostWithForecasts; compactVersion?: boolean; + showTitle?: boolean; }; /** @@ -22,6 +23,7 @@ type Props = { const ResponsiveCommentFeed: FC = ({ postData, compactVersion = false, + showTitle, }) => { const isLargeScreen = useBreakpoint("lg"); @@ -33,7 +35,13 @@ const ResponsiveCommentFeed: FC = ({ return null; } - return ; + return ( + + ); }; export default ResponsiveCommentFeed; diff --git a/front_end/src/app/(main)/questions/[id]/components/question_layout/forecaster_question_layout/index.tsx b/front_end/src/app/(main)/questions/[id]/components/question_layout/forecaster_question_layout/index.tsx deleted file mode 100644 index 648aebd016..0000000000 --- a/front_end/src/app/(main)/questions/[id]/components/question_layout/forecaster_question_layout/index.tsx +++ /dev/null @@ -1,42 +0,0 @@ -import { PropsWithChildren } from "react"; - -import CommentFeed from "@/components/comment_feed"; -import { PostWithForecasts } from "@/types/post"; -import { getPostTitle } from "@/utils/questions/helpers"; - -import Sidebar from "../../sidebar"; -import QuestionInfo from "../question_info"; -import QuestionSection from "../question_section"; - -type Props = { - postData: PostWithForecasts; - preselectedGroupQuestionId: number | undefined; -}; - -const ForecasterQuestionLayout: React.FC> = ({ - children, - postData, - preselectedGroupQuestionId, -}) => { - return ( -
- - {children} - - - - -
- ); -}; - -export default ForecasterQuestionLayout; diff --git a/front_end/src/app/(main)/questions/[id]/components/question_layout/index.tsx b/front_end/src/app/(main)/questions/[id]/components/question_layout/index.tsx deleted file mode 100644 index 54da2961bf..0000000000 --- a/front_end/src/app/(main)/questions/[id]/components/question_layout/index.tsx +++ /dev/null @@ -1,44 +0,0 @@ -import { PropsWithChildren } from "react"; - -import { PostWithForecasts } from "@/types/post"; - -import { QuestionVariantComposer } from "../question_variant_composer"; -import ConsumerQuestionLayout from "./consumer_question_layout"; -import ForecasterQuestionLayout from "./forecaster_question_layout"; -import { QuestionLayoutProvider } from "./question_layout_context"; - -type Props = { - postData: PostWithForecasts; - preselectedGroupQuestionId: number | undefined; -}; - -const QuestionLayout: React.FC> = ({ - postData, - preselectedGroupQuestionId, - children, -}) => { - return ( - - - {children} - - } - forecaster={ - - {children} - - } - /> - - ); -}; - -export default QuestionLayout; diff --git a/front_end/src/app/(main)/questions/[id]/components/question_layout/question_info.tsx b/front_end/src/app/(main)/questions/[id]/components/question_layout/question_info.tsx deleted file mode 100644 index e0f9f7418a..0000000000 --- a/front_end/src/app/(main)/questions/[id]/components/question_layout/question_info.tsx +++ /dev/null @@ -1,129 +0,0 @@ -import { useTranslations } from "next-intl"; - -import PostScoreData from "@/app/(main)/questions/[id]/components/post_score_data"; -import { CoherenceLinks } from "@/app/(main)/questions/components/coherence_links/coherence_links"; -import ConditionalTimeline from "@/components/conditional_timeline"; -import DetailedGroupCard from "@/components/detailed_question_card/detailed_group_card"; -import BackgroundInfo from "@/components/question/background_info"; -import PrivateNote from "@/components/question/private_note"; -import ResolutionCriteria from "@/components/question/resolution_criteria"; -import SectionToggle from "@/components/ui/section_toggle"; -import { GroupOfQuestionsGraphType, PostWithForecasts } from "@/types/post"; -import { - isConditionalPost, - isGroupOfQuestionsPost, -} from "@/utils/questions/helpers"; - -import HistogramDrawer from "../histogram_drawer"; -import KeyFactorsQuestionSection from "../key_factors/key_factors_question_section"; -import { QuestionVariantComposer } from "../question_variant_composer"; -import QuestionTimeline from "../question_view/consumer_question_view/timeline"; - -type Props = { - postData: PostWithForecasts; - preselectedGroupQuestionId: number | undefined; - showKeyFactors?: boolean; - showTimeline?: boolean; - keyFactorsDefaultCollapsed?: boolean; - hideKeyFactorOverlay?: boolean; -}; - -const QuestionInfo: React.FC = ({ - postData, - preselectedGroupQuestionId, - showKeyFactors, - showTimeline, - keyFactorsDefaultCollapsed, - hideKeyFactorOverlay, -}) => { - const t = useTranslations(); - return ( -
- {showTimeline && ( - - - - )} - - } - consumer={ -
- -
- } - /> - - - {isConditionalPost(postData) && } - - {showKeyFactors && ( - - )} - - - - - - - - - - ) : null - } - forecaster={ - isGroupOfQuestionsPost(postData) && - postData.group_of_questions.graph_type === - GroupOfQuestionsGraphType.FanGraph && ( - - ) - } - /> - - - -
- ); -}; - -export default QuestionInfo; diff --git a/front_end/src/app/(main)/questions/[id]/components/question_layout/question_layout_context.tsx b/front_end/src/app/(main)/questions/[id]/components/question_layout/question_layout_context.tsx index 24a7e085ea..8bb08f38b4 100644 --- a/front_end/src/app/(main)/questions/[id]/components/question_layout/question_layout_context.tsx +++ b/front_end/src/app/(main)/questions/[id]/components/question_layout/question_layout_context.tsx @@ -35,31 +35,48 @@ type QuestionLayoutContextValue = { requestReplyToComment: (commentId: number) => void; clearReplyToComment: () => void; - // Mobile tab state - mobileActiveTab?: string; - setMobileActiveTab: (tab: string) => void; + // Comment scroll trigger + scrollToCommentId: number | null; + requestScrollToComment: (commentId: number) => void; + clearScrollToComment: () => void; + + // Active tab state (shared between mobile + desktop tab bars) + activeTab?: string; + setActiveTab: (tab: string) => void; }; +const TAB_HASH_VALUES = new Set([ + "comments", + "timeline", + "my-scores", + "key-factors", + "info", + "question-links", + "private-notes", +]); + const QuestionLayoutContext = createContext({} as QuestionLayoutContextValue); export const QuestionLayoutProvider = ({ children }: PropsWithChildren) => { const hash = useHash(); const [keyFactorsExpanded, setKeyFactorsExpanded] = useState(); - const [mobileActiveTab, setMobileActiveTab] = useState(); + const [activeTab, setActiveTab] = useState(); const [keyFactorOverlay, setKeyFactorOverlay] = useState(null); - // Expand key factors section if URL hash points to it useEffect(() => { + if (!hash) return; if (hash === "key-factors") { setKeyFactorsExpanded(true); - setMobileActiveTab("key-factors"); + } + if (TAB_HASH_VALUES.has(hash)) { + setActiveTab(hash); } }, [hash]); const requestKeyFactorsExpand = useCallback(() => { setKeyFactorsExpanded(true); - setMobileActiveTab("key-factors"); + setActiveTab("key-factors"); }, []); const openKeyFactorOverlay = useCallback((kf: KeyFactor) => { @@ -85,6 +102,16 @@ export const QuestionLayoutProvider = ({ children }: PropsWithChildren) => { setReplyToCommentId(null); }, []); + const [scrollToCommentId, setScrollToCommentId] = useState( + null + ); + const requestScrollToComment = useCallback((commentId: number) => { + setScrollToCommentId(commentId); + }, []); + const clearScrollToComment = useCallback(() => { + setScrollToCommentId(null); + }, []); + const value = useMemo( () => ({ keyFactorsExpanded, @@ -96,8 +123,11 @@ export const QuestionLayoutProvider = ({ children }: PropsWithChildren) => { replyToCommentId, requestReplyToComment, clearReplyToComment, - mobileActiveTab, - setMobileActiveTab, + scrollToCommentId, + requestScrollToComment, + clearScrollToComment, + activeTab, + setActiveTab, }), [ keyFactorsExpanded, @@ -109,7 +139,10 @@ export const QuestionLayoutProvider = ({ children }: PropsWithChildren) => { replyToCommentId, requestReplyToComment, clearReplyToComment, - mobileActiveTab, + scrollToCommentId, + requestScrollToComment, + clearScrollToComment, + activeTab, ] ); diff --git a/front_end/src/app/(main)/questions/[id]/components/question_layout/question_section.tsx b/front_end/src/app/(main)/questions/[id]/components/question_layout/question_section.tsx deleted file mode 100644 index 53c3648565..0000000000 --- a/front_end/src/app/(main)/questions/[id]/components/question_layout/question_section.tsx +++ /dev/null @@ -1,11 +0,0 @@ -import { PropsWithChildren } from "react"; - -const QuestionSection: React.FC = ({ children }) => { - return ( -
- {children} -
- ); -}; - -export default QuestionSection; diff --git a/front_end/src/app/(main)/questions/[id]/components/question_page_shell/index.tsx b/front_end/src/app/(main)/questions/[id]/components/question_page_shell/index.tsx new file mode 100644 index 0000000000..0c67495458 --- /dev/null +++ b/front_end/src/app/(main)/questions/[id]/components/question_page_shell/index.tsx @@ -0,0 +1,284 @@ +"use client"; + +import { useLocale, useTranslations } from "next-intl"; +import { FC, Fragment, ReactNode, useEffect } from "react"; + +import useCoherenceLinksContext from "@/app/(main)/components/coherence_links_provider"; +import { PostStatusBox } from "@/app/(main)/questions/[id]/components/post_status_box"; +import DetailedGroupCard from "@/components/detailed_question_card/detailed_group_card"; +import DetailedQuestionCard from "@/components/detailed_question_card/detailed_question_card"; +import ForecastMaker from "@/components/forecast_maker"; +import CommunityDisclaimer from "@/components/post_card/community_disclaimer"; +import { useContentTranslatedBannerContext } from "@/contexts/translations_banner_context"; +import { + GroupOfQuestionsGraphType, + PostStatus, + PostWithForecasts, + QuestionStatus, +} from "@/types/post"; +import { TournamentType } from "@/types/projects"; +import { QuestionType } from "@/types/question"; +import cn from "@/utils/core/cn"; +import { + checkGroupOfQuestionsPostType, + isContinuousQuestion, + isGroupOfQuestionsPost, + isMultipleChoicePost, + isQuestionPost, +} from "@/utils/questions/helpers"; + +import MetaRow from "./meta_row"; +import QuestionPageShellTabs from "./tabs"; +import TitleRow from "./title_row"; +import KeyFactorsQuestionConsumerSection from "../key_factors/key_factors_question_consumer_section"; +import { isDisplayableQuestionLink } from "../key_factors/utils"; +import PostScoreData from "../post_score_data"; +import { QuestionLayoutProvider } from "../question_layout/question_layout_context"; +import { QuestionVariantComposer } from "../question_variant_composer"; +import ActionRow from "../question_view/action_row"; +import ConsumerQuestionPrediction from "../question_view/consumer_question_view/prediction"; +import QuestionTimeline from "../question_view/consumer_question_view/timeline"; + +const baseSectionClassName = + "relative z-10 flex w-[59rem] max-w-full flex-col gap-6 overflow-x-clip rounded border border-blue-400 p-4 text-gray-900 dark:border-blue-200-dark dark:text-gray-900-dark lg:p-8"; + +const mainSectionClassName = `${baseSectionClassName} bg-gray-0 dark:bg-gray-0-dark`; +const commentSectionClassName = `${baseSectionClassName} bg-blue-100 dark:bg-gray-0-dark`; + +type ShellProps = { + postData: PostWithForecasts; + preselectedGroupQuestionId?: number; +}; + +export const ForecasterShell: FC< + ShellProps & { mobileSidebar?: ReactNode } +> = ({ postData, preselectedGroupQuestionId, mobileSidebar }) => { + const { setBannerIsVisible } = useContentTranslatedBannerContext(); + const locale = useLocale(); + + useEffect(() => { + const timeoutId = window.setTimeout(() => { + setBannerIsVisible(Boolean(postData.is_current_content_translated)); + }, 0); + + return () => { + window.clearTimeout(timeoutId); + }; + }, [postData.is_current_content_translated, locale, setBannerIsVisible]); + + const isResolved = postData.status === PostStatus.RESOLVED; + const isGroup = isGroupOfQuestionsPost(postData); + + return ( + +
+
+ + {postData.projects?.default_project?.type === + TournamentType.Community && ( + + )} +
+ + +
+ +
+ {isQuestionPost(postData) && ( + + )} + {isGroup && ( + + )} +
+ {(!isResolved || isGroup) && } + +
+
+ +
+
+ {mobileSidebar} +
+ ); +}; + +export const ConsumerShell: FC<{ + postData: PostWithForecasts; + preselectedGroupQuestionId?: number; + mobileSidebar?: ReactNode; +}> = ({ postData, preselectedGroupQuestionId, mobileSidebar }) => { + const t = useTranslations(); + const { aggregateCoherenceLinks } = useCoherenceLinksContext(); + + const isFanGraph = + postData.group_of_questions?.graph_type === + GroupOfQuestionsGraphType.FanGraph; + + const isDateGroup = + postData.group_of_questions && + checkGroupOfQuestionsPostType(postData, QuestionType.Date); + + const isMultipleChoice = isMultipleChoicePost(postData); + const isNonFanGroup = isGroupOfQuestionsPost(postData) && !isFanGraph; + + const reverseOrder = + (isMultipleChoice || isGroupOfQuestionsPost(postData)) && !isDateGroup; + + const isContinuousSingleQuestion = + isQuestionPost(postData) && isContinuousQuestion(postData.question); + + const isBinarySingleQuestion = + isQuestionPost(postData) && + !isContinuousSingleQuestion && + !isMultipleChoice; + + const showSideBySide = + (isMultipleChoice || isNonFanGroup || isBinarySingleQuestion) && + !isContinuousSingleQuestion; + + const showClosedMessageMultipleChoice = + isMultipleChoicePost(postData) && + postData.question.status === QuestionStatus.CLOSED; + + const showClosedMessageFanGraph = + isFanGraph && postData.status === PostStatus.CLOSED; + + const questionLinkAggregates = + aggregateCoherenceLinks?.data.filter(isDisplayableQuestionLink) ?? []; + const hasKeyFactors = (postData.key_factors?.length ?? 0) > 0; + const hasQuestionLinks = questionLinkAggregates.length > 0; + const shouldShowKeyFactorsSection = hasKeyFactors || hasQuestionLinks; + + return ( +
+
+ + {postData.projects?.default_project?.type === + TournamentType.Community && ( + + )} +
+ + +
+
+ +
+
+ {showClosedMessageMultipleChoice && ( +

+ {t("predictionClosedMessage")} +

+ )} +
+
+ +
+ {!isFanGraph && !isDateGroup && ( + + )} + {showClosedMessageFanGraph && ( +

+ {t("predictionClosedMessage")} +

+ )} +
+
+ {shouldShowKeyFactorsSection && ( +
+ +
+ )} +
+
+ +
+ {mobileSidebar} +
+ ); +}; + +type Props = { + postData: PostWithForecasts; + preselectedGroupQuestionId?: number; + mobileSidebar?: ReactNode; +}; + +const QuestionPageShell: FC = ({ + postData, + preselectedGroupQuestionId, + mobileSidebar, +}) => { + return ( + + + } + forecaster={ + + } + /> + + ); +}; + +export default QuestionPageShell; diff --git a/front_end/src/app/(main)/questions/[id]/components/question_page_shell/meta_row.tsx b/front_end/src/app/(main)/questions/[id]/components/question_page_shell/meta_row.tsx new file mode 100644 index 0000000000..d3645e67cf --- /dev/null +++ b/front_end/src/app/(main)/questions/[id]/components/question_page_shell/meta_row.tsx @@ -0,0 +1,172 @@ +"use client"; + +import { faEllipsis } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { Popover, PopoverButton, PopoverPanel } from "@headlessui/react"; +import { useTranslations } from "next-intl"; +import { FC } from "react"; + +import ForecastersCounter from "@/app/(main)/questions/components/forecaster_counter"; +import { PostDropdownMenu } from "@/components/post_actions"; +import CommentStatus from "@/components/post_card/basic_post_card/comment_status"; +import PostVoter from "@/components/post_card/basic_post_card/post_voter"; +import PostStatus from "@/components/post_status"; +import Button from "@/components/ui/button"; +import Chip from "@/components/ui/chip"; +import useContainerSize from "@/hooks/use_container_size"; +import { PostWithForecasts } from "@/types/post"; +import { Project } from "@/types/projects"; +import { sendAnalyticsEvent } from "@/utils/analytics"; +import cn from "@/utils/core/cn"; +import { getPostLink, getProjectLink } from "@/utils/navigation"; +import { extractPostResolution } from "@/utils/questions/resolution"; + +import { getChipColor, getChipContent } from "./project_chip_helpers"; + +type Props = { + post: PostWithForecasts; + variant: "forecaster" | "consumer"; + className?: string; +}; + +const MetaRow: FC = ({ post, className, variant }) => { + const t = useTranslations(); + const resolutionData = extractPostResolution(post); + const { ref, width } = useContainerSize(); + + const projectsData = post.projects; + const allProjects: Project[] = projectsData + ? [ + ...(projectsData.index ?? []), + ...(projectsData.tournament ?? []), + ...(projectsData.question_series ?? []), + ...(projectsData.community ?? []), + ...(projectsData.category ?? []), + ...(projectsData.leaderboard_tag ?? []), + ] + : []; + + // Drop "forecasters" label first, then reduce visible chips + const compactCounters = width > 0 && width < 600; + const maxVisibleChips = width > 0 && width < 500 ? 1 : 2; + + const visibleChips = allProjects.slice(0, maxVisibleChips); + const hiddenChips = allProjects.slice(maxVisibleChips); + + return ( +
+ {/* Mobile row */} +
+
+ {variant === "forecaster" && } + + {variant === "forecaster" && ( + + )} + +
+
+ + + + } + /> +
+
+ + {/* Desktop row */} +
+
+ + + + +
+ +
+ {visibleChips.map((element) => ( + + sendAnalyticsEvent("questionTagClicked", { + event_category: element.name, + }) + } + > + {getChipContent(element)} + + ))} + + {hiddenChips.length > 0 && ( + + + {t("nMore", { count: hiddenChips.length })} + + + {hiddenChips.map((element) => ( + { + sendAnalyticsEvent("questionTagClicked", { + event_category: element.name, + }); + }} + > + {getChipContent(element)} + + ))} + + + )} +
+
+
+ ); +}; + +export default MetaRow; diff --git a/front_end/src/app/(main)/questions/[id]/components/question_page_shell/project_chip_helpers.tsx b/front_end/src/app/(main)/questions/[id]/components/question_page_shell/project_chip_helpers.tsx new file mode 100644 index 0000000000..aaeb72566d --- /dev/null +++ b/front_end/src/app/(main)/questions/[id]/components/question_page_shell/project_chip_helpers.tsx @@ -0,0 +1,24 @@ +import TrophyIcon from "@/components/icons/trophy"; +import { Project, TaxonomyProjectType, TournamentType } from "@/types/projects"; + +export const getChipContent = (element: Project) => { + if ( + element.type === TournamentType.Tournament || + element.type === TaxonomyProjectType.LeaderboardTag + ) { + return ( + + + {element.name} + + ); + } + return {element.name}; +}; + +export const getChipColor = (element: Project) => + Object.values(TaxonomyProjectType).includes( + element.type as TaxonomyProjectType + ) + ? "olive" + : "orange"; diff --git a/front_end/src/app/(main)/questions/[id]/components/question_page_shell/tab_bar.tsx b/front_end/src/app/(main)/questions/[id]/components/question_page_shell/tab_bar.tsx new file mode 100644 index 0000000000..130779263e --- /dev/null +++ b/front_end/src/app/(main)/questions/[id]/components/question_page_shell/tab_bar.tsx @@ -0,0 +1,133 @@ +"use client"; + +import { useTranslations } from "next-intl"; +import { FC, useEffect } from "react"; + +import { Tabs, TabsList, TabsTab } from "@/components/ui/tabs"; +import { useBreakpoint } from "@/hooks/tailwind"; +import { PostStatus, PostWithForecasts } from "@/types/post"; +import cn from "@/utils/core/cn"; + +import { shouldPostShowUserScores } from "../post_score_data/utils"; +import { useQuestionLayout } from "../question_layout/question_layout_context"; +import { hasTimeline } from "../question_view/consumer_question_view/timeline"; + +type TabKey = + | "comments" + | "key-factors" + | "info" + | "my-scores" + | "question-links" + | "private-notes" + | "timeline" + | "similar-questions"; + +type TabDef = { + key: TabKey; + label: string; + count?: number; +}; + +type Props = { + post: PostWithForecasts; + variant: "consumer" | "forecaster"; + className?: string; +}; + +const tabClassName = (isActive: boolean) => + cn( + "border-[1.25px] border-solid rounded-full font-medium transition-colors", + "text-sm leading-4 px-2 py-1 sm:text-base sm:leading-6 sm:px-[15px] sm:py-[5px]", + isActive + ? "bg-blue-500/60 border-transparent text-blue-900 dark:bg-blue-500-dark/60 dark:text-blue-900-dark" + : "bg-gray-0 border-blue-500/20 text-blue-900 dark:bg-gray-0-dark dark:border-blue-500-dark/20 dark:text-blue-900-dark" + ); + +const QuestionPageShellTabBar: FC = ({ post, variant, className }) => { + const t = useTranslations(); + const { activeTab, setActiveTab } = useQuestionLayout(); + + const isSm = useBreakpoint("sm"); + const commentCount = post.comment_count ?? 0; + const keyFactorsCount = post.key_factors?.length ?? 0; + const hasScores = shouldPostShowUserScores(post); + const hasSimilarQuestionsTab = + !isSm && post.curation_status === PostStatus.APPROVED; + + const forecasterTabs: TabDef[] = [ + { key: "comments", label: t("comments"), count: commentCount }, + { key: "key-factors", label: t("keyFactors"), count: keyFactorsCount }, + { key: "info", label: t("info") }, + { key: "question-links", label: t("questionLinks") }, + { key: "private-notes", label: t("privateNotes") }, + ...(hasSimilarQuestionsTab + ? [ + { + key: "similar-questions" as TabKey, + label: t("similarQuestions"), + }, + ] + : []), + ]; + + const consumerTabs: TabDef[] = [ + { key: "comments", label: t("comments"), count: commentCount }, + ...(!isSm && hasTimeline(post) + ? [{ key: "timeline" as TabKey, label: t("timeline") }] + : []), + { key: "key-factors", label: t("keyFactors"), count: keyFactorsCount }, + { key: "info", label: t("info") }, + ...(hasScores + ? [{ key: "my-scores" as TabKey, label: t("myScores") }] + : []), + ...(hasSimilarQuestionsTab + ? [ + { + key: "similar-questions" as TabKey, + label: t("similarQuestions"), + }, + ] + : []), + ]; + + const tabs = variant === "forecaster" ? forecasterTabs : consumerTabs; + + const defaultValue: TabKey = "comments"; + const active = + activeTab && tabs.some((tab) => tab.key === activeTab) + ? activeTab + : defaultValue; + + // Sync context when the active tab is dropped from the list (e.g. on resize). + useEffect(() => { + if (activeTab && active !== activeTab) { + setActiveTab(active); + } + }, [active, activeTab, setActiveTab]); + + return ( + + + {tabs.map((tab) => ( + + {tab.count !== undefined + ? `${tab.label} (${tab.count})` + : tab.label} + + ))} + + + ); +}; + +export default QuestionPageShellTabBar; diff --git a/front_end/src/app/(main)/questions/[id]/components/question_page_shell/tabs.tsx b/front_end/src/app/(main)/questions/[id]/components/question_page_shell/tabs.tsx new file mode 100644 index 0000000000..4fdcb85751 --- /dev/null +++ b/front_end/src/app/(main)/questions/[id]/components/question_page_shell/tabs.tsx @@ -0,0 +1,73 @@ +"use client"; + +import { FC } from "react"; + +import { PostWithForecasts } from "@/types/post"; + +import QuestionPageShellTabBar from "./tab_bar"; +import CommentsTab from "./tabs/comments"; +import KeyFactorsTab from "./tabs/key_factors"; +import MyScoresTab from "./tabs/my_scores"; +import PrivateNotesTab from "./tabs/private_notes"; +import QuestionInfoTab from "./tabs/question_info"; +import QuestionLinksTab from "./tabs/question_links"; +import SimilarQuestionsTab from "./tabs/similar_questions"; +import TimelineTab from "./tabs/timeline"; +import KeyFactorsFeed from "../key_factors/key_factors_feed"; +import { useQuestionLayout } from "../question_layout/question_layout_context"; + +type Variant = "consumer" | "forecaster"; + +type Props = { + post: PostWithForecasts; + variant: Variant; + className?: string; +}; + +const renderActivePanel = ( + activeTab: string | undefined, + post: PostWithForecasts, + variant: Variant +) => { + switch (activeTab) { + case "similar-questions": + return ; + case "timeline": + return variant === "consumer" ? : null; + case "key-factors": + return ; + case "info": + return ; + case "question-links": + return variant === "forecaster" ? : null; + case "private-notes": + return variant === "forecaster" ? : null; + case "my-scores": + return variant === "consumer" ? : null; + case "comments": + default: + return ; + } +}; + +const QuestionPageShellTabs: FC = ({ post, variant, className }) => { + const { activeTab } = useQuestionLayout(); + const isKeyFactors = activeTab === "key-factors"; + + return ( +
+ + {/* Mounted only when off the key-factors tab to power the overlay from any tab */} + {!isKeyFactors && ( +
+ +
+ )} +
+ {renderActivePanel(activeTab, post, variant)} +
+
+ ); +}; + +export default QuestionPageShellTabs; diff --git a/front_end/src/app/(main)/questions/[id]/components/question_page_shell/tabs/comments.tsx b/front_end/src/app/(main)/questions/[id]/components/question_page_shell/tabs/comments.tsx new file mode 100644 index 0000000000..fa806df186 --- /dev/null +++ b/front_end/src/app/(main)/questions/[id]/components/question_page_shell/tabs/comments.tsx @@ -0,0 +1,18 @@ +"use client"; + +import { FC } from "react"; + +import CommentFeed from "@/components/comment_feed"; +import { PostWithForecasts } from "@/types/post"; + +type Props = { + post: PostWithForecasts; +}; + +const CommentsTab: FC = ({ post }) => ( +
+ +
+); + +export default CommentsTab; diff --git a/front_end/src/app/(main)/questions/[id]/components/question_page_shell/tabs/key_factors.tsx b/front_end/src/app/(main)/questions/[id]/components/question_page_shell/tabs/key_factors.tsx new file mode 100644 index 0000000000..fc14e0345d --- /dev/null +++ b/front_end/src/app/(main)/questions/[id]/components/question_page_shell/tabs/key_factors.tsx @@ -0,0 +1,15 @@ +"use client"; + +import { FC } from "react"; + +import { PostWithForecasts } from "@/types/post"; + +import KeyFactorsFeed from "../../key_factors/key_factors_feed"; + +type Props = { + post: PostWithForecasts; +}; + +const KeyFactorsTab: FC = ({ post }) => ; + +export default KeyFactorsTab; diff --git a/front_end/src/app/(main)/questions/[id]/components/question_page_shell/tabs/my_scores.tsx b/front_end/src/app/(main)/questions/[id]/components/question_page_shell/tabs/my_scores.tsx new file mode 100644 index 0000000000..19b66f01d9 --- /dev/null +++ b/front_end/src/app/(main)/questions/[id]/components/question_page_shell/tabs/my_scores.tsx @@ -0,0 +1,14 @@ +import { FC } from "react"; + +import PostScoreData from "@/app/(main)/questions/[id]/components/post_score_data"; +import { PostWithForecasts } from "@/types/post"; + +type Props = { + post: PostWithForecasts; +}; + +const MyScoresTab: FC = ({ post }) => { + return ; +}; + +export default MyScoresTab; diff --git a/front_end/src/app/(main)/questions/[id]/components/question_page_shell/tabs/private_notes.tsx b/front_end/src/app/(main)/questions/[id]/components/question_page_shell/tabs/private_notes.tsx new file mode 100644 index 0000000000..ed1b3ea2de --- /dev/null +++ b/front_end/src/app/(main)/questions/[id]/components/question_page_shell/tabs/private_notes.tsx @@ -0,0 +1,16 @@ +"use client"; + +import { FC } from "react"; + +import PrivateNote from "@/components/question/private_note"; +import { PostWithForecasts } from "@/types/post"; + +type Props = { + post: PostWithForecasts; +}; + +const PrivateNotesTab: FC = ({ post }) => ( + +); + +export default PrivateNotesTab; diff --git a/front_end/src/app/(main)/questions/[id]/components/question_page_shell/tabs/question_info.tsx b/front_end/src/app/(main)/questions/[id]/components/question_page_shell/tabs/question_info.tsx new file mode 100644 index 0000000000..abb70ab170 --- /dev/null +++ b/front_end/src/app/(main)/questions/[id]/components/question_page_shell/tabs/question_info.tsx @@ -0,0 +1,146 @@ +"use client"; + +import { useTranslations } from "next-intl"; +import { FC } from "react"; + +import MarkdownEditor from "@/components/markdown_editor"; +import Chip from "@/components/ui/chip"; +import { PostWithForecasts } from "@/types/post"; +import { sendAnalyticsEvent } from "@/utils/analytics"; +import { getProjectLink } from "@/utils/navigation"; + +import SidebarQuestionInfo from "../../sidebar/sidebar_question_info"; +import { getChipColor, getChipContent } from "../project_chip_helpers"; + +type Props = { + post: PostWithForecasts; +}; + +type MarkdownSection = { title: string; markdown: string }; + +const useTextSections = (post: PostWithForecasts): MarkdownSection[] => { + const t = useTranslations(); + + if (post.conditional) { + const { condition, condition_child } = post.conditional; + const sections: MarkdownSection[] = []; + if (condition.resolution_criteria) { + sections.push({ + title: t("parentResolutionCriteria"), + markdown: condition.resolution_criteria, + }); + } + if (condition.fine_print) { + sections.push({ title: t("finePrint"), markdown: condition.fine_print }); + } + if (condition.description) { + sections.push({ + title: t("parentBackgroundInfo"), + markdown: condition.description, + }); + } + if (condition_child.resolution_criteria) { + sections.push({ + title: t("childResolutionCriteria"), + markdown: condition_child.resolution_criteria, + }); + } + if (condition_child.fine_print) { + sections.push({ + title: t("finePrint"), + markdown: condition_child.fine_print, + }); + } + if (condition_child.description) { + sections.push({ + title: t("childBackgroundInfo"), + markdown: condition_child.description, + }); + } + return sections; + } + + const resolution = + post.group_of_questions?.resolution_criteria ?? + post.question?.resolution_criteria ?? + ""; + const finePrint = + post.group_of_questions?.fine_print ?? post.question?.fine_print ?? ""; + const description = + post.group_of_questions?.description ?? post.question?.description ?? ""; + + const sections: MarkdownSection[] = []; + if (resolution) + sections.push({ title: t("resolutionCriteria"), markdown: resolution }); + if (finePrint) sections.push({ title: t("finePrint"), markdown: finePrint }); + if (description) + sections.push({ title: t("backgroundInfo"), markdown: description }); + return sections; +}; + +const QuestionInfoTab: FC = ({ post }) => { + const t = useTranslations(); + const sections = useTextSections(post); + + const projectsData = post.projects; + const allProjects = projectsData + ? [ + ...(projectsData.index ?? []), + ...(projectsData.tournament ?? []), + ...(projectsData.question_series ?? []), + ...(projectsData.community ?? []), + ...(projectsData.category ?? []), + ...(projectsData.leaderboard_tag ?? []), + ] + : []; + + return ( +
+ + +
+ {sections.map((section, idx) => ( +
+

+ {section.title} +

+
+ +
+
+ ))} +
+
+ ); +}; + +export default QuestionInfoTab; diff --git a/front_end/src/app/(main)/questions/[id]/components/question_page_shell/tabs/question_links.tsx b/front_end/src/app/(main)/questions/[id]/components/question_page_shell/tabs/question_links.tsx new file mode 100644 index 0000000000..f7bdf3f9f4 --- /dev/null +++ b/front_end/src/app/(main)/questions/[id]/components/question_page_shell/tabs/question_links.tsx @@ -0,0 +1,16 @@ +"use client"; + +import { FC } from "react"; + +import { CoherenceLinks } from "@/app/(main)/questions/components/coherence_links/coherence_links"; +import { PostWithForecasts } from "@/types/post"; + +type Props = { + post: PostWithForecasts; +}; + +const QuestionLinksTab: FC = ({ post }) => ( + +); + +export default QuestionLinksTab; diff --git a/front_end/src/app/(main)/questions/[id]/components/question_page_shell/tabs/similar_questions.tsx b/front_end/src/app/(main)/questions/[id]/components/question_page_shell/tabs/similar_questions.tsx new file mode 100644 index 0000000000..bbf8be0a0a --- /dev/null +++ b/front_end/src/app/(main)/questions/[id]/components/question_page_shell/tabs/similar_questions.tsx @@ -0,0 +1,72 @@ +"use client"; + +import { useQuery } from "@tanstack/react-query"; +import { FC } from "react"; + +import LoadingSpinner from "@/components/ui/loading_spiner"; +import ClientPostsApi from "@/services/api/posts/posts.client"; +import { PostStatus, PostWithForecasts } from "@/types/post"; + +import SimilarQuestionsList from "../../sidebar/similar_questions/similar_questions_list"; + +type Props = { + post: PostWithForecasts; + variant?: "forecaster" | "consumer"; +}; + +const SimilarQuestionsTab: FC = ({ post, variant }) => { + const isApproved = post.curation_status === PostStatus.APPROVED; + + const { + data: similarQuestions = [], + isSuccess: hasSimilarQuestionsResult, + isError: isSimilarError, + isLoading: isSimilarLoading, + } = useQuery({ + queryKey: ["similar-posts", post.id], + queryFn: () => ClientPostsApi.getSimilarPosts(post.id), + enabled: isApproved, + }); + + const { data: topQuestions = [], isLoading: isTopLoading } = useQuery({ + queryKey: ["top-posts-fallback", variant], + queryFn: () => + ClientPostsApi.getPostsWithCP({ + topic: "top-50", + for_main_feed: "false", + for_consumer_view: variant === "consumer" ? "true" : "false", + order_by: "-hotness", + statuses: [ + PostStatus.OPEN, + PostStatus.CLOSED, + PostStatus.RESOLVED, + PostStatus.UPCOMING, + ], + limit: 8, + }), + // only fetch when similar posts query settled with no results or errored + enabled: + !isApproved || + isSimilarError || + (hasSimilarQuestionsResult && !similarQuestions.length), + select: (data) => data.results.filter((q) => q.id !== post.id), + staleTime: 5 * 60 * 1000, + }); + + const displayQuestions = similarQuestions.length + ? similarQuestions + : topQuestions; + + if (!displayQuestions.length) { + if (isSimilarLoading || isTopLoading) { + return ; + } + return null; + } + + return ( + + ); +}; + +export default SimilarQuestionsTab; diff --git a/front_end/src/app/(main)/questions/[id]/components/question_page_shell/tabs/timeline.tsx b/front_end/src/app/(main)/questions/[id]/components/question_page_shell/tabs/timeline.tsx new file mode 100644 index 0000000000..4f64ea1e1f --- /dev/null +++ b/front_end/src/app/(main)/questions/[id]/components/question_page_shell/tabs/timeline.tsx @@ -0,0 +1,19 @@ +import { FC } from "react"; + +import { PostWithForecasts } from "@/types/post"; + +import QuestionTimeline from "../../question_view/consumer_question_view/timeline"; + +type Props = { + post: PostWithForecasts; +}; + +const TimelineTab: FC = ({ post }) => ( + +); + +export default TimelineTab; diff --git a/front_end/src/app/(main)/questions/[id]/components/question_page_shell/title_row.tsx b/front_end/src/app/(main)/questions/[id]/components/question_page_shell/title_row.tsx new file mode 100644 index 0000000000..69b40e9597 --- /dev/null +++ b/front_end/src/app/(main)/questions/[id]/components/question_page_shell/title_row.tsx @@ -0,0 +1,81 @@ +"use client"; + +import { FC } from "react"; + +import QuestionHeaderCPStatus from "@/app/(main)/questions/[id]/components/question_view/forecaster_question_view/question_header/question_header_cp_status"; +import QuestionTitle from "@/app/(main)/questions/[id]/components/question_view/shared/question_title"; +import ConditionalTile from "@/components/conditional_tile"; +import { PostWithForecasts } from "@/types/post"; +import { QuestionWithForecasts } from "@/types/question"; +import cn from "@/utils/core/cn"; +import { + isConditionalPost, + isContinuousQuestion, + isQuestionPost, +} from "@/utils/questions/helpers"; + +type Variant = "forecaster" | "consumer"; + +type Props = { + post: PostWithForecasts; + variant: Variant; + className?: string; +}; + +const TitleRow: FC = ({ post, variant, className }) => { + if (isConditionalPost(post)) { + return ( +
+ +
+ ); + } + + if (variant === "forecaster" && isQuestionPost(post)) { + return ( +
+
+
+ + {post.title} + +
+ +
+
+
+ {!isContinuousQuestion(post.question) && ( +
+ +
+ )} +
+ ); + } + + return ( + + {post.title} + + ); +}; + +export default TitleRow; diff --git a/front_end/src/app/(main)/questions/[id]/components/question_view/action_row.tsx b/front_end/src/app/(main)/questions/[id]/components/question_view/action_row.tsx new file mode 100644 index 0000000000..e02315914f --- /dev/null +++ b/front_end/src/app/(main)/questions/[id]/components/question_view/action_row.tsx @@ -0,0 +1,164 @@ +"use client"; + +import { faBell as faBellRegular } from "@fortawesome/free-regular-svg-icons"; +import { faEllipsis, faBell, faCode } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { cva, type VariantProps } from "class-variance-authority"; +import { useTranslations } from "next-intl"; +import { ComponentProps, FC } from "react"; + +import ShareIcon from "@/components/icons/share"; +import { PostDropdownMenu, SharePostMenu } from "@/components/post_actions"; +import Button from "@/components/ui/button"; +import useEmbedModalContext from "@/contexts/embed_modal_context"; +import { usePostSubscriptionContext } from "@/contexts/post_subscription_context"; +import { PostWithForecasts, PostStatus, QuestionStatus } from "@/types/post"; +import cn from "@/utils/core/cn"; +import { + getPostTitle, + isQuestionPost, + isGroupOfQuestionsPost, +} from "@/utils/questions/helpers"; +import { + isPostPrePrediction, + isQuestionPrePrediction, +} from "@/utils/questions/predictions"; + +import { AddKeyFactorsButton } from "../key_factors/add_button"; +import QuestionPredictButton from "./consumer_question_view/action_buttons/question_predict_button"; + +const pillVariants = cva( + "inline-flex items-center justify-center gap-2 rounded-full border text-sm font-medium leading-4 transition-colors text-blue-700 border-blue-400 bg-white dark:bg-gray-0-dark dark:border-blue-400-dark dark:text-blue-700-dark", + { + variants: { + variant: { + primary: + "pt-2 pb-2 pr-3 pl-3.5 bg-blue-900 border-blue-900 text-white hover:bg-blue-800 active:bg-blue-950 dark:bg-blue-900-dark dark:border-blue-900-dark dark:text-gray-200-dark dark:hover:bg-blue-800-dark", + secondary: + "pt-2 pb-2 pr-3 pl-3.5 hover:bg-gray-100 active:bg-gray-200 dark:hover:bg-gray-100-dark dark:active:bg-gray-200-dark", + icon: "h-8 w-8 p-0 hover:bg-gray-100 active:bg-gray-200 dark:hover:bg-gray-100-dark dark:active:bg-gray-200-dark", + }, + }, + defaultVariants: { + variant: "secondary", + }, + } +); + +type Props = { + post: PostWithForecasts; + variant: "forecaster" | "consumer"; +}; + +const ActionRow: FC = ({ post, variant }) => { + const t = useTranslations(); + const { updateIsOpen: openEmbedModal } = useEmbedModalContext(); + const { isSubscribed, isLoading, handleSubscribe, handleCustomize } = + usePostSubscriptionContext(); + + const isPredictable = + (isQuestionPost(post) && + (post.question.status === QuestionStatus.OPEN || + isQuestionPrePrediction(post.question))) || + (isGroupOfQuestionsPost(post) && + (post.status === PostStatus.OPEN || + post.status === PostStatus.APPROVED)) || + isPostPrePrediction(post); + + const isFollowPrimary = !(variant === "consumer" && isPredictable); + + return ( +
+ {/* Consumer: Predict (primary position) */} + {variant === "consumer" && isPredictable && ( + + )} + + {/* Follow — hidden on mobile for consumer */} + + + {isSubscribed ? t("followingButton") : t("followButton")} + + + {/* Share */} + + + + {t("share")} + + + + {/* Embed — hidden on mobile for consumer */} + openEmbedModal(true)} + > + + {t("embed")} + + + {/* Add Key Factor — forecaster only */} + {variant === "forecaster" && ( + + )} + + {/* … overflow — hidden on mobile for consumer */} +
+ + + + } + /> +
+
+ ); +}; + +export default ActionRow; + +type PillButtonProps = Omit, "variant"> & + VariantProps; + +const PillButton: FC = ({ variant, className, ...props }) => ( + diff --git a/front_end/src/app/(main)/questions/[id]/components/question_view/consumer_question_view/index.tsx b/front_end/src/app/(main)/questions/[id]/components/question_view/consumer_question_view/index.tsx deleted file mode 100644 index e532a3afb6..0000000000 --- a/front_end/src/app/(main)/questions/[id]/components/question_view/consumer_question_view/index.tsx +++ /dev/null @@ -1,112 +0,0 @@ -"use client"; - -import { useTranslations } from "next-intl"; - -import useCoherenceLinksContext from "@/app/(main)/components/coherence_links_provider"; -import KeyFactorsQuestionConsumerSection from "@/app/(main)/questions/[id]/components/key_factors/key_factors_question_consumer_section"; -import { PostStatusBox } from "@/app/(main)/questions/[id]/components/post_status_box"; -import ForecastersCounter from "@/app/(main)/questions/components/forecaster_counter"; -import CommentStatus from "@/components/post_card/basic_post_card/comment_status"; -import { - GroupOfQuestionsGraphType, - PostStatus, - PostWithForecasts, - QuestionStatus, -} from "@/types/post"; -import { QuestionType } from "@/types/question"; -import cn from "@/utils/core/cn"; -import { getPostLink } from "@/utils/navigation"; -import { - checkGroupOfQuestionsPostType, - isGroupOfQuestionsPost, - isMultipleChoicePost, -} from "@/utils/questions/helpers"; - -import QuestionActionButton from "./action_buttons"; -import ConsumerQuestionPrediction from "./prediction"; -import { isDisplayableQuestionLink } from "../../key_factors/utils"; -import QuestionTitle from "../shared/question_title"; - -type Props = { - postData: PostWithForecasts; -}; - -const ConsumerQuestionView: React.FC = ({ postData }) => { - const t = useTranslations(); - const { aggregateCoherenceLinks } = useCoherenceLinksContext(); - - const isFanGraph = - postData.group_of_questions?.graph_type === - GroupOfQuestionsGraphType.FanGraph; - - const isDateGroup = - postData.group_of_questions && - checkGroupOfQuestionsPostType(postData, QuestionType.Date); - - const reverseOrder = - (isMultipleChoicePost(postData) || isGroupOfQuestionsPost(postData)) && - !isDateGroup; - - const showClosedMessageMultipleChoice = - isMultipleChoicePost(postData) && - postData.question.status === QuestionStatus.CLOSED; - - const showClosedMessageFanGraph = - isFanGraph && postData.status === PostStatus.CLOSED; - - const questionLinkAggregates = - aggregateCoherenceLinks?.data.filter(isDisplayableQuestionLink) ?? []; - - const hasKeyFactors = (postData.key_factors?.length ?? 0) > 0; - const hasQuestionLinks = questionLinkAggregates.length > 0; - - const shouldShowKeyFactorsSection = hasKeyFactors || hasQuestionLinks; - - return ( -
- -
- - -
- - {postData.title} - -
- {showClosedMessageMultipleChoice && ( -

- {t("predictionClosedMessage")} -

- )} - -
- - - {showClosedMessageFanGraph && ( -

- {t("predictionClosedMessage")} -

- )} - - -
- - {shouldShowKeyFactorsSection && ( - - )} -
-
- ); -}; - -export default ConsumerQuestionView; diff --git a/front_end/src/app/(main)/questions/[id]/components/question_view/consumer_question_view/prediction/group_of_questions_prediction/index.tsx b/front_end/src/app/(main)/questions/[id]/components/question_view/consumer_question_view/prediction/group_of_questions_prediction/index.tsx index 7b77f9d265..eca1efc3cd 100644 --- a/front_end/src/app/(main)/questions/[id]/components/question_view/consumer_question_view/prediction/group_of_questions_prediction/index.tsx +++ b/front_end/src/app/(main)/questions/[id]/components/question_view/consumer_question_view/prediction/group_of_questions_prediction/index.tsx @@ -69,8 +69,8 @@ const GroupOfQuestionsPrediction: React.FC = ({ postData }) => { checkGroupOfQuestionsPostType(postData, QuestionType.Date) || postData.group_of_questions?.graph_type === GroupOfQuestionsGraphType.FanGraph - ? "mb-7" - : "mt-7"; + ? "md:mb-7" + : "md:mt-7"; return
{content}
; }; diff --git a/front_end/src/app/(main)/questions/[id]/components/question_view/consumer_question_view/prediction/single_question_prediction/binary_question_prediction.tsx b/front_end/src/app/(main)/questions/[id]/components/question_view/consumer_question_view/prediction/single_question_prediction/binary_question_prediction.tsx index 1c971ee26e..9ce2ebd064 100644 --- a/front_end/src/app/(main)/questions/[id]/components/question_view/consumer_question_view/prediction/single_question_prediction/binary_question_prediction.tsx +++ b/front_end/src/app/(main)/questions/[id]/components/question_view/consumer_question_view/prediction/single_question_prediction/binary_question_prediction.tsx @@ -56,7 +56,7 @@ const BinaryQuestionPrediction: React.FC = ({ ); return ( -
+
= ({ /> {!shouldHideChart && ( <> -
-
+
+
= ({ @@ -27,15 +29,13 @@ const QuestionTimeline: React.FC = ({ className, hideTitle, keyFactors, + isConsumerView = true, + preselectedGroupQuestionId, }) => { const isFanGraph = postData.group_of_questions?.graph_type === GroupOfQuestionsGraphType.FanGraph; - const wrapperClass = cn( - " hidden sm:block", - isFanGraph ? "mb-8" : "mt-8", - className - ); + const wrapperClass = cn(isFanGraph ? "mb-8" : "mt-8", className); if (isQuestionPost(postData)) { if (postData.question.status !== QuestionStatus.UPCOMING) { @@ -44,7 +44,7 @@ const QuestionTimeline: React.FC = ({
@@ -62,9 +62,32 @@ const QuestionTimeline: React.FC = ({ return (
{isDateType ? ( - +
+ + +
+ ) : isFanGraph ? ( +
+ + +
) : ( - + )}
); diff --git a/front_end/src/app/(main)/questions/[id]/components/question_view/forecaster_question_view/index.tsx b/front_end/src/app/(main)/questions/[id]/components/question_view/forecaster_question_view/index.tsx deleted file mode 100644 index 5e1e5af9c9..0000000000 --- a/front_end/src/app/(main)/questions/[id]/components/question_view/forecaster_question_view/index.tsx +++ /dev/null @@ -1,46 +0,0 @@ -import { Fragment } from "react"; - -import DetailedGroupCard from "@/components/detailed_question_card/detailed_group_card"; -import DetailedQuestionCard from "@/components/detailed_question_card/detailed_question_card"; -import ForecastMaker from "@/components/forecast_maker"; -import { PostStatus, PostWithForecasts } from "@/types/post"; -import { - isGroupOfQuestionsPost, - isQuestionPost, -} from "@/utils/questions/helpers"; - -import QuestionHeader from "./question_header"; - -type Props = { - postData: PostWithForecasts; - preselectedGroupQuestionId?: number | undefined; -}; - -const ForecasterQuestionView: React.FC = ({ - postData, - preselectedGroupQuestionId, -}) => { - const isResolved = postData.status === PostStatus.RESOLVED; - const isGroup = isGroupOfQuestionsPost(postData); - - return ( - - - {isQuestionPost(postData) && ( - - )} - {isGroup && ( - - )} - {(!isResolved || isGroup) && } - - ); -}; - -export default ForecasterQuestionView; diff --git a/front_end/src/app/(main)/questions/[id]/components/question_view/forecaster_question_view/question_header/index.tsx b/front_end/src/app/(main)/questions/[id]/components/question_view/forecaster_question_view/question_header/index.tsx deleted file mode 100644 index 063dc369bb..0000000000 --- a/front_end/src/app/(main)/questions/[id]/components/question_view/forecaster_question_view/question_header/index.tsx +++ /dev/null @@ -1,71 +0,0 @@ -"use client"; - -import { useLocale } from "next-intl"; -import { FC, useEffect } from "react"; - -import { PostStatusBox } from "@/app/(main)/questions/[id]/components/post_status_box"; -import QuestionHeaderInfo from "@/app/(main)/questions/[id]/components/question_view/forecaster_question_view/question_header/question_header_info"; -import ConditionalTile from "@/components/conditional_tile"; -import { useContentTranslatedBannerContext } from "@/contexts/translations_banner_context"; -import { PostWithForecasts } from "@/types/post"; -import { QuestionWithForecasts } from "@/types/question"; -import { - isContinuousQuestion, - isConditionalPost, - isQuestionPost, -} from "@/utils/questions/helpers"; - -import QuestionHeaderCPStatus from "./question_header_cp_status"; -import QuestionTitle from "../../shared/question_title"; - -const QuestionHeader: FC<{ post: PostWithForecasts }> = ({ post }) => { - const { setBannerIsVisible } = useContentTranslatedBannerContext(); - const locale = useLocale(); - - useEffect(() => { - if (post.is_current_content_translated) { - setTimeout(() => { - setBannerIsVisible(true); - }, 0); - } - }, [post, locale, setBannerIsVisible]); - - return ( -
-
- -
-
-
- {isConditionalPost(post) ? ( - - ) : ( -
- {post.title} - {isQuestionPost(post) && ( -
- -
- )} -
- )} - -
- {isQuestionPost(post) && !isContinuousQuestion(post.question) && ( -
- -
- )} -
-
- ); -}; - -export default QuestionHeader; diff --git a/front_end/src/app/(main)/questions/[id]/components/question_view/index.tsx b/front_end/src/app/(main)/questions/[id]/components/question_view/index.tsx deleted file mode 100644 index e04a963ae6..0000000000 --- a/front_end/src/app/(main)/questions/[id]/components/question_view/index.tsx +++ /dev/null @@ -1,29 +0,0 @@ -import { PostWithForecasts } from "@/types/post"; - -import ConsumerQuestionView from "./consumer_question_view"; -import ForecasterQuestionView from "./forecaster_question_view"; -import { QuestionVariantComposer } from "../question_variant_composer"; - -type Props = { - postData: PostWithForecasts; - preselectedGroupQuestionId?: number; -}; - -const QuestionView: React.FC = ({ - postData, - preselectedGroupQuestionId, -}) => { - return ( - } - forecaster={ - - } - /> - ); -}; - -export default QuestionView; diff --git a/front_end/src/app/(main)/questions/[id]/components/sidebar/index.tsx b/front_end/src/app/(main)/questions/[id]/components/sidebar/index.tsx index 36f779ec34..172e5cbbb0 100644 --- a/front_end/src/app/(main)/questions/[id]/components/sidebar/index.tsx +++ b/front_end/src/app/(main)/questions/[id]/components/sidebar/index.tsx @@ -1,74 +1,42 @@ import dynamic from "next/dynamic"; -import React, { FC, Suspense } from "react"; +import { FC, Suspense } from "react"; -import { PostDropdownMenu, SharePostMenu } from "@/components/post_actions"; -import PostSubscribeButton from "@/components/post_subscribe/subscribe_button"; +import LoadingSpinner from "@/components/ui/loading_spiner"; import { PostStatus, PostWithForecasts } from "@/types/post"; -import SidebarQuestionInfo from "./sidebar_question_info"; -import SidebarQuestionProjects from "./sidebar_question_projects"; -import QuestionEmbedButton from "../question_embed_button"; import SidebarContainer from "./sidebar_container"; +import SidebarQuestionInfo from "./sidebar_question_info"; const SimilarQuestions = dynamic(() => import("./similar_questions")); type Props = { postData: PostWithForecasts; layout?: "mobile" | "desktop"; - questionTitle: string; + variant?: "forecaster" | "consumer"; }; const Sidebar: FC = ({ postData, layout = "desktop", - questionTitle, + variant = "forecaster", }) => { if (layout === "mobile") { - return ( -
- - - - - - {postData.curation_status === PostStatus.APPROVED && ( - - - - )} -
- ); + return null; } return (
- -
-
- {postData.curation_status == PostStatus.APPROVED && ( - - )} - -
- -
- - + {variant === "forecaster" && ( + +
+
-
-
- -
- - - + + )} {postData.curation_status === PostStatus.APPROVED && ( - - + }> + )}
diff --git a/front_end/src/app/(main)/questions/[id]/components/sidebar/sidebar_container.tsx b/front_end/src/app/(main)/questions/[id]/components/sidebar/sidebar_container.tsx index ab512d69c7..125c4ef965 100644 --- a/front_end/src/app/(main)/questions/[id]/components/sidebar/sidebar_container.tsx +++ b/front_end/src/app/(main)/questions/[id]/components/sidebar/sidebar_container.tsx @@ -2,7 +2,7 @@ import React, { FC, PropsWithChildren } from "react"; const SidebarContainer: FC = ({ children }) => { return ( -
+
{children}
); diff --git a/front_end/src/app/(main)/questions/[id]/components/sidebar/sidebar_question_info.tsx b/front_end/src/app/(main)/questions/[id]/components/sidebar/sidebar_question_info.tsx index 6af8af6ee8..7fb4e74c79 100644 --- a/front_end/src/app/(main)/questions/[id]/components/sidebar/sidebar_question_info.tsx +++ b/front_end/src/app/(main)/questions/[id]/components/sidebar/sidebar_question_info.tsx @@ -23,7 +23,7 @@ const SidebarQuestionInfo: FC = ({ postData }) => {
- + {t("authorWithCount", { count: postData.coauthors.length + 1, })} @@ -31,7 +31,7 @@ const SidebarQuestionInfo: FC = ({ postData }) => {
{postData.author_username} @@ -39,7 +39,7 @@ const SidebarQuestionInfo: FC = ({ postData }) => { {postData.coauthors.map((coauthor) => ( @@ -51,10 +51,10 @@ const SidebarQuestionInfo: FC = ({ postData }) => { {postData.status === PostStatus.PENDING && (
- + {t("inReview")}: - + {postData.curation_status_updated_at ? ( ) : ( @@ -66,20 +66,20 @@ const SidebarQuestionInfo: FC = ({ postData }) => { {(postData.open_time || postData.published_at) && (
- + {t(isUpcoming ? "opens" : "opened")}: - +
)}
- + {postData.status === PostStatus.CLOSED ? t("closed") : t("closes")}: - + {postData.scheduled_close_time && ( )} @@ -87,13 +87,13 @@ const SidebarQuestionInfo: FC = ({ postData }) => {
- + {postData.status === PostStatus.RESOLVED ? t("resolved") : t("scheduledResolution")} : - + = ({ postData }) => { {!!postData.question?.spot_scoring_time && (
- + {t("spotScoingTime")}: - + {}
@@ -119,10 +119,10 @@ const SidebarQuestionInfo: FC = ({ postData }) => { postData.question?.default_aggregation_method !== AggregationMethod.recency_weighted && (
- + {t("cpAggregationMethod")}: - + {t(postData.question.default_aggregation_method)}
diff --git a/front_end/src/app/(main)/questions/[id]/components/sidebar/similar_questions/index.tsx b/front_end/src/app/(main)/questions/[id]/components/sidebar/similar_questions/index.tsx index 7a38303a9d..ad3525e250 100644 --- a/front_end/src/app/(main)/questions/[id]/components/sidebar/similar_questions/index.tsx +++ b/front_end/src/app/(main)/questions/[id]/components/sidebar/similar_questions/index.tsx @@ -2,19 +2,38 @@ import { FC } from "react"; import WithServerComponentErrorBoundary from "@/components/server_component_error_boundary"; import ServerPostsApi from "@/services/api/posts/posts.server"; +import { PostStatus } from "@/types/post"; -import SimilarQuestionsDrawer from "./similar_questions_drawer"; +import SimilarQuestionsList from "./similar_questions_list"; type Props = { post_id: number; + variant?: "forecaster" | "consumer"; }; -const SimilarQuestions: FC = async ({ post_id }) => { - const questions = await ServerPostsApi.getSimilarPosts(post_id); +const SimilarQuestions: FC = async ({ post_id, variant }) => { + let questions = await ServerPostsApi.getSimilarPosts(post_id); + + if (!questions.length) { + const { results } = await ServerPostsApi.getPostsWithCP({ + topic: "top-50", + for_main_feed: "false", + for_consumer_view: variant === "consumer" ? "true" : "false", + order_by: "-hotness", + statuses: [ + PostStatus.OPEN, + PostStatus.CLOSED, + PostStatus.RESOLVED, + PostStatus.UPCOMING, + ], + limit: 8, + }); + questions = results.filter((q) => q.id !== post_id); + } if (!questions.length) return null; - return ; + return ; }; export default WithServerComponentErrorBoundary(SimilarQuestions) as FC; diff --git a/front_end/src/app/(main)/questions/[id]/components/sidebar/similar_questions/similar_question_card.tsx b/front_end/src/app/(main)/questions/[id]/components/sidebar/similar_questions/similar_question_card.tsx index a334752c8e..ed579ca97b 100644 --- a/front_end/src/app/(main)/questions/[id]/components/sidebar/similar_questions/similar_question_card.tsx +++ b/front_end/src/app/(main)/questions/[id]/components/sidebar/similar_questions/similar_question_card.tsx @@ -1,67 +1,140 @@ +import { faComment, faUsers } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import Link from "next/link"; +import { useTranslations } from "next-intl"; import { FC } from "react"; -import BinaryCompactForecastText from "@/app/(main)/questions/components/binary_compact_forecast_text"; -import ForecastersCounter from "@/app/(main)/questions/components/forecaster_counter"; +import PostVoter from "@/components/post_card/basic_post_card/post_voter"; +import PostStatusIcon from "@/components/post_status/status_icon"; +import RichText from "@/components/rich_text"; import { PostWithForecasts } from "@/types/post"; -import { QuestionType, QuestionWithNumericForecasts } from "@/types/question"; import cn from "@/utils/core/cn"; +import { abbreviatedNumber } from "@/utils/formatters/number"; import { getPostLink } from "@/utils/navigation"; -import { - isGroupOfQuestionsPost, - isQuestionPost, -} from "@/utils/questions/helpers"; +import { extractPostResolution } from "@/utils/questions/resolution"; import SimilarPredictionChip from "./similar_question_prediction_chip"; type Props = { post: PostWithForecasts; + variant?: "forecaster" | "consumer"; }; -const SimilarQuestionCard: FC = ({ post }) => { - const isGroup = - isGroupOfQuestionsPost(post) || - post.question?.type === QuestionType.MultipleChoice; +const SimilarQuestionCard: FC = ({ post, variant = "forecaster" }) => { + const t = useTranslations(); + const isForecaster = variant === "forecaster"; - const isBinary = - isQuestionPost(post) && post.question?.type === QuestionType.Binary; - - return ( - -
-
-
-

- {post.title} -

+ const commentCount = post.comment_count ?? 0; + const commentCountFormatted = abbreviatedNumber( + commentCount, + 2, + false, + undefined, + 3 + ); + const forecastersFormatted = abbreviatedNumber( + post.nr_forecasters, + 2, + false, + undefined, + 3 + ); - {!isBinary && } -
+ const resolutionData = extractPostResolution(post); - {isBinary ? ( -
- + + {!isForecaster && ( +
+ + - + {(tags) => ( + + {t.rich("totalCommentsCount", { + total_count: commentCount, + total_count_formatted: commentCountFormatted, + ...tags, + strong: (chunks) => chunks, + })} + + )} + + + + -
- ) : ( - + + {(tags) => ( + + {t.rich("forecastersWithCount", { + count: post.nr_forecasters, + count_formatted: forecastersFormatted, + ...tags, + strong: (chunks) => chunks, + })} + + )} + + +
+ )} + +

+ {post.title} +

+ + + + + {isForecaster && ( +
+ + + + {commentCountFormatted} + +
+ +
+ + + {forecastersFormatted} +
-
- + )} +
); }; diff --git a/front_end/src/app/(main)/questions/[id]/components/sidebar/similar_questions/similar_question_prediction_chip.tsx b/front_end/src/app/(main)/questions/[id]/components/sidebar/similar_questions/similar_question_prediction_chip.tsx index e26d4418e9..f5a730f78d 100644 --- a/front_end/src/app/(main)/questions/[id]/components/sidebar/similar_questions/similar_question_prediction_chip.tsx +++ b/front_end/src/app/(main)/questions/[id]/components/sidebar/similar_questions/similar_question_prediction_chip.tsx @@ -1,31 +1,66 @@ +"use client"; + import { FC } from "react"; +import ConsumerQuestionTile from "@/components/consumer_post_card/consumer_question_tile"; +import GroupForecastCard from "@/components/consumer_post_card/group_forecast_card"; +import PercentageForecastCard from "@/components/consumer_post_card/group_forecast_card/percentage_forecast_card"; import GroupOfQuestionsTile from "@/components/post_card/group_of_questions_tile"; import QuestionTile from "@/components/post_card/question_tile"; import { useHideCP } from "@/contexts/cp_context"; import { PostWithForecasts } from "@/types/post"; import { QuestionType } from "@/types/question"; -import cn from "@/utils/core/cn"; import { + checkGroupOfQuestionsPostType, isGroupOfQuestionsPost, + isMultipleChoicePost, isQuestionPost, } from "@/utils/questions/helpers"; type Props = { post: PostWithForecasts; + variant?: "forecaster" | "consumer"; }; -const SimilarPredictionChip: FC = ({ post }) => { +const SimilarPredictionChip: FC = ({ post, variant }) => { const { hideCP } = useHideCP(); if (hideCP) { - return; + return null; } - const isMCQuestion = post?.question?.type === QuestionType.MultipleChoice; - if (isQuestionPost(post)) { + if (variant === "consumer") { + if ( + isMultipleChoicePost(post) || + checkGroupOfQuestionsPostType(post, QuestionType.Binary) + ) { + return ( +
+ +
+ ); + } + if (isGroupOfQuestionsPost(post)) { + return ( +
+ +
+ ); + } + if (isQuestionPost(post)) { + return ( +
+ +
+ ); + } + return null; + } + + // Forecaster view + if (isMultipleChoicePost(post)) { return ( -
+
= ({ post }) => { ); } + if (isQuestionPost(post)) { + return ( +
+ +
+ ); + } + if (isGroupOfQuestionsPost(post)) { return (
diff --git a/front_end/src/app/(main)/questions/[id]/components/sidebar/similar_questions/similar_questions_drawer.tsx b/front_end/src/app/(main)/questions/[id]/components/sidebar/similar_questions/similar_questions_drawer.tsx deleted file mode 100644 index c0ce7c18c7..0000000000 --- a/front_end/src/app/(main)/questions/[id]/components/sidebar/similar_questions/similar_questions_drawer.tsx +++ /dev/null @@ -1,47 +0,0 @@ -"use client"; - -import { useTranslations } from "next-intl"; -import { FC, useState } from "react"; - -import Button from "@/components/ui/button"; -import SectionToggle from "@/components/ui/section_toggle"; -import { PostWithForecasts } from "@/types/post"; - -import SimilarQuestion from "./similar_question_card"; - -interface Props { - questions: PostWithForecasts[]; -} - -const SimilarQuestionsDrawer: FC = ({ questions }) => { - const t = useTranslations(); - const [questionsDisplayLimit, setQuestionsDisplayLimit] = useState(3); - - return ( -
- -
- {questions - .slice(0, questionsDisplayLimit) - .map((question: PostWithForecasts) => ( - - ))} -
- {questions.length > questionsDisplayLimit ? ( - - ) : ( -
- )} -
-
- -
- ); -}; - -export default SimilarQuestionsDrawer; diff --git a/front_end/src/app/(main)/questions/[id]/components/sidebar/similar_questions/similar_questions_list.tsx b/front_end/src/app/(main)/questions/[id]/components/sidebar/similar_questions/similar_questions_list.tsx new file mode 100644 index 0000000000..535e46c7de --- /dev/null +++ b/front_end/src/app/(main)/questions/[id]/components/sidebar/similar_questions/similar_questions_list.tsx @@ -0,0 +1,22 @@ +import { FC } from "react"; + +import { PostWithForecasts } from "@/types/post"; + +import SimilarQuestion from "./similar_question_card"; + +interface Props { + questions: PostWithForecasts[]; + variant?: "forecaster" | "consumer"; +} + +const SimilarQuestionsList: FC = ({ questions, variant }) => { + return ( +
+ {questions.map((question: PostWithForecasts) => ( + + ))} +
+ ); +}; + +export default SimilarQuestionsList; diff --git a/front_end/src/app/(main)/questions/components/coherence_links/coherence_links.tsx b/front_end/src/app/(main)/questions/components/coherence_links/coherence_links.tsx index 78bb18de9c..c142b130bf 100644 --- a/front_end/src/app/(main)/questions/components/coherence_links/coherence_links.tsx +++ b/front_end/src/app/(main)/questions/components/coherence_links/coherence_links.tsx @@ -16,11 +16,12 @@ import { Post } from "@/types/post"; type Props = { post: Post; + hideToggle?: boolean; }; const MAX_COLLAPSED_HEIGHT = 9999; -export const CoherenceLinks: FC = ({ post }) => { +export const CoherenceLinks: FC = ({ post, hideToggle }) => { const t = useTranslations(); const expandLabel = t("showMore"); const collapseLabel = t("showLess"); @@ -51,57 +52,65 @@ export const CoherenceLinks: FC = ({ post }) => { ) return null; - return ( - - -
- {Array.from(coherenceLinks?.data ?? [], (link) => ( - - ))} - - {Array.from(newLinks, (id) => ( - - ))} + const inner = ( + +
+ {Array.from(coherenceLinks?.data ?? [], (link) => ( + + ))} -
- {(!coherenceLinks || coherenceLinks.data.length === 0) && - newLinks?.length === 0 && ( -
- - {t("noQuestionsLinkedP2")} - - - {t("noQuestionsLinkedP3")} - - - {t("noQuestionsLinkedP1")} - -
- )} + {Array.from(newLinks, (id) => ( + + ))} - {!user?.is_bot && ( - - {t("linkQuestion")} - +
+ {(!coherenceLinks || coherenceLinks.data.length === 0) && + newLinks?.length === 0 && ( +
+ + {t("noQuestionsLinkedP2")} + + + {t("noQuestionsLinkedP3")} + + + {t("noQuestionsLinkedP1")} + +
)} -
+ + {!user?.is_bot && ( + + {t("linkQuestion")} + + )}
- +
+
+ ); + + if (hideToggle) { + return inner; + } + + return ( + + {inner} ); }; diff --git a/front_end/src/app/(main)/questions/components/forecaster_counter.tsx b/front_end/src/app/(main)/questions/components/forecaster_counter.tsx index f115e76880..441a8c92e7 100644 --- a/front_end/src/app/(main)/questions/components/forecaster_counter.tsx +++ b/front_end/src/app/(main)/questions/components/forecaster_counter.tsx @@ -10,12 +10,14 @@ import { abbreviatedNumber } from "@/utils/formatters/number"; type Props = { forecasters?: number; compact?: boolean; + boldCount?: boolean; className?: string; }; const ForecastersCounter: FC = ({ forecasters, compact = false, + boldCount = false, className, }) => { const t = useTranslations(); @@ -35,7 +37,7 @@ const ForecastersCounter: FC = ({ return (
@@ -45,11 +47,13 @@ const ForecastersCounter: FC = ({ /> {/* Compact version - just shows number */} {compact && ( - + {forecastersFormatted} )} - {/* Full version - shows descriptive text */} + {/* Full version - shows descriptive text with label */} {!compact && ( {(tags) => ( @@ -58,6 +62,12 @@ const ForecastersCounter: FC = ({ count: forecasters, count_formatted: forecastersFormatted, ...tags, + strong: (chunks) => + boldCount ? ( + {chunks} + ) : ( + chunks + ), })} )} diff --git a/front_end/src/components/charts/group_chart.tsx b/front_end/src/components/charts/group_chart.tsx index 72c1f40d88..3afafe7ae6 100644 --- a/front_end/src/components/charts/group_chart.tsx +++ b/front_end/src/components/charts/group_chart.tsx @@ -376,7 +376,7 @@ const GroupChart: FC = ({ setLocalCursorTimestamp(null); } }, - onMouseLeaveCapture: () => { + onMouseLeave: () => { if (!onCursorChange) return; inPlotRef.current = false; setIsCursorActive(false); @@ -454,7 +454,6 @@ const GroupChart: FC = ({ tickLabelComponent={ diff --git a/front_end/src/components/charts/multiple_choice_chart.tsx b/front_end/src/components/charts/multiple_choice_chart.tsx index 368a668c35..4f75169002 100644 --- a/front_end/src/components/charts/multiple_choice_chart.tsx +++ b/front_end/src/components/charts/multiple_choice_chart.tsx @@ -51,9 +51,9 @@ import { scaleInternalLocation, unscaleNominalLocation } from "@/utils/math"; import ChartContainer from "./primitives/chart_container"; import ChartCursorLabel from "./primitives/chart_cursor_label"; +import SvgWrapper from "./primitives/svg_wrapper"; import XTickLabel from "./primitives/x_tick_label"; import ForecastAvailabilityChartOverflow from "../post_card/chart_overflow"; -import SvgWrapper from "./primitives/svg_wrapper"; import YTickLabel from "./primitives/y_tick_label"; type ColoredLinePoint = { @@ -426,7 +426,6 @@ const MultipleChoiceChart: FC = ({ diff --git a/front_end/src/components/charts/numeric_chart.tsx b/front_end/src/components/charts/numeric_chart.tsx index d8ccecc738..8b5bdc097c 100644 --- a/front_end/src/components/charts/numeric_chart.tsx +++ b/front_end/src/components/charts/numeric_chart.tsx @@ -663,7 +663,6 @@ const NumericChart: FC = ({ & { chartWidth: number; - withCursor?: boolean; fontSize?: number; dx?: number; }; const XTickLabel: FC = ({ chartWidth, - withCursor, fontSize = 10, dx = 0, ...props @@ -20,18 +18,18 @@ const XTickLabel: FC = ({ const x = (props.x ?? 0) + dx; - const overlapsRightEdge = withCursor - ? x > chartWidth - estimatedTextWidth - : x > chartWidth - 12; - - if (overlapsRightEdge) { - return null; + let textAnchor: "start" | "middle" | "end" = "middle"; + if (x - estimatedTextWidth < 0) { + textAnchor = "start"; + } else if (x > chartWidth - estimatedTextWidth) { + textAnchor = "end"; } return ( = ({ }); } }, [questionLayout?.replyToCommentId, comment.id, questionLayout]); + + useEffect(() => { + if (questionLayout?.scrollToCommentId === comment.id) { + questionLayout.clearScrollToComment(); + requestAnimationFrame(() => { + commentRef.current?.scrollIntoView({ + behavior: "smooth", + block: "center", + }); + }); + } + }, [questionLayout?.scrollToCommentId, comment.id, questionLayout]); const [errorMessage, setErrorMessage] = useState(); const [commentMarkdown, setCommentMarkdown] = useState(comment.text); const [tempCommentMarkdown, setTempCommentMarkdown] = useState(""); @@ -759,9 +772,10 @@ const Comment: FC = ({ })} >
= ({ )} · @@ -950,6 +965,7 @@ const Comment: FC = ({ withUgcLinks withTwitterPreview withCodeBlocks + contentEditableClassName="text-base font-normal leading-6 [&_p]:!text-gray-700 dark:[&_p]:!text-gray-700-dark [&_ul]:!text-gray-700 dark:[&_ul]:!text-gray-700-dark [&_ol]:!text-gray-700 dark:[&_ol]:!text-gray-700-dark" /> )} {commentKeyFactors.length > 0 && @@ -1061,7 +1077,17 @@ const Comment: FC = ({
0 && "pr-1.5 md:pr-2")}> - + + +
diff --git a/front_end/src/components/comment_feed/comment_card.tsx b/front_end/src/components/comment_feed/comment_card.tsx index f335d38bb6..dc7edf43ff 100644 --- a/front_end/src/components/comment_feed/comment_card.tsx +++ b/front_end/src/components/comment_feed/comment_card.tsx @@ -108,16 +108,15 @@ const ExpandableCommentContent = ({ > {/* Author info */}
-
+
{formatUsername(comment.author)} - · {t("onDate", { diff --git a/front_end/src/components/comment_feed/comment_date.tsx b/front_end/src/components/comment_feed/comment_date.tsx index 312eb013d2..cdc6cd20df 100644 --- a/front_end/src/components/comment_feed/comment_date.tsx +++ b/front_end/src/components/comment_feed/comment_date.tsx @@ -19,7 +19,7 @@ export const CommentDate: FC<{ comment: CommentType }> = ({ comment }) => { return ( {formatDate(locale, new Date(comment.created_at))} diff --git a/front_end/src/components/comment_feed/comment_voter.tsx b/front_end/src/components/comment_feed/comment_voter.tsx index 9486e8409d..b698054d0c 100644 --- a/front_end/src/components/comment_feed/comment_voter.tsx +++ b/front_end/src/components/comment_feed/comment_voter.tsx @@ -66,6 +66,7 @@ const CommentVoter: FC = ({ voteData, className, onVoteChange }) => { onVoteDown={() => handleVote(-1)} commentArea={true} disabled={user?.is_bot || user?.id === voteData.commentAuthorId} + showZeroVotes /> ); }; diff --git a/front_end/src/components/comment_feed/comment_wrapper.tsx b/front_end/src/components/comment_feed/comment_wrapper.tsx index 3897113576..3f4380b6b6 100644 --- a/front_end/src/components/comment_feed/comment_wrapper.tsx +++ b/front_end/src/components/comment_feed/comment_wrapper.tsx @@ -51,7 +51,7 @@ export const CommentWrapper: FC = ({ return (
= ({ // eslint-disable-next-line react-hooks/exhaustive-deps }, [hash, isLoading]); - // Handling filters change + // Handling filters change — always fetch from offset 0 and replace useEffect(() => { - const finalFilters = { - ...feedFilters, - offset, - }; - void fetchComments(true, finalFilters); + void fetchComments(false, { ...feedFilters }); // eslint-disable-next-line react-hooks/exhaustive-deps }, [feedFilters]); @@ -349,6 +345,11 @@ const CommentFeed: FC = ({ } }; + const showBotPrivacyToggle = !profileId && !!user?.is_bot; + const showWelcomePrompt = !!postId && showWelcomeMessage && !user?.is_bot; + const showCommentHeader = + !compactVersion && (showTitle || showBotPrivacyToggle || showWelcomePrompt); + return ( = ({ compactVersion && "p-0 xs:p-0" )} > - {!compactVersion && ( + {showCommentHeader && (
= ({ )}
)} + {!compactVersion && ( +
+ + {totalCount ? `${totalCount} ` : ""} + {t("commentsWithCount", { count: totalCount })} + {postData?.last_viewed_at && ( + <> + {getUnreadCount(comments) > 0 && ( + + ({getUnreadCount(comments)} {t("unread")}) + + )} + + )} + + + + +
+ )} {!compactVersion && postId && !user?.is_bot && ( <> {showWelcomeMessage && !getIsMessagePreviouslyClosed() ? null : ( @@ -427,44 +455,23 @@ const CommentFeed: FC = ({ )} )} - -
- - {totalCount ? `${totalCount} ` : ""} - {t("commentsWithCount", { count: totalCount })} - {postData?.last_viewed_at && ( - <> - {getUnreadCount(comments) > 0 && ( - - ({getUnreadCount(comments)} {t("unread")}) - - )} - - )} - - - - +
+ {comments.map((comment: CommentType) => ( + + ))}
- {comments.map((comment: CommentType) => ( - - ))} {comments.length === 0 && !isLoading && ( <>
diff --git a/front_end/src/components/consumer_post_card/group_forecast_card/index.tsx b/front_end/src/components/consumer_post_card/group_forecast_card/index.tsx index 3ec03fc11d..47797ae3f9 100644 --- a/front_end/src/components/consumer_post_card/group_forecast_card/index.tsx +++ b/front_end/src/components/consumer_post_card/group_forecast_card/index.tsx @@ -59,6 +59,9 @@ const GroupForecastCard: FC = ({ post, compact }) => { post.group_of_questions && checkGroupOfQuestionsPostType(post, QuestionType.Date) ) { + if (compact) { + return ; + } return ( ); diff --git a/front_end/src/components/detailed_question_card/detailed_question_card/index.tsx b/front_end/src/components/detailed_question_card/detailed_question_card/index.tsx index 1ba998949c..d0da1e1fdd 100644 --- a/front_end/src/components/detailed_question_card/detailed_question_card/index.tsx +++ b/front_end/src/components/detailed_question_card/detailed_question_card/index.tsx @@ -94,6 +94,7 @@ const DetailedQuestionCard: FC = ({ ) => ( + + + +); + +export default ShareIcon; diff --git a/front_end/src/components/icons/trophy.tsx b/front_end/src/components/icons/trophy.tsx new file mode 100644 index 0000000000..53ac18ec6b --- /dev/null +++ b/front_end/src/components/icons/trophy.tsx @@ -0,0 +1,19 @@ +import { SVGProps } from "react"; + +const TrophyIcon = (props: SVGProps) => ( + + + +); + +export default TrophyIcon; diff --git a/front_end/src/components/post_actions/post_dropdown_menu.tsx b/front_end/src/components/post_actions/post_dropdown_menu.tsx index 8007d11295..bb8e1e74a5 100644 --- a/front_end/src/components/post_actions/post_dropdown_menu.tsx +++ b/front_end/src/components/post_actions/post_dropdown_menu.tsx @@ -29,9 +29,10 @@ import { canChangeQuestionResolution } from "@/utils/questions/resolution"; type Props = { post: Post; button?: React.ReactNode; + hideShare?: boolean; }; -export const PostDropdownMenu: FC = ({ post, button }) => { +export const PostDropdownMenu: FC = ({ post, button, hideShare }) => { const t = useTranslations(); const { user } = useAuth(); const router = useRouter(); @@ -52,7 +53,7 @@ export const PostDropdownMenu: FC = ({ post, button }) => { // Curators can edit approved posts that are not yet open (isCurator && isApproved && isUpcoming); - const isLargeScreen = useBreakpoint("lg"); + const isMediumScreen = useBreakpoint("md"); const canResolve = isQuestionPost(post) && @@ -126,14 +127,18 @@ export const PostDropdownMenu: FC = ({ post, button }) => { const menuItems: MenuItemProps[] = [ // Mobile menu items - ...(!isLargeScreen + ...(!isMediumScreen ? [ - { - id: "share", - name: t("share"), - className: "capitalize", - items: shareMenuItems, - }, + ...(!hideShare + ? [ + { + id: "share", + name: t("share"), + className: "capitalize", + items: shareMenuItems, + }, + ] + : []), { id: "subscription", name: isSubscribed ? t("followingButton") : t("followButton"), diff --git a/front_end/src/components/post_card/basic_post_card/comment_status.tsx b/front_end/src/components/post_card/basic_post_card/comment_status.tsx index 0676760754..9d79ad222b 100644 --- a/front_end/src/components/post_card/basic_post_card/comment_status.tsx +++ b/front_end/src/components/post_card/basic_post_card/comment_status.tsx @@ -54,7 +54,7 @@ const CommentStatus: FC = ({ - {!!vote.score != null && vote.score !== 0 && ( - - {vote.score} + {vote.score != null && vote.score !== 0 && ( + + {vote.score} )} - {!!votes != null && votes !== 0 && ( + {votes != null && (votes !== 0 || showZeroVotes) && ( { return await this.get( - `/posts/${postId}/similar-posts/`, - { - next: { revalidate: 3600 }, - } + `/posts/${postId}/similar-posts/` ); } diff --git a/front_end/src/stories/question_page/binary_group/binary_group_timeline.stories.tsx b/front_end/src/stories/question_page/binary_group/binary_group_timeline.stories.tsx index a760a6926e..388e91d7b0 100644 --- a/front_end/src/stories/question_page/binary_group/binary_group_timeline.stories.tsx +++ b/front_end/src/stories/question_page/binary_group/binary_group_timeline.stories.tsx @@ -1,9 +1,11 @@ import type { Meta, StoryObj } from "@storybook/nextjs-vite"; -import ConsumerQuestionLayout from "@/app/(main)/questions/[id]/components/question_layout/consumer_question_layout"; -import ForecasterQuestionLayout from "@/app/(main)/questions/[id]/components/question_layout/forecaster_question_layout"; -import ConsumerQuestionView from "@/app/(main)/questions/[id]/components/question_view/consumer_question_view"; -import ForecasterQuestionView from "@/app/(main)/questions/[id]/components/question_view/forecaster_question_view"; +import { CoherenceLinksProvider } from "@/app/(main)/components/coherence_links_provider"; +import { QuestionLayoutProvider } from "@/app/(main)/questions/[id]/components/question_layout/question_layout_context"; +import { + ConsumerShell, + ForecasterShell, +} from "@/app/(main)/questions/[id]/components/question_page_shell"; import { PostSubscriptionProvider } from "@/contexts/post_subscription_context"; import { getMockData as getBinaryGroupMockData } from "@/stories/feed_card/binary_group/mock_data"; import { MockCommentsFeedProvider } from "@/stories/utils/mocks/mock_comments_feed_provider"; @@ -24,7 +26,7 @@ type StoryProps = { const meta = { title: "Question Page/Binary Group/Timeline Chart", - component: ForecasterQuestionView, + component: ForecasterShell, argTypes: { isConsumer: { control: { type: "boolean", order: 0 } }, hideUserPredictions: { @@ -37,23 +39,17 @@ const meta = { decorators: [ (Story, context) => { const { postData } = context.args; - const Layout = context.args.isConsumer - ? ConsumerQuestionLayout - : ForecasterQuestionLayout; return ( - -
- - - -
-
+ + +
+ + + +
+
+
); }, @@ -66,7 +62,7 @@ type Story = StoryObj; const render = createConditionalRenderer({ componentSelector: (args) => - args.isConsumer ? ConsumerQuestionView : ForecasterQuestionView, + args.isConsumer ? ConsumerShell : ForecasterShell, transformRules: [ { key: "hideUserPredictions", diff --git a/front_end/src/stories/question_page/binary_group/fan_chart.stories.tsx b/front_end/src/stories/question_page/binary_group/fan_chart.stories.tsx index 3ae4d2dd4f..f92f915e51 100644 --- a/front_end/src/stories/question_page/binary_group/fan_chart.stories.tsx +++ b/front_end/src/stories/question_page/binary_group/fan_chart.stories.tsx @@ -1,9 +1,11 @@ import type { Meta, StoryObj } from "@storybook/nextjs-vite"; -import ConsumerQuestionLayout from "@/app/(main)/questions/[id]/components/question_layout/consumer_question_layout"; -import ForecasterQuestionLayout from "@/app/(main)/questions/[id]/components/question_layout/forecaster_question_layout"; -import ConsumerQuestionView from "@/app/(main)/questions/[id]/components/question_view/consumer_question_view"; -import ForecasterQuestionView from "@/app/(main)/questions/[id]/components/question_view/forecaster_question_view"; +import { CoherenceLinksProvider } from "@/app/(main)/components/coherence_links_provider"; +import { QuestionLayoutProvider } from "@/app/(main)/questions/[id]/components/question_layout/question_layout_context"; +import { + ConsumerShell, + ForecasterShell, +} from "@/app/(main)/questions/[id]/components/question_page_shell"; import { PostSubscriptionProvider } from "@/contexts/post_subscription_context"; import { getMockData as getBinaryGroupMockData } from "@/stories/feed_card/binary_group/mock_data"; import { MockCommentsFeedProvider } from "@/stories/utils/mocks/mock_comments_feed_provider"; @@ -24,7 +26,7 @@ type StoryProps = { const meta = { title: "Question Page/Binary Group/Fan Chart", - component: ForecasterQuestionView, + component: ForecasterShell, argTypes: { isConsumer: { control: { type: "boolean", order: 0 } }, hideUserPredictions: { @@ -37,23 +39,17 @@ const meta = { decorators: [ (Story, context) => { const { postData } = context.args; - const Layout = context.args.isConsumer - ? ConsumerQuestionLayout - : ForecasterQuestionLayout; return ( - -
- - - -
-
+ + +
+ + + +
+
+
); }, @@ -66,7 +62,7 @@ type Story = StoryObj; const render = createConditionalRenderer({ componentSelector: (args) => - args.isConsumer ? ConsumerQuestionView : ForecasterQuestionView, + args.isConsumer ? ConsumerShell : ForecasterShell, transformRules: [ { key: "hideUserPredictions", diff --git a/front_end/src/stories/question_page/binary_question/binary_question.stories.tsx b/front_end/src/stories/question_page/binary_question/binary_question.stories.tsx index e46f7e5f0d..a3ef20478c 100644 --- a/front_end/src/stories/question_page/binary_question/binary_question.stories.tsx +++ b/front_end/src/stories/question_page/binary_question/binary_question.stories.tsx @@ -1,9 +1,11 @@ import type { Meta, StoryObj } from "@storybook/nextjs-vite"; -import ConsumerQuestionLayout from "@/app/(main)/questions/[id]/components/question_layout/consumer_question_layout"; -import ForecasterQuestionLayout from "@/app/(main)/questions/[id]/components/question_layout/forecaster_question_layout"; -import ConsumerQuestionView from "@/app/(main)/questions/[id]/components/question_view/consumer_question_view"; -import ForecasterQuestionView from "@/app/(main)/questions/[id]/components/question_view/forecaster_question_view"; +import { CoherenceLinksProvider } from "@/app/(main)/components/coherence_links_provider"; +import { QuestionLayoutProvider } from "@/app/(main)/questions/[id]/components/question_layout/question_layout_context"; +import { + ConsumerShell, + ForecasterShell, +} from "@/app/(main)/questions/[id]/components/question_page_shell"; import { PostSubscriptionProvider } from "@/contexts/post_subscription_context"; import { getMockData as getBinaryQuestionMockData } from "@/stories/feed_card/binary_question/mock_data"; import { MockCommentsFeedProvider } from "@/stories/utils/mocks/mock_comments_feed_provider"; @@ -29,7 +31,7 @@ type StoryProps = { const meta = { title: "Question Page/Binary Question", - component: ForecasterQuestionView, + component: ForecasterShell, argTypes: { isConsumer: { control: { type: "boolean" } }, hideUserPredictions: { @@ -47,23 +49,17 @@ const meta = { decorators: [ (Story, context) => { const { postData } = context.args; - const Layout = context.args.isConsumer - ? ConsumerQuestionLayout - : ForecasterQuestionLayout; return ( - -
- - - -
-
+ + +
+ + + +
+
+
); }, @@ -76,7 +72,7 @@ type Story = StoryObj; const render = createConditionalRenderer({ componentSelector: (args) => - args.isConsumer ? ConsumerQuestionView : ForecasterQuestionView, + args.isConsumer ? ConsumerShell : ForecasterShell, transformRules: [ { key: "cpMovement", diff --git a/front_end/src/stories/question_page/continuous_question/continuous_question.stories.tsx b/front_end/src/stories/question_page/continuous_question/continuous_question.stories.tsx index 6f6ba8937f..1e20eafc99 100644 --- a/front_end/src/stories/question_page/continuous_question/continuous_question.stories.tsx +++ b/front_end/src/stories/question_page/continuous_question/continuous_question.stories.tsx @@ -1,9 +1,11 @@ import type { Meta, StoryObj } from "@storybook/nextjs-vite"; -import ConsumerQuestionLayout from "@/app/(main)/questions/[id]/components/question_layout/consumer_question_layout"; -import ForecasterQuestionLayout from "@/app/(main)/questions/[id]/components/question_layout/forecaster_question_layout"; -import ConsumerQuestionView from "@/app/(main)/questions/[id]/components/question_view/consumer_question_view"; -import ForecasterQuestionView from "@/app/(main)/questions/[id]/components/question_view/forecaster_question_view"; +import { CoherenceLinksProvider } from "@/app/(main)/components/coherence_links_provider"; +import { QuestionLayoutProvider } from "@/app/(main)/questions/[id]/components/question_layout/question_layout_context"; +import { + ConsumerShell, + ForecasterShell, +} from "@/app/(main)/questions/[id]/components/question_page_shell"; import { PostSubscriptionProvider } from "@/contexts/post_subscription_context"; import { getMockData as getContinuousMockData } from "@/stories/feed_card/continuous_question/mock_data"; import { MockCommentsFeedProvider } from "@/stories/utils/mocks/mock_comments_feed_provider"; @@ -24,7 +26,7 @@ type StoryProps = { const meta = { title: "Question Page/Continuous Question", - component: ForecasterQuestionView, + component: ForecasterShell, argTypes: { isConsumer: { control: { type: "boolean" } }, hideUserPredictions: { @@ -37,23 +39,17 @@ const meta = { decorators: [ (Story, context) => { const { postData } = context.args; - const Layout = context.args.isConsumer - ? ConsumerQuestionLayout - : ForecasterQuestionLayout; return ( - -
- - - -
-
+ + +
+ + + +
+
+
); }, @@ -66,7 +62,7 @@ type Story = StoryObj; const render = createConditionalRenderer({ componentSelector: (args) => - args.isConsumer ? ConsumerQuestionView : ForecasterQuestionView, + args.isConsumer ? ConsumerShell : ForecasterShell, transformRules: [ { key: "hideUserPredictions", diff --git a/front_end/src/stories/question_page/date_group/date_group_timeline.stories.tsx b/front_end/src/stories/question_page/date_group/date_group_timeline.stories.tsx index cd105edd92..971ad26dbc 100644 --- a/front_end/src/stories/question_page/date_group/date_group_timeline.stories.tsx +++ b/front_end/src/stories/question_page/date_group/date_group_timeline.stories.tsx @@ -1,9 +1,11 @@ import type { Meta, StoryObj } from "@storybook/nextjs-vite"; -import ConsumerQuestionLayout from "@/app/(main)/questions/[id]/components/question_layout/consumer_question_layout"; -import ForecasterQuestionLayout from "@/app/(main)/questions/[id]/components/question_layout/forecaster_question_layout"; -import ConsumerQuestionView from "@/app/(main)/questions/[id]/components/question_view/consumer_question_view"; -import ForecasterQuestionView from "@/app/(main)/questions/[id]/components/question_view/forecaster_question_view"; +import { CoherenceLinksProvider } from "@/app/(main)/components/coherence_links_provider"; +import { QuestionLayoutProvider } from "@/app/(main)/questions/[id]/components/question_layout/question_layout_context"; +import { + ConsumerShell, + ForecasterShell, +} from "@/app/(main)/questions/[id]/components/question_page_shell"; import { PostSubscriptionProvider } from "@/contexts/post_subscription_context"; import { getMockData as getDateGroupMockData } from "@/stories/feed_card/date_group/mock_data"; import { MockCommentsFeedProvider } from "@/stories/utils/mocks/mock_comments_feed_provider"; @@ -24,7 +26,7 @@ type StoryProps = { const meta = { title: "Question Page/Date Group/Timeline Chart", - component: ForecasterQuestionView, + component: ForecasterShell, argTypes: { isConsumer: { control: { type: "boolean", order: 0 } }, hideUserPredictions: { @@ -37,23 +39,17 @@ const meta = { decorators: [ (Story, context) => { const { postData } = context.args; - const Layout = context.args.isConsumer - ? ConsumerQuestionLayout - : ForecasterQuestionLayout; return ( - -
- - - -
-
+ + +
+ + + +
+
+
); }, @@ -66,7 +62,7 @@ type Story = StoryObj; const render = createConditionalRenderer({ componentSelector: (args) => - args.isConsumer ? ConsumerQuestionView : ForecasterQuestionView, + args.isConsumer ? ConsumerShell : ForecasterShell, transformRules: [ { key: "hideUserPredictions", diff --git a/front_end/src/stories/question_page/date_group/fan_chart.stories.tsx b/front_end/src/stories/question_page/date_group/fan_chart.stories.tsx index 6df09ad358..ed6b1a7aec 100644 --- a/front_end/src/stories/question_page/date_group/fan_chart.stories.tsx +++ b/front_end/src/stories/question_page/date_group/fan_chart.stories.tsx @@ -1,9 +1,11 @@ import type { Meta, StoryObj } from "@storybook/nextjs-vite"; -import ConsumerQuestionLayout from "@/app/(main)/questions/[id]/components/question_layout/consumer_question_layout"; -import ForecasterQuestionLayout from "@/app/(main)/questions/[id]/components/question_layout/forecaster_question_layout"; -import ConsumerQuestionView from "@/app/(main)/questions/[id]/components/question_view/consumer_question_view"; -import ForecasterQuestionView from "@/app/(main)/questions/[id]/components/question_view/forecaster_question_view"; +import { CoherenceLinksProvider } from "@/app/(main)/components/coherence_links_provider"; +import { QuestionLayoutProvider } from "@/app/(main)/questions/[id]/components/question_layout/question_layout_context"; +import { + ConsumerShell, + ForecasterShell, +} from "@/app/(main)/questions/[id]/components/question_page_shell"; import { PostSubscriptionProvider } from "@/contexts/post_subscription_context"; import { getMockData as getDateGroupMockData } from "@/stories/feed_card/date_group/mock_data"; import { MockCommentsFeedProvider } from "@/stories/utils/mocks/mock_comments_feed_provider"; @@ -24,7 +26,7 @@ type StoryProps = { const meta = { title: "Question Page/Date Group/Fan Chart", - component: ForecasterQuestionView, + component: ForecasterShell, argTypes: { isConsumer: { control: { type: "boolean", order: 0 } }, hideUserPredictions: { @@ -37,23 +39,17 @@ const meta = { decorators: [ (Story, context) => { const { postData } = context.args; - const Layout = context.args.isConsumer - ? ConsumerQuestionLayout - : ForecasterQuestionLayout; return ( - -
- - - -
-
+ + +
+ + + +
+
+
); }, @@ -66,7 +62,7 @@ type Story = StoryObj; const render = createConditionalRenderer({ componentSelector: (args) => - args.isConsumer ? ConsumerQuestionView : ForecasterQuestionView, + args.isConsumer ? ConsumerShell : ForecasterShell, transformRules: [ { key: "hideUserPredictions", diff --git a/front_end/src/stories/question_page/date_question/date_question.stories.tsx b/front_end/src/stories/question_page/date_question/date_question.stories.tsx index 2827fa5959..3c076d5c35 100644 --- a/front_end/src/stories/question_page/date_question/date_question.stories.tsx +++ b/front_end/src/stories/question_page/date_question/date_question.stories.tsx @@ -1,9 +1,11 @@ import type { Meta, StoryObj } from "@storybook/nextjs-vite"; -import ConsumerQuestionLayout from "@/app/(main)/questions/[id]/components/question_layout/consumer_question_layout"; -import ForecasterQuestionLayout from "@/app/(main)/questions/[id]/components/question_layout/forecaster_question_layout"; -import ConsumerQuestionView from "@/app/(main)/questions/[id]/components/question_view/consumer_question_view"; -import ForecasterQuestionView from "@/app/(main)/questions/[id]/components/question_view/forecaster_question_view"; +import { CoherenceLinksProvider } from "@/app/(main)/components/coherence_links_provider"; +import { QuestionLayoutProvider } from "@/app/(main)/questions/[id]/components/question_layout/question_layout_context"; +import { + ConsumerShell, + ForecasterShell, +} from "@/app/(main)/questions/[id]/components/question_page_shell"; import { PostSubscriptionProvider } from "@/contexts/post_subscription_context"; import { getMockData as getDateQuestionMockData } from "@/stories/feed_card/date_question/mock_data"; import { MockCommentsFeedProvider } from "@/stories/utils/mocks/mock_comments_feed_provider"; @@ -24,7 +26,7 @@ type StoryProps = { const meta = { title: "Question Page/Date Question", - component: ForecasterQuestionView, + component: ForecasterShell, argTypes: { isConsumer: { control: { type: "boolean" } }, hideUserPredictions: { @@ -37,23 +39,17 @@ const meta = { decorators: [ (Story, context) => { const { postData } = context.args; - const Layout = context.args.isConsumer - ? ConsumerQuestionLayout - : ForecasterQuestionLayout; return ( - -
- - - -
-
+ + +
+ + + +
+
+
); }, @@ -66,7 +62,7 @@ type Story = StoryObj; const render = createConditionalRenderer({ componentSelector: (args) => - args.isConsumer ? ConsumerQuestionView : ForecasterQuestionView, + args.isConsumer ? ConsumerShell : ForecasterShell, transformRules: [ { key: "hideUserPredictions", diff --git a/front_end/src/stories/question_page/mc_question/mc_question.stories.tsx b/front_end/src/stories/question_page/mc_question/mc_question.stories.tsx index f2fd9b1fdf..fa7a9d3937 100644 --- a/front_end/src/stories/question_page/mc_question/mc_question.stories.tsx +++ b/front_end/src/stories/question_page/mc_question/mc_question.stories.tsx @@ -1,9 +1,11 @@ import type { Meta, StoryObj } from "@storybook/nextjs-vite"; -import ConsumerQuestionLayout from "@/app/(main)/questions/[id]/components/question_layout/consumer_question_layout"; -import ForecasterQuestionLayout from "@/app/(main)/questions/[id]/components/question_layout/forecaster_question_layout"; -import ConsumerQuestionView from "@/app/(main)/questions/[id]/components/question_view/consumer_question_view"; -import ForecasterQuestionView from "@/app/(main)/questions/[id]/components/question_view/forecaster_question_view"; +import { CoherenceLinksProvider } from "@/app/(main)/components/coherence_links_provider"; +import { QuestionLayoutProvider } from "@/app/(main)/questions/[id]/components/question_layout/question_layout_context"; +import { + ConsumerShell, + ForecasterShell, +} from "@/app/(main)/questions/[id]/components/question_page_shell"; import { PostSubscriptionProvider } from "@/contexts/post_subscription_context"; import { getMockData as getMultipleChoiceMockData } from "@/stories/feed_card/mc_question/mock_data"; import { MockCommentsFeedProvider } from "@/stories/utils/mocks/mock_comments_feed_provider"; @@ -23,7 +25,7 @@ type StoryProps = { const meta = { title: "Question Page/Multiple Choice Question", - component: ForecasterQuestionView, + component: ForecasterShell, argTypes: { isConsumer: { control: { type: "boolean" } }, hideUserPredictions: { @@ -35,22 +37,17 @@ const meta = { decorators: [ (Story, context) => { const { postData } = context.args; - const Layout = context.args.isConsumer - ? ConsumerQuestionLayout - : ForecasterQuestionLayout; - return ( - -
- - - -
-
+ + +
+ + + +
+
+
); }, @@ -63,7 +60,7 @@ type Story = StoryObj; const render = createConditionalRenderer({ componentSelector: (args) => - args.isConsumer ? ConsumerQuestionView : ForecasterQuestionView, + args.isConsumer ? ConsumerShell : ForecasterShell, transformRules: [ { key: "hideUserPredictions", diff --git a/front_end/src/stories/question_page/numeric_group/numeric_fan_chart.stories.tsx b/front_end/src/stories/question_page/numeric_group/numeric_fan_chart.stories.tsx index 013a241c83..2ef322934b 100644 --- a/front_end/src/stories/question_page/numeric_group/numeric_fan_chart.stories.tsx +++ b/front_end/src/stories/question_page/numeric_group/numeric_fan_chart.stories.tsx @@ -1,9 +1,11 @@ import type { Meta, StoryObj } from "@storybook/nextjs-vite"; -import ConsumerQuestionLayout from "@/app/(main)/questions/[id]/components/question_layout/consumer_question_layout"; -import ForecasterQuestionLayout from "@/app/(main)/questions/[id]/components/question_layout/forecaster_question_layout"; -import ConsumerQuestionView from "@/app/(main)/questions/[id]/components/question_view/consumer_question_view"; -import ForecasterQuestionView from "@/app/(main)/questions/[id]/components/question_view/forecaster_question_view"; +import { CoherenceLinksProvider } from "@/app/(main)/components/coherence_links_provider"; +import { QuestionLayoutProvider } from "@/app/(main)/questions/[id]/components/question_layout/question_layout_context"; +import { + ConsumerShell, + ForecasterShell, +} from "@/app/(main)/questions/[id]/components/question_page_shell"; import { PostSubscriptionProvider } from "@/contexts/post_subscription_context"; import { getMockData as getNumericGroupMockData } from "@/stories/feed_card/numeric_group/mock_data"; import { MockCommentsFeedProvider } from "@/stories/utils/mocks/mock_comments_feed_provider"; @@ -24,7 +26,7 @@ type StoryProps = { const meta = { title: "Question Page/Numeric Group/Numeric Fan Chart", - component: ForecasterQuestionView, + component: ForecasterShell, argTypes: { isConsumer: { control: { type: "boolean", order: 0 } }, hideUserPredictions: { @@ -37,24 +39,17 @@ const meta = { decorators: [ (Story, context) => { const { postData } = context.args; - const Layout = context.args.isConsumer - ? ConsumerQuestionLayout - : ForecasterQuestionLayout; - return ( - -
- - - -
-
+ + +
+ + + +
+
+
); }, @@ -67,7 +62,7 @@ type Story = StoryObj; const render = createConditionalRenderer({ componentSelector: (args) => - args.isConsumer ? ConsumerQuestionView : ForecasterQuestionView, + args.isConsumer ? ConsumerShell : ForecasterShell, transformRules: [ { key: "hideUserPredictions", diff --git a/front_end/src/stories/question_page/numeric_group/numeric_group_timeline.stories.tsx b/front_end/src/stories/question_page/numeric_group/numeric_group_timeline.stories.tsx index ff74ebedc1..768d7e7945 100644 --- a/front_end/src/stories/question_page/numeric_group/numeric_group_timeline.stories.tsx +++ b/front_end/src/stories/question_page/numeric_group/numeric_group_timeline.stories.tsx @@ -1,9 +1,11 @@ import type { Meta, StoryObj } from "@storybook/nextjs-vite"; -import ConsumerQuestionLayout from "@/app/(main)/questions/[id]/components/question_layout/consumer_question_layout"; -import ForecasterQuestionLayout from "@/app/(main)/questions/[id]/components/question_layout/forecaster_question_layout"; -import ConsumerQuestionView from "@/app/(main)/questions/[id]/components/question_view/consumer_question_view"; -import ForecasterQuestionView from "@/app/(main)/questions/[id]/components/question_view/forecaster_question_view"; +import { CoherenceLinksProvider } from "@/app/(main)/components/coherence_links_provider"; +import { QuestionLayoutProvider } from "@/app/(main)/questions/[id]/components/question_layout/question_layout_context"; +import { + ConsumerShell, + ForecasterShell, +} from "@/app/(main)/questions/[id]/components/question_page_shell"; import { PostSubscriptionProvider } from "@/contexts/post_subscription_context"; import { getMockData as getNumericGroupMockData } from "@/stories/feed_card/numeric_group/mock_data"; import { MockCommentsFeedProvider } from "@/stories/utils/mocks/mock_comments_feed_provider"; @@ -24,7 +26,7 @@ type StoryProps = { const meta = { title: "Question Page/Numeric Group/Numeric Timeline Chart", - component: ForecasterQuestionView, + component: ForecasterShell, argTypes: { isConsumer: { control: { type: "boolean", order: 0 } }, hideUserPredictions: { @@ -37,24 +39,17 @@ const meta = { decorators: [ (Story, context) => { const { postData } = context.args; - const Layout = context.args.isConsumer - ? ConsumerQuestionLayout - : ForecasterQuestionLayout; - return ( - -
- - - -
-
+ + +
+ + + +
+
+
); }, @@ -67,7 +62,7 @@ type Story = StoryObj; const render = createConditionalRenderer({ componentSelector: (args) => - args.isConsumer ? ConsumerQuestionView : ForecasterQuestionView, + args.isConsumer ? ConsumerShell : ForecasterShell, transformRules: [ { key: "hideUserPredictions", diff --git a/posts/serializers.py b/posts/serializers.py index 1903f1f389..cf3136dff7 100644 --- a/posts/serializers.py +++ b/posts/serializers.py @@ -476,6 +476,7 @@ def serialize_post_many( include_movements: bool = False, include_conditional_cps: bool = False, include_average_scores: bool = False, + include_user_forecasts: bool = False, ) -> list[dict]: current_user = ( current_user if current_user and not current_user.is_anonymous else None @@ -502,7 +503,7 @@ def serialize_post_many( if with_cp: qs = qs.prefetch_questions_scores() - if current_user: + if current_user and include_user_forecasts: qs = qs.prefetch_user_forecasts(current_user.id) if with_subscriptions and current_user: diff --git a/posts/views.py b/posts/views.py index d5d79c4c97..e416db8ffb 100644 --- a/posts/views.py +++ b/posts/views.py @@ -41,9 +41,9 @@ vote_post, ) from posts.services.feed import get_posts_feed, get_similar_posts -from posts.services.onboarding import get_onboarding_feed from posts.services.hotness import handle_post_boost, compute_hotness_total_boosts from posts.services.notes import update_private_note, get_private_notes_feed +from posts.services.onboarding import get_onboarding_feed from posts.services.spam_detection import check_and_handle_post_spam from posts.services.subscriptions import create_subscription from posts.utils import check_can_edit_post, get_post_slug @@ -112,6 +112,7 @@ def posts_list_api_view(request): include_movements=include_movements, include_conditional_cps=include_conditional_cps, include_average_scores=True, + include_user_forecasts=True, ) return paginator.get_paginated_response(data) @@ -189,6 +190,7 @@ def posts_list_oldapi_view(request): with_cp=True, current_user=request.user, include_descriptions=True, + include_user_forecasts=True, ) # Given we limit the feed to binary questions, we expect each post to have a question with a description @@ -206,6 +208,7 @@ def post_detail_oldapi_view(request: Request, pk): current_user=request.user, with_cp=True, with_subscriptions=True, + include_user_forecasts=True, ) if not posts: @@ -239,6 +242,7 @@ def post_detail(request: Request, pk): include_cp_history=True, include_movements=True, include_average_scores=True, + include_user_forecasts=True, ) if not posts: @@ -570,7 +574,12 @@ def post_similar_posts_api_view(request: Request, pk): # Not to overload the redis posts = get_similar_posts(post) - posts = serialize_post_many(posts, with_cp=True, group_cutoff=1) + posts = serialize_post_many( + posts, + with_cp=True, + group_cutoff=1, + current_user=request.user if request.user.is_authenticated else None, + ) return Response(posts)