{showClosedMessageMultipleChoice && (
{t("predictionClosedMessage")}
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..bc8f7bfaca 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 (
-
+
= ({ post }) => {
const { setBannerIsVisible } = useContentTranslatedBannerContext();
const locale = useLocale();
@@ -29,7 +31,11 @@ const QuestionHeader: FC<{ post: PostWithForecasts }> = ({ post }) => {
-
+
= ({
forecasters,
compact = false,
+ boldCount = false,
className,
}) => {
const t = useTranslations();
@@ -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,7 +62,12 @@ const ForecastersCounter: FC = ({
count: forecasters,
count_formatted: forecastersFormatted,
...tags,
- strong: (chunks) => chunks,
+ strong: (chunks) =>
+ boldCount ? (
+ {chunks}
+ ) : (
+ chunks
+ ),
})}
)}
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"),
From 3354ab734d217bbeafae20db09527d68765c8add Mon Sep 17 00:00:00 2001
From: Nikita <93587872+ncarazon@users.noreply.github.com>
Date: Tue, 28 Apr 2026 12:01:09 +0300
Subject: [PATCH 05/11] Add desktop tab bar with shared active-tab state
(#4649)
feat: add desktop tab bar with shared active-tab state
---
front_end/messages/cs.json | 1 +
front_end/messages/en.json | 1 +
front_end/messages/es.json | 1 +
front_end/messages/pt.json | 1 +
front_end/messages/zh-TW.json | 1 +
front_end/messages/zh.json | 1 +
.../item_view/key_factor_header.tsx | 2 +-
.../consumer_tabs.tsx | 6 +-
.../question_layout_context.tsx | 32 ++++--
.../question_page_shell/tab_bar.tsx | 101 ++++++++++++++++++
10 files changed, 133 insertions(+), 14 deletions(-)
create mode 100644 front_end/src/app/(main)/questions/[id]/components/question_page_shell/tab_bar.tsx
diff --git a/front_end/messages/cs.json b/front_end/messages/cs.json
index ccbc9e74d1..cce3388655 100644
--- a/front_end/messages/cs.json
+++ b/front_end/messages/cs.json
@@ -1819,6 +1819,7 @@
"loadMore": "Načíst více",
"noPrivateNotes": "Žádné soukromé poznámky",
"privateNotes": "Soukromé poznámky",
+ "questionLinks": "Odkazy otázky",
"justNow": "právě teď",
"cmmButtonShort": "Mysl",
"FABRegisterBot": "Zaregistrujte se, abyste přihlásili svého robota do turnaje",
diff --git a/front_end/messages/en.json b/front_end/messages/en.json
index 738732f05d..b6cbe3f7ed 100644
--- a/front_end/messages/en.json
+++ b/front_end/messages/en.json
@@ -1943,6 +1943,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 0dfaebf276..b5e6b6d9df 100644
--- a/front_end/messages/es.json
+++ b/front_end/messages/es.json
@@ -1818,6 +1818,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",
diff --git a/front_end/messages/pt.json b/front_end/messages/pt.json
index c2b46abc65..44572a7658 100644
--- a/front_end/messages/pt.json
+++ b/front_end/messages/pt.json
@@ -1816,6 +1816,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",
diff --git a/front_end/messages/zh-TW.json b/front_end/messages/zh-TW.json
index d201f840f7..ff60a4ea62 100644
--- a/front_end/messages/zh-TW.json
+++ b/front_end/messages/zh-TW.json
@@ -1815,6 +1815,7 @@
"loadMore": "載入更多",
"noPrivateNotes": "尚無私人筆記",
"privateNotes": "私人筆記",
+ "questionLinks": "問題連結",
"justNow": "剛剛",
"cmmButtonShort": "心智",
"FABRegisterBot": "註冊以參加賽事",
diff --git a/front_end/messages/zh.json b/front_end/messages/zh.json
index 781de24f59..0f27de4cb3 100644
--- a/front_end/messages/zh.json
+++ b/front_end/messages/zh.json
@@ -1820,6 +1820,7 @@
"loadMore": "加載更多",
"noPrivateNotes": "尚無私人筆記",
"privateNotes": "私人筆記",
+ "questionLinks": "问题链接",
"justNow": "刚刚",
"cmmButtonShort": "心情",
"FABRegisterBot": "注册您的机器人以参加锦标赛",
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/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
index ec36fe6f3c..9ef4f44496 100644
--- 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
@@ -7,14 +7,14 @@ import { Tabs } from "@/components/ui/tabs/index";
import { useQuestionLayout } from "../question_layout_context";
const ConsumerTabs: React.FC = ({ children }) => {
- const { mobileActiveTab, setMobileActiveTab } = useQuestionLayout();
+ const { activeTab, setActiveTab } = useQuestionLayout();
return (
{children}
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..54d12c2d3d 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,43 @@ type QuestionLayoutContextValue = {
requestReplyToComment: (commentId: number) => void;
clearReplyToComment: () => void;
- // Mobile tab state
- mobileActiveTab?: string;
- setMobileActiveTab: (tab: string) => void;
+ // Active tab state (shared between mobile + desktop tab bars)
+ activeTab?: string;
+ setActiveTab: (tab: string) => void;
};
+const TAB_HASH_VALUES = new Set([
+ "comments",
+ "timeline",
+ "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) => {
@@ -96,8 +108,8 @@ export const QuestionLayoutProvider = ({ children }: PropsWithChildren) => {
replyToCommentId,
requestReplyToComment,
clearReplyToComment,
- mobileActiveTab,
- setMobileActiveTab,
+ activeTab,
+ setActiveTab,
}),
[
keyFactorsExpanded,
@@ -109,7 +121,7 @@ export const QuestionLayoutProvider = ({ children }: PropsWithChildren) => {
replyToCommentId,
requestReplyToComment,
clearReplyToComment,
- mobileActiveTab,
+ activeTab,
]
);
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..1693a1bc03
--- /dev/null
+++ b/front_end/src/app/(main)/questions/[id]/components/question_page_shell/tab_bar.tsx
@@ -0,0 +1,101 @@
+"use client";
+
+import { useTranslations } from "next-intl";
+import { FC } from "react";
+
+import { Tabs, TabsList, TabsTab } from "@/components/ui/tabs";
+import { PostWithForecasts } from "@/types/post";
+import cn from "@/utils/core/cn";
+
+import { useQuestionLayout } from "../question_layout/question_layout_context";
+
+type TabKey =
+ | "comments"
+ | "key-factors"
+ | "info"
+ | "question-links"
+ | "private-notes";
+
+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-base leading-6 px-[15px] py-[5px] 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 commentCount = post.comment_count ?? 0;
+ const keyFactorsCount = post.key_factors?.length ?? 0;
+
+ const tabs: TabDef[] =
+ variant === "forecaster"
+ ? [
+ { 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") },
+ ]
+ : [
+ { key: "comments", label: t("comments"), count: commentCount },
+ {
+ key: "key-factors",
+ label: t("keyFactors"),
+ count: keyFactorsCount,
+ },
+ { key: "info", label: t("info") },
+ ];
+
+ const defaultValue: TabKey = "comments";
+ const active =
+ activeTab && tabs.some((tab) => tab.key === activeTab)
+ ? activeTab
+ : defaultValue;
+
+ return (
+
+
+ {tabs.map((tab) => (
+
+ {tab.count !== undefined
+ ? `${tab.label} (${tab.count})`
+ : tab.label}
+
+ ))}
+
+
+ );
+};
+
+export default QuestionPageShellTabBar;
From faccea993d98df48d3ea91e7ecef05293084a039 Mon Sep 17 00:00:00 2001
From: Nikita <93587872+ncarazon@users.noreply.github.com>
Date: Tue, 28 Apr 2026 12:02:45 +0300
Subject: [PATCH 06/11] Add tab content adapters (#4656)
feat: add tab content adapters
---
front_end/messages/cs.json | 1 +
front_end/messages/en.json | 1 +
front_end/messages/es.json | 1 +
front_end/messages/pt.json | 1 +
front_end/messages/zh-TW.json | 1 +
front_end/messages/zh.json | 1 +
.../responsive_comment_feed.tsx | 10 +-
.../components/question_page_shell/tabs.tsx | 54 ++++++
.../question_page_shell/tabs/comments.tsx | 19 +++
.../question_page_shell/tabs/key_factors.tsx | 15 ++
.../tabs/private_notes.tsx | 16 ++
.../tabs/question_info.tsx | 154 ++++++++++++++++++
.../tabs/question_links.tsx | 16 ++
.../sidebar/sidebar_question_info.tsx | 30 ++--
.../coherence_links/coherence_links.tsx | 105 ++++++------
.../src/components/question/private_note.tsx | 68 +++++---
16 files changed, 410 insertions(+), 83 deletions(-)
create mode 100644 front_end/src/app/(main)/questions/[id]/components/question_page_shell/tabs.tsx
create mode 100644 front_end/src/app/(main)/questions/[id]/components/question_page_shell/tabs/comments.tsx
create mode 100644 front_end/src/app/(main)/questions/[id]/components/question_page_shell/tabs/key_factors.tsx
create mode 100644 front_end/src/app/(main)/questions/[id]/components/question_page_shell/tabs/private_notes.tsx
create mode 100644 front_end/src/app/(main)/questions/[id]/components/question_page_shell/tabs/question_info.tsx
create mode 100644 front_end/src/app/(main)/questions/[id]/components/question_page_shell/tabs/question_links.tsx
diff --git a/front_end/messages/cs.json b/front_end/messages/cs.json
index cce3388655..0a1cd6fd24 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",
diff --git a/front_end/messages/en.json b/front_end/messages/en.json
index b6cbe3f7ed..d4b729c939 100644
--- a/front_end/messages/en.json
+++ b/front_end/messages/en.json
@@ -1067,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",
diff --git a/front_end/messages/es.json b/front_end/messages/es.json
index b5e6b6d9df..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",
diff --git a/front_end/messages/pt.json b/front_end/messages/pt.json
index 44572a7658..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",
diff --git a/front_end/messages/zh-TW.json b/front_end/messages/zh-TW.json
index ff60a4ea62..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": "開始日期",
diff --git a/front_end/messages/zh.json b/front_end/messages/zh.json
index 0f27de4cb3..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": "開始日期",
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_page_shell/tabs.tsx b/front_end/src/app/(main)/questions/[id]/components/question_page_shell/tabs.tsx
new file mode 100644
index 0000000000..a6fce55406
--- /dev/null
+++ b/front_end/src/app/(main)/questions/[id]/components/question_page_shell/tabs.tsx
@@ -0,0 +1,54 @@
+"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 PrivateNotesTab from "./tabs/private_notes";
+import QuestionInfoTab from "./tabs/question_info";
+import QuestionLinksTab from "./tabs/question_links";
+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 "key-factors":
+ return ;
+ case "info":
+ return ;
+ case "question-links":
+ return variant === "forecaster" ? : null;
+ case "private-notes":
+ return variant === "forecaster" ? : null;
+ case "comments":
+ default:
+ return ;
+ }
+};
+
+const QuestionPageShellTabs: FC = ({ post, variant, className }) => {
+ const { activeTab } = useQuestionLayout();
+
+ return (
+
+
+
{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..b7b7aad1c9
--- /dev/null
+++ b/front_end/src/app/(main)/questions/[id]/components/question_page_shell/tabs/comments.tsx
@@ -0,0 +1,19 @@
+"use client";
+
+import { FC } from "react";
+
+import { PostWithForecasts } from "@/types/post";
+
+import ResponsiveCommentFeed from "../../question_layout/consumer_question_layout/responsive_comment_feed";
+
+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/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..c3b035429b
--- /dev/null
+++ b/front_end/src/app/(main)/questions/[id]/components/question_page_shell/tabs/question_info.tsx
@@ -0,0 +1,154 @@
+"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 { TaxonomyProjectType } from "@/types/projects";
+import { sendAnalyticsEvent } from "@/utils/analytics";
+import { getProjectLink } from "@/utils/navigation";
+
+import SidebarQuestionInfo from "../../sidebar/sidebar_question_info";
+
+type Props = {
+ post: PostWithForecasts;
+};
+
+type MarkdownSection = { title: string; markdown: string };
+
+const getChipText = (name: string, type?: string) =>
+ type === "leaderboard_tag" ? `🏆 ${name}` : name;
+
+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/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/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/components/question/private_note.tsx b/front_end/src/components/question/private_note.tsx
index 0b433df5b9..2fa134ee85 100644
--- a/front_end/src/components/question/private_note.tsx
+++ b/front_end/src/components/question/private_note.tsx
@@ -17,6 +17,7 @@ import { formatDate } from "@/utils/formatters/date";
type Props = {
post: Post;
+ hideToggle?: boolean;
};
const SavedAgo: FC<{ savedAt: Date }> = ({ savedAt }) => {
@@ -54,7 +55,7 @@ const SavedAgo: FC<{ savedAt: Date }> = ({ savedAt }) => {
});
};
-const PrivateNote: FC
= ({ post: { private_note, id } }) => {
+const PrivateNote: FC = ({ post: { private_note, id }, hideToggle }) => {
const t = useTranslations();
const locale = useLocale();
const { text, updated_at } = private_note || {};
@@ -94,6 +95,52 @@ const PrivateNote: FC = ({ post: { private_note, id } }) => {
return null;
}
+ const editorBody = (
+ {
+ saveNoteDebounced(val);
+ }}
+ onBlur={() => {
+ const val = editorRef.current?.getMarkdown();
+ if (!isNil(val)) {
+ saveNote(val);
+ }
+ }}
+ withUgcLinks
+ withCodeBlocks
+ />
+ );
+
+ const editor = (
+ {editorBody}
+ );
+
+ if (hideToggle) {
+ return (
+
+
+ {editorBody}
+
+ {(noteStatusDetails || updated_at) && (
+
+ {noteStatusDetails ??
+ (updated_at &&
+ t.rich("privateNoteUpdatedFrom", {
+ date: () => (
+
+ {formatDate(locale, new Date(updated_at))}
+
+ ),
+ }))}
+
+ )}
+
+ );
+ }
+
return (
= ({ post: { private_note, id } }) => {
}
}}
>
-
- {
- saveNoteDebounced(val);
- }}
- onBlur={() => {
- const val = editorRef.current?.getMarkdown();
- if (!isNil(val)) {
- saveNote(val);
- }
- }}
- withUgcLinks
- withCodeBlocks
- />
-
+ {editor}
);
};
From 6b4cd20fc1c5e9108a64ba6d5a7deff34e14888b Mon Sep 17 00:00:00 2001
From: Nikita
Date: Tue, 28 Apr 2026 12:12:19 +0300
Subject: [PATCH 07/11] fix: duplicated import
---
.../forecaster_question_view/question_header/index.tsx | 2 --
1 file changed, 2 deletions(-)
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
index a8bd1332d2..6a9ea8de3a 100644
--- 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
@@ -11,8 +11,6 @@ import { PostWithForecasts } from "@/types/post";
import ActionRow from "../../action_row";
-import ActionRow from "../../action_row";
-
const QuestionHeader: FC<{ post: PostWithForecasts }> = ({ post }) => {
const { setBannerIsVisible } = useContentTranslatedBannerContext();
const locale = useLocale();
From f118806e3538f5193c051fece1355c27c5970d3b Mon Sep 17 00:00:00 2001
From: Nikita <93587872+ncarazon@users.noreply.github.com>
Date: Fri, 1 May 2026 12:46:42 +0300
Subject: [PATCH 08/11] Sidebar reflow (#4660)
* feat: change sidebar reflow for forecaster and consumer views
* feat: redesign similar questions sidebar with new card layout and charts
* feat: differentiate similar questions chart display between forecaster and consumer views
* feat: add vertical bar chart for date/numeric group questions in similar questions consumer sidebar
* fix: disable frontend cache for similar posts endpoint to ensure per-user vote state is returned correctly
* fix: limit vertical bar chart to visible choices count to prevent overlapping labels
* fix: replace VerticalBarConsumerCard with GroupForecastCard in similar questions sidebar and remove hideResolutionIcon prop
* Similar questions endpoint: added user context to the API response
---------
Co-authored-by: hlbmtc
---
.../[id]/[[...slug]]/page_component.tsx | 10 +-
.../question_layout/question_info.tsx | 11 ++
.../[id]/components/sidebar/index.tsx | 31 ++--
.../components/sidebar/sidebar_container.tsx | 2 +-
.../sidebar/similar_questions/index.tsx | 7 +-
.../similar_question_card.tsx | 161 +++++++++++++-----
.../similar_question_prediction_chip.tsx | 62 ++++++-
.../similar_questions_drawer.tsx | 47 -----
.../similar_questions_list.tsx | 22 +++
.../group_forecast_card/index.tsx | 3 +
.../post_card/basic_post_card/post_voter.tsx | 10 +-
.../question_continuous_tile.tsx | 2 +-
.../src/services/api/posts/posts.shared.ts | 5 +-
posts/serializers.py | 3 +-
posts/views.py | 13 +-
15 files changed, 267 insertions(+), 122 deletions(-)
delete mode 100644 front_end/src/app/(main)/questions/[id]/components/sidebar/similar_questions/similar_questions_drawer.tsx
create mode 100644 front_end/src/app/(main)/questions/[id]/components/sidebar/similar_questions/similar_questions_list.tsx
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 a4432d015e..46428f7028 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
@@ -18,6 +18,7 @@ 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 { QuestionVariantComposer } from "../components/question_variant_composer";
import QuestionView from "../components/question_view";
import Sidebar from "../components/sidebar";
import { SLUG_POST_SUB_QUESTION_ID } from "../search_params";
@@ -101,7 +102,14 @@ const IndividualQuestionPage: FC<{
/>
-
+
+ }
+ consumer={
+
+ }
+ />
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
index e0f9f7418a..b57ddcd50a 100644
--- 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
@@ -18,6 +18,8 @@ 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";
+import SidebarContainer from "../sidebar/sidebar_container";
+import SidebarQuestionInfo from "../sidebar/sidebar_question_info";
type Props = {
postData: PostWithForecasts;
@@ -122,6 +124,15 @@ const QuestionInfo: React.FC = ({
+
+
+
+
+ }
+ forecaster={null}
+ />
);
};
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 21980d6905..a221af3fc1 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,5 +1,5 @@
import dynamic from "next/dynamic";
-import React, { FC, Suspense } from "react";
+import { FC, Suspense } from "react";
import { PostStatus, PostWithForecasts } from "@/types/post";
@@ -11,19 +11,26 @@ const SimilarQuestions = dynamic(() => import("./similar_questions"));
type Props = {
postData: PostWithForecasts;
layout?: "mobile" | "desktop";
+ variant?: "forecaster" | "consumer";
};
-const Sidebar: FC = ({ postData, layout = "desktop" }) => {
+const Sidebar: FC = ({
+ postData,
+ layout = "desktop",
+ variant = "forecaster",
+}) => {
if (layout === "mobile") {
return (
-
-
-
+ {variant === "forecaster" && (
+
+
+
+ )}
{postData.curation_status === PostStatus.APPROVED && (
-
+
)}
@@ -32,13 +39,17 @@ const Sidebar: FC = ({ postData, layout = "desktop" }) => {
return (
-
-
-
+ {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/similar_questions/index.tsx b/front_end/src/app/(main)/questions/[id]/components/sidebar/similar_questions/index.tsx
index 7a38303a9d..ba3673cba4 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
@@ -3,18 +3,19 @@ import { FC } from "react";
import WithServerComponentErrorBoundary from "@/components/server_component_error_boundary";
import ServerPostsApi from "@/services/api/posts/posts.server";
-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 SimilarQuestions: FC = async ({ post_id, variant }) => {
const questions = await ServerPostsApi.getSimilarPosts(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/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/post_card/basic_post_card/post_voter.tsx b/front_end/src/components/post_card/basic_post_card/post_voter.tsx
index d78200217f..ed9c3514f9 100644
--- a/front_end/src/components/post_card/basic_post_card/post_voter.tsx
+++ b/front_end/src/components/post_card/basic_post_card/post_voter.tsx
@@ -17,9 +17,10 @@ type Props = {
className?: string;
post: Post;
questionPage?: boolean;
+ compact?: boolean;
};
-const PostVoter: FC = ({ className, post, questionPage }) => {
+const PostVoter: FC = ({ className, post, questionPage, compact }) => {
const { user } = useAuth();
const { setCurrentModal } = useModal();
@@ -77,7 +78,12 @@ const PostVoter: FC = ({ className, post, questionPage }) => {
)}
{vote.score != null && vote.score !== 0 && (
-
+
{vote.score}
)}
diff --git a/front_end/src/components/post_card/question_tile/question_continuous_tile.tsx b/front_end/src/components/post_card/question_tile/question_continuous_tile.tsx
index f0d7587895..d40d463329 100644
--- a/front_end/src/components/post_card/question_tile/question_continuous_tile.tsx
+++ b/front_end/src/components/post_card/question_tile/question_continuous_tile.tsx
@@ -133,7 +133,7 @@ const QuestionContinuousTile: FC = ({
// Binary questions use original side-by-side layout
if (question.type === QuestionType.Binary) {
return (
-
+
{
return await this.get(
- `/posts/${postId}/similar-posts/`,
- {
- next: { revalidate: 3600 },
- }
+ `/posts/${postId}/similar-posts/`
);
}
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..84f8a7fadb 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)
From 282a5916dd431a29d1d1f05f79e799ba6aff3cd1 Mon Sep 17 00:00:00 2001
From: Nikita <93587872+ncarazon@users.noreply.github.com>
Date: Fri, 1 May 2026 13:41:00 +0300
Subject: [PATCH 09/11] Integrate the shell for desktop (#4676)
* feat: integrate shell, widen column to 59rem, delete dead layouts
* fix: CommunityDisclaimer placement and add shell z-index
* feat: replace direct DOM scrolling with context-based comment navigation, improve cross-tab comment access, and refine consumer shell layout handling for different question types
* feat: refine question page and comment feed styling, spacing, and layout
* fix: stack comment date below author name on question page and align date/vote typography to design spec
* feat: add My Scores tab for consumer, wire PostScoreData into forecaster shell with Resolution Criteria and Background Info, and fix duplicate comments on tab re-entry
* refactor: gate My Scores tab on user scores, extract comment header condition into named vars, and deduplicate post score routing logic
* fix: forward mobileSidebar to ConsumerShell, fix translated banner timer cleanup, update my-scores hash key, and widen Storybook story wrappers to 59rem
* fix: restore preselectedGroupQuestionId in ConsumerShell for group and fan graph questions
---
.../components/comments_feed_provider.tsx | 3 +
.../[id]/[[...slug]]/page_component.tsx | 22 +-
.../key_factors/item_view/more_panel.tsx | 8 +-
.../key_factors/key_factor_detail_overlay.tsx | 9 +-
.../single_question_score_data.tsx | 23 +-
.../[id]/components/post_score_data/utils.ts | 30 +-
.../consumer_tabs.tsx | 24 --
.../consumer_question_layout/index.tsx | 120 -------
.../forecaster_question_layout/index.tsx | 37 ---
.../[id]/components/question_layout/index.tsx | 44 ---
.../question_layout/question_info.tsx | 140 --------
.../question_layout_context.tsx | 23 +-
.../question_layout/question_section.tsx | 11 -
.../components/question_page_shell/index.tsx | 299 ++++++++++++++++++
.../question_page_shell/tab_bar.tsx | 26 +-
.../components/question_page_shell/tabs.tsx | 13 +-
.../question_page_shell/tabs/comments.tsx | 2 +-
.../question_page_shell/tabs/my_scores.tsx | 14 +
.../consumer_question_view/index.tsx | 104 ------
.../consumer_question_view/timeline/index.tsx | 11 +-
.../forecaster_question_view/index.tsx | 46 ---
.../question_header/index.tsx | 48 ---
.../[id]/components/question_view/index.tsx | 29 --
.../src/components/comment_feed/comment.tsx | 37 ++-
.../components/comment_feed/comment_card.tsx | 5 +-
.../components/comment_feed/comment_date.tsx | 2 +-
.../components/comment_feed/comment_voter.tsx | 1 +
.../comment_feed/comment_wrapper.tsx | 2 +-
.../src/components/comment_feed/index.tsx | 93 +++---
.../detailed_question_card/index.tsx | 1 +
front_end/src/components/voter.tsx | 10 +-
.../binary_group_timeline.stories.tsx | 38 +--
.../binary_group/fan_chart.stories.tsx | 38 +--
.../binary_question.stories.tsx | 38 +--
.../continuous_question.stories.tsx | 38 +--
.../date_group_timeline.stories.tsx | 38 +--
.../date_group/fan_chart.stories.tsx | 38 +--
.../date_question/date_question.stories.tsx | 38 +--
.../mc_question/mc_question.stories.tsx | 37 +--
.../numeric_fan_chart.stories.tsx | 39 +--
.../numeric_group_timeline.stories.tsx | 39 +--
41 files changed, 691 insertions(+), 927 deletions(-)
delete mode 100644 front_end/src/app/(main)/questions/[id]/components/question_layout/consumer_question_layout/consumer_tabs.tsx
delete mode 100644 front_end/src/app/(main)/questions/[id]/components/question_layout/consumer_question_layout/index.tsx
delete mode 100644 front_end/src/app/(main)/questions/[id]/components/question_layout/forecaster_question_layout/index.tsx
delete mode 100644 front_end/src/app/(main)/questions/[id]/components/question_layout/index.tsx
delete mode 100644 front_end/src/app/(main)/questions/[id]/components/question_layout/question_info.tsx
delete mode 100644 front_end/src/app/(main)/questions/[id]/components/question_layout/question_section.tsx
create mode 100644 front_end/src/app/(main)/questions/[id]/components/question_page_shell/index.tsx
create mode 100644 front_end/src/app/(main)/questions/[id]/components/question_page_shell/tabs/my_scores.tsx
delete mode 100644 front_end/src/app/(main)/questions/[id]/components/question_view/consumer_question_view/index.tsx
delete mode 100644 front_end/src/app/(main)/questions/[id]/components/question_view/forecaster_question_view/index.tsx
delete mode 100644 front_end/src/app/(main)/questions/[id]/components/question_view/forecaster_question_view/question_header/index.tsx
delete mode 100644 front_end/src/app/(main)/questions/[id]/components/question_view/index.tsx
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 46428f7028..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
@@ -17,9 +17,8 @@ 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 QuestionPageShell from "../components/question_page_shell";
import { QuestionVariantComposer } from "../components/question_variant_composer";
-import QuestionView from "../components/question_view";
import Sidebar from "../components/sidebar";
import { SLUG_POST_SUB_QUESTION_ID } from "../search_params";
import { cachedGetPost } from "./utils/get_post";
@@ -85,22 +84,13 @@ const IndividualQuestionPage: FC<{
/>
)}
-
- {isCommunityQuestion && defaultProject && (
-
- )}
-
-
+ mobileSidebar={
+
+ }
+ />
= ({
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/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..2cbf3e32b4 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,34 @@ 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;
+};
+
+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 9ef4f44496..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 { activeTab, setActiveTab } = 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/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 dea74f00d4..0000000000
--- a/front_end/src/app/(main)/questions/[id]/components/question_layout/forecaster_question_layout/index.tsx
+++ /dev/null
@@ -1,37 +0,0 @@
-import { PropsWithChildren } from "react";
-
-import CommentFeed from "@/components/comment_feed";
-import { PostWithForecasts } from "@/types/post";
-
-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 b57ddcd50a..0000000000
--- a/front_end/src/app/(main)/questions/[id]/components/question_layout/question_info.tsx
+++ /dev/null
@@ -1,140 +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";
-import SidebarContainer from "../sidebar/sidebar_container";
-import SidebarQuestionInfo from "../sidebar/sidebar_question_info";
-
-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 && (
-
- )
- }
- />
-
-
-
-
-
-
-
- }
- forecaster={null}
- />
-
- );
-};
-
-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 54d12c2d3d..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,6 +35,11 @@ type QuestionLayoutContextValue = {
requestReplyToComment: (commentId: number) => void;
clearReplyToComment: () => 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;
@@ -43,7 +48,7 @@ type QuestionLayoutContextValue = {
const TAB_HASH_VALUES = new Set([
"comments",
"timeline",
- "scores",
+ "my-scores",
"key-factors",
"info",
"question-links",
@@ -97,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,
@@ -108,6 +123,9 @@ export const QuestionLayoutProvider = ({ children }: PropsWithChildren) => {
replyToCommentId,
requestReplyToComment,
clearReplyToComment,
+ scrollToCommentId,
+ requestScrollToComment,
+ clearScrollToComment,
activeTab,
setActiveTab,
}),
@@ -121,6 +139,9 @@ export const QuestionLayoutProvider = ({ children }: PropsWithChildren) => {
replyToCommentId,
requestReplyToComment,
clearReplyToComment,
+ 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 f261fcf834..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 (
-
- );
-};
-
-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..df642aebea
--- /dev/null
+++ b/front_end/src/app/(main)/questions/[id]/components/question_page_shell/index.tsx
@@ -0,0 +1,299 @@
+"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 { 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-5 overflow-x-clip rounded border border-blue-400 p-4 text-gray-900 dark:border-blue-200-dark dark:text-gray-900-dark lg:gap-6 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 && (
+
+ )}
+
+
+
+
+
+
+ {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 && (
+
+ )}
+
+
+
+
+
+
+ {showClosedMessageMultipleChoice && (
+
+ {t("predictionClosedMessage")}
+
+ )}
+
+ {!isContinuousSingleQuestion && (
+
+
+
+ )}
+ {!isFanGraph && (
+
+ )}
+ {isFanGraph && isGroupOfQuestionsPost(postData) && (
+
+ )}
+ {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/tab_bar.tsx b/front_end/src/app/(main)/questions/[id]/components/question_page_shell/tab_bar.tsx
index 1693a1bc03..04de216da4 100644
--- 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
@@ -7,12 +7,14 @@ import { Tabs, TabsList, TabsTab } from "@/components/ui/tabs";
import { 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";
type TabKey =
| "comments"
| "key-factors"
| "info"
+ | "my-scores"
| "question-links"
| "private-notes";
@@ -43,20 +45,19 @@ const QuestionPageShellTabBar: FC = ({ post, variant, className }) => {
const commentCount = post.comment_count ?? 0;
const keyFactorsCount = post.key_factors?.length ?? 0;
+ const hasScores = shouldPostShowUserScores(post);
+
+ 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") },
+ ];
const tabs: TabDef[] =
variant === "forecaster"
- ? [
- { 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") },
- ]
+ ? forecasterTabs
: [
{ key: "comments", label: t("comments"), count: commentCount },
{
@@ -65,6 +66,9 @@ const QuestionPageShellTabBar: FC = ({ post, variant, className }) => {
count: keyFactorsCount,
},
{ key: "info", label: t("info") },
+ ...(hasScores
+ ? [{ key: "my-scores" as TabKey, label: t("myScores") }]
+ : []),
];
const defaultValue: TabKey = "comments";
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
index a6fce55406..0288bdbc7c 100644
--- 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
@@ -7,9 +7,11 @@ 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 KeyFactorsFeed from "../key_factors/key_factors_feed";
import { useQuestionLayout } from "../question_layout/question_layout_context";
type Variant = "consumer" | "forecaster";
@@ -34,6 +36,8 @@ const renderActivePanel = (
return variant === "forecaster" ? : null;
case "private-notes":
return variant === "forecaster" ? : null;
+ case "my-scores":
+ return variant === "consumer" ? : null;
case "comments":
default:
return ;
@@ -42,11 +46,18 @@ const renderActivePanel = (
const QuestionPageShellTabs: FC = ({ post, variant, className }) => {
const { activeTab } = useQuestionLayout();
+ const isKeyFactors = activeTab === "key-factors";
return (
-
{renderActivePanel(activeTab, post, variant)}
+ {/* Mounted only when off the key-factors tab to power the overlay from any tab */}
+ {!isKeyFactors && (
+
+
+
+ )}
+
{renderActivePanel(activeTab, post, variant)}
);
};
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
index b7b7aad1c9..4126b0fe7b 100644
--- 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
@@ -11,7 +11,7 @@ type Props = {
};
const CommentsTab: FC = ({ post }) => (
-
+
);
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_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 2e80c6cf68..0000000000
--- a/front_end/src/app/(main)/questions/[id]/components/question_view/consumer_question_view/index.tsx
+++ /dev/null
@@ -1,104 +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 MetaRow from "@/app/(main)/questions/[id]/components/question_page_shell/meta_row";
-import TitleRow from "@/app/(main)/questions/[id]/components/question_page_shell/title_row";
-import {
- GroupOfQuestionsGraphType,
- PostStatus,
- PostWithForecasts,
- QuestionStatus,
-} from "@/types/post";
-import { QuestionType } from "@/types/question";
-import cn from "@/utils/core/cn";
-import {
- checkGroupOfQuestionsPostType,
- isGroupOfQuestionsPost,
- isMultipleChoicePost,
-} from "@/utils/questions/helpers";
-
-import ActionRow from "../action_row";
-import ConsumerQuestionPrediction from "./prediction";
-import { isDisplayableQuestionLink } from "../../key_factors/utils";
-
-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 (
-
-
-
-
-
-
-
-
- {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/timeline/index.tsx b/front_end/src/app/(main)/questions/[id]/components/question_view/consumer_question_view/timeline/index.tsx
index 420802ebc4..8c600c23cc 100644
--- a/front_end/src/app/(main)/questions/[id]/components/question_view/consumer_question_view/timeline/index.tsx
+++ b/front_end/src/app/(main)/questions/[id]/components/question_view/consumer_question_view/timeline/index.tsx
@@ -20,6 +20,8 @@ type Props = {
className?: string;
hideTitle?: boolean;
keyFactors?: KeyFactor[];
+ isConsumerView?: boolean;
+ preselectedGroupQuestionId?: number;
};
const QuestionTimeline: React.FC = ({
@@ -27,6 +29,8 @@ const QuestionTimeline: React.FC = ({
className,
hideTitle,
keyFactors,
+ isConsumerView = true,
+ preselectedGroupQuestionId,
}) => {
const isFanGraph =
postData.group_of_questions?.graph_type ===
@@ -44,7 +48,7 @@ const QuestionTimeline: React.FC = ({
@@ -64,7 +68,10 @@ const QuestionTimeline: React.FC = ({
{isDateType ? (
) : (
-
+
)}
);
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 6a9ea8de3a..0000000000
--- a/front_end/src/app/(main)/questions/[id]/components/question_view/forecaster_question_view/question_header/index.tsx
+++ /dev/null
@@ -1,48 +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 MetaRow from "@/app/(main)/questions/[id]/components/question_page_shell/meta_row";
-import TitleRow from "@/app/(main)/questions/[id]/components/question_page_shell/title_row";
-import { useContentTranslatedBannerContext } from "@/contexts/translations_banner_context";
-import { PostWithForecasts } from "@/types/post";
-
-import ActionRow from "../../action_row";
-
-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 (
-
- );
-};
-
-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/components/comment_feed/comment.tsx b/front_end/src/components/comment_feed/comment.tsx
index e0aff3a1d0..23ea40e36b 100644
--- a/front_end/src/components/comment_feed/comment.tsx
+++ b/front_end/src/components/comment_feed/comment.tsx
@@ -6,6 +6,7 @@ import {
faReply,
faThumbtack,
faXmark,
+ faEllipsis,
} from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import Link from "next/link";
@@ -269,6 +270,18 @@ const Comment: FC = ({
});
}
}, [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 = ({
})}
>
= ({
)}
·
@@ -1061,7 +1076,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 && postId && !user?.is_bot && (
- <>
- {showWelcomeMessage && !getIsMessagePreviouslyClosed() ? null : (
-
{
- onNewComment(newComment);
- }
- }
- isPrivateFeed={feedFilters.is_private}
- userPermission={postData?.user_permission}
- />
- )}
- >
- )}
-
-
-
+
+
{totalCount ? `${totalCount} ` : ""}
{t("commentsWithCount", { count: totalCount })}
{postData?.last_viewed_at && (
@@ -443,28 +425,51 @@ const CommentFeed: FC = ({
)}
-
- {comments.map((comment: CommentType) => (
-
- ))}
+ {!compactVersion && postId && !user?.is_bot && (
+ <>
+ {showWelcomeMessage && !getIsMessagePreviouslyClosed() ? null : (
+ {
+ onNewComment(newComment);
+ }
+ }
+ isPrivateFeed={feedFilters.is_private}
+ userPermission={postData?.user_permission}
+ />
+ )}
+ >
+ )}
+
+ {comments.map((comment: CommentType) => (
+
+ ))}
+
{comments.length === 0 && !isLoading && (
<>
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 = ({
= ({
@@ -31,6 +32,7 @@ const Voter: FC = ({
upChevronClassName,
downChevronClassName,
voteClassName,
+ showZeroVotes,
}) => {
return (
= ({
/>
)}
- {!!votes != null && votes !== 0 && (
+ {votes != null && (votes !== 0 || showZeroVotes) && (
{
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",
From c936d3a4e264401dc91c124efabe8312ba656d1d Mon Sep 17 00:00:00 2001
From: Nikita <93587872+ncarazon@users.noreply.github.com>
Date: Thu, 7 May 2026 11:27:10 +0300
Subject: [PATCH 10/11] Mobile: introduce forecaster tab bar + styling-parity
pass (#4682)
* fix: mobile consumer layout: center title, show prediction for all question types, fix comments tab blank below lg
* feat: consumer view mobile - typography, spacing, tab bar sizing, horizontal scroll, and timeline in tab on mobile
* feat: adjust title typography, vote count on mobile, and restore Forecast Timeline label across all views
* feat: consumer/forecaster mobile - similar questions tab, hide sidebar author section on mobile, shared chip helpers with TrophyIcon, fix continuous graph clipping, and consumer title right padding
* feat: consumer mobile - remove group prediction top margin on mobile and show compact bar chart for date groups instead of scatter
* fix: format posts/views.py
* fix: mobile polish pass, vote count size, comment text styling, section toggle arrow, and key factors link
* fix: restrict fan graph/date group bottom margin to desktop only
* fix: align x-axis tick labels with chart boundaries
* fix: restructure consumer chart layout for date/fan graph groups and fix CommunityDisclaimer to only show for Community tournament type
* refactor: migrate similar posts fetching to TanStack Query, co-located in SimilarQuestionsTab
* feat: fall back to top-50 hot questions when no similar questions exist
* fix: skip top-posts fallback fetch when similar questions already exist
* fix: improve top-questions fallback with correct fetch params, loading state, and error handling
* fix: sort tailwind classes to satisfy prettier lint rule
---
.../key_factors_question_consumer_section.tsx | 12 ++--
.../components/question_page_shell/index.tsx | 71 ++++++++----------
.../question_page_shell/meta_row.tsx | 38 +++-------
.../project_chip_helpers.tsx | 24 +++++++
.../question_page_shell/tab_bar.tsx | 55 +++++++++-----
.../components/question_page_shell/tabs.tsx | 10 ++-
.../question_page_shell/tabs/comments.tsx | 5 +-
.../tabs/question_info.tsx | 16 ++---
.../tabs/similar_questions.tsx | 72 +++++++++++++++++++
.../question_page_shell/tabs/timeline.tsx | 19 +++++
.../question_page_shell/title_row.tsx | 3 +-
.../components/question_view/action_row.tsx | 2 +-
.../group_of_questions_prediction/index.tsx | 4 +-
.../binary_question_prediction.tsx | 2 +-
.../continuous_question_prediction.tsx | 4 +-
.../consumer_question_view/timeline/index.tsx | 28 ++++++--
.../[id]/components/sidebar/index.tsx | 19 +----
.../sidebar/similar_questions/index.tsx | 20 +++++-
.../src/components/charts/group_chart.tsx | 2 +-
.../charts/primitives/x_tick_label.tsx | 14 ++--
.../src/components/comment_feed/comment.tsx | 1 +
.../src/components/comment_feed/index.tsx | 6 +-
.../post_card/basic_post_card/post_voter.tsx | 2 +-
.../src/components/ui/section_toggle.tsx | 4 +-
posts/views.py | 2 +-
25 files changed, 281 insertions(+), 154 deletions(-)
create mode 100644 front_end/src/app/(main)/questions/[id]/components/question_page_shell/project_chip_helpers.tsx
create mode 100644 front_end/src/app/(main)/questions/[id]/components/question_page_shell/tabs/similar_questions.tsx
create mode 100644 front_end/src/app/(main)/questions/[id]/components/question_page_shell/tabs/timeline.tsx
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/question_page_shell/index.tsx b/front_end/src/app/(main)/questions/[id]/components/question_page_shell/index.tsx
index df642aebea..0c67495458 100644
--- 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
@@ -16,6 +16,7 @@ import {
PostWithForecasts,
QuestionStatus,
} from "@/types/post";
+import { TournamentType } from "@/types/projects";
import { QuestionType } from "@/types/question";
import cn from "@/utils/core/cn";
import {
@@ -39,7 +40,7 @@ import ConsumerQuestionPrediction from "../question_view/consumer_question_view/
import QuestionTimeline from "../question_view/consumer_question_view/timeline";
const baseSectionClassName =
- "relative z-10 flex w-[59rem] max-w-full flex-col gap-5 overflow-x-clip rounded border border-blue-400 p-4 text-gray-900 dark:border-blue-200-dark dark:text-gray-900-dark lg:gap-6 lg:p-8";
+ "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`;
@@ -70,10 +71,11 @@ export const ForecasterShell: FC<
return (
-
+
- {postData.projects?.default_project && (
+ {postData.projects?.default_project?.type ===
+ TournamentType.Community && (
)}
{isGroup && (
@@ -168,10 +169,11 @@ export const ConsumerShell: FC<{
const shouldShowKeyFactorsSection = hasKeyFactors || hasQuestionLinks;
return (
-
+
- {postData.projects?.default_project && (
+ {postData.projects?.default_project?.type ===
+ TournamentType.Community && (
-
+
-
+
{showClosedMessageMultipleChoice && (
{t("predictionClosedMessage")}
@@ -205,43 +207,24 @@ export const ConsumerShell: FC<{
showSideBySide && "sm:flex-row sm:items-center sm:gap-8"
)}
>
- {!isContinuousSingleQuestion && (
-
-
-
- )}
- {!isFanGraph && (
+
+
+
+ {!isFanGraph && !isDateGroup && (
- )}
- {isFanGraph && isGroupOfQuestionsPost(postData) && (
-
)}
{showClosedMessageFanGraph && (
@@ -250,10 +233,12 @@ export const ConsumerShell: FC<{
)}
- {shouldShowKeyFactorsSection && (
-
- )}
+ {shouldShowKeyFactorsSection && (
+
+
+
+ )}
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
index 19e7bbdef5..d3645e67cf 100644
--- 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
@@ -7,7 +7,6 @@ import { useTranslations } from "next-intl";
import { FC } from "react";
import ForecastersCounter from "@/app/(main)/questions/components/forecaster_counter";
-import TrophyIcon from "@/components/icons/trophy";
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";
@@ -16,12 +15,14 @@ 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, TaxonomyProjectType, TournamentType } from "@/types/projects";
+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";
@@ -52,28 +53,6 @@ const MetaRow: FC = ({ post, className, variant }) => {
const visibleChips = allProjects.slice(0, maxVisibleChips);
const hiddenChips = allProjects.slice(maxVisibleChips);
- const getChipContent = (element: Project) => {
- if (
- element.type === TournamentType.Tournament ||
- element.type === TaxonomyProjectType.LeaderboardTag
- ) {
- return (
-
-
- {element.name}
-
- );
- }
- return {element.name};
- };
-
- const chipColor = (element: Project) =>
- Object.values(TaxonomyProjectType).includes(
- element.type as TaxonomyProjectType
- )
- ? "olive"
- : "orange";
-
return (
{/* Mobile row */}
@@ -84,12 +63,15 @@ const MetaRow: FC
= ({ post, className, variant }) => {
)}
>
- {variant === "forecaster" &&
}
+ {variant === "forecaster" &&
}
{variant === "forecaster" && (
@@ -143,7 +125,7 @@ const MetaRow: FC
= ({ post, className, variant }) => {
{visibleChips.map((element) => (
= ({ post, className, variant }) => {
{hiddenChips.map((element) => (
{
+ 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
index 04de216da4..82469e928b 100644
--- 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
@@ -4,11 +4,13 @@ import { useTranslations } from "next-intl";
import { FC } from "react";
import { Tabs, TabsList, TabsTab } from "@/components/ui/tabs";
-import { PostWithForecasts } from "@/types/post";
+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"
@@ -16,7 +18,9 @@ type TabKey =
| "info"
| "my-scores"
| "question-links"
- | "private-notes";
+ | "private-notes"
+ | "timeline"
+ | "similar-questions";
type TabDef = {
key: TabKey;
@@ -33,7 +37,7 @@ type Props = {
const tabClassName = (isActive: boolean) =>
cn(
"border-[1.25px] border-solid rounded-full font-medium transition-colors",
- "text-base leading-6 px-[15px] py-[5px] sm:text-base sm:leading-6 sm:px-[15px] sm:py-[5px]",
+ "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"
@@ -43,9 +47,12 @@ 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 },
@@ -53,23 +60,37 @@ const QuestionPageShellTabBar: FC = ({ post, variant, className }) => {
{ 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 tabs: TabDef[] =
- variant === "forecaster"
- ? forecasterTabs
- : [
- { key: "comments", label: t("comments"), count: commentCount },
+ 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: "key-factors",
- label: t("keyFactors"),
- count: keyFactorsCount,
+ key: "similar-questions" as TabKey,
+ label: t("similarQuestions"),
},
- { key: "info", label: t("info") },
- ...(hasScores
- ? [{ key: "my-scores" as TabKey, label: t("myScores") }]
- : []),
- ];
+ ]
+ : []),
+ ];
+
+ const tabs = variant === "forecaster" ? forecasterTabs : consumerTabs;
const defaultValue: TabKey = "comments";
const active =
@@ -84,7 +105,7 @@ const QuestionPageShellTabBar: FC = ({ post, variant, className }) => {
onChange={setActiveTab}
className={cn("bg-transparent dark:bg-transparent", className)}
>
-
+
{tabs.map((tab) => (
{
switch (activeTab) {
+ case "similar-questions":
+ return ;
+ case "timeline":
+ return variant === "consumer" ? : null;
case "key-factors":
return ;
case "info":
@@ -57,7 +63,9 @@ const QuestionPageShellTabs: FC = ({ post, variant, className }) => {
)}
- {renderActivePanel(activeTab, post, variant)}
+
+ {renderActivePanel(activeTab, post, variant)}
+
);
};
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
index 4126b0fe7b..fa806df186 100644
--- 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
@@ -2,17 +2,16 @@
import { FC } from "react";
+import CommentFeed from "@/components/comment_feed";
import { PostWithForecasts } from "@/types/post";
-import ResponsiveCommentFeed from "../../question_layout/consumer_question_layout/responsive_comment_feed";
-
type Props = {
post: PostWithForecasts;
};
const CommentsTab: FC = ({ post }) => (
-
+
);
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
index c3b035429b..abb70ab170 100644
--- 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
@@ -6,11 +6,11 @@ import { FC } from "react";
import MarkdownEditor from "@/components/markdown_editor";
import Chip from "@/components/ui/chip";
import { PostWithForecasts } from "@/types/post";
-import { TaxonomyProjectType } from "@/types/projects";
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;
@@ -18,9 +18,6 @@ type Props = {
type MarkdownSection = { title: string; markdown: string };
-const getChipText = (name: string, type?: string) =>
- type === "leaderboard_tag" ? `🏆 ${name}` : name;
-
const useTextSections = (post: PostWithForecasts): MarkdownSection[] => {
const t = useTranslations();
@@ -109,22 +106,17 @@ const QuestionInfoTab: FC = ({ post }) => {
{allProjects.map((element) => (
sendAnalyticsEvent("questionTagClicked", {
event_category: element.name,
})
}
>
- {getChipText(element.name, element.type)}
+ {getChipContent(element)}
))}
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
index 70a68b8121..69b40e9597 100644
--- 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
@@ -41,7 +41,7 @@ const TitleRow: FC = ({ post, variant, className }) => {
>
-
+
{post.title}
@@ -69,6 +69,7 @@ const TitleRow: FC
= ({ post, variant, className }) => {
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
index 627db46d37..e02315914f 100644
--- 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
@@ -70,7 +70,7 @@ const ActionRow: FC = ({ post, variant }) => {
return (
= ({ 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 bc8f7bfaca..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 && (
<>
-
-
+
+
= ({
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) {
@@ -66,7 +62,27 @@ const QuestionTimeline: React.FC = ({
return (
{isDateType ? (
-
+
+
+
+
+ ) : isFanGraph ? (
+
+
+
+
) : (
= ({
variant = "forecaster",
}) => {
if (layout === "mobile") {
- return (
-
- {variant === "forecaster" && (
-
-
-
- )}
-
- {postData.curation_status === PostStatus.APPROVED && (
-
-
-
- )}
-
- );
+ return null;
}
return (
@@ -48,7 +35,7 @@ const Sidebar: FC = ({
)}
{postData.curation_status === PostStatus.APPROVED && (
-
+ }>
)}
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 ba3673cba4..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,6 +2,7 @@ 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 SimilarQuestionsList from "./similar_questions_list";
@@ -11,7 +12,24 @@ type Props = {
};
const SimilarQuestions: FC = async ({ post_id, variant }) => {
- const questions = await ServerPostsApi.getSimilarPosts(post_id);
+ 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;
diff --git a/front_end/src/components/charts/group_chart.tsx b/front_end/src/components/charts/group_chart.tsx
index 72c1f40d88..bc6d9bc5d9 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);
diff --git a/front_end/src/components/charts/primitives/x_tick_label.tsx b/front_end/src/components/charts/primitives/x_tick_label.tsx
index e86605402c..220e898fd9 100644
--- a/front_end/src/components/charts/primitives/x_tick_label.tsx
+++ b/front_end/src/components/charts/primitives/x_tick_label.tsx
@@ -20,18 +20,20 @@ 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 (
+ withCursor ? x > chartWidth - estimatedTextWidth : x > chartWidth - 12
+ ) {
+ textAnchor = "end";
}
return (
= ({
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 &&
diff --git a/front_end/src/components/comment_feed/index.tsx b/front_end/src/components/comment_feed/index.tsx
index 85f18114ed..b7d116d1c9 100644
--- a/front_end/src/components/comment_feed/index.tsx
+++ b/front_end/src/components/comment_feed/index.tsx
@@ -410,7 +410,7 @@ const CommentFeed: FC = ({
)}
)}
-
+
{totalCount ? `${totalCount} ` : ""}
{t("commentsWithCount", { count: totalCount })}
@@ -427,7 +427,7 @@ const CommentFeed: FC = ({
{menuItems.find((item) => item.id === feedFilters.sort)?.name ??
"sort"}
@@ -453,7 +453,7 @@ const CommentFeed: FC = ({
)}
>
)}
-
+
{comments.map((comment: CommentType) => (
= ({ className, post, questionPage, compact }) => {
{vote.score != null && vote.score !== 0 && (
diff --git a/front_end/src/components/ui/section_toggle.tsx b/front_end/src/components/ui/section_toggle.tsx
index 731b71958e..fb6a77df98 100644
--- a/front_end/src/components/ui/section_toggle.tsx
+++ b/front_end/src/components/ui/section_toggle.tsx
@@ -120,8 +120,8 @@ const iconVariants = cva("h-4 duration-75 ease-linear print:hidden", {
dark: "text-blue-900 dark:text-blue-900-dark",
},
open: {
- true: "rotate-180",
- false: null,
+ true: null,
+ false: "-rotate-90",
},
},
defaultVariants: {
diff --git a/posts/views.py b/posts/views.py
index 84f8a7fadb..e416db8ffb 100644
--- a/posts/views.py
+++ b/posts/views.py
@@ -242,7 +242,7 @@ def post_detail(request: Request, pk):
include_cp_history=True,
include_movements=True,
include_average_scores=True,
- include_user_forecasts=True
+ include_user_forecasts=True,
)
if not posts:
From ec7bbe121da9f3d5577cdeb7364208a26e827650 Mon Sep 17 00:00:00 2001
From: Nikita
Date: Mon, 11 May 2026 11:30:05 +0300
Subject: [PATCH 11/11] fix: Czech translation, user scores resolution guard,
tab bar desync on resize, chart label right-boundary overflow, compact UI
regressions, and Tailwind class typo in comment status
---
front_end/messages/cs.json | 2 +-
.../[id]/components/post_score_data/utils.ts | 6 ++-
.../question_page_shell/tab_bar.tsx | 9 +++-
.../src/components/charts/group_chart.tsx | 1 -
.../charts/multiple_choice_chart.tsx | 3 +-
.../src/components/charts/numeric_chart.tsx | 1 -
.../charts/primitives/x_tick_label.tsx | 6 +--
.../src/components/comment_feed/index.tsx | 52 ++++++++++---------
.../basic_post_card/comment_status.tsx | 2 +-
.../post_card/basic_post_card/post_voter.tsx | 2 +-
10 files changed, 45 insertions(+), 39 deletions(-)
diff --git a/front_end/messages/cs.json b/front_end/messages/cs.json
index 0a1cd6fd24..ec789e9761 100644
--- a/front_end/messages/cs.json
+++ b/front_end/messages/cs.json
@@ -1820,7 +1820,7 @@
"loadMore": "Načíst více",
"noPrivateNotes": "Žádné soukromé poznámky",
"privateNotes": "Soukromé poznámky",
- "questionLinks": "Odkazy otázky",
+ "questionLinks": "Odkazy na otázku",
"justNow": "právě teď",
"cmmButtonShort": "Mysl",
"FABRegisterBot": "Zaregistrujte se, abyste přihlásili svého robota do turnaje",
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 2cbf3e32b4..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
@@ -24,7 +24,11 @@ export const shouldQuestionShowUserScores = (
question: QuestionWithForecasts
) => {
const userScores = question.my_forecasts?.score_data;
- return !isNil(userScores) && Object.keys(userScores).length > 0;
+ return (
+ !isNil(userScores) &&
+ Object.keys(userScores).length > 0 &&
+ !isUnsuccessfullyResolved(question.resolution)
+ );
};
function someQuestionIn(
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
index 82469e928b..130779263e 100644
--- 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
@@ -1,7 +1,7 @@
"use client";
import { useTranslations } from "next-intl";
-import { FC } from "react";
+import { FC, useEffect } from "react";
import { Tabs, TabsList, TabsTab } from "@/components/ui/tabs";
import { useBreakpoint } from "@/hooks/tailwind";
@@ -98,6 +98,13 @@ const QuestionPageShellTabBar: FC = ({ post, variant, className }) => {
? 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 (
= ({
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
@@ -23,9 +21,7 @@ const XTickLabel: FC = ({
let textAnchor: "start" | "middle" | "end" = "middle";
if (x - estimatedTextWidth < 0) {
textAnchor = "start";
- } else if (
- withCursor ? x > chartWidth - estimatedTextWidth : x > chartWidth - 12
- ) {
+ } else if (x > chartWidth - estimatedTextWidth) {
textAnchor = "end";
}
diff --git a/front_end/src/components/comment_feed/index.tsx b/front_end/src/components/comment_feed/index.tsx
index b7d116d1c9..2dcddad8d8 100644
--- a/front_end/src/components/comment_feed/index.tsx
+++ b/front_end/src/components/comment_feed/index.tsx
@@ -410,31 +410,33 @@ const CommentFeed: FC = ({
)}
)}
-
-
- {totalCount ? `${totalCount} ` : ""}
- {t("commentsWithCount", { count: totalCount })}
- {postData?.last_viewed_at && (
- <>
- {getUnreadCount(comments) > 0 && (
-
- ({getUnreadCount(comments)} {t("unread")})
-
- )}
- >
- )}
-
-
-
- {menuItems.find((item) => item.id === feedFilters.sort)?.name ??
- "sort"}
-
-
-
-
+ {!compactVersion && (
+
+
+ {totalCount ? `${totalCount} ` : ""}
+ {t("commentsWithCount", { count: totalCount })}
+ {postData?.last_viewed_at && (
+ <>
+ {getUnreadCount(comments) > 0 && (
+
+ ({getUnreadCount(comments)} {t("unread")})
+
+ )}
+ >
+ )}
+
+
+
+ {menuItems.find((item) => item.id === feedFilters.sort)?.name ??
+ "sort"}
+
+
+
+
+ )}
{!compactVersion && postId && !user?.is_bot && (
<>
{showWelcomeMessage && !getIsMessagePreviouslyClosed() ? null : (
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 214fcc75c3..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
= ({
= ({ className, post, questionPage, compact }) => {
{vote.score != null && vote.score !== 0 && (