diff --git a/front_end/messages/cs.json b/front_end/messages/cs.json index 2eccb229a1..a26d2a6808 100644 --- a/front_end/messages/cs.json +++ b/front_end/messages/cs.json @@ -1784,5 +1784,40 @@ "switchToBotAccount": "Přepnout na účet bota", "impersonationBannerText": "Momentálně si prohlížíte Metaculus jako váš bot.", "stopImpersonating": "Přepnout zpět na můj účet", + "tournamentsHeroLiveTitle": "Předpovídejte klíčová témata,

šplhejte po žebříčku, vyhrajte ceny.", + "tournamentsHeroLiveShown": "{count, plural, one {# turnaj zobrazen} other {# turnajů zobrazeno}}", + "tournamentsHeroSeriesTitle": "Předpovídejte klíčová témata,

procvičujte a budujte si záznamy.", + "tournamentsHeroSeriesShown": "{count, plural, one {# série otázek zobrazena} other {# sérií otázek zobrazeno}}", + "tournamentsHeroIndexesTitle": "Objevte složitá témata,

sledujte jejich vývoj.", + "tournamentsHeroIndexesShown": "{count, plural, one {# index zobrazen} other {# indexů zobrazeno}}", + "tournamentsInfoAria": "Informace o turnaji", + "tournamentsInfoTitle": "Účast zdarma; získejte peněžní ceny a zlepšete své předpovědi.", + "tournamentsInfoScoringLink": "Jak funguje bodování?", + "tournamentsInfoPrizesLink": "Jak jsou rozdělovány ceny?", + "tournamentsInfoCta": "Přihlaste se k soutěži", + "tournamentPrizePool": "CENOVÝ FOND", + "tournamentNoPrizePool": "ŽÁDNÝ CENOVÝ FOND", + "tournamentTimelineOngoing": "Probíhá", + "tournamentTimelineJustStarted": "Právě začalo", + "tournamentTimelineStarts": "Začíná {when}", + "tournamentTimelineEnds": "Končí {when}", + "tournamentTimelineClosed": "Ukončeno", + "tournamentTimelineAllResolved": "Všechny otázky vyřešeny", + "tournamentRelativeSoon": "brzy", + "tournamentRelativeUnderMinute": "za méně než minutu", + "tournamentRelativeFarFuture": "v daleké budoucnosti", + "tournamentRelativeFromNow": "za {n} {unit}", + "tournamentUnit": "{unit, select, minute {minuta} hour {hodina} day {den} week {týden} month {měsíc} year {rok} other {den}}", + "tournamentUnitPlural": "{unit, select, minute {minut} hour {hodin} day {dní} week {týdnů} month {měsíců} year {let} other {dní}}", + "tournamentQuestionsCount": "{count, plural, one {# otázka} other {# otázek}}", + "tournamentQuestionsCountUpper": "{count} OTÁZKY", + "tournamentsEmptySearchTitle": "Nebyly nalezeny žádné výsledky", + "tournamentsEmptySearchBody": "Zkuste jiný vyhledávací výraz nebo vymažte vyhledávání.", + "tournamentsEmptyDefaultTitle": "Zobrazeno {count} turnajů", + "tournamentsEmptyDefaultBody": "Zkontrolujte později nebo vyzkoušejte jinou kartu.", + "tournamentsTabLive": "Živé turnaje", + "tournamentsTabSeries": "Série otázek", + "tournamentsTabIndexes": "Indexy", + "tournamentsTabArchived": "Archivováno", "othersCount": "Ostatní ({count})" } diff --git a/front_end/messages/en.json b/front_end/messages/en.json index f834758161..a9ed7dd4e7 100644 --- a/front_end/messages/en.json +++ b/front_end/messages/en.json @@ -1778,5 +1778,40 @@ "switchToBotAccount": "Switch to Bot Account", "impersonationBannerText": "You are currently viewing Metaculus as your bot.", "stopImpersonating": "Switch back to my account", + "tournamentsHeroLiveTitle": "Forecast key topics,

climb the leaderboards, win prizes.", + "tournamentsHeroLiveShown": "{count, plural, one {# tournament shown} other {# tournaments shown}}", + "tournamentsHeroSeriesTitle": "Forecast key topics,

practice and build a track record.", + "tournamentsHeroSeriesShown": "{count, plural, one {# question series shown} other {# question series shown}}", + "tournamentsHeroIndexesTitle": "Discover complex topics,

monitor their progress.", + "tournamentsHeroIndexesShown": "{count, plural, one {# index shown} other {# indexes shown}}", + "tournamentsInfoAria": "Tournament info", + "tournamentsInfoTitle": "Free to participate; get paid cash prizes and practice forecasting.", + "tournamentsInfoScoringLink": "How does scoring work?", + "tournamentsInfoPrizesLink": "How are prizes distributed?", + "tournamentsInfoCta": "Sign up to compete", + "tournamentPrizePool": "PRIZE POOL", + "tournamentNoPrizePool": "NO PRIZE POOL", + "tournamentTimelineOngoing": "Ongoing", + "tournamentTimelineJustStarted": "Just started", + "tournamentTimelineStarts": "Starts {when}", + "tournamentTimelineEnds": "Ends {when}", + "tournamentTimelineClosed": "Closed", + "tournamentTimelineAllResolved": "All questions resolved", + "tournamentRelativeSoon": "soon", + "tournamentRelativeUnderMinute": "in under a minute", + "tournamentRelativeFarFuture": "in the far future", + "tournamentRelativeFromNow": "{n} {unit} from now", + "tournamentUnit": "{unit, select, minute {minute} hour {hour} day {day} week {week} month {month} year {year} other {day}}", + "tournamentUnitPlural": "{unit, select, minute {minutes} hour {hours} day {days} week {weeks} month {months} year {years} other {days}}", + "tournamentQuestionsCount": "{count, plural, one {# question} other {# questions}}", + "tournamentQuestionsCountUpper": "{count} QUESTIONS", + "tournamentsEmptySearchTitle": "No results found", + "tournamentsEmptySearchBody": "Try a different search term, or clear the search.", + "tournamentsEmptyDefaultTitle": "{count} tournaments shown", + "tournamentsEmptyDefaultBody": "Check back later or try another tab.", + "tournamentsTabLive": "Live Tournaments", + "tournamentsTabSeries": "Question Series", + "tournamentsTabIndexes": "Indexes", + "tournamentsTabArchived": "Archived", "none": "none" } diff --git a/front_end/messages/es.json b/front_end/messages/es.json index 8bcc58af75..97e5b213f0 100644 --- a/front_end/messages/es.json +++ b/front_end/messages/es.json @@ -1784,5 +1784,40 @@ "switchToBotAccount": "Cambiar a cuenta de bot", "impersonationBannerText": "Actualmente estás viendo Metaculus como tu bot.", "stopImpersonating": "Volver a mi cuenta", + "tournamentsHeroLiveTitle": "Pronostica temas clave,

súbete al marcador, gana premios.", + "tournamentsHeroLiveShown": "{count, plural, one {# torneo mostrado} other {# torneos mostrados}}", + "tournamentsHeroSeriesTitle": "Pronostica temas clave,

practica y construye un historial.", + "tournamentsHeroSeriesShown": "{count, plural, one {# serie de preguntas mostrada} other {# series de preguntas mostradas}}", + "tournamentsHeroIndexesTitle": "Descubre temas complejos,

monitorea su progreso.", + "tournamentsHeroIndexesShown": "{count, plural, one {# índice mostrado} other {# índices mostrados}}", + "tournamentsInfoAria": "Información del torneo", + "tournamentsInfoTitle": "Participación gratuita; recibe premios en efectivo y práctica en pronósticos.", + "tournamentsInfoScoringLink": "¿Cómo funciona el puntaje?", + "tournamentsInfoPrizesLink": "¿Cómo se distribuyen los premios?", + "tournamentsInfoCta": "Regístrate para competir", + "tournamentPrizePool": "PREMIO TOTAL", + "tournamentNoPrizePool": "SIN PREMIO TOTAL", + "tournamentTimelineOngoing": "En curso", + "tournamentTimelineJustStarted": "Acaba de comenzar", + "tournamentTimelineStarts": "Comienza {when}", + "tournamentTimelineEnds": "Termina {when}", + "tournamentTimelineClosed": "Cerrado", + "tournamentTimelineAllResolved": "Todas las preguntas resueltas", + "tournamentRelativeSoon": "pronto", + "tournamentRelativeUnderMinute": "en menos de un minuto", + "tournamentRelativeFarFuture": "en el futuro lejano", + "tournamentRelativeFromNow": "{n} {unit} a partir de ahora", + "tournamentUnit": "{unit, select, minute {minuto} hour {hora} day {día} week {semana} month {mes} year {año} other {día}}", + "tournamentUnitPlural": "{unit, select, minute {minutos} hour {horas} day {días} week {semanas} month {meses} year {años} other {días}}", + "tournamentQuestionsCount": "{count, plural, one {# pregunta} other {# preguntas}}", + "tournamentQuestionsCountUpper": "{count} PREGUNTAS", + "tournamentsEmptySearchTitle": "No se encontraron resultados", + "tournamentsEmptySearchBody": "Prueba un término de búsqueda diferente o borra la búsqueda.", + "tournamentsEmptyDefaultTitle": "{count} torneos mostrados", + "tournamentsEmptyDefaultBody": "Vuelve más tarde o prueba otra pestaña.", + "tournamentsTabLive": "Torneos en Vivo", + "tournamentsTabSeries": "Series de Preguntas", + "tournamentsTabIndexes": "Índices", + "tournamentsTabArchived": "Archivado", "othersCount": "Otros ({count})" } diff --git a/front_end/messages/pt.json b/front_end/messages/pt.json index f63b14ef68..63caea084d 100644 --- a/front_end/messages/pt.json +++ b/front_end/messages/pt.json @@ -1782,5 +1782,40 @@ "switchToBotAccount": "Mudar para Conta de Bot", "impersonationBannerText": "Você está visualizando o Metaculus atualmente como seu bot.", "stopImpersonating": "Voltar para minha conta", + "tournamentsHeroLiveTitle": "Preveja tópicos chave,

suba nas classificações, ganhe prêmios.", + "tournamentsHeroLiveShown": "{count, plural, one {# torneio mostrado} other {# torneios mostrados}}", + "tournamentsHeroSeriesTitle": "Preveja tópicos chave,

pratique e construa um histórico.", + "tournamentsHeroSeriesShown": "{count, plural, one {# série de perguntas mostrada} other {# séries de perguntas mostradas}}", + "tournamentsHeroIndexesTitle": "Descubra tópicos complexos,

monitore o progresso deles.", + "tournamentsHeroIndexesShown": "{count, plural, one {# índice mostrado} other {# índices mostrados}}", + "tournamentsInfoAria": "Informações do Torneio", + "tournamentsInfoTitle": "Participe gratuitamente; receba prêmios em dinheiro e pratique previsões.", + "tournamentsInfoScoringLink": "Como a pontuação funciona?", + "tournamentsInfoPrizesLink": "Como são distribuídos os prêmios?", + "tournamentsInfoCta": "Inscreva-se para competir", + "tournamentPrizePool": "PRÊMIO", + "tournamentNoPrizePool": "SEM PRÊMIO", + "tournamentTimelineOngoing": "Em andamento", + "tournamentTimelineJustStarted": "Acabou de começar", + "tournamentTimelineStarts": "Começa {when}", + "tournamentTimelineEnds": "Termina {when}", + "tournamentTimelineClosed": "Encerrado", + "tournamentTimelineAllResolved": "Todas as perguntas resolvidas", + "tournamentRelativeSoon": "em breve", + "tournamentRelativeUnderMinute": "em menos de um minuto", + "tournamentRelativeFarFuture": "no futuro distante", + "tournamentRelativeFromNow": "em {n} {unit}", + "tournamentUnit": "{unit, select, minute {minuto} hour {hora} day {dia} week {semana} month {mês} year {ano} other {dia}}", + "tournamentUnitPlural": "{unit, select, minute {minutos} hour {horas} day {dias} week {semanas} month {meses} year {anos} other {dias}}", + "tournamentQuestionsCount": "{count, plural, one {# pergunta} other {# perguntas}}", + "tournamentQuestionsCountUpper": "{count} PERGUNTAS", + "tournamentsEmptySearchTitle": "Nenhum resultado encontrado", + "tournamentsEmptySearchBody": "Tente um termo de pesquisa diferente ou limpe a pesquisa.", + "tournamentsEmptyDefaultTitle": "{count} torneios mostrados", + "tournamentsEmptyDefaultBody": "Volte mais tarde ou tente outra aba.", + "tournamentsTabLive": "Torneios ao Vivo", + "tournamentsTabSeries": "Série de Perguntas", + "tournamentsTabIndexes": "Índices", + "tournamentsTabArchived": "Arquivado", "othersCount": "Outros ({count})" } diff --git a/front_end/messages/zh-TW.json b/front_end/messages/zh-TW.json index 3f56e705ac..93d3321879 100644 --- a/front_end/messages/zh-TW.json +++ b/front_end/messages/zh-TW.json @@ -1781,5 +1781,40 @@ "switchToBotAccount": "切換到機器人帳戶", "impersonationBannerText": "您目前正在以您的機器人帳戶查看 Metaculus。", "stopImpersonating": "切換回我的帳戶", + "tournamentsHeroLiveTitle": "預測關鍵議題,

攀登排行榜,贏得獎品。", + "tournamentsHeroLiveShown": "{count, plural, one {顯示 # 個錦標賽} other {顯示 # 個錦標賽}}", + "tournamentsHeroSeriesTitle": "預測關鍵議題,

練習並建立成果紀錄。", + "tournamentsHeroSeriesShown": "{count, plural, one {顯示 # 個問題系列} other {顯示 # 個問題系列}}", + "tournamentsHeroIndexesTitle": "探索複雜議題,

監控其進展。", + "tournamentsHeroIndexesShown": "{count, plural, one {顯示 # 個指數} other {顯示 # 個指數}}", + "tournamentsInfoAria": "錦標賽資訊", + "tournamentsInfoTitle": "免費參加;獲得現金獎勵並練習預測。", + "tournamentsInfoScoringLink": "計分方式如何運作?", + "tournamentsInfoPrizesLink": "獎品如何分配?", + "tournamentsInfoCta": "註冊參賽", + "tournamentPrizePool": "獎金池", + "tournamentNoPrizePool": "無獎金池", + "tournamentTimelineOngoing": "進行中", + "tournamentTimelineJustStarted": "剛剛開始", + "tournamentTimelineStarts": "{when} 開始", + "tournamentTimelineEnds": "{when} 結束", + "tournamentTimelineClosed": "已結束", + "tournamentTimelineAllResolved": "所有問題已解決", + "tournamentRelativeSoon": "即將", + "tournamentRelativeUnderMinute": "在不到一分鐘內", + "tournamentRelativeFarFuture": "在遙遠的未來", + "tournamentRelativeFromNow": "{n} {unit} 後", + "tournamentUnit": "{unit, select, minute {分鐘} hour {小時} day {天} week {週} month {月} year {年} other {天}}", + "tournamentUnitPlural": "{unit, select, minute {分鐘} hour {小時} day {天} week {週} month {月} year {年} other {天}}", + "tournamentQuestionsCount": "{count, plural, one {# 個問題} other {# 個問題}}", + "tournamentQuestionsCountUpper": "{count} 題問題", + "tournamentsEmptySearchTitle": "找不到結果", + "tournamentsEmptySearchBody": "嘗試使用不同的搜索詞,或清除搜索。", + "tournamentsEmptyDefaultTitle": "顯示 {count} 個比賽", + "tournamentsEmptyDefaultBody": "稍後再查看或嘗試其他標籤。", + "tournamentsTabLive": "現場錦標賽", + "tournamentsTabSeries": "問答系列", + "tournamentsTabIndexes": "指數", + "tournamentsTabArchived": "已存檔", "withdrawAfterPercentSetting2": "問題總生命周期後撤回" } diff --git a/front_end/messages/zh.json b/front_end/messages/zh.json index ef1f8fb4b7..9655b10d09 100644 --- a/front_end/messages/zh.json +++ b/front_end/messages/zh.json @@ -1786,5 +1786,40 @@ "switchToBotAccount": "切换到机器人账户", "impersonationBannerText": "您当前正在以机器人身份查看 Metaculus。", "stopImpersonating": "切换回我的账户", + "tournamentsHeroLiveTitle": "预测关键话题,

登上排行榜,赢得奖品。", + "tournamentsHeroLiveShown": "{count, plural, one {显示#场锦标赛} other {显示#场锦标赛}}", + "tournamentsHeroSeriesTitle": "预测关键话题,

实践并建立记录。", + "tournamentsHeroSeriesShown": "{count, plural, one {显示#个问题系列} other {显示#个问题系列}}", + "tournamentsHeroIndexesTitle": "发现复杂话题,

监控其进展。", + "tournamentsHeroIndexesShown": "{count, plural, one {显示#个指数} other {显示#个指数}}", + "tournamentsInfoAria": "比赛信息", + "tournamentsInfoTitle": "免费参加;赢取现金奖励并提高预测技能。", + "tournamentsInfoScoringLink": "评分机制如何运作?", + "tournamentsInfoPrizesLink": "奖品如何分发?", + "tournamentsInfoCta": "注册参赛", + "tournamentPrizePool": "奖金池", + "tournamentNoPrizePool": "无奖金池", + "tournamentTimelineOngoing": "进行中", + "tournamentTimelineJustStarted": "刚刚开始", + "tournamentTimelineStarts": "开始于{when}", + "tournamentTimelineEnds": "结束于{when}", + "tournamentTimelineClosed": "已关闭", + "tournamentTimelineAllResolved": "所有问题已解决", + "tournamentRelativeSoon": "很快", + "tournamentRelativeUnderMinute": "不到一分钟", + "tournamentRelativeFarFuture": "在遥远的未来", + "tournamentRelativeFromNow": "{n}{unit}后", + "tournamentUnit": "{unit, select, minute {分钟} hour {小时} day {天} week {周} month {个月} year {年} other {天}}", + "tournamentUnitPlural": "{unit, select, minute {分钟} hour {小时} day {天} week {周} month {个月} year {年} other {天}}", + "tournamentQuestionsCount": "{count, plural, one {# 个问题} other {# 个问题}}", + "tournamentQuestionsCountUpper": "{count} 题目", + "tournamentsEmptySearchTitle": "未找到结果", + "tournamentsEmptySearchBody": "尝试不同的搜索词,或清除搜索。", + "tournamentsEmptyDefaultTitle": "显示了 {count} 场比赛", + "tournamentsEmptyDefaultBody": "稍后再查看或尝试其他选项卡。", + "tournamentsTabLive": "直播锦标赛", + "tournamentsTabSeries": "问题系列", + "tournamentsTabIndexes": "索引", + "tournamentsTabArchived": "已归档", "othersCount": "其他({count})" } diff --git a/front_end/src/app/(main)/(tournaments)/tournaments/archived/page.tsx b/front_end/src/app/(main)/(tournaments)/tournaments/archived/page.tsx new file mode 100644 index 0000000000..009aeac714 --- /dev/null +++ b/front_end/src/app/(main)/(tournaments)/tournaments/archived/page.tsx @@ -0,0 +1,20 @@ +import ServerProjectsApi from "@/services/api/projects/projects.server"; + +import ArchivedTournamentsGrid from "../components/tournaments_grid/archived_tournaments_grid"; +import TournamentsScreen from "../components/tournaments_screen"; + +const ArchivedPage: React.FC = async () => { + const tournaments = await ServerProjectsApi.getTournaments(); + const nowTs = Date.now(); + return ( + + + + ); +}; + +export default ArchivedPage; diff --git a/front_end/src/app/(main)/(tournaments)/tournaments/components/tournament_filters.tsx b/front_end/src/app/(main)/(tournaments)/tournaments/components/tournament_filters.tsx deleted file mode 100644 index eaceabce0b..0000000000 --- a/front_end/src/app/(main)/(tournaments)/tournaments/components/tournament_filters.tsx +++ /dev/null @@ -1,75 +0,0 @@ -"use client"; -import { useTranslations } from "next-intl"; -import { ChangeEvent, FC } from "react"; - -import SearchInput from "@/components/search_input"; -import Listbox, { SelectOption } from "@/components/ui/listbox"; -import useSearchInputState from "@/hooks/use_search_input_state"; -import useSearchParams from "@/hooks/use_search_params"; -import { TournamentsSortBy } from "@/types/projects"; - -import { - TOURNAMENTS_SEARCH, - TOURNAMENTS_SORT, -} from "../constants/query_params"; - -const TournamentFilters: FC = () => { - const t = useTranslations(); - const { params, setParam, shallowNavigateToSearchParams } = useSearchParams(); - - const [searchQuery, setSearchQuery] = useSearchInputState( - TOURNAMENTS_SEARCH, - { mode: "client", debounceTime: 300, modifySearchParams: true } - ); - - const handleSearchChange = (event: ChangeEvent) => { - setSearchQuery(event.target.value); - }; - const handleSearchErase = () => { - setSearchQuery(""); - }; - - const sortBy = - (params.get(TOURNAMENTS_SORT) as TournamentsSortBy) ?? - TournamentsSortBy.StartDateDesc; - const sortOptions: SelectOption[] = [ - { - label: t("highestPrizePool"), - value: TournamentsSortBy.PrizePoolDesc, - }, - { - label: t("endingSoon"), - value: TournamentsSortBy.CloseDateAsc, - }, - { - label: t("newest"), - value: TournamentsSortBy.StartDateDesc, - }, - ]; - const handleSortByChange = (value: TournamentsSortBy) => { - setParam(TOURNAMENTS_SORT, value, false); - shallowNavigateToSearchParams(); - }; - - return ( -
- -
- -
-
- ); -}; - -export default TournamentFilters; diff --git a/front_end/src/app/(main)/(tournaments)/tournaments/components/tournament_tabs.tsx b/front_end/src/app/(main)/(tournaments)/tournaments/components/tournament_tabs.tsx new file mode 100644 index 0000000000..2c604cf9a5 --- /dev/null +++ b/front_end/src/app/(main)/(tournaments)/tournaments/components/tournament_tabs.tsx @@ -0,0 +1,48 @@ +"use client"; + +import { useTranslations } from "next-intl"; +import React from "react"; + +import TournamentsTabsShell from "./tournaments-tabs-shell"; +import { Section, TournamentsSection } from "../types"; + +type Props = { current: TournamentsSection }; + +const TAB_KEYS = { + live: "tournamentsTabLive", + series: "tournamentsTabSeries", + indexes: "tournamentsTabIndexes", + archived: "tournamentsTabArchived", +} as const satisfies Record; + +const TournamentsTabs: React.FC = ({ current }) => { + const t = useTranslations(); + type PlainKey = Parameters[0]; + + const sections: Section[] = [ + { + value: "live", + href: "/tournaments", + label: t(TAB_KEYS.live as PlainKey), + }, + { + value: "series", + href: "/tournaments/question-series", + label: t(TAB_KEYS.series as PlainKey), + }, + { + value: "indexes", + href: "/tournaments/indexes", + label: t(TAB_KEYS.indexes as PlainKey), + }, + { + value: "archived", + href: "/tournaments/archived", + label: t(TAB_KEYS.archived as PlainKey), + }, + ]; + + return ; +}; + +export default TournamentsTabs; diff --git a/front_end/src/app/(main)/(tournaments)/tournaments/components/tournaments-tabs-shell.tsx b/front_end/src/app/(main)/(tournaments)/tournaments/components/tournaments-tabs-shell.tsx new file mode 100644 index 0000000000..218086b073 --- /dev/null +++ b/front_end/src/app/(main)/(tournaments)/tournaments/components/tournaments-tabs-shell.tsx @@ -0,0 +1,41 @@ +"use client"; + +import React from "react"; + +import { Tabs, TabsList, TabsTab } from "@/components/ui/tabs"; +import cn from "@/utils/core/cn"; + +import { Section, TournamentsSection } from "../types"; + +type Props = { + current: TournamentsSection; + sections: Section[]; +}; + +const TournamentsTabsShell: React.FC = ({ current, sections }) => { + return ( + + + {sections.map((tab) => ( + + !isActive + ? `hover:bg-blue-400 dark:hover:bg-blue-400-dark text-blue-800 dark:text-blue-800-dark ${tab.value === "archived" && "bg-transparent text-blue-600 dark:text-blue-600-dark lg:text-blue-800 lg:dark:text-blue-800-dark"}` + : "" + } + key={tab.value} + value={tab.value} + href={tab.href} + > + {tab.label} + + ))} + + + ); +}; + +export default TournamentsTabsShell; diff --git a/front_end/src/app/(main)/(tournaments)/tournaments/components/tournaments_container.tsx b/front_end/src/app/(main)/(tournaments)/tournaments/components/tournaments_container.tsx new file mode 100644 index 0000000000..26bde3e07a --- /dev/null +++ b/front_end/src/app/(main)/(tournaments)/tournaments/components/tournaments_container.tsx @@ -0,0 +1,14 @@ +import React, { PropsWithChildren } from "react"; + +const TournamentsContainer: React.FC = ({ children }) => { + return ( +
+ {children} +
+ ); +}; + +export default TournamentsContainer; diff --git a/front_end/src/app/(main)/(tournaments)/tournaments/components/tournaments_filter.tsx b/front_end/src/app/(main)/(tournaments)/tournaments/components/tournaments_filter.tsx new file mode 100644 index 0000000000..b2d207e3a2 --- /dev/null +++ b/front_end/src/app/(main)/(tournaments)/tournaments/components/tournaments_filter.tsx @@ -0,0 +1,61 @@ +"use client"; +import { useTranslations } from "next-intl"; +import { useCallback } from "react"; + +import Listbox, { SelectOption } from "@/components/ui/listbox"; +import { useBreakpoint } from "@/hooks/tailwind"; +import useSearchParams from "@/hooks/use_search_params"; +import { TournamentsSortBy } from "@/types/projects"; + +import { useTournamentsSection } from "./tournaments_provider"; +import { TOURNAMENTS_SORT } from "../constants/query_params"; + +const TournamentsFilter: React.FC = () => { + const t = useTranslations(); + const { closeInfo } = useTournamentsSection(); + const { params, setParam, shallowNavigateToSearchParams } = useSearchParams(); + const sortBy = + (params.get(TOURNAMENTS_SORT) as TournamentsSortBy) ?? + TournamentsSortBy.StartDateDesc; + + const sortOptions: SelectOption[] = [ + { + label: t("highestPrizePool"), + value: TournamentsSortBy.PrizePoolDesc, + }, + { + label: t("endingSoon"), + value: TournamentsSortBy.CloseDateAsc, + }, + { + label: t("newest"), + value: TournamentsSortBy.StartDateDesc, + }, + ]; + const handleSortByChange = (value: TournamentsSortBy) => { + setParam(TOURNAMENTS_SORT, value, false); + shallowNavigateToSearchParams(); + }; + + const isLg = useBreakpoint("lg"); + + const handleOpenChange = useCallback( + (open: boolean) => { + if (open) closeInfo(); + }, + [closeInfo] + ); + + return ( + + ); +}; + +export default TournamentsFilter; diff --git a/front_end/src/app/(main)/(tournaments)/tournaments/components/tournaments_grid/archived_tournaments_grid.tsx b/front_end/src/app/(main)/(tournaments)/tournaments/components/tournaments_grid/archived_tournaments_grid.tsx new file mode 100644 index 0000000000..ba65fea356 --- /dev/null +++ b/front_end/src/app/(main)/(tournaments)/tournaments/components/tournaments_grid/archived_tournaments_grid.tsx @@ -0,0 +1,29 @@ +"use client"; + +import React from "react"; + +import { TournamentType } from "@/types/projects"; + +import { useTournamentsSection } from "../tournaments_provider"; +import LiveTournamentCard from "./live_tournament_card"; +import QuestionSeriesCard from "./question_series_card"; +import TournamentsGrid from "./tournaments_grid"; + +const ArchivedTournamentsGrid: React.FC = () => { + const { items, nowTs } = useTournamentsSection(); + + return ( + { + if (item.type === TournamentType.QuestionSeries) { + return ; + } + + return ; + }} + /> + ); +}; + +export default ArchivedTournamentsGrid; diff --git a/front_end/src/app/(main)/(tournaments)/tournaments/components/tournaments_grid/index_tournament_card.tsx b/front_end/src/app/(main)/(tournaments)/tournaments/components/tournaments_grid/index_tournament_card.tsx new file mode 100644 index 0000000000..9273596674 --- /dev/null +++ b/front_end/src/app/(main)/(tournaments)/tournaments/components/tournaments_grid/index_tournament_card.tsx @@ -0,0 +1,160 @@ +"use client"; + +import { faList } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { useTranslations } from "next-intl"; +import React, { useMemo } from "react"; + +import { TournamentPreview } from "@/types/projects"; +import cn from "@/utils/core/cn"; + +import TournamentCardShell from "./tournament_card_shell"; + +type Props = { + item: TournamentPreview; +}; + +const IndexTournamentCard: React.FC = ({ item }) => { + const t = useTranslations(); + + const description = useMemo(() => { + return htmlBoldToText(item.description_preview || ""); + }, [item.description_preview]); + + return ( + +
+
+
+ {item.header_image ? ( + // eslint-disable-next-line @next/next/no-img-element + {item.name} + ) : ( +
+ +
+ )} +
+ +
+
+ {item.name} +
+ +

+ {t.rich("tournamentQuestionsCountUpper", { + count: item.questions_count ?? 0, + n: (chunks) => <>{chunks}, + })} +

+
+
+
+ +
+
+ {item.header_image ? ( + // eslint-disable-next-line @next/next/no-img-element + {item.name} + ) : ( +
+ +
+ )} +
+ +
+

+ {t.rich("tournamentQuestionsCountUpper", { + count: item.questions_count ?? 0, + n: (chunks) => ( + + {chunks} + + ), + })} +

+ +
+ {item.name} +
+ + {description ? ( +

+ {description} +

+ ) : null} +
+
+
+ ); +}; + +function htmlBoldToText(html: string): string { + const raw = (html ?? "").trim(); + if (!raw) return ""; + + if (typeof window !== "undefined" && "DOMParser" in window) { + const doc = new DOMParser().parseFromString(raw, "text/html"); + const boldNodes = doc.querySelectorAll("b, strong"); + const text = Array.from(boldNodes) + .map((n) => (n.textContent || "").trim()) + .filter(Boolean) + .join(" ") + .replace(/\s+/g, " ") + .trim(); + + return text; + } + + const matches = raw.match(/<(b|strong)[^>]*>(.*?)<\/\1>/gis) ?? []; + return matches + .map((m) => + m + .replace(/<[^>]*>/g, " ") + .replace(/\s+/g, " ") + .trim() + ) + .filter(Boolean) + .join(" ") + .trim(); +} + +export default IndexTournamentCard; diff --git a/front_end/src/app/(main)/(tournaments)/tournaments/components/tournaments_grid/index_tournaments_grid.tsx b/front_end/src/app/(main)/(tournaments)/tournaments/components/tournaments_grid/index_tournaments_grid.tsx new file mode 100644 index 0000000000..8ca7d8a87a --- /dev/null +++ b/front_end/src/app/(main)/(tournaments)/tournaments/components/tournaments_grid/index_tournaments_grid.tsx @@ -0,0 +1,21 @@ +"use client"; + +import React from "react"; + +import TournamentsGrid from "./tournaments_grid"; +import { useTournamentsSection } from "../tournaments_provider"; +import IndexTournamentCard from "./index_tournament_card"; + +const IndexTournamentsGrid: React.FC = () => { + const { items } = useTournamentsSection(); + + return ( + } + className="grid-cols-1 md:grid-cols-3 xl:grid-cols-3" + /> + ); +}; + +export default IndexTournamentsGrid; diff --git a/front_end/src/app/(main)/(tournaments)/tournaments/components/tournaments_grid/live_tournament_card.tsx b/front_end/src/app/(main)/(tournaments)/tournaments/components/tournaments_grid/live_tournament_card.tsx new file mode 100644 index 0000000000..af403b2a62 --- /dev/null +++ b/front_end/src/app/(main)/(tournaments)/tournaments/components/tournaments_grid/live_tournament_card.tsx @@ -0,0 +1,341 @@ +"use client"; + +import { faList, faUsers } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { useTranslations } from "next-intl"; +import React, { useMemo } from "react"; + +import { TournamentPreview, TournamentTimeline } from "@/types/projects"; +import cn from "@/utils/core/cn"; +import { bucketRelativeMs } from "@/utils/formatters/date"; + +import TournamentCardShell from "./tournament_card_shell"; + +type Props = { + item: TournamentPreview; + nowTs?: number; +}; + +const LiveTournamentCard: React.FC = ({ item, nowTs = 0 }) => { + const t = useTranslations(); + const prize = useMemo( + () => formatMoneyUSD(item.prize_pool), + [item.prize_pool] + ); + + return ( + +
+ {item.header_image ? ( + // eslint-disable-next-line @next/next/no-img-element + {item.name} + ) : ( +
+ +
+ )} +
+ +
+
+ {prize && ( + + {prize} + + )} + {prize ? ` ${t("tournamentPrizePool")}` : t("tournamentNoPrizePool")} +
+ +
+ {item.forecasters_count ?? 0} + +
+
+ +
+
+ {item.name} +
+ + +
+
+ ); +}; + +function TournamentTimelineBar({ + nowTs, + timeline, + startDate, + forecastingEndDate, + closeDate, + isOngoing, +}: { + nowTs: number | null; + timeline: TournamentTimeline | null; + startDate?: string | null; + forecastingEndDate?: string | null; + closeDate?: string | null; + isOngoing: boolean; +}) { + const startTs = safeTs(startDate); + const closedTs = safeTs(forecastingEndDate ?? closeDate ?? null); + if (!startTs || !closedTs) return null; + + const isClosed = + timeline?.all_questions_closed != null + ? Boolean(timeline.all_questions_closed) + : !isOngoing; + + const isResolved = Boolean(timeline?.all_questions_resolved); + + if (!isClosed) { + return ; + } + + return ( + + ); +} + +function ActiveMiniBar({ + nowTs, + startTs, + endTs, +}: { + nowTs: number | null; + startTs: number; + endTs: number; +}) { + const t = useTranslations(); + let label = t("tournamentTimelineOngoing"); + let p = 0; + + if (nowTs == null) { + label = t("tournamentTimelineOngoing"); + } else if (nowTs < startTs) { + label = t("tournamentTimelineStarts", { + when: formatRelative(t, startTs - nowTs), + }); + } else { + const sinceStart = nowTs - startTs; + label = + sinceStart < JUST_STARTED_MS + ? t("tournamentTimelineJustStarted") + : t("tournamentTimelineEnds", { + when: formatRelative(t, endTs - nowTs), + }); + + const total = Math.max(1, endTs - startTs); + p = clamp01((nowTs - startTs) / total); + } + + const pct = (p * 100).toFixed(4); + + return ( +
+

+ {label} +

+ +
+
+ + +
+
+ ); +} + +function Marker({ pct }: { pct: number }) { + const clamped = Math.max(0, Math.min(100, pct)); + const left = `${clamped}%`; + const thumbLeft = `clamp(5px, ${left}, calc(100% - 5px))`; + + return ( +
+ ); +} + +function ClosedMiniBar({ + nowTs, + isResolved, + timeline, + closeDate, +}: { + nowTs: number | null; + isResolved: boolean; + timeline: TournamentTimeline | null; + closeDate: string | null; +}) { + const t = useTranslations(); + const label = isResolved + ? t("tournamentTimelineAllResolved") + : t("tournamentTimelineClosed"); + let progress = isResolved ? 50 : 0; + + if (nowTs != null) { + const resolvedTs = pickResolveTs(nowTs, timeline); + const winnersTs = pickWinnersTs(resolvedTs, closeDate); + + if (resolvedTs && nowTs >= resolvedTs) progress = 50; + if (winnersTs && nowTs >= winnersTs) progress = 100; + if (isResolved) progress = Math.max(progress, 50); + } + + return ( +
+

+ {label} +

+ +
+
+ + + = 50} /> + = 100} /> +
+
+ ); +} + +function ClosedChip({ + left, + active, +}: { + left: "0%" | "50%" | "100%"; + active: boolean; +}) { + return ( +
+
+
+ ); +} + +function safeTs(iso?: string | null): number | null { + if (!iso) return null; + const t = new Date(iso).getTime(); + return Number.isFinite(t) ? t : null; +} + +function clamp01(x: number) { + return Math.max(0, Math.min(1, x)); +} + +function formatRelative( + t: ReturnType, + deltaMs: number +) { + const r = bucketRelativeMs(deltaMs); + if (r.kind === "soon") return t("tournamentRelativeSoon"); + if (r.kind === "farFuture") return t("tournamentRelativeFarFuture"); + if (r.kind === "underMinute") return t("tournamentRelativeUnderMinute"); + const { n, unit } = r.value; + + const unitLabel = + n === 1 + ? t("tournamentUnit", { unit }) + : t("tournamentUnitPlural", { unit }); + + return t("tournamentRelativeFromNow", { n, unit: unitLabel }); +} + +function formatMoneyUSD(amount: string | null | undefined) { + if (!amount) return null; + const n = Number(amount); + if (!Number.isFinite(n)) return null; + return n.toLocaleString("en-US", { + style: "currency", + currency: "USD", + currencyDisplay: "narrowSymbol", + maximumFractionDigits: 0, + }); +} + +function pickResolveTs(nowTs: number, timeline: TournamentTimeline | null) { + const scheduled = safeTs(timeline?.latest_scheduled_resolve_time); + const actual = safeTs(timeline?.latest_actual_resolve_time); + const isAllResolved = Boolean(timeline?.all_questions_resolved); + let effectiveScheduled = scheduled; + if (effectiveScheduled && nowTs >= effectiveScheduled && !isAllResolved) { + effectiveScheduled = nowTs + ONE_DAY_MS; + } + + return (isAllResolved ? actual : null) ?? effectiveScheduled ?? null; +} + +function pickWinnersTs(resolvedTs: number | null, closeDate: string | null) { + const closeTs = safeTs(closeDate); + if (closeTs) return closeTs; + return resolvedTs ? resolvedTs + TWO_WEEKS_MS : null; +} + +const ONE_DAY_MS = 24 * 60 * 60 * 1000; +const TWO_WEEKS_MS = 14 * ONE_DAY_MS; +const JUST_STARTED_MS = 36 * 60 * 60 * 1000; + +export default LiveTournamentCard; diff --git a/front_end/src/app/(main)/(tournaments)/tournaments/components/tournaments_grid/live_tournaments_grid.tsx b/front_end/src/app/(main)/(tournaments)/tournaments/components/tournaments_grid/live_tournaments_grid.tsx new file mode 100644 index 0000000000..82c7129614 --- /dev/null +++ b/front_end/src/app/(main)/(tournaments)/tournaments/components/tournaments_grid/live_tournaments_grid.tsx @@ -0,0 +1,22 @@ +"use client"; + +import React from "react"; + +import TournamentsGrid from "./tournaments_grid"; +import { useTournamentsSection } from "../tournaments_provider"; +import LiveTournamentCard from "./live_tournament_card"; + +const LiveTournamentsGrid: React.FC = () => { + const { items, nowTs } = useTournamentsSection(); + + return ( + ( + + )} + /> + ); +}; + +export default LiveTournamentsGrid; diff --git a/front_end/src/app/(main)/(tournaments)/tournaments/components/tournaments_grid/question_series_card.tsx b/front_end/src/app/(main)/(tournaments)/tournaments/components/tournaments_grid/question_series_card.tsx new file mode 100644 index 0000000000..30b5765f2b --- /dev/null +++ b/front_end/src/app/(main)/(tournaments)/tournaments/components/tournaments_grid/question_series_card.tsx @@ -0,0 +1,60 @@ +"use client"; + +import { faList } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { useTranslations } from "next-intl"; +import React from "react"; + +import { TournamentPreview } from "@/types/projects"; + +import TournamentCardShell from "./tournament_card_shell"; + +type Props = { item: TournamentPreview }; + +const QuestionSeriesCard: React.FC = ({ item }) => { + const t = useTranslations(); + const questionsCount = item.questions_count ?? 0; + + return ( + +
+ {item.header_image ? ( + // eslint-disable-next-line @next/next/no-img-element + {item.name} + ) : ( +
+ +
+ )} +
+ +
+

+ {t.rich("tournamentQuestionsCount", { + count: questionsCount, + num: (chunks) => ( + + {chunks} + + ), + })} +

+ +
+ {item.name} +
+
+
+ ); +}; + +export default QuestionSeriesCard; diff --git a/front_end/src/app/(main)/(tournaments)/tournaments/components/tournaments_grid/series_tournaments_grid.tsx b/front_end/src/app/(main)/(tournaments)/tournaments/components/tournaments_grid/series_tournaments_grid.tsx new file mode 100644 index 0000000000..47c5daf826 --- /dev/null +++ b/front_end/src/app/(main)/(tournaments)/tournaments/components/tournaments_grid/series_tournaments_grid.tsx @@ -0,0 +1,20 @@ +"use client"; + +import React from "react"; + +import TournamentsGrid from "./tournaments_grid"; +import { useTournamentsSection } from "../tournaments_provider"; +import QuestionSeriesCard from "./question_series_card"; + +const SeriesTournamentsGrid: React.FC = () => { + const { items } = useTournamentsSection(); + + return ( + } + /> + ); +}; + +export default SeriesTournamentsGrid; diff --git a/front_end/src/app/(main)/(tournaments)/tournaments/components/tournaments_grid/tournament_card_shell.tsx b/front_end/src/app/(main)/(tournaments)/tournaments/components/tournaments_grid/tournament_card_shell.tsx new file mode 100644 index 0000000000..554001c6f5 --- /dev/null +++ b/front_end/src/app/(main)/(tournaments)/tournaments/components/tournaments_grid/tournament_card_shell.tsx @@ -0,0 +1,39 @@ +"use client"; + +import Link from "next/link"; +import React, { PropsWithChildren, useMemo } from "react"; + +import { TournamentPreview } from "@/types/projects"; +import cn from "@/utils/core/cn"; +import { getProjectLink } from "@/utils/navigation"; + +type Props = PropsWithChildren<{ + item: TournamentPreview; + className?: string; +}>; + +const TournamentCardShell: React.FC = ({ + item, + className, + children, +}) => { + const href = useMemo(() => getProjectLink(item), [item]); + + return ( + + {children} + + ); +}; + +export default TournamentCardShell; diff --git a/front_end/src/app/(main)/(tournaments)/tournaments/components/tournaments_grid/tournaments_grid.tsx b/front_end/src/app/(main)/(tournaments)/tournaments/components/tournaments_grid/tournaments_grid.tsx new file mode 100644 index 0000000000..29c5363d41 --- /dev/null +++ b/front_end/src/app/(main)/(tournaments)/tournaments/components/tournaments_grid/tournaments_grid.tsx @@ -0,0 +1,33 @@ +"use client"; + +import React from "react"; + +import { TournamentPreview } from "@/types/projects"; +import cn from "@/utils/core/cn"; + +type Props = { + items: TournamentPreview[]; + renderItem?: (item: TournamentPreview) => React.ReactNode; + className?: string; +}; + +const TournamentsGrid: React.FC = ({ items, renderItem, className }) => { + return ( +
+ {items.map((item) => + renderItem ? ( + renderItem(item) + ) : ( +
+ ) + )} +
+ ); +}; + +export default TournamentsGrid; diff --git a/front_end/src/app/(main)/(tournaments)/tournaments/components/tournaments_header.tsx b/front_end/src/app/(main)/(tournaments)/tournaments/components/tournaments_header.tsx new file mode 100644 index 0000000000..5961785681 --- /dev/null +++ b/front_end/src/app/(main)/(tournaments)/tournaments/components/tournaments_header.tsx @@ -0,0 +1,91 @@ +"use client"; + +import React, { useEffect, useRef, useState } from "react"; + +import { useBreakpoint } from "@/hooks/tailwind"; +import cn from "@/utils/core/cn"; + +import TournamentsTabs from "./tournament_tabs"; +import TournamentsFilter from "./tournaments_filter"; +import TournamentsInfoPopover from "./tournaments_popover/tournaments_info_popover"; +import { useTournamentsSection } from "./tournaments_provider"; +import TournamentsSearch from "./tournaments_search"; + +const STICKY_TOP = 48; +const POPOVER_GAP = 10; + +const TournamentsHeader: React.FC = () => { + const { current, infoOpen, toggleInfo, closeInfo } = useTournamentsSection(); + + const sentinelRef = useRef(null); + const isLg = useBreakpoint("lg"); + const [stuck, setStuck] = useState(!isLg); + + useEffect(() => { + const el = sentinelRef.current; + if (!el || !isLg) return; + + const obs = new IntersectionObserver( + ([entry]) => setStuck(!entry?.isIntersecting), + { + root: null, + threshold: 0, + rootMargin: `-${STICKY_TOP}px 0px 0px 0px`, + } + ); + + obs.observe(el); + return () => obs.disconnect(); + }, [isLg]); + + const showInfo = true; + + return ( + <> +
+ +
+
+
+
+ +
+ + {current !== "indexes" && ( +
+ + + + {showInfo && isLg ? ( + (next ? toggleInfo() : closeInfo())} + offsetPx={POPOVER_GAP} + stickyTopPx={STICKY_TOP} + /> + ) : null} +
+ )} +
+
+
+ + ); +}; + +const popoverSafeGlassClasses = cn( + "bg-white/70 dark:bg-slate-950/45", + "backdrop-blur-md supports-[backdrop-filter]:backdrop-blur-md", + "border-b border-blue-400/50 dark:border-blue-400-dark/50" +); + +export default TournamentsHeader; diff --git a/front_end/src/app/(main)/(tournaments)/tournaments/components/tournaments_hero.tsx b/front_end/src/app/(main)/(tournaments)/tournaments/components/tournaments_hero.tsx new file mode 100644 index 0000000000..7f74af5966 --- /dev/null +++ b/front_end/src/app/(main)/(tournaments)/tournaments/components/tournaments_hero.tsx @@ -0,0 +1,53 @@ +"use client"; + +import { useTranslations } from "next-intl"; +import React from "react"; + +import { useTournamentsSection } from "./tournaments_provider"; +import { TournamentsSection } from "../types"; + +const HERO_KEYS = { + live: { + titleKey: "tournamentsHeroLiveTitle", + shownKey: "tournamentsHeroLiveShown", + }, + series: { + titleKey: "tournamentsHeroSeriesTitle", + shownKey: "tournamentsHeroSeriesShown", + }, + indexes: { + titleKey: "tournamentsHeroIndexesTitle", + shownKey: "tournamentsHeroIndexesShown", + }, + archived: null, +} as const satisfies Record< + TournamentsSection, + { titleKey: string; shownKey: string } | null +>; + +const TournamentsHero: React.FC = () => { + const t = useTranslations(); + const { current, count } = useTournamentsSection(); + + const keys = HERO_KEYS[current]; + if (!keys) return null; + + type RichKey = Parameters[0]; + type PlainKey = Parameters[0]; + + return ( +
+

+ {t.rich(keys.titleKey as RichKey, { + br: () =>
, + })} +

+ +

+ {t(keys.shownKey as PlainKey, { count })} +

+
+ ); +}; + +export default TournamentsHero; diff --git a/front_end/src/app/(main)/(tournaments)/tournaments/components/tournaments_list.tsx b/front_end/src/app/(main)/(tournaments)/tournaments/components/tournaments_list.tsx deleted file mode 100644 index 42e52aea94..0000000000 --- a/front_end/src/app/(main)/(tournaments)/tournaments/components/tournaments_list.tsx +++ /dev/null @@ -1,145 +0,0 @@ -"use client"; -import { differenceInMilliseconds } from "date-fns"; -import { useTranslations } from "next-intl"; -import { FC, useEffect, useMemo, useState } from "react"; - -import TournamentCard from "@/components/tournament_card"; -import Button from "@/components/ui/button"; -import useSearchParams from "@/hooks/use_search_params"; -import { - TournamentPreview, - TournamentsSortBy, - TournamentType, -} from "@/types/projects"; -import { getProjectLink } from "@/utils/navigation"; - -import { - TOURNAMENTS_SEARCH, - TOURNAMENTS_SORT, -} from "../constants/query_params"; - -type Props = { - items: TournamentPreview[]; - title: string; - cardsPerPage: number; - initialCardsCount?: number; - withEmptyState?: boolean; - disableClientSort?: boolean; -}; - -const TournamentsList: FC = ({ - items, - title, - cardsPerPage, - initialCardsCount, - withEmptyState, - disableClientSort = false, -}) => { - const t = useTranslations(); - const { params } = useSearchParams(); - - const searchString = params.get(TOURNAMENTS_SEARCH) ?? ""; - const sortBy: TournamentsSortBy | null = disableClientSort - ? null - : (params.get(TOURNAMENTS_SORT) as TournamentsSortBy | null) ?? - TournamentsSortBy.StartDateDesc; - - const filteredItems = useMemo( - () => filterItems(items, decodeURIComponent(searchString), sortBy), - [items, searchString, sortBy] - ); - - const [displayItemsCount, setDisplayItemsCount] = useState( - initialCardsCount ?? cardsPerPage - ); - const hasMoreItems = displayItemsCount < filteredItems.length; - // reset pagination when filter applied - useEffect(() => { - setDisplayItemsCount(initialCardsCount ?? cardsPerPage); - }, [cardsPerPage, filteredItems.length, initialCardsCount]); - - if (!withEmptyState && filteredItems.length === 0) { - return null; - } - - return ( - <> -

{title}

- {filteredItems.length === 0 && withEmptyState && ( -
{t("noResults")}
- )} -
-
- {filteredItems.slice(0, displayItemsCount).map((item) => ( - - ))} -
- {hasMoreItems && ( -
- -
- )} -
- - ); -}; - -function filterItems( - items: TournamentPreview[], - searchString: string, - sortBy: TournamentsSortBy | null -) { - let filteredItems; - - if (searchString) { - const sanitizedSearchString = searchString.trim().toLowerCase(); - const words = sanitizedSearchString.split(/\s+/); - - filteredItems = items.filter((item) => - words.every((word) => item.name.toLowerCase().includes(word)) - ); - } else { - filteredItems = items; - } - - if (!sortBy) { - return filteredItems; - } - - return [...filteredItems].sort((a, b) => { - switch (sortBy) { - case TournamentsSortBy.PrizePoolDesc: - return Number(b.prize_pool) - Number(a.prize_pool); - case TournamentsSortBy.CloseDateAsc: - return differenceInMilliseconds( - new Date(a.close_date ?? 0), - new Date(b.close_date ?? 0) - ); - case TournamentsSortBy.StartDateDesc: - return differenceInMilliseconds( - new Date(b.start_date), - new Date(a.start_date) - ); - default: - return 0; - } - }); -} - -export default TournamentsList; diff --git a/front_end/src/app/(main)/(tournaments)/tournaments/components/tournaments_mobile_ctrl.tsx b/front_end/src/app/(main)/(tournaments)/tournaments/components/tournaments_mobile_ctrl.tsx new file mode 100644 index 0000000000..dda37d5780 --- /dev/null +++ b/front_end/src/app/(main)/(tournaments)/tournaments/components/tournaments_mobile_ctrl.tsx @@ -0,0 +1,32 @@ +"use client"; + +import React, { useState } from "react"; + +import TournamentsFilter from "./tournaments_filter"; +import TournamentsInfo from "./tournaments_popover/tournaments_info"; +import TournamentsInfoButton from "./tournaments_popover/tournaments_info_button"; +import TournamentsSearch from "./tournaments_search"; + +const TournamentsMobileCtrl: React.FC = () => { + const [isInfoOpen, setIsInfoOpen] = useState(true); + + return ( +
+ {isInfoOpen && setIsInfoOpen(false)} />} +
+ + +
+ +
+ + setIsInfoOpen((p) => !p)} + /> +
+
+ ); +}; + +export default TournamentsMobileCtrl; diff --git a/front_end/src/app/(main)/(tournaments)/tournaments/components/tournaments_popover/tournaments_info.tsx b/front_end/src/app/(main)/(tournaments)/tournaments/components/tournaments_popover/tournaments_info.tsx new file mode 100644 index 0000000000..520864dab0 --- /dev/null +++ b/front_end/src/app/(main)/(tournaments)/tournaments/components/tournaments_popover/tournaments_info.tsx @@ -0,0 +1,63 @@ +"use client"; + +import { faXmark } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import Link from "next/link"; +import { useTranslations } from "next-intl"; +import React from "react"; + +import Button from "@/components/ui/button"; +import { useAuth } from "@/contexts/auth_context"; +import { useModal } from "@/contexts/modal_context"; + +type Props = { + onClose: () => void; +}; + +const TournamentsInfo: React.FC = ({ onClose }) => { + const t = useTranslations(); + const { user } = useAuth(); + const isLoggedOut = !user; + const { setCurrentModal } = useModal(); + const handleSignup = () => setCurrentModal({ type: "signup", data: {} }); + + return ( +
+
+ {t("tournamentsInfoTitle")} +
+ +
+ + {t("tournamentsInfoScoringLink")} + + + {t("tournamentsInfoPrizesLink")} + +
+ + {isLoggedOut && ( + + )} + + +
+ ); +}; + +export default TournamentsInfo; diff --git a/front_end/src/app/(main)/(tournaments)/tournaments/components/tournaments_popover/tournaments_info_button.tsx b/front_end/src/app/(main)/(tournaments)/tournaments/components/tournaments_popover/tournaments_info_button.tsx new file mode 100644 index 0000000000..36efbc5caa --- /dev/null +++ b/front_end/src/app/(main)/(tournaments)/tournaments/components/tournaments_popover/tournaments_info_button.tsx @@ -0,0 +1,43 @@ +import { useFloating } from "@floating-ui/react"; +import { useTranslations } from "next-intl"; + +import Button from "@/components/ui/button"; + +type Props = { + isOpen: boolean; + onClick?: () => void; + refs?: ReturnType["refs"]; + getReferenceProps?: ( + userProps?: React.HTMLProps + ) => Record; + disabled?: boolean; +}; + +const TournamentsInfoButton: React.FC = ({ + isOpen, + onClick, + refs, + disabled, + getReferenceProps, +}) => { + const t = useTranslations(); + + return ( + + ); +}; + +export default TournamentsInfoButton; diff --git a/front_end/src/app/(main)/(tournaments)/tournaments/components/tournaments_popover/tournaments_info_popover.tsx b/front_end/src/app/(main)/(tournaments)/tournaments/components/tournaments_popover/tournaments_info_popover.tsx new file mode 100644 index 0000000000..38af03acc6 --- /dev/null +++ b/front_end/src/app/(main)/(tournaments)/tournaments/components/tournaments_popover/tournaments_info_popover.tsx @@ -0,0 +1,110 @@ +"use client"; + +import { + autoUpdate, + flip, + FloatingPortal, + offset, + shift, + useClick, + useDismiss, + useFloating, + useInteractions, + useRole, +} from "@floating-ui/react"; +import React from "react"; + +import cn from "@/utils/core/cn"; + +import TournamentsInfo from "./tournaments_info"; +import TournamentsInfoButton from "./tournaments_info_button"; +import { useTournamentsSection } from "../tournaments_provider"; + +type Props = { + open: boolean; + onOpenChange: (next: boolean) => void; + disabled?: boolean; + offsetPx?: number; + stickyTopPx?: number; +}; + +const TournamentsInfoPopover: React.FC = ({ + open, + onOpenChange, + disabled, + offsetPx = 12, + stickyTopPx = 0, +}) => { + const { current } = useTournamentsSection(); + const { refs, floatingStyles, context, isPositioned } = useFloating({ + open, + onOpenChange, + placement: "bottom-end", + strategy: "fixed", + whileElementsMounted: autoUpdate, + middleware: [ + offset(({ rects }) => { + const header = document.getElementById("tournamentsStickyHeader"); + if (!header) return offsetPx; + + const headerBottom = header.getBoundingClientRect().bottom; + + const referenceBottom = rects.reference.y + rects.reference.height; + const needed = headerBottom + offsetPx - referenceBottom; + return Math.max(offsetPx, needed); + }), + flip({ padding: 12 }), + shift({ + padding: { + top: stickyTopPx + 8, + left: 12, + right: 12, + bottom: 12, + }, + }), + ], + }); + + const click = useClick(context, { enabled: !disabled }); + const dismiss = useDismiss(context, { outsidePress: false }); + const role = useRole(context, { role: "dialog" }); + + const { getReferenceProps, getFloatingProps } = useInteractions([ + click, + dismiss, + role, + ]); + + if (current === "series" || current === "indexes") { + return null; + } + + return ( + <> + + + {open ? ( + +
+ onOpenChange(false)} /> +
+
+ ) : null} + + ); +}; + +export default TournamentsInfoPopover; diff --git a/front_end/src/app/(main)/(tournaments)/tournaments/components/tournaments_provider.tsx b/front_end/src/app/(main)/(tournaments)/tournaments/components/tournaments_provider.tsx new file mode 100644 index 0000000000..410592fd3d --- /dev/null +++ b/front_end/src/app/(main)/(tournaments)/tournaments/components/tournaments_provider.tsx @@ -0,0 +1,69 @@ +"use client"; + +import React, { createContext, useContext, useMemo, useState } from "react"; + +import { TournamentPreview } from "@/types/projects"; + +import { selectTournamentsForSection } from "../helpers"; +import { useTournamentFilters } from "../hooks/use_tournament_filters"; +import { TournamentsSection } from "../types"; + +type TournamentsSectionCtxValue = { + current: TournamentsSection; + items: TournamentPreview[]; + count: number; + nowTs?: number; + infoOpen: boolean; + toggleInfo: () => void; + closeInfo: () => void; +}; + +const TournamentsSectionCtx = createContext( + null +); + +export function TournamentsSectionProvider(props: { + tournaments: TournamentPreview[]; + current: TournamentsSection; + children: React.ReactNode; + nowTs?: number; +}) { + const { tournaments, current, children, nowTs } = props; + const [infoOpen, setInfoOpen] = useState(true); + + const sectionItems = useMemo( + () => selectTournamentsForSection(tournaments, current), + [tournaments, current] + ); + + const { filtered } = useTournamentFilters(sectionItems); + + const value = useMemo( + () => ({ + current, + items: filtered, + count: filtered.length, + infoOpen, + nowTs, + toggleInfo: () => setInfoOpen((v) => !v), + closeInfo: () => setInfoOpen(false), + }), + [current, filtered, infoOpen, nowTs] + ); + + return ( + + {children} + + ); +} + +export function useTournamentsSection() { + const ctx = useContext(TournamentsSectionCtx); + if (!ctx) { + throw new Error( + "useTournamentsSection must be used within TournamentsSectionProvider" + ); + } + return ctx; +} diff --git a/front_end/src/app/(main)/(tournaments)/tournaments/components/tournaments_results.tsx b/front_end/src/app/(main)/(tournaments)/tournaments/components/tournaments_results.tsx new file mode 100644 index 0000000000..f43dde271d --- /dev/null +++ b/front_end/src/app/(main)/(tournaments)/tournaments/components/tournaments_results.tsx @@ -0,0 +1,60 @@ +"use client"; + +import { useSearchParams } from "next/navigation"; +import { useTranslations } from "next-intl"; +import React from "react"; + +import cn from "@/utils/core/cn"; + +import { useTournamentsSection } from "./tournaments_provider"; +import { TOURNAMENTS_SEARCH } from "../constants/query_params"; + +type Props = { + children: React.ReactNode; + className?: string; +}; + +const TournamentsResults: React.FC = ({ children, className }) => { + const t = useTranslations(); + const { count } = useTournamentsSection(); + const params = useSearchParams(); + + const q = (params.get(TOURNAMENTS_SEARCH) ?? "").trim(); + const isSearching = q.length > 0; + + type PlainKey = Parameters[0]; + + if (count > 0) { + return
{children}
; + } + + const titleKey = ( + isSearching ? "tournamentsEmptySearchTitle" : "tournamentsEmptyDefaultTitle" + ) as PlainKey; + + const bodyKey = ( + isSearching ? "tournamentsEmptySearchBody" : "tournamentsEmptyDefaultBody" + ) as PlainKey; + + return ( +
+
+

+ {t(titleKey, { count })} +

+ +

+ {t(bodyKey)} +

+
+
+ ); +}; + +export default TournamentsResults; diff --git a/front_end/src/app/(main)/(tournaments)/tournaments/components/tournaments_screen.tsx b/front_end/src/app/(main)/(tournaments)/tournaments/components/tournaments_screen.tsx new file mode 100644 index 0000000000..bee2fd8163 --- /dev/null +++ b/front_end/src/app/(main)/(tournaments)/tournaments/components/tournaments_screen.tsx @@ -0,0 +1,46 @@ +import React from "react"; + +import { TournamentPreview } from "@/types/projects"; + +import TournamentsContainer from "./tournaments_container"; +import TournamentsHeader from "./tournaments_header"; +import TournamentsHero from "./tournaments_hero"; +import TournamentsMobileCtrl from "./tournaments_mobile_ctrl"; +import { TournamentsSectionProvider } from "./tournaments_provider"; +import { TournamentsSection } from "../types"; +import TournamentsResults from "./tournaments_results"; + +type Props = { + current: TournamentsSection; + tournaments: TournamentPreview[]; + children: React.ReactNode; + nowTs?: number; +}; + +const TournamentsScreen: React.FC = ({ + current, + tournaments, + children, + nowTs, +}) => { + return ( + + + +
+ + + + {children} + +
+
+
+ ); +}; + +export default TournamentsScreen; diff --git a/front_end/src/app/(main)/(tournaments)/tournaments/components/tournaments_search.tsx b/front_end/src/app/(main)/(tournaments)/tournaments/components/tournaments_search.tsx new file mode 100644 index 0000000000..605278efd6 --- /dev/null +++ b/front_end/src/app/(main)/(tournaments)/tournaments/components/tournaments_search.tsx @@ -0,0 +1,44 @@ +"use client"; + +import { useTranslations } from "next-intl"; +import { ChangeEvent } from "react"; + +import ExpandableSearchInput from "@/components/expandable_search_input"; +import useSearchInputState from "@/hooks/use_search_input_state"; + +import { TOURNAMENTS_SEARCH } from "../constants/query_params"; + +const TournamentsSearch: React.FC = () => { + const t = useTranslations(); + const [searchQuery, setSearchQuery] = useSearchInputState( + TOURNAMENTS_SEARCH, + { + mode: "client", + debounceTime: 300, + modifySearchParams: true, + } + ); + + const handleSearchChange = (event: ChangeEvent) => { + setSearchQuery(event.target.value); + }; + + const handleSearchErase = () => { + setSearchQuery(""); + }; + + return ( +
+ +
+ ); +}; + +export default TournamentsSearch; diff --git a/front_end/src/app/(main)/(tournaments)/tournaments/helpers/index.ts b/front_end/src/app/(main)/(tournaments)/tournaments/helpers/index.ts new file mode 100644 index 0000000000..d668c6ab5b --- /dev/null +++ b/front_end/src/app/(main)/(tournaments)/tournaments/helpers/index.ts @@ -0,0 +1,39 @@ +import { isValid } from "date-fns"; +import { toDate } from "date-fns-tz"; + +import { TournamentPreview, TournamentType } from "@/types/projects"; + +import { TournamentsSection } from "../types"; + +const archiveEndTs = (t: TournamentPreview) => + [t.forecasting_end_date, t.close_date, t.start_date] + .map((s) => (s ? toDate(s.trim(), { timeZone: "UTC" }) : null)) + .find((d) => d && isValid(d)) + ?.getTime() ?? 0; + +export function selectTournamentsForSection( + tournaments: TournamentPreview[], + section: TournamentsSection +): TournamentPreview[] { + if (section === "archived") { + const archived = tournaments.filter((t) => !t.is_ongoing); + archived.sort((a, b) => archiveEndTs(b) - archiveEndTs(a)); + return archived; + } + + const ongoing = tournaments.filter((t) => t.is_ongoing); + + if (section === "series") { + return ongoing.filter((t) => t.type === TournamentType.QuestionSeries); + } + + if (section === "indexes") { + return ongoing.filter((t) => t.type === TournamentType.Index); + } + + return ongoing.filter( + (t) => + t.type !== TournamentType.QuestionSeries && + t.type !== TournamentType.Index + ); +} diff --git a/front_end/src/app/(main)/(tournaments)/tournaments/helpers/tournament_filters.ts b/front_end/src/app/(main)/(tournaments)/tournaments/helpers/tournament_filters.ts new file mode 100644 index 0000000000..67a29a63ea --- /dev/null +++ b/front_end/src/app/(main)/(tournaments)/tournaments/helpers/tournament_filters.ts @@ -0,0 +1,72 @@ +import { differenceInMilliseconds } from "date-fns"; + +import { TournamentPreview, TournamentsSortBy } from "@/types/projects"; + +import { + TOURNAMENTS_SEARCH, + TOURNAMENTS_SORT, +} from "../constants/query_params"; + +type ParamsLike = Pick; + +type Options = { + disableClientSort?: boolean; + defaultSort?: TournamentsSortBy; +}; + +export function filterTournamentsFromParams( + items: TournamentPreview[], + params: ParamsLike, + opts: Options = {} +) { + const searchString = params.get(TOURNAMENTS_SEARCH) ?? ""; + + const sortBy: TournamentsSortBy | null = opts.disableClientSort + ? null + : (params.get(TOURNAMENTS_SORT) as TournamentsSortBy | null) ?? + opts.defaultSort ?? + TournamentsSortBy.StartDateDesc; + + return filterTournaments(items, decodeURIComponent(searchString), sortBy); +} + +export function filterTournaments( + items: TournamentPreview[], + searchString: string, + sortBy: TournamentsSortBy | null +) { + let filtered = items; + + if (searchString) { + const sanitized = searchString.trim().toLowerCase(); + const words = sanitized.split(/\s+/); + + filtered = items.filter((item) => + words.every((word) => item.name.toLowerCase().includes(word)) + ); + } + + if (!sortBy) return filtered; + + return [...filtered].sort((a, b) => { + switch (sortBy) { + case TournamentsSortBy.PrizePoolDesc: + return Number(b.prize_pool) - Number(a.prize_pool); + + case TournamentsSortBy.CloseDateAsc: + return differenceInMilliseconds( + new Date(a.close_date ?? 0), + new Date(b.close_date ?? 0) + ); + + case TournamentsSortBy.StartDateDesc: + return differenceInMilliseconds( + new Date(b.start_date), + new Date(a.start_date) + ); + + default: + return 0; + } + }); +} diff --git a/front_end/src/app/(main)/(tournaments)/tournaments/hooks/use_tournament_filters.ts b/front_end/src/app/(main)/(tournaments)/tournaments/hooks/use_tournament_filters.ts new file mode 100644 index 0000000000..70d72b70a8 --- /dev/null +++ b/front_end/src/app/(main)/(tournaments)/tournaments/hooks/use_tournament_filters.ts @@ -0,0 +1,26 @@ +"use client"; + +import { useMemo } from "react"; + +import useSearchParams from "@/hooks/use_search_params"; +import { TournamentPreview } from "@/types/projects"; + +import { filterTournamentsFromParams } from "../helpers/tournament_filters"; + +type Options = { + disableClientSort?: boolean; +}; + +export function useTournamentFilters( + items: TournamentPreview[], + opts: Options = {} +) { + const { params } = useSearchParams(); + + const filtered = useMemo( + () => filterTournamentsFromParams(items, params, opts), + [items, params, opts] + ); + + return { filtered }; +} diff --git a/front_end/src/app/(main)/(tournaments)/tournaments/indexes/page.tsx b/front_end/src/app/(main)/(tournaments)/tournaments/indexes/page.tsx new file mode 100644 index 0000000000..fd9b837adb --- /dev/null +++ b/front_end/src/app/(main)/(tournaments)/tournaments/indexes/page.tsx @@ -0,0 +1,16 @@ +import ServerProjectsApi from "@/services/api/projects/projects.server"; + +import IndexTournamentsGrid from "../components/tournaments_grid/index_tournaments_grid"; +import TournamentsScreen from "../components/tournaments_screen"; + +const IndexesPage: React.FC = async () => { + const tournaments = await ServerProjectsApi.getTournaments(); + + return ( + + + + ); +}; + +export default IndexesPage; diff --git a/front_end/src/app/(main)/(tournaments)/tournaments/page.tsx b/front_end/src/app/(main)/(tournaments)/tournaments/page.tsx index cd1ada1d90..889b2dbdb5 100644 --- a/front_end/src/app/(main)/(tournaments)/tournaments/page.tsx +++ b/front_end/src/app/(main)/(tournaments)/tournaments/page.tsx @@ -1,14 +1,7 @@ -import { isValid } from "date-fns"; -import { toDate } from "date-fns-tz"; -import Link from "next/link"; -import { getTranslations } from "next-intl/server"; - import ServerProjectsApi from "@/services/api/projects/projects.server"; -import { TournamentPreview, TournamentType } from "@/types/projects"; -import { getPublicSettings } from "@/utils/public_settings.server"; -import TournamentFilters from "./components/tournament_filters"; -import TournamentsList from "./components/tournaments_list"; +import LiveTournamentsGrid from "./components/tournaments_grid/live_tournaments_grid"; +import TournamentsScreen from "./components/tournaments_screen"; export const metadata = { title: "Tournaments | Metaculus", @@ -16,106 +9,15 @@ export const metadata = { "Help the global community tackle complex challenges in Metaculus Tournaments. Prove your forecasting abilities, support impactful policy decisions, and compete for cash prizes.", }; -export default async function Tournaments() { - const t = await getTranslations(); - +const LiveTournamentsPage: React.FC = async () => { const tournaments = await ServerProjectsApi.getTournaments(); - const { activeTournaments, archivedTournaments, questionSeries, indexes } = - extractTournamentLists(tournaments); - - const { PUBLIC_MINIMAL_UI } = getPublicSettings(); + const nowTs = Date.now(); return ( -
- {!PUBLIC_MINIMAL_UI && ( -
-

- {t("tournaments")} -

-

{t("tournamentsHero1")}

-

{t("tournamentsHero2")}

-

- {t.rich("tournamentsHero3", { - scores: (chunks) => ( - {chunks} - ), - })} -

-

- {t.rich("tournamentsHero4", { - email: (chunks) => ( - {chunks} - ), - })} -

-
- )} - - - -
- - - - - - {indexes.length > 0 && ( -
- -
- )} - - -
+ + + ); -} - -const archiveEndTs = (t: TournamentPreview) => - [t.forecasting_end_date, t.close_date, t.start_date] - .map((s) => (s ? toDate(s.trim(), { timeZone: "UTC" }) : null)) - .find((d) => d && isValid(d)) - ?.getTime() ?? 0; - -function extractTournamentLists(tournaments: TournamentPreview[]) { - const activeTournaments: TournamentPreview[] = []; - const archivedTournaments: TournamentPreview[] = []; - const questionSeries: TournamentPreview[] = []; - const indexes: TournamentPreview[] = []; - - for (const t of tournaments) { - if (t.is_ongoing) { - if (t.type === TournamentType.QuestionSeries) { - questionSeries.push(t); - } else if (t.type === TournamentType.Index) { - indexes.push(t); - } else { - activeTournaments.push(t); - } - } else { - archivedTournaments.push(t); - } - } +}; - archivedTournaments.sort((a, b) => archiveEndTs(b) - archiveEndTs(a)); - return { activeTournaments, archivedTournaments, questionSeries, indexes }; -} +export default LiveTournamentsPage; diff --git a/front_end/src/app/(main)/(tournaments)/tournaments/question-series/page.tsx b/front_end/src/app/(main)/(tournaments)/tournaments/question-series/page.tsx new file mode 100644 index 0000000000..c7a00455c0 --- /dev/null +++ b/front_end/src/app/(main)/(tournaments)/tournaments/question-series/page.tsx @@ -0,0 +1,16 @@ +import ServerProjectsApi from "@/services/api/projects/projects.server"; + +import SeriesTournamentsGrid from "../components/tournaments_grid/series_tournaments_grid"; +import TournamentsScreen from "../components/tournaments_screen"; + +const QuestionSeriesPage: React.FC = async () => { + const tournaments = await ServerProjectsApi.getTournaments(); + + return ( + + + + ); +}; + +export default QuestionSeriesPage; diff --git a/front_end/src/app/(main)/(tournaments)/tournaments/types/index.ts b/front_end/src/app/(main)/(tournaments)/tournaments/types/index.ts new file mode 100644 index 0000000000..44bb9ee2ec --- /dev/null +++ b/front_end/src/app/(main)/(tournaments)/tournaments/types/index.ts @@ -0,0 +1,6 @@ +export type TournamentsSection = "live" | "series" | "indexes" | "archived"; +export type Section = { + value: TournamentsSection; + href: string; + label: string; +}; diff --git a/front_end/src/components/expandable_search_input.tsx b/front_end/src/components/expandable_search_input.tsx new file mode 100644 index 0000000000..d3accaf603 --- /dev/null +++ b/front_end/src/components/expandable_search_input.tsx @@ -0,0 +1,132 @@ +"use client"; + +import { faMagnifyingGlass } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import React, { + ChangeEventHandler, + FC, + useEffect, + useMemo, + useRef, + useState, +} from "react"; + +import SearchInput from "@/components/search_input"; +import Button from "@/components/ui/button"; +import cn from "@/utils/core/cn"; + +type Props = { + value: string; + onChange: ChangeEventHandler; + onErase: () => void; + placeholder?: string; + collapsedWidthClassName?: string; + expandedWidthClassName?: string; + keepOpenWhenHasValue?: boolean; + collapseOnBlur?: boolean; + collapseOnErase?: boolean; + className?: string; + buttonClassName?: string; + inputClassName?: string; +}; + +const ExpandableSearchInput: FC = ({ + value, + onChange, + onErase, + placeholder = "search...", + collapsedWidthClassName = "w-9", + expandedWidthClassName = "w-[220px]", + keepOpenWhenHasValue = true, + collapseOnBlur = true, + collapseOnErase = true, + className, + buttonClassName, + inputClassName, +}) => { + const [open, setOpen] = useState(false); + const rootRef = useRef(null); + const inputRef = useRef(null); + const isExpanded = useMemo(() => { + if (open) return true; + if (keepOpenWhenHasValue && value) return true; + return false; + }, [open, keepOpenWhenHasValue, value]); + + useEffect(() => { + if (isExpanded) inputRef.current?.focus(); + }, [isExpanded]); + + const collapseIfAllowed = () => { + if (!collapseOnBlur) return; + if (keepOpenWhenHasValue && value) return; + setOpen(false); + }; + + const handleErase = () => { + onErase(); + if (collapseOnErase) setOpen(false); + inputRef.current?.blur(); + }; + + return ( +
{ + const next = e.relatedTarget as Node | null; + if (next && rootRef.current?.contains(next)) return; + collapseIfAllowed(); + }} + onKeyDownCapture={(e) => { + if (e.key === "Escape") { + if (!(keepOpenWhenHasValue && value)) setOpen(false); + (e.target as HTMLElement)?.blur?.(); + } + }} + > + {!isExpanded ? ( + + ) : ( + + )} +
+ ); +}; + +export default ExpandableSearchInput; diff --git a/front_end/src/components/search_input.tsx b/front_end/src/components/search_input.tsx index 0957a6aa70..b1129689a0 100644 --- a/front_end/src/components/search_input.tsx +++ b/front_end/src/components/search_input.tsx @@ -1,12 +1,13 @@ import { faMagnifyingGlass, faXmark } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { Field, Input } from "@headlessui/react"; -import { ChangeEventHandler, FC, FormEvent } from "react"; +import React, { ChangeEventHandler, FC, FormEvent } from "react"; import Button from "@/components/ui/button"; import cn from "@/utils/core/cn"; type Size = "base" | "lg"; +type IconPosition = "right" | "left"; type Props = { value: string; @@ -20,6 +21,10 @@ type Props = { eraseButtonClassName?: string; submitButtonClassName?: string; submitIconClassName?: string; + iconPosition?: IconPosition; + rightControlsClassName?: string; + rightButtonClassName?: string; + inputRef?: React.Ref; }; const SearchInput: FC = ({ @@ -34,10 +39,17 @@ const SearchInput: FC = ({ eraseButtonClassName, submitButtonClassName, submitIconClassName, + iconPosition = "right", + rightControlsClassName, + rightButtonClassName, + inputRef, }) => { + const isForm = !!onSubmit; + const isLeft = iconPosition === "left"; + return ( = ({ onSubmit?.(value); }} > + {isLeft && ( + + + + )} + - + + {!!value && ( )} - + + {!isLeft && ( + + )} ); diff --git a/front_end/src/components/ui/listbox.tsx b/front_end/src/components/ui/listbox.tsx index cf97026ad6..67e4df2abc 100644 --- a/front_end/src/components/ui/listbox.tsx +++ b/front_end/src/components/ui/listbox.tsx @@ -49,6 +49,7 @@ type Props = { renderInPortal?: boolean; preventParentScroll?: boolean; menuMinWidthMatchesButton?: boolean; + onOpenChange?: (open: boolean) => void; } & (SingleSelectProps | MultiSelectProps); const Listbox = (props: Props) => { @@ -95,6 +96,7 @@ const Listbox = (props: Props) => { > {({ open }) => ( <> + ({ return {menu}; } +function OpenStateReporter({ + open, + onOpenChange, +}: { + open: boolean; + onOpenChange?: (open: boolean) => void; +}) { + useEffect(() => { + onOpenChange?.(open); + }, [open, onOpenChange]); + + return null; +} + export default Listbox; diff --git a/front_end/src/types/projects.ts b/front_end/src/types/projects.ts index e97689858d..5291f4fa23 100644 --- a/front_end/src/types/projects.ts +++ b/front_end/src/types/projects.ts @@ -69,6 +69,8 @@ export type TournamentPreview = Project & { default_permission: ProjectPermissions | null; score_type: string; followers_count?: number; + timeline: TournamentTimeline; + description_preview?: string; }; export type TournamentTimeline = { diff --git a/front_end/src/utils/formatters/date.ts b/front_end/src/utils/formatters/date.ts index 2dc20cb5f9..f41dc76469 100644 --- a/front_end/src/utils/formatters/date.ts +++ b/front_end/src/utils/formatters/date.ts @@ -6,6 +6,82 @@ import { } from "date-fns"; import { es, cs, pt, zhTW, zhCN, enUS } from "date-fns/locale"; +export const DURATION_KEYS = [ + "years", + "months", + "weeks", + "days", + "hours", + "minutes", + "seconds", +] as const; + +export type DurationKey = (typeof DURATION_KEYS)[number]; + +const UNIT_MS: Record = { + seconds: 1_000, + minutes: 60_000, + hours: 3_600_000, + days: 86_400_000, + weeks: 604_800_000, + months: 2_592_000_000, + years: 31_536_000_000, +} as const; + +type RelativeBucket = { + key: Exclude; + n: number; + unit: "minute" | "hour" | "day" | "week" | "month" | "year"; +}; + +export function bucketRelativeMs( + deltaMs: number +): + | { kind: "soon" } + | { kind: "underMinute" } + | { kind: "farFuture" } + | { kind: "bucket"; value: RelativeBucket } { + if (!Number.isFinite(deltaMs) || deltaMs <= 0) return { kind: "soon" }; + if (deltaMs > 20 * UNIT_MS.years) return { kind: "farFuture" }; + if (deltaMs < UNIT_MS.minutes) return { kind: "underMinute" }; + + const keys: RelativeBucket["key"][] = [ + "minutes", + "hours", + "days", + "weeks", + "months", + "years", + ]; + + for (const key of keys) { + const unitMs = UNIT_MS[key]; + const nextKey = keys[keys.indexOf(key) + 1]; + const upper = nextKey ? UNIT_MS[nextKey] : Infinity; + + if (deltaMs < upper) { + const n = Math.round(deltaMs / unitMs); + return { + kind: "bucket", + value: { + key, + n, + unit: key.replace(/s$/, "") as RelativeBucket["unit"], + }, + }; + } + } + + return { + kind: "bucket", + value: { + key: "years", + n: Math.round(deltaMs / UNIT_MS.years), + unit: "year", + }, + }; +} + export function formatDate(locale: string, date: Date) { return intlFormat( new Date(date), @@ -65,29 +141,19 @@ export function formatRelativeDate( export const truncateDuration = ( duration: Duration, - truncateNumUnits: number = 1 + truncateNumUnits = 1 ): Duration => { - const truncatedDuration: Duration = {}; + const truncated: Duration = {}; let numUnits = 0; - for (const key of [ - "years", - "months", - "weeks", - "days", - "hours", - "minutes", - "seconds", - ]) { - if (duration[key as keyof Duration] && numUnits < truncateNumUnits) { - numUnits++; - truncatedDuration[key as keyof Duration] = - duration[key as keyof Duration]; - } - if (numUnits >= truncateNumUnits) { - return truncatedDuration; + for (const key of DURATION_KEYS) { + if (duration[key] && numUnits < truncateNumUnits) { + numUnits++; + truncated[key] = duration[key]; } + if (numUnits >= truncateNumUnits) return truncated; } + return duration; }; diff --git a/projects/serializers/common.py b/projects/serializers/common.py index 428f3af1b6..d03bd68909 100644 --- a/projects/serializers/common.py +++ b/projects/serializers/common.py @@ -1,7 +1,7 @@ from collections import defaultdict -from typing import Any, Callable +from typing import Any, Callable, Iterable -from django.db.models import Q, QuerySet +from django.db.models import Q from rest_framework import serializers from rest_framework.exceptions import ValidationError @@ -74,6 +74,7 @@ class Meta: class TournamentShortSerializer(serializers.ModelSerializer): score_type = serializers.SerializerMethodField(read_only=True) is_current_content_translated = serializers.SerializerMethodField(read_only=True) + description_preview = serializers.SerializerMethodField(read_only=True) class Meta: model = Project @@ -97,8 +98,15 @@ class Meta: "visibility", "is_current_content_translated", "bot_leaderboard_status", + "description_preview", ) + def get_description_preview(self, project: Project) -> str: + raw = (project.description or "").strip() + if not raw: + return "" + return raw[:140].rstrip() + def get_score_type(self, project: Project) -> str | None: if not project.primary_leaderboard_id: return None @@ -253,12 +261,12 @@ def serialize_index_data(index: ProjectIndex): def serialize_tournaments_with_counts( - qs: QuerySet[Project], sort_key: Callable[[Project], Any] + projects: Iterable[Project], sort_key: Callable[[dict], Any] ) -> list[dict]: - projects: list[Project] = list(qs.all()) + projects = list(projects) questions_count_map = get_projects_questions_count_cached([p.id for p in projects]) - data = [] + data: list[dict] = [] for obj in projects: serialized_tournament = TournamentShortSerializer(obj).data serialized_tournament["questions_count"] = questions_count_map.get(obj.id) or 0 diff --git a/projects/services/cache.py b/projects/services/cache.py index 2200a84d71..63479fa4cf 100644 --- a/projects/services/cache.py +++ b/projects/services/cache.py @@ -1,10 +1,11 @@ from django.core.cache import cache from projects.models import Project -from .common import get_questions_count_for_projects +from .common import get_questions_count_for_projects, get_project_timeline_data QUESTIONS_COUNT_CACHE_PREFIX = "project_questions_count:v1" QUESTIONS_COUNT_CACHE_TIMEOUT = 1 * 3600 # 3 hour +PROJECT_TIMELINE_TTL_SECONDS = 5 * 360 def get_projects_questions_count_cache_key(project_id: int) -> str: @@ -46,3 +47,16 @@ def invalidate_projects_questions_count_cache(projects: list[Project]) -> None: get_projects_questions_count_cache_key(project.id) for project in projects ] cache.delete_many(cache_keys) + + +def get_project_timeline_data_cached(project: Project): + key = f"project_timeline:v1:{project.id}" + return cache.get_or_set( + key, + lambda: get_project_timeline_data(project), + PROJECT_TIMELINE_TTL_SECONDS, + ) + + +def get_projects_timeline_cached(projects: list[Project]) -> dict[int, dict]: + return {p.id: get_project_timeline_data_cached(p) for p in projects} diff --git a/projects/views/common.py b/projects/views/common.py index 2ec16457b1..c02dc334d3 100644 --- a/projects/views/common.py +++ b/projects/views/common.py @@ -21,7 +21,10 @@ serialize_index_data, serialize_tournaments_with_counts, ) -from projects.services.cache import get_projects_questions_count_cached +from projects.services.cache import ( + get_projects_questions_count_cached, + get_projects_timeline_cached, +) from projects.services.common import ( get_projects_qs, get_project_permission_for_user, @@ -132,12 +135,17 @@ def tournaments_list_api_view(request: Request): ) .exclude(visibility=Project.Visibility.UNLISTED) .filter_tournament() - .prefetch_related("primary_leaderboard") + .select_related("primary_leaderboard") ) - + projects = list(qs) data = serialize_tournaments_with_counts( - qs, sort_key=lambda x: x["questions_count"] + projects, sort_key=lambda r: r["questions_count"] ) + + timeline_map = get_projects_timeline_cached(projects) + for row in data: + row["timeline"] = timeline_map.get(row["id"]) + return Response(data)