diff --git a/Claude.md b/Claude.md index b64d4a2a9a..fc33845865 100644 --- a/Claude.md +++ b/Claude.md @@ -10,6 +10,7 @@ - Check the existing code style and follow it - Destructure imports when possible (eg. import { foo } from 'bar') - Do not add excesive comments. Add comments only to document what would be surprising to a senior engineer. +- For any frontend content visible to the user, use the translation mechanism used across the whole frontend.`const t = useTranslations()` and then `t("stringKey")` while addingt the "stringKey" to all the correspondong language files (en.json, es.json, etc). # Workflow - Be sure to run the linter, type checker, formatter and try to build the code when you’re done making a series of code changes. \ No newline at end of file diff --git a/front_end/messages/cs.json b/front_end/messages/cs.json index 26a131762d..e3ed021e33 100644 --- a/front_end/messages/cs.json +++ b/front_end/messages/cs.json @@ -638,6 +638,8 @@ "newsLetter": "Newsletter", "research": "Výzkum", "updates": "Aktualizace", + "researchAndUpdates": "Výzkum a aktualizace", + "seeMore": "Zobrazit více", "posts": "příspěvky", "notebook": "notebook", "notebookExample": "obsah založený na textu, který není otázkou", @@ -1747,5 +1749,47 @@ "noPrivateNotes": "Žádné soukromé poznámky", "privateNotes": "Soukromé poznámky", "justNow": "právě teď", - "othersCount": "Ostatní ({count})" + "staffPicks": "Výběr personálu", + "othersCount": "Ostatní ({count})", + "forIndividuals": "Pro jednotlivce", + "heroIndividualsTitle": "Rozhodujte se na základě důvěryhodných komunitních předpovědí", + "exploreQuestions": "Prozkoumat otázky", + "heroIndividualsDescription": "Získejte spolehlivé informace o tématech, která vás zajímají", + "forBusinesses": "Pro firmy", + "partnerWithMetaculus": "Spolupracujte s Metaculus", + "hireProForecasters": "Najměte profesionální prognostiky", + "hireProForecastersDescription": "Získejte odborné předpovědi pro vaše klíčové otázky", + "hostPrivateInstances": "Hostujte soukromé instance", + "hostPrivateInstancesDescription": "Objevte poznatky z vaší organizace", + "whatsMetaculus": "Co je Metaculus?", + "metaculusDescription": "Metaculus je online platforma pro předpovídání a agregační nástroj, který pracuje na zlepšení lidského uvažování a koordinace v tématech globálního významu.", + "openQuestions": "Otevřené otázky", + "forecastsSubmitted": "Odeslaných předpovědí", + "yearsOfPrediction": "Let předpovídání", + "featuredIn": "Zmíněno v", + "popular": "Populární", + "exploreAll": "Prozkoumat vše", + "exploreNTournaments": "Prozkoumat {count} turnajů", + "metaculusFutureEval": "Metaculus FutureEval", + "futureEvalDescription": "FutureEval měří schopnost AI předpovídat budoucí události. Je zaručeně odolné proti únikům.", + "futureEvalTagline": "Používáme předpovídání jako způsob hodnocení rozumování ve srovnání s realitou.", + "modelLeaderboard": "Žebříček modelů", + "modelLeaderboardDescription": "Spouštíme všechny hlavní modely s jednoduchým výzvou na většinu otevřených otázek předpovídání na Metaculus a sbíráme jejich předpovědi.", + "botsVsHumans": "Boti vs Lidé", + "botsVsHumansDescription": "Pořádáme pravidelné turnaje otevřené pro všechny tvůrce, kteří soutěží na základě přesnosti svého bota proti sobě navzájem a proti nejlepším lidským předpovídačům.", + "quarterlyBiweeklyTournaments": "Čtvrtletní a dvoutýdenní turnaje", + "tournamentsDescriptionPart1": "Připojte se k více než 100 týmům a jednotlivým tvůrcům botů, kteří soutěží o cenový fond ve výši 50 000 dolarů.", + "theFall2025": "Podzim 2025", + "tournamentsDescriptionPart2": "turnaj je živý, nebo se připojte", + "miniBench": "MiniBench", + "leaderboardDataNotAvailable": "Údaje o žebříčku nejsou momentálně k dispozici, prosím, zkontrolujte později!", + "viewLess": "Zobrazit méně", + "explore": "Prozkoumat", + "company": "Společnost", + "resources": "Zdroje", + "publicBenefitCorporation": "Obecně prospěšná společnost", + "tournamentsForAIBots": "Turnaje pro AI roboty", + "futureEval": "Budoucí posouzení", + "launchATournament": "Spusťte turnaj", + "thousandsOfOpenQuestions": "10 000+ otevřených otázek" } diff --git a/front_end/messages/en.json b/front_end/messages/en.json index 9bd8855811..82c7a2b884 100644 --- a/front_end/messages/en.json +++ b/front_end/messages/en.json @@ -1,4 +1,25 @@ { + "exploreNTournaments": "Explore {count} tournaments", + "popular": "Popular", + "exploreAll": "Explore all", + "thousandsOfOpenQuestions": "10,000+ open questions", + "whatsMetaculus": "What's Metaculus?", + "metaculusDescription": "Metaculus is an online forecasting platform and aggregation engine working to improve human reasoning and coordination on topics of global importance.", + "openQuestions": "Open questions", + "forecastsSubmitted": "Forecasts submitted", + "yearsOfPrediction": "Years of prediction", + "featuredIn": "Featured in", + "forIndividuals": "For individuals", + "heroIndividualsTitle": "Make decisions based on trusted community forecasts", + "exploreQuestions": "Explore questions", + "heroIndividualsDescription": "Get reliable insights on the topics that matter to you", + "forBusinesses": "For businesses", + "partnerWithMetaculus": "Partner with Metaculus", + "hireProForecasters": "Hire Pro Forecasters", + "hireProForecastersDescription": "Get expert forecasts on your critical questions", + "hostPrivateInstances": "Host private instances", + "hostPrivateInstancesDescription": "Surface insights from within your organization", + "staffPicks": "Staff Picks", "current_week": "Current Week", "placementFirst": "1st place", "placementSecond": "2nd place", @@ -623,6 +644,12 @@ "termsOfUse": "Terms of Use", "faq": "FAQ", "contact": "Contact", + "company": "Company", + "resources": "Resources", + "publicBenefitCorporation": "Public Benefit Corporation", + "tournamentsForAIBots": "Tournaments for AI bots", + "futureEval": "FutureEval", + "launchATournament": "Launch a Tournament", "contactUs": "Contact Us", "thankYouForGettingInTouch": "Thank you for getting in touch. We’ll get back to you soon!", "yourEmail": "Your Email", @@ -849,6 +876,8 @@ "newsLetter": "Newsletter", "research": "Research", "updates": "Updates", + "researchAndUpdates": "Research and updates", + "seeMore": "See more", "posts": "posts", "notebook": "notebook", "existingQuestion": "Existing Question", @@ -1741,5 +1770,20 @@ "noPrivateNotes": "No private notes yet", "privateNotes": "Private Notes", "justNow": "just now", - "none": "none" + "none": "none", + "metaculusFutureEval": "Metaculus FutureEval", + "futureEvalDescription": "FutureEval measures AI's ability to predict future outcomes. It is guaranteed leak proof.", + "futureEvalTagline": "We use forecasting as a way to evaluate reasoning against reality.", + "modelLeaderboard": "Model leaderboard", + "modelLeaderboardDescription": "We run all major models with a simple prompt on most open Metaculus forecasting questions, and collect their forecasts.", + "botsVsHumans": "Bots vs Humans", + "botsVsHumansDescription": "We run regular tournaments, open to all builders who compete based on their bot's accuracy against both each other and top human forecasters.", + "quarterlyBiweeklyTournaments": "Quarterly and Bi-weekly tournaments", + "tournamentsDescriptionPart1": "Join 100+ teams and individual bot builders competing for $50,000 prize pool.", + "theFall2025": "The Fall 2025", + "tournamentsDescriptionPart2": "tournament is live or join", + "miniBench": "MiniBench", + "leaderboardDataNotAvailable": "Leaderboard data not currently available, please check back soon!", + "viewLess": "View less", + "explore": "Explore" } diff --git a/front_end/messages/es.json b/front_end/messages/es.json index beb94e5ba4..626a0c0022 100644 --- a/front_end/messages/es.json +++ b/front_end/messages/es.json @@ -640,6 +640,8 @@ "newsLetter": "Boletín", "research": "Investigación", "updates": "Actualizaciones", + "researchAndUpdates": "Investigación y actualizaciones", + "seeMore": "Ver más", "posts": "publicaciones", "notebook": "notebook", "notebookExample": "contenido basado en texto que no es una pregunta", @@ -1747,5 +1749,47 @@ "noPrivateNotes": "Aún no hay notas privadas", "privateNotes": "Notas privadas", "justNow": "justo ahora", - "othersCount": "Otros ({count})" + "staffPicks": "Selecciones del personal", + "othersCount": "Otros ({count})", + "forIndividuals": "Para individuos", + "heroIndividualsTitle": "Toma decisiones basadas en pronósticos comunitarios confiables", + "exploreQuestions": "Explorar preguntas", + "heroIndividualsDescription": "Obtén información confiable sobre los temas que te importan", + "forBusinesses": "Para empresas", + "partnerWithMetaculus": "Colabora con Metaculus", + "hireProForecasters": "Contrata pronosticadores profesionales", + "hireProForecastersDescription": "Obtén pronósticos expertos para tus preguntas críticas", + "hostPrivateInstances": "Aloja instancias privadas", + "hostPrivateInstancesDescription": "Descubre información desde dentro de tu organización", + "whatsMetaculus": "¿Qué es Metaculus?", + "metaculusDescription": "Metaculus es una plataforma de pronósticos en línea y motor de agregación que trabaja para mejorar el razonamiento humano y la coordinación en temas de importancia global.", + "openQuestions": "Preguntas abiertas", + "forecastsSubmitted": "Pronósticos enviados", + "yearsOfPrediction": "Años de predicción", + "featuredIn": "Destacado en", + "popular": "Popular", + "exploreAll": "Explorar todo", + "exploreNTournaments": "Explorar {count} torneos", + "metaculusFutureEval": "Metaculus FutureEval", + "futureEvalDescription": "FutureEval mide la capacidad de la inteligencia artificial para predecir resultados futuros. Está garantizado que no tiene fugas.", + "futureEvalTagline": "Usamos la previsión como una forma de evaluar el razonamiento frente a la realidad.", + "modelLeaderboard": "Clasificación de modelos", + "modelLeaderboardDescription": "Probaremos todos los modelos principales con un simple aviso en la mayoría de las preguntas de previsión abiertas de Metaculus, y recogeremos sus previsiones.", + "botsVsHumans": "Bots vs Humanos", + "botsVsHumansDescription": "Organizamos torneos regulares, abiertos a todos los desarrolladores que compitan según la precisión de su bot, tanto entre ellos como frente a los principales pronosticadores humanos.", + "quarterlyBiweeklyTournaments": "Torneos trimestrales y quincenales", + "tournamentsDescriptionPart1": "Únete a más de 100 equipos y constructores de bots individuales que compiten por un premio acumulado de $50,000.", + "theFall2025": "El Otoño 2025", + "tournamentsDescriptionPart2": "el torneo está en vivo o únete", + "miniBench": "MiniBench", + "leaderboardDataNotAvailable": "Datos de clasificación actualmente no disponibles, ¡por favor vuelva pronto!", + "viewLess": "Ver menos", + "explore": "Explorar", + "company": "Empresa", + "resources": "Recursos", + "publicBenefitCorporation": "Corporación de Beneficio Público", + "tournamentsForAIBots": "Torneos para bots de IA", + "futureEval": "EvaluaciónFutura", + "launchATournament": "Iniciar un Torneo", + "thousandsOfOpenQuestions": "10,000+ preguntas abiertas" } diff --git a/front_end/messages/pt.json b/front_end/messages/pt.json index 08b0670c10..1fe0b9f23a 100644 --- a/front_end/messages/pt.json +++ b/front_end/messages/pt.json @@ -717,6 +717,8 @@ "newsLetter": "Newsletter", "research": "Pesquisa", "updates": "Atualizações", + "researchAndUpdates": "Pesquisa e atualizações", + "seeMore": "Ver mais", "posts": "postagens", "notebook": "caderno", "notebookExample": "conteúdo baseado em texto que não é uma pergunta", @@ -1745,5 +1747,47 @@ "noPrivateNotes": "Ainda não há notas privadas", "privateNotes": "Notas Privadas", "justNow": "agora mesmo", - "othersCount": "Outros ({count})" + "staffPicks": "Escolhas da Equipe", + "othersCount": "Outros ({count})", + "forIndividuals": "Para indivíduos", + "heroIndividualsTitle": "Tome decisões com base em previsões comunitárias confiáveis", + "exploreQuestions": "Explorar perguntas", + "heroIndividualsDescription": "Obtenha informações confiáveis sobre os temas que importam para você", + "forBusinesses": "Para empresas", + "partnerWithMetaculus": "Parceria com Metaculus", + "hireProForecasters": "Contrate previsores profissionais", + "hireProForecastersDescription": "Obtenha previsões especializadas para suas questões críticas", + "hostPrivateInstances": "Hospede instâncias privadas", + "hostPrivateInstancesDescription": "Descubra insights de dentro da sua organização", + "whatsMetaculus": "O que é Metaculus?", + "metaculusDescription": "Metaculus é uma plataforma de previsões online e motor de agregação que trabalha para melhorar o raciocínio humano e a coordenação em temas de importância global.", + "openQuestions": "Perguntas abertas", + "forecastsSubmitted": "Previsões enviadas", + "yearsOfPrediction": "Anos de previsão", + "featuredIn": "Destaque em", + "popular": "Popular", + "exploreAll": "Explorar tudo", + "exploreNTournaments": "Explore {count} torneios", + "metaculusFutureEval": "Metaculus FutureEval", + "futureEvalDescription": "FutureEval mede a capacidade da IA de prever resultados futuros. É garantido à prova de vazamento.", + "futureEvalTagline": "Usamos previsão como uma forma de avaliar o raciocínio em relação à realidade.", + "modelLeaderboard": "Tabela de classificação dos modelos", + "modelLeaderboardDescription": "Executamos todos os grandes modelos com um prompt simples em maioria das perguntas abertas de previsão do Metaculus, e coletamos suas previsões.", + "botsVsHumans": "Bots vs Humanos", + "botsVsHumansDescription": "Realizamos torneios regulares, abertos a todos os criadores que competem com base na precisão do bot contra outros e os melhores preditores humanos.", + "quarterlyBiweeklyTournaments": "Torneios trimestrais e quinzenais", + "tournamentsDescriptionPart1": "Junte-se a mais de 100 equipes e criadores de bots individuais competindo por um prêmio total de $50,000.", + "theFall2025": "O Outono 2025", + "tournamentsDescriptionPart2": "torneio está ativo ou junte-se", + "miniBench": "MiniBench", + "leaderboardDataNotAvailable": "Dados da tabela de classificação não estão disponíveis no momento, por favor, volte em breve!", + "viewLess": "Ver menos", + "explore": "Explorar", + "company": "Empresa", + "resources": "Recursos", + "publicBenefitCorporation": "Corporação de Benefício Público", + "tournamentsForAIBots": "Torneios para Bots de IA", + "futureEval": "FutureEval", + "launchATournament": "Lançar um Torneio", + "thousandsOfOpenQuestions": "10.000+ perguntas abertas" } diff --git a/front_end/messages/zh-TW.json b/front_end/messages/zh-TW.json index b366547944..e6ce7fb45b 100644 --- a/front_end/messages/zh-TW.json +++ b/front_end/messages/zh-TW.json @@ -769,6 +769,8 @@ "newsLetter": "新聞信", "research": "研究", "updates": "更新", + "researchAndUpdates": "研究和更新", + "seeMore": "查看更多", "posts": "發帖", "notebook": "筆記本", "existingQuestion": "現有問題", @@ -1744,5 +1746,47 @@ "noPrivateNotes": "尚無私人筆記", "privateNotes": "私人筆記", "justNow": "剛剛", - "withdrawAfterPercentSetting2": "問題總生命周期後撤回" + "staffPicks": "員工推薦", + "withdrawAfterPercentSetting2": "問題總生命周期後撤回", + "forIndividuals": "適用於個人", + "heroIndividualsTitle": "根據可信賴的社群預測做出決策", + "exploreQuestions": "探索問題", + "heroIndividualsDescription": "獲取您關心話題的可靠見解", + "forBusinesses": "適用於企業", + "partnerWithMetaculus": "與 Metaculus 合作", + "hireProForecasters": "聘請專業預測師", + "hireProForecastersDescription": "為您的關鍵問題獲取專家預測", + "hostPrivateInstances": "託管私有實例", + "hostPrivateInstancesDescription": "從您的組織內部發掘見解", + "whatsMetaculus": "什麼是 Metaculus?", + "metaculusDescription": "Metaculus 是一個線上預測平台和聚合引擎,致力於改善人類在全球重要議題上的推理和協調能力。", + "openQuestions": "開放問題", + "forecastsSubmitted": "已提交預測", + "yearsOfPrediction": "預測年數", + "featuredIn": "媒體報導", + "popular": "熱門", + "exploreAll": "探索全部", + "exploreNTournaments": "探索 {count} 場比賽", + "metaculusFutureEval": "Metaculus 未來評估", + "futureEvalDescription": "未來評估測量 AI 預測未來結果的能力,並保證不會洩漏。", + "futureEvalTagline": "我們使用預測作為評估推理與現實對比的方法。", + "modelLeaderboard": "模型排行榜", + "modelLeaderboardDescription": "我們在大多數開放的 Metaculus 預測問題上使用簡單提示運行所有主要模型,並收集其預測結果。", + "botsVsHumans": "機器人對人類", + "botsVsHumansDescription": "我們定期舉辦比賽,對所有根據其機器人的準確性相互競爭以及與頂尖人類預測者競爭的建造者開放。", + "quarterlyBiweeklyTournaments": "季度和雙周比賽", + "tournamentsDescriptionPart1": "加入100多個團隊和個人機器人建造者,競爭50,000美元獎金池。", + "theFall2025": "2025年秋季", + "tournamentsDescriptionPart2": "比賽正在進行或加入", + "miniBench": "迷你測試", + "leaderboardDataNotAvailable": "排行榜數據目前不可用,請稍後再檢查!", + "viewLess": "查看較少", + "explore": "探索", + "company": "公司", + "resources": "資源", + "publicBenefitCorporation": "公益公司", + "tournamentsForAIBots": "AI 機器人比賽", + "futureEval": "未來評估", + "launchATournament": "發起比賽", + "thousandsOfOpenQuestions": "10,000+ 開放問題" } diff --git a/front_end/messages/zh.json b/front_end/messages/zh.json index 1f9390681a..85067d844c 100644 --- a/front_end/messages/zh.json +++ b/front_end/messages/zh.json @@ -629,6 +629,8 @@ "newsLetter": "新聞簡報", "research": "研究", "updates": "更新", + "researchAndUpdates": "研究和更新", + "seeMore": "查看更多", "posts": "帖子", "notebook": "筆記本", "notebookExample": "非問題形式的文本內容", @@ -1749,5 +1751,47 @@ "noPrivateNotes": "尚無私人筆記", "privateNotes": "私人筆記", "justNow": "刚刚", - "othersCount": "其他({count})" + "staffPicks": "员工精选", + "othersCount": "其他({count})", + "forIndividuals": "适用于个人", + "heroIndividualsTitle": "根据可信赖的社区预测做出决策", + "exploreQuestions": "探索问题", + "heroIndividualsDescription": "获取您关心话题的可靠见解", + "forBusinesses": "适用于企业", + "partnerWithMetaculus": "与 Metaculus 合作", + "hireProForecasters": "聘请专业预测师", + "hireProForecastersDescription": "为您的关键问题获取专家预测", + "hostPrivateInstances": "托管私有实例", + "hostPrivateInstancesDescription": "从您的组织内部发掘见解", + "whatsMetaculus": "什么是 Metaculus?", + "metaculusDescription": "Metaculus 是一个在线预测平台和聚合引擎,致力于改善人类在全球重要议题上的推理和协调能力。", + "openQuestions": "开放问题", + "forecastsSubmitted": "已提交预测", + "yearsOfPrediction": "预测年数", + "featuredIn": "媒体报道", + "popular": "热门", + "exploreAll": "探索全部", + "exploreNTournaments": "探索 {count} 个锦标赛", + "metaculusFutureEval": "Metaculus未来评估", + "futureEvalDescription": "未来评估测量AI预测未来结果的能力。它保证是防泄漏的。", + "futureEvalTagline": "我们使用预测作为评估推理与现实的方式。", + "modelLeaderboard": "模型排行榜", + "modelLeaderboardDescription": "我们在大多数开放的Metaculus预测问题上使用简单提示运行所有主要模型,并收集它们的预测。", + "botsVsHumans": "机器人对抗人类", + "botsVsHumansDescription": "我们定期举办锦标赛,所有创建者都可以参加,这些比赛根据他们的机器人的准确性与顶级人类预测者和其他机器人竞争。", + "quarterlyBiweeklyTournaments": "季度和双周锦标赛", + "tournamentsDescriptionPart1": "加入100多个团队和个人机器人创建者,争夺50,000美元的奖金池。", + "theFall2025": "2025年秋季", + "tournamentsDescriptionPart2": "锦标赛正在进行中或加入", + "miniBench": "MiniBench", + "leaderboardDataNotAvailable": "排行榜数据暂时不可用,请稍后再来查看!", + "viewLess": "查看更少", + "explore": "探索", + "company": "公司", + "resources": "资源", + "publicBenefitCorporation": "公益公司", + "tournamentsForAIBots": "AI机器人比赛", + "futureEval": "未来评估", + "launchATournament": "发起比赛", + "thousandsOfOpenQuestions": "10,000+ 开放问题" } diff --git a/front_end/public/images/pie-chart.png b/front_end/public/images/pie-chart.png new file mode 100644 index 0000000000..bf7baedf6c Binary files /dev/null and b/front_end/public/images/pie-chart.png differ diff --git a/front_end/public/images/puzzle.png b/front_end/public/images/puzzle.png new file mode 100644 index 0000000000..2915fef591 Binary files /dev/null and b/front_end/public/images/puzzle.png differ diff --git a/front_end/src/app/(main)/(home)/new/components/all_categories_section.tsx b/front_end/src/app/(main)/(home)/new/components/all_categories_section.tsx new file mode 100644 index 0000000000..5e03b7cb59 --- /dev/null +++ b/front_end/src/app/(main)/(home)/new/components/all_categories_section.tsx @@ -0,0 +1,81 @@ +import Link from "next/link"; +import { getTranslations } from "next-intl/server"; +import { FC } from "react"; + +import { POST_CATEGORIES_FILTER } from "@/constants/posts_feed"; +import { Category } from "@/types/projects"; +import cn from "@/utils/core/cn"; + +type CategoryWithPosts = Category & { posts: string[] }; + +type Props = { + categories: CategoryWithPosts[]; + className?: string; +}; + +const AllCategoriesSection: FC = async ({ categories, className }) => { + const t = await getTranslations(); + + if (!categories || categories.length === 0) { + return null; + } + + const sortedCategories = [...categories] + .filter((c) => c && c.name) + .sort((a, b) => a.name.localeCompare(b.name)); + + return ( +
+

+ {t("allCategories")} +

+
+ {sortedCategories.map((category) => ( + + ))} +
+
+ ); +}; + +type CategoryCardProps = { + category: CategoryWithPosts; +}; + +const CategoryCard: FC = ({ category }) => { + const categoryUrl = `/questions/?${POST_CATEGORIES_FILTER}=${category.slug}&for_main_feed=false`; + + return ( +
+ + {category.emoji} + + {category.name} + + + + {category.posts && category.posts.length > 0 && ( +
+ {category.posts.slice(0, 3).map((title, index) => ( +
+
+ + {index + 1}. + + {title} +
+ {index < Math.min(category.posts.length, 3) - 1 && ( +
+ )} +
+ ))} +
+ )} +
+ ); +}; + +export default AllCategoriesSection; diff --git a/front_end/src/app/(main)/(home)/new/components/future_eval_section.tsx b/front_end/src/app/(main)/(home)/new/components/future_eval_section.tsx new file mode 100644 index 0000000000..73fd42845e --- /dev/null +++ b/front_end/src/app/(main)/(home)/new/components/future_eval_section.tsx @@ -0,0 +1,220 @@ +import Link from "next/link"; +import { getTranslations } from "next-intl/server"; +import { FC } from "react"; + +import WithServerComponentErrorBoundary from "@/components/server_component_error_boundary"; +import Button from "@/components/ui/button"; +import ServerLeaderboardApi from "@/services/api/leaderboard/leaderboard.server"; +import cn from "@/utils/core/cn"; + +import FutureEvalTable from "./future_eval_table"; + +const ListStarIcon = () => ( + + + + + + + + + + + + + + + +); + +const PersonIcon = () => ( + + + + + + + + + + + +); + +const TrophyIcon = () => ( + + + + + + + + + + +); + +type FeatureItemProps = { + icon: React.ReactNode; + title: string; + description: React.ReactNode; +}; + +const FeatureItem: FC = ({ icon, title, description }) => ( +
+
+ {icon} +
+
+

+ {title} +

+

+ {description} +

+
+
+); + +const FutureEvalSection: FC<{ className?: string }> = async ({ className }) => { + const t = await getTranslations(); + const data = await ServerLeaderboardApi.getGlobalLeaderboard( + null, + null, + "manual", + "Global Bot Leaderboard" + ); + + const hasData = data?.entries?.length > 0; + if (!hasData) { + return null; + } + + return ( +
+ {/* Header */} +
+
+

+ {t("metaculusFutureEval")} +

+

+ {t("futureEvalDescription")} +

+
+ +
+ + {/* Content */} +
+ {/* Info box - determines container height */} +
+
+

+ {t("futureEvalTagline")} +

+
+ } + title={t("modelLeaderboard")} + description={t("modelLeaderboardDescription")} + /> + } + title={t("botsVsHumans")} + description={t("botsVsHumansDescription")} + /> + } + title={t("quarterlyBiweeklyTournaments")} + description={ + <> + {t("tournamentsDescriptionPart1")}{" "} + + {t("theFall2025")} + {" "} + {t("tournamentsDescriptionPart2")}{" "} + + {t("miniBench")} + + . + + } + /> +
+
+
+ + {/* Table wrapper - stretches to match first child height, content scrolls */} +
+ +
+
+
+ ); +}; + +export default WithServerComponentErrorBoundary(FutureEvalSection); diff --git a/front_end/src/app/(main)/(home)/new/components/future_eval_table.tsx b/front_end/src/app/(main)/(home)/new/components/future_eval_table.tsx new file mode 100644 index 0000000000..0562a32397 --- /dev/null +++ b/front_end/src/app/(main)/(home)/new/components/future_eval_table.tsx @@ -0,0 +1,170 @@ +"use client"; + +import Link from "next/link"; +import { useTranslations } from "next-intl"; +import { useMemo, useState } from "react"; + +import MedalIcon from "@/app/(main)/(leaderboards)/components/medal_icon"; +import { + entryIconPair, + entryLabel, + shouldDisplayEntry, +} from "@/app/(main)/aib/components/aib/leaderboard/utils"; +import { LightDarkIcon } from "@/app/(main)/aib/components/aib/light-dark-icon"; +import type { LeaderboardDetails, MedalType } from "@/types/scoring"; +import cn from "@/utils/core/cn"; + +type Props = { details: LeaderboardDetails; className?: string }; + +const INITIAL_ROWS = 5; + +const MedalRow = ({ rank }: { rank: number }) => { + const medalType: MedalType = + rank === 1 ? "gold" : rank === 2 ? "silver" : "bronze"; + + return rank <= 3 ? ( + + ) : ( + + {rank} + + ); +}; +const FutureEvalTable: React.FC = ({ details, className }) => { + const t = useTranslations(); + const [expanded, setExpanded] = useState(false); + + const rows = useMemo(() => { + const entries = (details.entries ?? []) + .filter((e) => shouldDisplayEntry(e)) + .map((entry, i) => { + const label = entryLabel(entry, t); + const icons = entryIconPair(entry); + const userId = entry.user?.id; + return { + rank: i + 1, + label, + username: entry.user?.username ?? "", + icons, + forecasts: entry.contribution_count, + score: entry.score, + profileHref: userId ? `/accounts/profile/${userId}/` : null, + isAggregate: !entry.user?.username, + }; + }); + + return entries; + }, [details.entries, t]); + + const visibleRows = expanded ? rows : rows.slice(0, INITIAL_ROWS); + + return ( +
+
+ + + + + + + + + + + + + + + + + {visibleRows.map((r) => ( + + + + + + + + + ))} + +
{t("rank")}{t("aibLbThModel")}{t("score")} + {t("aibLbThForecasts")} +
+
+ +
+
+
+ {(r.icons.light || r.icons.dark) && ( + + )} +
+ {r.isAggregate || !r.profileHref ? ( + r.label + ) : ( + + {r.label} + + )} +
+
+
+ + {fmt(r.score, 2)} + + + + {r.forecasts} + +
+
+ {rows.length > INITIAL_ROWS && ( + + )} +
+ ); +}; + +const fmt = (n: number | null | undefined, d = 2) => + n == null || Number.isNaN(n) ? "—" : n.toFixed(d); + +const Th: React.FC> = ({ + className = "", + children, +}) => ( + + {children} + +); + +const Td: React.FC> = ({ + className = "", + children, +}) => {children}; + +export default FutureEvalTable; diff --git a/front_end/src/app/(main)/(home)/new/components/hero_ctas.tsx b/front_end/src/app/(main)/(home)/new/components/hero_ctas.tsx new file mode 100644 index 0000000000..4a771c72f2 --- /dev/null +++ b/front_end/src/app/(main)/(home)/new/components/hero_ctas.tsx @@ -0,0 +1,151 @@ +import Image from "next/image"; +import Link from "next/link"; +import { useTranslations } from "next-intl"; +import { FC, PropsWithChildren } from "react"; + +import Button from "@/components/ui/button"; +import cn from "@/utils/core/cn"; + +type HeroCTACardProps = { + href: string; + topTitle: string; + imageSrc: string; + imageAlt: string; + title: string; + buttonText: string; + bgColorClasses: string; + textColorClasses: string; + buttonClassName: string; +}; + +const HeroCTACard: FC> = ({ + href, + topTitle, + imageSrc, + imageAlt, + title, + children, + buttonText, + bgColorClasses, + textColorClasses, + buttonClassName, +}) => { + return ( +
+
+ {imageAlt} +
+ +

+ {topTitle} +

+
+
+

+ {title} +

+
{children}
+
+
+ + + +
+ ); +}; + +type Props = { + individualsHref?: string; + businessesHref?: string; +}; + +const HeroCTAs: FC = ({ + individualsHref = "/questions/", + businessesHref = "/services/", +}) => { + const t = useTranslations(); + return ( +
+ +

+ {t("heroIndividualsDescription")} +

+
+ + +
+
+

+ {t("hireProForecasters")} +

+

+ {t("hireProForecastersDescription")} +

+
+
+

+ {t("launchTournament")} +

+

+ {t("launchTournamentDescription")} +

+
+
+

+ {t("hostPrivateInstances")} +

+

+ {t("hostPrivateInstancesDescription")} +

+
+
+
+
+ ); +}; + +export default HeroCTAs; diff --git a/front_end/src/app/(main)/(home)/new/components/homepage_filters.ts b/front_end/src/app/(main)/(home)/new/components/homepage_filters.ts new file mode 100644 index 0000000000..491dc518c8 --- /dev/null +++ b/front_end/src/app/(main)/(home)/new/components/homepage_filters.ts @@ -0,0 +1,45 @@ +import { PostsParams } from "@/services/api/posts/posts.shared"; +import { PostForecastType } from "@/types/post"; +import { QuestionType } from "@/types/question"; + +export type TabId = "popular" | "news" | "new"; + +export const TABS: { id: TabId; label: string }[] = [ + { id: "popular", label: "Popular" }, + { id: "news", label: "In the news" }, + { id: "new", label: "New" }, +]; + +const allowedTypes = [ + QuestionType.Binary, + QuestionType.MultipleChoice, + QuestionType.Numeric, + QuestionType.Discrete, + QuestionType.Date, + PostForecastType.Group, +]; + +export const FILTERS: Record = { + popular: { + for_main_feed: "true", + for_consumer_view: "false", + order_by: "-hotness", + statuses: ["open"], + limit: 7, + forecast_type: allowedTypes, + }, + news: { + for_main_feed: "true", + statuses: "open", + order_by: "-news_hotness", + limit: 7, + forecast_type: allowedTypes, + }, + new: { + for_main_feed: "true", + for_consumer_view: "false", + order_by: "-open_time", + limit: 7, + forecast_type: allowedTypes, + }, +}; diff --git a/front_end/src/app/(main)/(home)/new/components/homepage_forecasts.tsx b/front_end/src/app/(main)/(home)/new/components/homepage_forecasts.tsx new file mode 100644 index 0000000000..17fc5fd939 --- /dev/null +++ b/front_end/src/app/(main)/(home)/new/components/homepage_forecasts.tsx @@ -0,0 +1,557 @@ +"use client"; + +import Link from "next/link"; +import { useTranslations } from "next-intl"; +import { FC, useState, useTransition } from "react"; + +import PostCard from "@/components/post_card"; +import ClientPostsApi from "@/services/api/posts/posts.client"; +import { PostWithForecasts } from "@/types/post"; +import cn from "@/utils/core/cn"; + +import { FILTERS, TABS, TabId } from "./homepage_filters"; + +type Props = { + initialPopularPosts: PostWithForecasts[]; + className?: string; +}; + +const HomePageForecasts: FC = ({ initialPopularPosts, className }) => { + const t = useTranslations(); + const [activeTab, setActiveTab] = useState("popular"); + const [posts, setPosts] = useState(initialPopularPosts); + const [isPending, startTransition] = useTransition(); + const [cachedPosts, setCachedPosts] = useState< + Partial> + >({ + popular: initialPopularPosts, + }); + + const tabLabels: Record = { + popular: t("popular"), + news: t("inTheNews"), + new: t("new"), + }; + + const handleTabChange = (tabId: TabId) => { + if (tabId === activeTab) return; + + setActiveTab(tabId); + + if (cachedPosts[tabId]) { + setPosts(cachedPosts[tabId] ?? []); + return; + } + + startTransition(async () => { + const response = await ClientPostsApi.getPostsWithCP(FILTERS[tabId]); + const newPosts = response.results; + setCachedPosts((prev) => ({ ...prev, [tabId]: newPosts })); + setPosts(newPosts); + }); + }; + + return ( +
+

+ {t("forecasts")} +

+ +
+ {TABS.map((tab) => ( + + ))} +
+ +
+ {posts.map((post) => ( +
+ +
+ ))} + + +
+
+ ); +}; + +const ExploreAllCard: FC = () => { + const t = useTranslations(); + return ( + +
+
+ {t("exploreAll")} + +
+

+ {t("thousandsOfOpenQuestions")} +

+
+ +
+ +
+ + ); +}; + +const ExploreImagesGrid: FC<{ className?: string }> = ({ className }) => { + return ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ); +}; + +export default HomePageForecasts; diff --git a/front_end/src/app/(main)/(home)/new/components/research_and_updates.tsx b/front_end/src/app/(main)/(home)/new/components/research_and_updates.tsx new file mode 100644 index 0000000000..9a62b6a460 --- /dev/null +++ b/front_end/src/app/(main)/(home)/new/components/research_and_updates.tsx @@ -0,0 +1,158 @@ +"use server"; +import { intlFormat } from "date-fns"; +import Image from "next/image"; +import Link from "next/link"; +import { getLocale, getTranslations } from "next-intl/server"; +import { FC } from "react"; + +import WithServerComponentErrorBoundary from "@/components/server_component_error_boundary"; +import Button from "@/components/ui/button"; +import { NotebookPost } from "@/types/post"; +import cn from "@/utils/core/cn"; +import { estimateReadingTime, getMarkdownSummary } from "@/utils/markdown"; +import { getPostLink } from "@/utils/navigation"; + +const CARD_GRADIENTS = [ + "radial-gradient(ellipse at center, #ede28f 0%, #c5b3c2 50%, #9d83f5 100%)", + "radial-gradient(ellipse at center, #b5ed8f 0%, #d5b889 50%, #f58383 100%)", + "radial-gradient(ellipse at center, #ed8fd9 0%, #b8c2c7 50%, #83f5b4 100%)", + "radial-gradient(ellipse at center, #ed8f8f 0%, #f1bf89 50%, #f5ef83 100%)", +]; + +type Props = { + posts: NotebookPost[]; + className?: string; +}; + +const ResearchAndUpdates: FC = async ({ posts, className }) => { + const t = await getTranslations(); + const locale = await getLocale(); + + return ( +
+
+
+

+ {t("researchAndUpdates")} +

+

+ {t("partnersUseForecasts")} +

+
+ +
+ +
+ {posts.slice(0, 4).map((post, index) => ( + + ))} +
+
+ ); +}; + +type PostCardProps = { + post: NotebookPost; + index: number; + locale: string; +}; + +const PostCard: FC = async ({ post, index, locale }) => { + const t = await getTranslations(); + const { + title, + created_at, + id, + notebook, + slug, + author_username, + comment_count = 0, + } = post; + + const readingTime = estimateReadingTime(notebook.markdown); + const summary = + notebook.markdown_summary || + getMarkdownSummary({ + markdown: notebook.markdown, + width: 280, + height: 60, + withLinks: false, + }); + + const gradient = CARD_GRADIENTS[index % CARD_GRADIENTS.length]; + + return ( + +
+ {notebook.image_url && false ? ( + + ) : ( +
+ )} +
+ +
+
+ + {intlFormat( + new Date(created_at), + { + year: "numeric", + month: "short", + day: "numeric", + }, + { locale } + )} + +

+ {title} +

+

+ {summary} +

+
+ +
+ + {author_username} + +
+ + {comment_count} {t("commentsWithCount", { count: comment_count })} + + + + {t("estimatedReadingTime", { minutes: readingTime })} + +
+
+
+ + ); +}; + +export default WithServerComponentErrorBoundary(ResearchAndUpdates); diff --git a/front_end/src/app/(main)/(home)/new/components/staff_picks.tsx b/front_end/src/app/(main)/(home)/new/components/staff_picks.tsx new file mode 100644 index 0000000000..728b51c9e5 --- /dev/null +++ b/front_end/src/app/(main)/(home)/new/components/staff_picks.tsx @@ -0,0 +1,48 @@ +import Link from "next/link"; +import { useTranslations } from "next-intl"; +import { FC, ReactNode } from "react"; + +import cn from "@/utils/core/cn"; + +type StaffPickItem = { + name: string; + emoji: string | ReactNode; + url: string; +}; + +type Props = { + items: StaffPickItem[]; +}; + +const StaffPicks: FC = ({ items }) => { + const t = useTranslations(); + return ( +
+

+ {t("staffPicks")} +

+
+ {items.map((item, idx) => ( + + + {typeof item.emoji === "string" ? ( + {item.emoji} + ) : ( + item.emoji + )} + + {item.name} + + ))} +
+
+ ); +}; + +export default StaffPicks; diff --git a/front_end/src/app/(main)/(home)/new/components/tournaments_section.tsx b/front_end/src/app/(main)/(home)/new/components/tournaments_section.tsx new file mode 100644 index 0000000000..28a464de14 --- /dev/null +++ b/front_end/src/app/(main)/(home)/new/components/tournaments_section.tsx @@ -0,0 +1,66 @@ +import { getTranslations } from "next-intl/server"; +import { FC } from "react"; + +import WithServerComponentErrorBoundary from "@/components/server_component_error_boundary"; +import TournamentCard from "@/components/tournament_card"; +import Button from "@/components/ui/button"; +import ServerProjectsApi from "@/services/api/projects/projects.server"; +import { TournamentType } from "@/types/projects"; +import cn from "@/utils/core/cn"; + +const TournamentsSection: FC<{ className?: string }> = async ({ + className, +}) => { + const t = await getTranslations(); + const tournaments = await ServerProjectsApi.getTournaments({ + show_on_homepage: true, + }); + const allTournaments = (await ServerProjectsApi.getTournaments()).filter( + (t) => t.is_ongoing + ); + + return ( +
+
+
+

+ {t("forecasting")} {t("tournaments")} +

+

+ {t("joinTournaments")} +

+
+ +
+
+ {tournaments.map((tournament) => ( + + ))} +
+
+ ); +}; + +export default WithServerComponentErrorBoundary(TournamentsSection); diff --git a/front_end/src/app/(main)/(home)/new/components/why_metaculus.tsx b/front_end/src/app/(main)/(home)/new/components/why_metaculus.tsx new file mode 100644 index 0000000000..7695586917 --- /dev/null +++ b/front_end/src/app/(main)/(home)/new/components/why_metaculus.tsx @@ -0,0 +1,383 @@ +"use client"; + +import { useTranslations } from "next-intl"; +import React, { FC, useEffect, useState } from "react"; + +import ClientMiscApi from "@/services/api/misc/misc.client"; +import cn from "@/utils/core/cn"; + +const AeiLogo: FC<{ className?: string }> = ({ className }) => ( + + + + + + + + + + + + + + +); + +const NasdaqLogo: FC<{ className?: string }> = ({ className }) => ( + + + + + + + + + + + + + + + + +); + +const TheAtlanticLogo: FC<{ className?: string }> = ({ className }) => ( + + + + + + + + + + + + + + +); + +const ForbesLogo: FC<{ className?: string }> = ({ className }) => ( + + + +); + +const TheEconomistLogo: FC<{ className?: string }> = ({ className }) => ( + + + + + + + + + + + +); + +const BloombergLogo: FC<{ className?: string }> = ({ className }) => ( + + + + + + + + + + + + + + + + + + +); + +const FEATURED_IN = [ + { + href: "https://www.nasdaq.com/", + label: "Nasdaq", + component: ( + + ), + }, + { + href: "https://www.forbes.com/", + label: "Forbes", + component: ( + + ), + }, + { + href: "https://www.theatlantic.com/", + label: "The Atlantic", + component: ( + + ), + }, + { + href: "https://www.aei.org/", + label: "AEI", + component: ( + + ), + }, + { + href: "https://www.theaeconomist.com/", + label: "The Economist", + component: , + }, + { + href: "https://www.bloomberg.com/", + label: "Bloomberg", + component: ( + + ), + }, +]; + +const fetchSiteStats = async () => { + try { + return await ClientMiscApi.getSiteStats(); + } catch (error) { + console.error(error); + } +}; + +const WhyMetaculus: FC<{ className?: string }> = ({ className }) => { + const t = useTranslations(); + const [siteStats, setSiteStats] = useState({ + predictions: 2133159, + questions: 17357, + years_of_predictions: 10, + }); + + useEffect(() => { + fetchSiteStats().then((stats) => { + if (!stats) { + return; + } + + setSiteStats({ + predictions: stats.predictions, + questions: stats.questions, + years_of_predictions: stats.years_of_predictions, + }); + }); + }, []); + + return ( +
+

+ {t("whatsMetaculus")} +

+ + {/* Divider */} +
+ +
+ {/* Description & Stats */} +
+

+ {t("metaculusDescription")} +

+ +
+ + + +
+
+ + {/* Divider 2 */} +
+ + {/* Featured In */} +
+ + {t("featuredIn")} + +
+ {FEATURED_IN.map((item) => ( + + {item.component} + + ))} +
+
+
+
+ ); +}; + +const Stat: FC<{ number: string; label: string }> = ({ number, label }) => ( +
+ {number} + {label} +
+); + +export default WhyMetaculus; diff --git a/front_end/src/app/(main)/(home)/new/page.tsx b/front_end/src/app/(main)/(home)/new/page.tsx new file mode 100644 index 0000000000..9b925f6c57 --- /dev/null +++ b/front_end/src/app/(main)/(home)/new/page.tsx @@ -0,0 +1,86 @@ +import { redirect } from "next/navigation"; +import { Suspense } from "react"; + +import OnboardingCheck from "@/components/onboarding/onboarding_check"; +import serverMiscApi from "@/services/api/misc/misc.server"; +import ServerPostsApi from "@/services/api/posts/posts.server"; +import ServerProjectsApi from "@/services/api/projects/projects.server"; +import { NotebookPost } from "@/types/post"; +import { getPublicSettings } from "@/utils/public_settings.server"; +import { convertSidebarItem } from "@/utils/sidebar"; + +import EmailConfirmation from "../components/email_confirmation"; +import AllCategoriesSection from "./components/all_categories_section"; +import FutureEvalSection from "./components/future_eval_section"; +import HeroCTAs from "./components/hero_ctas"; +import { FILTERS } from "./components/homepage_filters"; +import HomePageForecasts from "./components/homepage_forecasts"; +import ResearchAndUpdates from "./components/research_and_updates"; +import StaffPicks from "./components/staff_picks"; +import TournamentsSection from "./components/tournaments_section"; +import WhyMetaculus from "./components/why_metaculus"; + +export default async function Home() { + const { PUBLIC_LANDING_PAGE_URL } = getPublicSettings(); + + if (PUBLIC_LANDING_PAGE_URL !== "/") { + return redirect(PUBLIC_LANDING_PAGE_URL); + } + + const sidebarItems = await serverMiscApi.getSidebarItems(); + const homepagePosts = await ServerPostsApi.getPostsForHomepage(); + const categories = await ServerProjectsApi.getHomepageCategories(); + + const postNotebooks = homepagePosts.filter( + (post) => !!post.notebook + ) as unknown as NotebookPost[]; + + const hotTopics = sidebarItems + .filter(({ section }) => section === "hot_topics") + .map((item) => convertSidebarItem(item)); + + const initialPopularPosts = await ServerPostsApi.getPostsWithCP( + FILTERS.popular + ); + + const contentWidthClassNames = + "2xl:max-w-[1352px] w-full md:max-2xl:px-20 mx-auto px-4"; + + return ( +
+ + + +
+ + + +
+ +
+ +
+
+ + + + +
+ +
+
+ + + +
+ ); +} diff --git a/front_end/src/app/(main)/components/footer.tsx b/front_end/src/app/(main)/components/footer.tsx index bc721e24af..23ff0a941d 100644 --- a/front_end/src/app/(main)/components/footer.tsx +++ b/front_end/src/app/(main)/components/footer.tsx @@ -1,192 +1,350 @@ "use client"; -import { faTwitter, faDiscord } from "@fortawesome/free-brands-svg-icons"; +import { faXTwitter, faDiscord } from "@fortawesome/free-brands-svg-icons"; +import { faChevronDown } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; -import Image from "next/image"; +import { + Listbox, + ListboxButton, + ListboxOptions, + ListboxOption, +} from "@headlessui/react"; import Link from "next/link"; -import { useTranslations } from "next-intl"; +import { useTranslations, useLocale } from "next-intl"; import { FC } from "react"; +import { updateLanguagePreference } from "@/app/(main)/accounts/profile/actions"; +import { APP_LANGUAGES } from "@/components/language_menu"; import { useModal } from "@/contexts/modal_context"; +import useAppTheme from "@/hooks/use_app_theme"; +import useMounted from "@/hooks/use_mounted"; +import { AppTheme } from "@/types/theme"; +import cn from "@/utils/core/cn"; +import { logError } from "@/utils/core/errors"; + +const MetaculusTextLogo: FC<{ className?: string }> = ({ className }) => ( + + + + + + + + + + +); + +const ComputerIcon: FC<{ className?: string }> = ({ className }) => ( + + + +); + +type FooterLink = + | { href: string; labelKey: string; isModal?: false; external?: false } + | { labelKey: string; isModal: true; href?: undefined; external?: false } + | { href: string; labelKey: string; external: true; isModal?: false }; + +const FOOTER_LINKS = { + explore: [ + { href: "/questions", labelKey: "questions" }, + { href: "/tournaments", labelKey: "tournaments" }, + { href: "/aib", labelKey: "tournamentsForAIBots" }, + { href: "/futureeval", labelKey: "futureEval" }, + ], + services: [ + { href: "/services#launch-a-tournament", labelKey: "launchATournament" }, + { href: "/services#private-instances", labelKey: "privateInstances" }, + { href: "/services#pro-forecasters", labelKey: "proForecasters" }, + ], + company: [ + { href: "/about/", labelKey: "about" }, + { labelKey: "contact", isModal: true }, + { + href: "https://apply.workable.com/metaculus", + labelKey: "careers", + external: true, + }, + { href: "/faq", labelKey: "faq" }, + ], + resources: [ + { href: "/help/prediction-resources", labelKey: "forecastingResources" }, + { href: "/press", labelKey: "forJournalists" }, + { href: "/api", labelKey: "api" }, + ], +} as const satisfies Record; + +const THEME_OPTIONS = [ + { value: AppTheme.System, labelKey: "settingsThemeSystemDefault" }, + { value: AppTheme.Light, labelKey: "settingsThemeLightMode" }, + { value: AppTheme.Dark, labelKey: "settingsThemeDarkMode" }, +] as const satisfies readonly { value: AppTheme; labelKey: string }[]; + +const FooterLinkColumn: FC<{ + title: string; + links: readonly FooterLink[]; + onContactClick?: () => void; +}> = ({ title, links, onContactClick }) => { + const t = useTranslations(); + + return ( +
+ {title} + {links.map((link, index) => { + if (link.isModal) { + return ( + + ); + } + if (link.external) { + return ( + + {t(link.labelKey as Parameters[0])} + + ); + } + return ( + + {t(link.labelKey as Parameters[0])} + + ); + })} +
+ ); +}; + +const LanguageSelector: FC = () => { + const locale = useLocale(); + const currentLanguage = + APP_LANGUAGES.find((l) => l.locale === locale) ?? + APP_LANGUAGES[APP_LANGUAGES.length - 1]; + + if (!currentLanguage) { + return null; + } + + const handleLanguageChange = (newLocale: string) => { + updateLanguagePreference(newLocale, false) + .then(() => window.location.reload()) + .catch(logError); + }; + + return ( + +
+ + + a + / + + + {currentLanguage.name} + + + + {APP_LANGUAGES.map((language) => ( + + cn( + "cursor-pointer px-3 py-2 text-sm text-gray-900 hover:bg-gray-100", + selected && "bg-gray-100 font-medium" + ) + } + > + {language.name} + + ))} + +
+
+ ); +}; + +const ThemeSelector: FC = () => { + const t = useTranslations(); + const mounted = useMounted(); + const { themeChoice, setTheme } = useAppTheme(); + + const currentTheme = mounted ? themeChoice : AppTheme.System; + const currentOption = + THEME_OPTIONS.find((opt) => opt.value === currentTheme) ?? THEME_OPTIONS[0]; + + const handleThemeChange = (value: AppTheme) => { + setTheme(value); + }; + + return ( + +
+ + + {t(currentOption.labelKey as Parameters[0])} + + + + {THEME_OPTIONS.map((option) => ( + + cn( + "cursor-pointer text-nowrap px-3 py-2 text-sm text-gray-900 hover:bg-gray-100", + selected && "bg-gray-100 font-medium" + ) + } + > + {t(option.labelKey as Parameters[0])} + + ))} + +
+
+ ); +}; const Footer: FC = () => { const t = useTranslations(); const { setCurrentModal } = useModal(); + const handleContactClick = () => setCurrentModal({ type: "contactUs" }); + return ( -
-
-
    -
  • - - {t("about")} - -
  • -
  • - - {t("api")} - -
  • -
-
    -
  • - - {t("faq")} - -
  • -
  • +
    + {/* Main content */} +
    + {/* Left column - Logo, description, socials, selectors */} +
    + {/* Logo and description */} +
    + +

    + {t("publicBenefitCorporation")} +

    +

    + {t("metaculusDescription")} +

    +
    + + {/* Social icons */} +
  • -
  • - - {t("forJournalists")} - -
  • -
- -
-
+ {/* Language and Theme selectors */} +
+ + +
+
- + + + {/* Bottom links */} + - - ); diff --git a/front_end/src/app/(main)/components/headers/components/navbar_links.tsx b/front_end/src/app/(main)/components/headers/components/navbar_links.tsx index 49d744747c..fa179e9467 100644 --- a/front_end/src/app/(main)/components/headers/components/navbar_links.tsx +++ b/front_end/src/app/(main)/components/headers/components/navbar_links.tsx @@ -12,7 +12,7 @@ const NavbarLinks: FC = ({ links, className }) => { return (
    diff --git a/front_end/src/app/(main)/components/headers/components/navbar_logo.tsx b/front_end/src/app/(main)/components/headers/components/navbar_logo.tsx index 890c38a9ee..cff180eead 100644 --- a/front_end/src/app/(main)/components/headers/components/navbar_logo.tsx +++ b/front_end/src/app/(main)/components/headers/components/navbar_logo.tsx @@ -2,8 +2,9 @@ import Link from "next/link"; import { FC } from "react"; import { usePublicSettings } from "@/contexts/public_settings_context"; +import cn from "@/utils/core/cn"; -const NavbarLogo: FC = () => { +const NavbarLogo: FC<{ className?: string }> = ({ className }) => { const { PUBLIC_MINIMAL_UI } = usePublicSettings(); if (PUBLIC_MINIMAL_UI) { @@ -13,24 +14,13 @@ const NavbarLogo: FC = () => { return ( -

    - - - - - - +

    + { return ( <>
    - +
    + - {/* Global Search */} - - - {/* Regular links */} - - - - + {/* Regular links */} + + + + -
      -
    • + {/* The More menu */} +
      { ))} -
    • +
    + + + {/* Global Search */} + + +
      {!!user && (
    • = ({ className }) => { const locale = useLocale(); - const languageMenuItems = [ - ...APP_LANGUAGES, - { - name: "Untranslated", - locale: "original", // Check the translations documentation why this is the case - }, - ]; + const languageMenuItems = APP_LANGUAGES; return ( diff --git a/front_end/src/services/api/projects/projects.shared.ts b/front_end/src/services/api/projects/projects.shared.ts index 6b8e5cd381..d929badfcc 100644 --- a/front_end/src/services/api/projects/projects.shared.ts +++ b/front_end/src/services/api/projects/projects.shared.ts @@ -38,6 +38,12 @@ class ProjectsApi extends ApiService { return await this.get("/projects/categories/"); } + async getHomepageCategories(): Promise<(Category & { posts: string[] })[]> { + return await this.get<(Category & { posts: string[] })[]>( + `/projects/homepage_categories/` + ); + } + async getNewsCategories(): Promise { return await this.get("/projects/news-categories/"); } diff --git a/projects/models.py b/projects/models.py index 62f8adda8f..6901136953 100644 --- a/projects/models.py +++ b/projects/models.py @@ -12,6 +12,7 @@ from django.db.models.functions import Coalesce from django.utils import timezone as django_timezone from sql_util.aggregates import SubqueryAggregate +from django.contrib.postgres.expressions import ArraySubquery from projects.permissions import ObjectPermission from questions.constants import UnsuccessfulResolutionType @@ -45,6 +46,31 @@ def filter_leaderboard_tags(self): def filter_communities(self): return self.filter(type=Project.ProjectTypes.COMMUNITY) + def annotate_top_n_post_titles(self, n: int = 3): + from posts.models import Post + + now = django_timezone.now() + # We must query the M2M through table directly instead of Post.objects.filter(projects=OuterRef("pk")) + # When filtering Post with an M2M relation using OuterRef, Django generates a JOIN that + # includes extra columns in the SELECT. ArraySubquery wraps the query in PostgreSQL's ARRAY() + # which requires exactly one column. Querying the through table with a direct FK lookup + # generates a clean single-column SELECT. + ThroughModel = Post.projects.through + subquery = ( + ThroughModel.objects.filter( + project_id=OuterRef("pk"), + post__curation_status=Post.CurationStatus.APPROVED, + post__open_time__lte=now, + ) + .filter( + Q(post__actual_close_time__isnull=True) + | Q(post__actual_close_time__gt=now) + ) + .order_by("-post__hotness") + .values("post__title")[:n] + ) + return self.annotate(top_n_post_titles=ArraySubquery(subquery)) + def annotate_posts_count(self): from posts.models import Post diff --git a/projects/urls.py b/projects/urls.py index bc68c80e5d..ce0156f7fc 100644 --- a/projects/urls.py +++ b/projects/urls.py @@ -6,6 +6,7 @@ path("projects/topics/", views.topics_list_api_view), path("projects/news-categories/", views.news_categories_list_api_view), path("projects/categories/", views.categories_list_api_view), + path("projects/homepage_categories/", views.homepage_categories_list_api_view), path("projects/leaderboard-tags/", views.leaderboard_tags_list_api_view), path("projects/tournaments/", views.tournaments_list_api_view), path("projects/site_main/", views.site_main_view), diff --git a/projects/views/common.py b/projects/views/common.py index f6f9830d18..46456dc7e6 100644 --- a/projects/views/common.py +++ b/projects/views/common.py @@ -1,4 +1,5 @@ from django.http import HttpResponse +from django.views.decorators.cache import cache_page from rest_framework import serializers, status from rest_framework.decorators import api_view, permission_classes from rest_framework.generics import get_object_or_404 @@ -77,6 +78,24 @@ def news_categories_list_api_view(request: Request): return Response(data) +@cache_page(60 * 30) +@api_view(["GET"]) +@permission_classes([AllowAny]) +def homepage_categories_list_api_view(request: Request): + qs = ( + get_projects_qs(user=request.user) + .filter_category() + .annotate_top_n_post_titles() + ) + + data = [ + {**CategorySerializer(obj).data, "posts": obj.top_n_post_titles} + for obj in qs.all() + ] + + return Response(data) + + @api_view(["GET"]) @permission_classes([AllowAny]) def categories_list_api_view(request: Request):