From 420b572b6cb81292d96a2f395bf49215501c72b6 Mon Sep 17 00:00:00 2001 From: Nikita Date: Thu, 18 Dec 2025 19:22:18 +0200 Subject: [PATCH 01/25] feat: set up new tournaments page shell --- .../tournaments/archived/page.tsx | 15 ++ .../components/new/tournament_tabs.tsx | 35 +++++ .../components/new/tournaments-tabs-shell.tsx | 39 +++++ .../components/new/tournaments_container.tsx | 11 ++ .../components/new/tournaments_filter.tsx | 46 ++++++ .../archived_tournaments_grid.tsx | 23 +++ .../index_tournaments_grid.tsx | 24 ++++ .../live_tournaments_grid.tsx | 25 ++++ .../series_tournaments_grid.tsx | 25 ++++ .../new/tournaments_grid/tournaments_grid.tsx | 17 +++ .../components/new/tournaments_header.tsx | 59 ++++++++ .../components/new/tournaments_screen.tsx | 20 +++ .../components/old/old_tournaments_screen.tsx | 92 ++++++++++++ .../components/old/tournament_filters.tsx | 39 +++++ .../components/{ => old}/tournaments_list.tsx | 2 +- .../components/tournament_filters.tsx | 75 ---------- .../tournaments/helpers/index.ts | 63 ++++++++ .../tournaments/helpers/tournament_filters.ts | 72 ++++++++++ .../hooks/use_tournament_filters.ts | 26 ++++ .../tournaments/indexes/page.tsx | 16 +++ .../(main)/(tournaments)/tournaments/page.tsx | 124 +++------------- .../tournaments/question-series/page.tsx | 16 +++ .../(tournaments)/tournaments/types/index.ts | 6 + .../components/expandable_search_input.tsx | 136 ++++++++++++++++++ front_end/src/components/search_input.tsx | 77 +++++++--- 25 files changed, 886 insertions(+), 197 deletions(-) create mode 100644 front_end/src/app/(main)/(tournaments)/tournaments/archived/page.tsx create mode 100644 front_end/src/app/(main)/(tournaments)/tournaments/components/new/tournament_tabs.tsx create mode 100644 front_end/src/app/(main)/(tournaments)/tournaments/components/new/tournaments-tabs-shell.tsx create mode 100644 front_end/src/app/(main)/(tournaments)/tournaments/components/new/tournaments_container.tsx create mode 100644 front_end/src/app/(main)/(tournaments)/tournaments/components/new/tournaments_filter.tsx create mode 100644 front_end/src/app/(main)/(tournaments)/tournaments/components/new/tournaments_grid/archived_tournaments_grid.tsx create mode 100644 front_end/src/app/(main)/(tournaments)/tournaments/components/new/tournaments_grid/index_tournaments_grid.tsx create mode 100644 front_end/src/app/(main)/(tournaments)/tournaments/components/new/tournaments_grid/live_tournaments_grid.tsx create mode 100644 front_end/src/app/(main)/(tournaments)/tournaments/components/new/tournaments_grid/series_tournaments_grid.tsx create mode 100644 front_end/src/app/(main)/(tournaments)/tournaments/components/new/tournaments_grid/tournaments_grid.tsx create mode 100644 front_end/src/app/(main)/(tournaments)/tournaments/components/new/tournaments_header.tsx create mode 100644 front_end/src/app/(main)/(tournaments)/tournaments/components/new/tournaments_screen.tsx create mode 100644 front_end/src/app/(main)/(tournaments)/tournaments/components/old/old_tournaments_screen.tsx create mode 100644 front_end/src/app/(main)/(tournaments)/tournaments/components/old/tournament_filters.tsx rename front_end/src/app/(main)/(tournaments)/tournaments/components/{ => old}/tournaments_list.tsx (99%) delete mode 100644 front_end/src/app/(main)/(tournaments)/tournaments/components/tournament_filters.tsx create mode 100644 front_end/src/app/(main)/(tournaments)/tournaments/helpers/index.ts create mode 100644 front_end/src/app/(main)/(tournaments)/tournaments/helpers/tournament_filters.ts create mode 100644 front_end/src/app/(main)/(tournaments)/tournaments/hooks/use_tournament_filters.ts create mode 100644 front_end/src/app/(main)/(tournaments)/tournaments/indexes/page.tsx create mode 100644 front_end/src/app/(main)/(tournaments)/tournaments/question-series/page.tsx create mode 100644 front_end/src/app/(main)/(tournaments)/tournaments/types/index.ts create mode 100644 front_end/src/components/expandable_search_input.tsx 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..233cf88d9f --- /dev/null +++ b/front_end/src/app/(main)/(tournaments)/tournaments/archived/page.tsx @@ -0,0 +1,15 @@ +import ServerProjectsApi from "@/services/api/projects/projects.server"; + +import ArchivedTournamentsGrid from "../components/new/tournaments_grid/archived_tournaments_grid"; +import TournamentsScreen from "../components/new/tournaments_screen"; + +const ArchivedPage: React.FC = async () => { + const tournaments = await ServerProjectsApi.getTournaments(); + return ( + + + + ); +}; + +export default ArchivedPage; diff --git a/front_end/src/app/(main)/(tournaments)/tournaments/components/new/tournament_tabs.tsx b/front_end/src/app/(main)/(tournaments)/tournaments/components/new/tournament_tabs.tsx new file mode 100644 index 0000000000..6c98a3629b --- /dev/null +++ b/front_end/src/app/(main)/(tournaments)/tournaments/components/new/tournament_tabs.tsx @@ -0,0 +1,35 @@ +import React from "react"; + +import TournamentsTabsShell from "./tournaments-tabs-shell"; +import { Section, TournamentsSection } from "../../types"; + +type Props = { current: TournamentsSection }; + +const TournamentsTabs: React.FC = ({ current }) => { + const sections: Section[] = [ + { + value: "live", + href: "/tournaments", + label: "Live Tournaments", + }, + { + value: "series", + href: "/tournaments/question-series", + label: "Question Series", + }, + { + value: "indexes", + href: "/tournaments/indexes", + label: "Indexes", + }, + { + value: "archived", + href: "/tournaments/archived", + label: "Archived", + }, + ]; + + return ; +}; + +export default TournamentsTabs; diff --git a/front_end/src/app/(main)/(tournaments)/tournaments/components/new/tournaments-tabs-shell.tsx b/front_end/src/app/(main)/(tournaments)/tournaments/components/new/tournaments-tabs-shell.tsx new file mode 100644 index 0000000000..04732017b1 --- /dev/null +++ b/front_end/src/app/(main)/(tournaments)/tournaments/components/new/tournaments-tabs-shell.tsx @@ -0,0 +1,39 @@ +"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"}` + : "" + } + 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/new/tournaments_container.tsx b/front_end/src/app/(main)/(tournaments)/tournaments/components/new/tournaments_container.tsx new file mode 100644 index 0000000000..f973f683b8 --- /dev/null +++ b/front_end/src/app/(main)/(tournaments)/tournaments/components/new/tournaments_container.tsx @@ -0,0 +1,11 @@ +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/new/tournaments_filter.tsx b/front_end/src/app/(main)/(tournaments)/tournaments/components/new/tournaments_filter.tsx new file mode 100644 index 0000000000..02d721921b --- /dev/null +++ b/front_end/src/app/(main)/(tournaments)/tournaments/components/new/tournaments_filter.tsx @@ -0,0 +1,46 @@ +"use client"; +import { useTranslations } from "next-intl"; + +import Listbox, { SelectOption } from "@/components/ui/listbox"; +import useSearchParams from "@/hooks/use_search_params"; +import { TournamentsSortBy } from "@/types/projects"; + +import { TOURNAMENTS_SORT } from "../../constants/query_params"; + +const TournamentsFilter: React.FC = () => { + const t = useTranslations(); + 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(); + }; + + return ( + + ); +}; + +export default TournamentsFilter; diff --git a/front_end/src/app/(main)/(tournaments)/tournaments/components/new/tournaments_grid/archived_tournaments_grid.tsx b/front_end/src/app/(main)/(tournaments)/tournaments/components/new/tournaments_grid/archived_tournaments_grid.tsx new file mode 100644 index 0000000000..b4ac9400fe --- /dev/null +++ b/front_end/src/app/(main)/(tournaments)/tournaments/components/new/tournaments_grid/archived_tournaments_grid.tsx @@ -0,0 +1,23 @@ +"use client"; +import { TournamentPreview } from "@/types/projects"; + +import TournamentsGrid from "./tournaments_grid"; +import { selectTournamentsForSection } from "../../../helpers"; + +type Props = { + tournaments: TournamentPreview[]; +}; + +const ArchivedTournamentsGrid: React.FC = ({ tournaments }) => { + const list = selectTournamentsForSection(tournaments, "archived"); + return ( + ( +
Archived Tournaments ({filtered.length})
+ )} + /> + ); +}; + +export default ArchivedTournamentsGrid; diff --git a/front_end/src/app/(main)/(tournaments)/tournaments/components/new/tournaments_grid/index_tournaments_grid.tsx b/front_end/src/app/(main)/(tournaments)/tournaments/components/new/tournaments_grid/index_tournaments_grid.tsx new file mode 100644 index 0000000000..332d22d03d --- /dev/null +++ b/front_end/src/app/(main)/(tournaments)/tournaments/components/new/tournaments_grid/index_tournaments_grid.tsx @@ -0,0 +1,24 @@ +"use client"; +import { TournamentPreview } from "@/types/projects"; + +import TournamentsGrid from "./tournaments_grid"; +import { selectTournamentsForSection } from "../../../helpers"; + +type Props = { + tournaments: TournamentPreview[]; +}; + +const IndexTournamentsGrid: React.FC = ({ tournaments }) => { + const list = selectTournamentsForSection(tournaments, "indexes"); + + return ( + ( +
Indexes ({filtered.length})
+ )} + /> + ); +}; + +export default IndexTournamentsGrid; diff --git a/front_end/src/app/(main)/(tournaments)/tournaments/components/new/tournaments_grid/live_tournaments_grid.tsx b/front_end/src/app/(main)/(tournaments)/tournaments/components/new/tournaments_grid/live_tournaments_grid.tsx new file mode 100644 index 0000000000..1e8a7bf2b3 --- /dev/null +++ b/front_end/src/app/(main)/(tournaments)/tournaments/components/new/tournaments_grid/live_tournaments_grid.tsx @@ -0,0 +1,25 @@ +"use client"; +import React from "react"; + +import { TournamentPreview } from "@/types/projects"; + +import TournamentsGrid from "./tournaments_grid"; +import { selectTournamentsForSection } from "../../../helpers"; + +type Props = { + tournaments: TournamentPreview[]; +}; + +const LiveTournamentsGrid: React.FC = ({ tournaments }) => { + const list = selectTournamentsForSection(tournaments, "live"); + return ( + ( +
Live Tournaments ({filtered.length})
+ )} + /> + ); +}; + +export default LiveTournamentsGrid; diff --git a/front_end/src/app/(main)/(tournaments)/tournaments/components/new/tournaments_grid/series_tournaments_grid.tsx b/front_end/src/app/(main)/(tournaments)/tournaments/components/new/tournaments_grid/series_tournaments_grid.tsx new file mode 100644 index 0000000000..3fa1ba90bd --- /dev/null +++ b/front_end/src/app/(main)/(tournaments)/tournaments/components/new/tournaments_grid/series_tournaments_grid.tsx @@ -0,0 +1,25 @@ +"use client"; +import { TournamentPreview } from "@/types/projects"; + +import TournamentsGrid from "./tournaments_grid"; +import { selectTournamentsForSection } from "../../../helpers"; + +type Props = { + tournaments: TournamentPreview[]; +}; + +const SeriesTournamentsGrid: React.FC = ({ tournaments }) => { + const items = selectTournamentsForSection(tournaments, "series"); + return ( + ( +
+ Question Series Tournaments ({filtered.length}) +
+ )} + /> + ); +}; + +export default SeriesTournamentsGrid; diff --git a/front_end/src/app/(main)/(tournaments)/tournaments/components/new/tournaments_grid/tournaments_grid.tsx b/front_end/src/app/(main)/(tournaments)/tournaments/components/new/tournaments_grid/tournaments_grid.tsx new file mode 100644 index 0000000000..c31f8fff25 --- /dev/null +++ b/front_end/src/app/(main)/(tournaments)/tournaments/components/new/tournaments_grid/tournaments_grid.tsx @@ -0,0 +1,17 @@ +"use client"; + +import { TournamentPreview } from "@/types/projects"; + +import { useTournamentFilters } from "../../../hooks/use_tournament_filters"; + +type Props = { + items: TournamentPreview[]; + render: (items: TournamentPreview[]) => React.ReactNode; +}; + +const TournamentsGrid: React.FC = ({ items, render }) => { + const { filtered } = useTournamentFilters(items); + return
{render(filtered)}
; +}; + +export default TournamentsGrid; diff --git a/front_end/src/app/(main)/(tournaments)/tournaments/components/new/tournaments_header.tsx b/front_end/src/app/(main)/(tournaments)/tournaments/components/new/tournaments_header.tsx new file mode 100644 index 0000000000..b789fb48c2 --- /dev/null +++ b/front_end/src/app/(main)/(tournaments)/tournaments/components/new/tournaments_header.tsx @@ -0,0 +1,59 @@ +"use client"; + +import React, { ChangeEvent, useEffect, useState } from "react"; + +import ExpandableSearchInput from "@/components/expandable_search_input"; +import useSearchInputState from "@/hooks/use_search_input_state"; + +import TournamentsTabs from "./tournament_tabs"; +import TournamentsFilter from "./tournaments_filter"; +import { TOURNAMENTS_SEARCH } from "../../constants/query_params"; +import { TournamentsSection } from "../../types"; + +type Props = { current: TournamentsSection }; + +const TournamentsHeader: React.FC = ({ current }) => { + const [searchQuery, setSearchQuery] = useSearchInputState( + TOURNAMENTS_SEARCH, + { + mode: "client", + debounceTime: 300, + modifySearchParams: true, + } + ); + + const [draftQuery, setDraftQuery] = useState(searchQuery); + + useEffect(() => { + setDraftQuery(searchQuery); + }, [searchQuery]); + + const handleSearchChange = (event: ChangeEvent) => { + const next = event.target.value; + setDraftQuery(next); + setSearchQuery(next); + }; + + const handleSearchErase = () => { + setDraftQuery(""); + setSearchQuery(""); + }; + + return ( +
+ +
+ + +
+
+ ); +}; + +export default TournamentsHeader; diff --git a/front_end/src/app/(main)/(tournaments)/tournaments/components/new/tournaments_screen.tsx b/front_end/src/app/(main)/(tournaments)/tournaments/components/new/tournaments_screen.tsx new file mode 100644 index 0000000000..b20b314098 --- /dev/null +++ b/front_end/src/app/(main)/(tournaments)/tournaments/components/new/tournaments_screen.tsx @@ -0,0 +1,20 @@ +import React from "react"; + +import TournamentsContainer from "./tournaments_container"; +import TournamentsHeader from "./tournaments_header"; +import { TournamentsSection } from "../../types"; +type Props = { + current: TournamentsSection; + children: React.ReactNode; +}; + +const TournamentsScreen: React.FC = ({ current, children }) => { + return ( + + +
{children}
+
+ ); +}; + +export default TournamentsScreen; diff --git a/front_end/src/app/(main)/(tournaments)/tournaments/components/old/old_tournaments_screen.tsx b/front_end/src/app/(main)/(tournaments)/tournaments/components/old/old_tournaments_screen.tsx new file mode 100644 index 0000000000..28e21c5b56 --- /dev/null +++ b/front_end/src/app/(main)/(tournaments)/tournaments/components/old/old_tournaments_screen.tsx @@ -0,0 +1,92 @@ +import Link from "next/link"; +import { getTranslations } from "next-intl/server"; +import React from "react"; + +import { TournamentPreview } from "@/types/projects"; +import { getPublicSettings } from "@/utils/public_settings.server"; + +import TournamentFilters from "./tournament_filters"; +import TournamentsList from "./tournaments_list"; + +type Props = { + activeTournaments: TournamentPreview[]; + archivedTournaments: TournamentPreview[]; + questionSeries: TournamentPreview[]; + indexes: TournamentPreview[]; +}; + +const OldTournamentsScreen: React.FC = async ({ + activeTournaments, + archivedTournaments, + questionSeries, + indexes, +}) => { + const t = await getTranslations(); + const { PUBLIC_MINIMAL_UI } = getPublicSettings(); + + 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 && ( +
+ +
+ )} + + +
+ ); +}; + +export default OldTournamentsScreen; diff --git a/front_end/src/app/(main)/(tournaments)/tournaments/components/old/tournament_filters.tsx b/front_end/src/app/(main)/(tournaments)/tournaments/components/old/tournament_filters.tsx new file mode 100644 index 0000000000..3abe3f1732 --- /dev/null +++ b/front_end/src/app/(main)/(tournaments)/tournaments/components/old/tournament_filters.tsx @@ -0,0 +1,39 @@ +"use client"; +import { ChangeEvent, FC } from "react"; + +import SearchInput from "@/components/search_input"; +import useSearchInputState from "@/hooks/use_search_input_state"; + +import { TOURNAMENTS_SEARCH } from "../../constants/query_params"; +import TournamentsFilter from "../new/tournaments_filter"; + +const TournamentFilters: FC = () => { + 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 TournamentFilters; diff --git a/front_end/src/app/(main)/(tournaments)/tournaments/components/tournaments_list.tsx b/front_end/src/app/(main)/(tournaments)/tournaments/components/old/tournaments_list.tsx similarity index 99% rename from front_end/src/app/(main)/(tournaments)/tournaments/components/tournaments_list.tsx rename to front_end/src/app/(main)/(tournaments)/tournaments/components/old/tournaments_list.tsx index 42e52aea94..90d1dd711d 100644 --- a/front_end/src/app/(main)/(tournaments)/tournaments/components/tournaments_list.tsx +++ b/front_end/src/app/(main)/(tournaments)/tournaments/components/old/tournaments_list.tsx @@ -16,7 +16,7 @@ import { getProjectLink } from "@/utils/navigation"; import { TOURNAMENTS_SEARCH, TOURNAMENTS_SORT, -} from "../constants/query_params"; +} from "../../constants/query_params"; type Props = { items: TournamentPreview[]; 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/helpers/index.ts b/front_end/src/app/(main)/(tournaments)/tournaments/helpers/index.ts new file mode 100644 index 0000000000..dd90caa01f --- /dev/null +++ b/front_end/src/app/(main)/(tournaments)/tournaments/helpers/index.ts @@ -0,0 +1,63 @@ +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 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 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..217f1878da --- /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/new/tournaments_grid/index_tournaments_grid"; +import TournamentsScreen from "../components/new/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..cb8516b856 100644 --- a/front_end/src/app/(main)/(tournaments)/tournaments/page.tsx +++ b/front_end/src/app/(main)/(tournaments)/tournaments/page.tsx @@ -1,14 +1,9 @@ -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/new/tournaments_grid/live_tournaments_grid"; +import TournamentsScreen from "./components/new/tournaments_screen"; +import OldTournamentsScreen from "./components/old/old_tournaments_screen"; +import { extractTournamentLists } from "./helpers"; export const metadata = { title: "Tournaments | Metaculus", @@ -16,106 +11,29 @@ 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 isOldScreen = false; +const LiveTournamentsPage: React.FC = async () => { const tournaments = await ServerProjectsApi.getTournaments(); const { activeTournaments, archivedTournaments, questionSeries, indexes } = extractTournamentLists(tournaments); - const { PUBLIC_MINIMAL_UI } = getPublicSettings(); - - 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 && ( -
- -
- )} - - -
+ return ( + + + ); -} - -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..bee9af8592 --- /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/new/tournaments_grid/series_tournaments_grid"; +import TournamentsScreen from "../components/new/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..0d2e067f77 --- /dev/null +++ b/front_end/src/components/expandable_search_input.tsx @@ -0,0 +1,136 @@ +"use client"; + +import { faMagnifyingGlass } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import React, { + ChangeEventHandler, + FC, + useCallback, + useEffect, + 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; + className?: string; + inputClassName?: string; +}; + +const ExpandableSearchInput: FC = ({ + value, + onChange, + onErase, + placeholder = "search...", + collapsedWidthClassName = "w-9", + expandedWidthClassName = "w-[220px]", + keepOpenWhenHasValue = true, + collapseOnBlur = true, + className, + inputClassName, +}) => { + const [open, setOpen] = useState(false); + const rootRef = useRef(null); + const isExpanded = open || (keepOpenWhenHasValue && !!value); + + const getInputEl = useCallback( + () => + rootRef.current?.querySelector('input[type="search"]'), + [] + ); + + const focusInput = useCallback(() => { + requestAnimationFrame(() => getInputEl()?.focus()); + }, [getInputEl]); + + const blurInput = useCallback(() => { + requestAnimationFrame(() => getInputEl()?.blur()); + }, [getInputEl]); + + useEffect(() => { + if (isExpanded) focusInput(); + }, [isExpanded, focusInput]); + + const collapseIfAllowed = () => { + if (!collapseOnBlur) return; + if (keepOpenWhenHasValue && value) return; + setOpen(false); + }; + + return ( +
{ + const next = e.relatedTarget as Node | null; + if (next && rootRef.current?.contains(next)) return; + collapseIfAllowed(); + }} + onKeyDownCapture={(e) => { + if (e.key === "Escape") { + if (!value) setOpen(false); + (e.target as HTMLElement)?.blur?.(); + } + }} + > + {!isExpanded ? ( + + ) : ( + { + onErase(); + if (keepOpenWhenHasValue) setOpen(false); + blurInput(); + }} + placeholder={placeholder} + className="h-9 w-full" + iconPosition="left" + inputClassName={cn( + "h-9 border border-gray-300 bg-gray-0 text-sm font-medium", + "placeholder:text-gray-600 dark:placeholder:text-gray-600-dark", + "focus:outline-none focus:border-blue-500", + "dark:border-gray-500-dark dark:bg-gray-0-dark", + inputClassName + )} + submitIconClassName="text-gray-700 dark:text-gray-700-dark" + /> + )} +
+ ); +}; + +export default ExpandableSearchInput; diff --git a/front_end/src/components/search_input.tsx b/front_end/src/components/search_input.tsx index 0957a6aa70..82c66aba43 100644 --- a/front_end/src/components/search_input.tsx +++ b/front_end/src/components/search_input.tsx @@ -7,6 +7,7 @@ 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,9 @@ type Props = { eraseButtonClassName?: string; submitButtonClassName?: string; submitIconClassName?: string; + iconPosition?: IconPosition; + rightControlsClassName?: string; + rightButtonClassName?: string; }; const SearchInput: FC = ({ @@ -34,10 +38,16 @@ const SearchInput: FC = ({ eraseButtonClassName, submitButtonClassName, submitIconClassName, + iconPosition = "right", + rightControlsClassName, + rightButtonClassName, }) => { + const isForm = !!onSubmit; + const isLeft = iconPosition === "left"; + return ( = ({ onSubmit?.(value); }} > + {isLeft && ( + + + + )} + - + + {!!value && ( )} - + + {!isLeft && ( + + )} ); From 583f8de7451ef9d8a6ee030fc9766e1a7e778217 Mon Sep 17 00:00:00 2001 From: Nikita Date: Fri, 19 Dec 2025 15:03:45 +0200 Subject: [PATCH 02/25] refactor: introduce provider --- .../tournaments/archived/page.tsx | 4 +- .../archived_tournaments_grid.tsx | 22 +++----- .../index_tournaments_grid.tsx | 23 +++----- .../live_tournaments_grid.tsx | 22 +++----- .../series_tournaments_grid.tsx | 26 ++++----- .../new/tournaments_grid/tournaments_grid.tsx | 14 ++--- .../components/new/tournaments_header.tsx | 10 ++-- .../components/new/tournaments_provider.tsx | 55 +++++++++++++++++++ .../components/new/tournaments_screen.tsx | 17 +++++- .../tournaments/indexes/page.tsx | 4 +- .../(main)/(tournaments)/tournaments/page.tsx | 4 +- .../tournaments/question-series/page.tsx | 4 +- 12 files changed, 123 insertions(+), 82 deletions(-) create mode 100644 front_end/src/app/(main)/(tournaments)/tournaments/components/new/tournaments_provider.tsx diff --git a/front_end/src/app/(main)/(tournaments)/tournaments/archived/page.tsx b/front_end/src/app/(main)/(tournaments)/tournaments/archived/page.tsx index 233cf88d9f..05db394724 100644 --- a/front_end/src/app/(main)/(tournaments)/tournaments/archived/page.tsx +++ b/front_end/src/app/(main)/(tournaments)/tournaments/archived/page.tsx @@ -6,8 +6,8 @@ import TournamentsScreen from "../components/new/tournaments_screen"; const ArchivedPage: React.FC = async () => { const tournaments = await ServerProjectsApi.getTournaments(); return ( - - + + ); }; diff --git a/front_end/src/app/(main)/(tournaments)/tournaments/components/new/tournaments_grid/archived_tournaments_grid.tsx b/front_end/src/app/(main)/(tournaments)/tournaments/components/new/tournaments_grid/archived_tournaments_grid.tsx index b4ac9400fe..b6df91073f 100644 --- a/front_end/src/app/(main)/(tournaments)/tournaments/components/new/tournaments_grid/archived_tournaments_grid.tsx +++ b/front_end/src/app/(main)/(tournaments)/tournaments/components/new/tournaments_grid/archived_tournaments_grid.tsx @@ -1,22 +1,18 @@ "use client"; -import { TournamentPreview } from "@/types/projects"; + +import React from "react"; import TournamentsGrid from "./tournaments_grid"; -import { selectTournamentsForSection } from "../../../helpers"; +import { useTournamentsSection } from "../tournaments_provider"; -type Props = { - tournaments: TournamentPreview[]; -}; +const ArchivedTournamentsGrid: React.FC = () => { + const { items } = useTournamentsSection(); -const ArchivedTournamentsGrid: React.FC = ({ tournaments }) => { - const list = selectTournamentsForSection(tournaments, "archived"); return ( - ( -
Archived Tournaments ({filtered.length})
- )} - /> +
+
Archived Tournaments ({items.length})
+ +
); }; diff --git a/front_end/src/app/(main)/(tournaments)/tournaments/components/new/tournaments_grid/index_tournaments_grid.tsx b/front_end/src/app/(main)/(tournaments)/tournaments/components/new/tournaments_grid/index_tournaments_grid.tsx index 332d22d03d..0ba93d4982 100644 --- a/front_end/src/app/(main)/(tournaments)/tournaments/components/new/tournaments_grid/index_tournaments_grid.tsx +++ b/front_end/src/app/(main)/(tournaments)/tournaments/components/new/tournaments_grid/index_tournaments_grid.tsx @@ -1,23 +1,18 @@ "use client"; -import { TournamentPreview } from "@/types/projects"; -import TournamentsGrid from "./tournaments_grid"; -import { selectTournamentsForSection } from "../../../helpers"; +import React from "react"; -type Props = { - tournaments: TournamentPreview[]; -}; +import TournamentsGrid from "./tournaments_grid"; +import { useTournamentsSection } from "../tournaments_provider"; -const IndexTournamentsGrid: React.FC = ({ tournaments }) => { - const list = selectTournamentsForSection(tournaments, "indexes"); +const IndexTournamentsGrid: React.FC = () => { + const { items } = useTournamentsSection(); return ( - ( -
Indexes ({filtered.length})
- )} - /> +
+
Indexes ({items.length})
+ +
); }; diff --git a/front_end/src/app/(main)/(tournaments)/tournaments/components/new/tournaments_grid/live_tournaments_grid.tsx b/front_end/src/app/(main)/(tournaments)/tournaments/components/new/tournaments_grid/live_tournaments_grid.tsx index 1e8a7bf2b3..c6a36ff987 100644 --- a/front_end/src/app/(main)/(tournaments)/tournaments/components/new/tournaments_grid/live_tournaments_grid.tsx +++ b/front_end/src/app/(main)/(tournaments)/tournaments/components/new/tournaments_grid/live_tournaments_grid.tsx @@ -1,24 +1,18 @@ "use client"; -import React from "react"; -import { TournamentPreview } from "@/types/projects"; +import React from "react"; import TournamentsGrid from "./tournaments_grid"; -import { selectTournamentsForSection } from "../../../helpers"; +import { useTournamentsSection } from "../tournaments_provider"; -type Props = { - tournaments: TournamentPreview[]; -}; +const LiveTournamentsGrid: React.FC = () => { + const { items } = useTournamentsSection(); -const LiveTournamentsGrid: React.FC = ({ tournaments }) => { - const list = selectTournamentsForSection(tournaments, "live"); return ( - ( -
Live Tournaments ({filtered.length})
- )} - /> +
+
Live Tournaments ({items.length})
+ +
); }; diff --git a/front_end/src/app/(main)/(tournaments)/tournaments/components/new/tournaments_grid/series_tournaments_grid.tsx b/front_end/src/app/(main)/(tournaments)/tournaments/components/new/tournaments_grid/series_tournaments_grid.tsx index 3fa1ba90bd..18fde3cbbc 100644 --- a/front_end/src/app/(main)/(tournaments)/tournaments/components/new/tournaments_grid/series_tournaments_grid.tsx +++ b/front_end/src/app/(main)/(tournaments)/tournaments/components/new/tournaments_grid/series_tournaments_grid.tsx @@ -1,24 +1,20 @@ "use client"; -import { TournamentPreview } from "@/types/projects"; + +import React from "react"; import TournamentsGrid from "./tournaments_grid"; -import { selectTournamentsForSection } from "../../../helpers"; +import { useTournamentsSection } from "../tournaments_provider"; -type Props = { - tournaments: TournamentPreview[]; -}; +const SeriesTournamentsGrid: React.FC = () => { + const { items } = useTournamentsSection(); -const SeriesTournamentsGrid: React.FC = ({ tournaments }) => { - const items = selectTournamentsForSection(tournaments, "series"); return ( - ( -
- Question Series Tournaments ({filtered.length}) -
- )} - /> +
+
+ Question Series Tournaments ({items.length}) +
+ +
); }; diff --git a/front_end/src/app/(main)/(tournaments)/tournaments/components/new/tournaments_grid/tournaments_grid.tsx b/front_end/src/app/(main)/(tournaments)/tournaments/components/new/tournaments_grid/tournaments_grid.tsx index c31f8fff25..5db386e255 100644 --- a/front_end/src/app/(main)/(tournaments)/tournaments/components/new/tournaments_grid/tournaments_grid.tsx +++ b/front_end/src/app/(main)/(tournaments)/tournaments/components/new/tournaments_grid/tournaments_grid.tsx @@ -1,17 +1,13 @@ "use client"; -import { TournamentPreview } from "@/types/projects"; +import React from "react"; -import { useTournamentFilters } from "../../../hooks/use_tournament_filters"; +import { TournamentPreview } from "@/types/projects"; -type Props = { - items: TournamentPreview[]; - render: (items: TournamentPreview[]) => React.ReactNode; -}; +type Props = { items: TournamentPreview[] }; -const TournamentsGrid: React.FC = ({ items, render }) => { - const { filtered } = useTournamentFilters(items); - return
{render(filtered)}
; +const TournamentsGrid: React.FC = ({ items }) => { + return
{items.length}
; }; export default TournamentsGrid; diff --git a/front_end/src/app/(main)/(tournaments)/tournaments/components/new/tournaments_header.tsx b/front_end/src/app/(main)/(tournaments)/tournaments/components/new/tournaments_header.tsx index b789fb48c2..2464b9f014 100644 --- a/front_end/src/app/(main)/(tournaments)/tournaments/components/new/tournaments_header.tsx +++ b/front_end/src/app/(main)/(tournaments)/tournaments/components/new/tournaments_header.tsx @@ -7,12 +7,12 @@ import useSearchInputState from "@/hooks/use_search_input_state"; import TournamentsTabs from "./tournament_tabs"; import TournamentsFilter from "./tournaments_filter"; +import { useTournamentsSection } from "./tournaments_provider"; import { TOURNAMENTS_SEARCH } from "../../constants/query_params"; -import { TournamentsSection } from "../../types"; -type Props = { current: TournamentsSection }; +const TournamentsHeader: React.FC = () => { + const { current } = useTournamentsSection(); -const TournamentsHeader: React.FC = ({ current }) => { const [searchQuery, setSearchQuery] = useSearchInputState( TOURNAMENTS_SEARCH, { @@ -24,9 +24,7 @@ const TournamentsHeader: React.FC = ({ current }) => { const [draftQuery, setDraftQuery] = useState(searchQuery); - useEffect(() => { - setDraftQuery(searchQuery); - }, [searchQuery]); + useEffect(() => setDraftQuery(searchQuery), [searchQuery]); const handleSearchChange = (event: ChangeEvent) => { const next = event.target.value; diff --git a/front_end/src/app/(main)/(tournaments)/tournaments/components/new/tournaments_provider.tsx b/front_end/src/app/(main)/(tournaments)/tournaments/components/new/tournaments_provider.tsx new file mode 100644 index 0000000000..94b71f56d2 --- /dev/null +++ b/front_end/src/app/(main)/(tournaments)/tournaments/components/new/tournaments_provider.tsx @@ -0,0 +1,55 @@ +"use client"; + +import React, { createContext, useContext, useMemo } 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; +}; + +const TournamentsSectionCtx = createContext( + null +); + +export function TournamentsSectionProvider(props: { + tournaments: TournamentPreview[]; + current: TournamentsSection; + children: React.ReactNode; +}) { + const { tournaments, current, children } = props; + + const sectionItems = useMemo( + () => selectTournamentsForSection(tournaments, current), + [tournaments, current] + ); + + const { filtered } = useTournamentFilters(sectionItems); + + const value = useMemo( + () => ({ current, items: filtered, count: filtered.length }), + [current, filtered] + ); + + 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/new/tournaments_screen.tsx b/front_end/src/app/(main)/(tournaments)/tournaments/components/new/tournaments_screen.tsx index b20b314098..a198ba3bcb 100644 --- a/front_end/src/app/(main)/(tournaments)/tournaments/components/new/tournaments_screen.tsx +++ b/front_end/src/app/(main)/(tournaments)/tournaments/components/new/tournaments_screen.tsx @@ -1,18 +1,29 @@ import React from "react"; +import { TournamentPreview } from "@/types/projects"; + import TournamentsContainer from "./tournaments_container"; import TournamentsHeader from "./tournaments_header"; +import { TournamentsSectionProvider } from "./tournaments_provider"; import { TournamentsSection } from "../../types"; + type Props = { current: TournamentsSection; + tournaments: TournamentPreview[]; children: React.ReactNode; }; -const TournamentsScreen: React.FC = ({ current, children }) => { +const TournamentsScreen: React.FC = ({ + current, + tournaments, + children, +}) => { return ( - -
{children}
+ + +
{children}
+
); }; diff --git a/front_end/src/app/(main)/(tournaments)/tournaments/indexes/page.tsx b/front_end/src/app/(main)/(tournaments)/tournaments/indexes/page.tsx index 217f1878da..ab920b3f29 100644 --- a/front_end/src/app/(main)/(tournaments)/tournaments/indexes/page.tsx +++ b/front_end/src/app/(main)/(tournaments)/tournaments/indexes/page.tsx @@ -7,8 +7,8 @@ const IndexesPage: React.FC = async () => { const tournaments = await ServerProjectsApi.getTournaments(); return ( - - + + ); }; diff --git a/front_end/src/app/(main)/(tournaments)/tournaments/page.tsx b/front_end/src/app/(main)/(tournaments)/tournaments/page.tsx index cb8516b856..193f4c4ca8 100644 --- a/front_end/src/app/(main)/(tournaments)/tournaments/page.tsx +++ b/front_end/src/app/(main)/(tournaments)/tournaments/page.tsx @@ -30,8 +30,8 @@ const LiveTournamentsPage: React.FC = async () => { } return ( - - + + ); }; 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 index bee9af8592..7b55eb3d82 100644 --- a/front_end/src/app/(main)/(tournaments)/tournaments/question-series/page.tsx +++ b/front_end/src/app/(main)/(tournaments)/tournaments/question-series/page.tsx @@ -7,8 +7,8 @@ const QuestionSeriesPage: React.FC = async () => { const tournaments = await ServerProjectsApi.getTournaments(); return ( - - + + ); }; From df071e51262ccb65a1a2ba885c368e743eff37d4 Mon Sep 17 00:00:00 2001 From: Nikita Date: Fri, 19 Dec 2025 17:56:19 +0200 Subject: [PATCH 03/25] feat: add hero --- front_end/messages/cs.json | 6 +++ front_end/messages/en.json | 6 +++ front_end/messages/es.json | 6 +++ front_end/messages/pt.json | 6 +++ front_end/messages/zh-TW.json | 6 +++ front_end/messages/zh.json | 6 +++ .../components/new/tournaments_header.tsx | 27 ++++++---- .../components/new/tournaments_hero.tsx | 53 +++++++++++++++++++ 8 files changed, 105 insertions(+), 11 deletions(-) create mode 100644 front_end/src/app/(main)/(tournaments)/tournaments/components/new/tournaments_hero.tsx diff --git a/front_end/messages/cs.json b/front_end/messages/cs.json index 450b590084..169be86b68 100644 --- a/front_end/messages/cs.json +++ b/front_end/messages/cs.json @@ -1785,5 +1785,11 @@ "impersonationBannerText": "Momentálně si prohlížíte Metaculus jako váš bot.", "stopImpersonating": "Přepnout zpět na můj účet", "editedOnDate": "Upraveno dne {date}", + "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}}", "othersCount": "Ostatní ({count})" } diff --git a/front_end/messages/en.json b/front_end/messages/en.json index cc8d4c170e..8d71fdcdc5 100644 --- a/front_end/messages/en.json +++ b/front_end/messages/en.json @@ -1779,5 +1779,11 @@ "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}}", "none": "none" } diff --git a/front_end/messages/es.json b/front_end/messages/es.json index d24ff1c26d..41a0ce1782 100644 --- a/front_end/messages/es.json +++ b/front_end/messages/es.json @@ -1785,5 +1785,11 @@ "impersonationBannerText": "Actualmente estás viendo Metaculus como tu bot.", "stopImpersonating": "Volver a mi cuenta", "editedOnDate": "Editado el {date}", + "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}}", "othersCount": "Otros ({count})" } diff --git a/front_end/messages/pt.json b/front_end/messages/pt.json index f642e603c9..0b97945f54 100644 --- a/front_end/messages/pt.json +++ b/front_end/messages/pt.json @@ -1783,5 +1783,11 @@ "impersonationBannerText": "Você está visualizando o Metaculus atualmente como seu bot.", "stopImpersonating": "Voltar para minha conta", "editedOnDate": "Editado em {date}", + "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}}", "othersCount": "Outros ({count})" } diff --git a/front_end/messages/zh-TW.json b/front_end/messages/zh-TW.json index bfdca86cdd..6464f9678f 100644 --- a/front_end/messages/zh-TW.json +++ b/front_end/messages/zh-TW.json @@ -1782,5 +1782,11 @@ "impersonationBannerText": "您目前正在以您的機器人帳戶查看 Metaculus。", "stopImpersonating": "切換回我的帳戶", "editedOnDate": "編輯於 {date}", + "tournamentsHeroLiveTitle": "預測關鍵議題,

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

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

監控其進展。", + "tournamentsHeroIndexesShown": "{count, plural, one {顯示 # 個指數} other {顯示 # 個指數}}", "withdrawAfterPercentSetting2": "問題總生命周期後撤回" } diff --git a/front_end/messages/zh.json b/front_end/messages/zh.json index 6b3fa1773a..adfcd8997d 100644 --- a/front_end/messages/zh.json +++ b/front_end/messages/zh.json @@ -1787,5 +1787,11 @@ "impersonationBannerText": "您当前正在以机器人身份查看 Metaculus。", "stopImpersonating": "切换回我的账户", "editedOnDate": "编辑于 {date}", + "tournamentsHeroLiveTitle": "预测关键话题,

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

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

监控其进展。", + "tournamentsHeroIndexesShown": "{count, plural, one {显示#个指数} other {显示#个指数}}", "othersCount": "其他({count})" } diff --git a/front_end/src/app/(main)/(tournaments)/tournaments/components/new/tournaments_header.tsx b/front_end/src/app/(main)/(tournaments)/tournaments/components/new/tournaments_header.tsx index 2464b9f014..6b35f3624d 100644 --- a/front_end/src/app/(main)/(tournaments)/tournaments/components/new/tournaments_header.tsx +++ b/front_end/src/app/(main)/(tournaments)/tournaments/components/new/tournaments_header.tsx @@ -7,6 +7,7 @@ import useSearchInputState from "@/hooks/use_search_input_state"; import TournamentsTabs from "./tournament_tabs"; import TournamentsFilter from "./tournaments_filter"; +import TournamentsHero from "./tournaments_hero"; import { useTournamentsSection } from "./tournaments_provider"; import { TOURNAMENTS_SEARCH } from "../../constants/query_params"; @@ -38,18 +39,22 @@ const TournamentsHeader: React.FC = () => { }; return ( -
- -
- - +
+
+ +
+ + +
+ +
); }; diff --git a/front_end/src/app/(main)/(tournaments)/tournaments/components/new/tournaments_hero.tsx b/front_end/src/app/(main)/(tournaments)/tournaments/components/new/tournaments_hero.tsx new file mode 100644 index 0000000000..eea00fad13 --- /dev/null +++ b/front_end/src/app/(main)/(tournaments)/tournaments/components/new/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; From 49430cca85776f97bb10424e2006161d855b8eaa Mon Sep 17 00:00:00 2001 From: Nikita Date: Mon, 22 Dec 2025 14:40:38 +0200 Subject: [PATCH 04/25] feat: add info popover --- front_end/messages/cs.json | 5 + front_end/messages/en.json | 5 + front_end/messages/es.json | 5 + front_end/messages/pt.json | 5 + front_end/messages/zh-TW.json | 5 + front_end/messages/zh.json | 5 + .../components/new/tournaments_container.tsx | 5 +- .../new/tournaments_grid/tournaments_grid.tsx | 8 +- .../components/new/tournaments_header.tsx | 19 +- .../new/tournaments_info_popover.tsx | 305 ++++++++++++++++++ .../components/new/tournaments_provider.tsx | 17 +- .../components/new/tournaments_screen.tsx | 6 +- .../components/expandable_search_input.tsx | 5 +- front_end/src/hooks/use_is_in_viewport.ts | 19 ++ 14 files changed, 402 insertions(+), 12 deletions(-) create mode 100644 front_end/src/app/(main)/(tournaments)/tournaments/components/new/tournaments_info_popover.tsx create mode 100644 front_end/src/hooks/use_is_in_viewport.ts diff --git a/front_end/messages/cs.json b/front_end/messages/cs.json index 169be86b68..9047be0ef7 100644 --- a/front_end/messages/cs.json +++ b/front_end/messages/cs.json @@ -1791,5 +1791,10 @@ "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", "othersCount": "Ostatní ({count})" } diff --git a/front_end/messages/en.json b/front_end/messages/en.json index 8d71fdcdc5..cb482d5d1b 100644 --- a/front_end/messages/en.json +++ b/front_end/messages/en.json @@ -1785,5 +1785,10 @@ "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", "none": "none" } diff --git a/front_end/messages/es.json b/front_end/messages/es.json index 41a0ce1782..7c61253619 100644 --- a/front_end/messages/es.json +++ b/front_end/messages/es.json @@ -1791,5 +1791,10 @@ "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", "othersCount": "Otros ({count})" } diff --git a/front_end/messages/pt.json b/front_end/messages/pt.json index 0b97945f54..f4471b0d97 100644 --- a/front_end/messages/pt.json +++ b/front_end/messages/pt.json @@ -1789,5 +1789,10 @@ "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", "othersCount": "Outros ({count})" } diff --git a/front_end/messages/zh-TW.json b/front_end/messages/zh-TW.json index 6464f9678f..21931c8369 100644 --- a/front_end/messages/zh-TW.json +++ b/front_end/messages/zh-TW.json @@ -1788,5 +1788,10 @@ "tournamentsHeroSeriesShown": "{count, plural, one {顯示 # 個問題系列} other {顯示 # 個問題系列}}", "tournamentsHeroIndexesTitle": "探索複雜議題,

監控其進展。", "tournamentsHeroIndexesShown": "{count, plural, one {顯示 # 個指數} other {顯示 # 個指數}}", + "tournamentsInfoAria": "錦標賽資訊", + "tournamentsInfoTitle": "免費參加;獲得現金獎勵並練習預測。", + "tournamentsInfoScoringLink": "計分方式如何運作?", + "tournamentsInfoPrizesLink": "獎品如何分配?", + "tournamentsInfoCta": "註冊參賽", "withdrawAfterPercentSetting2": "問題總生命周期後撤回" } diff --git a/front_end/messages/zh.json b/front_end/messages/zh.json index adfcd8997d..fcf5202979 100644 --- a/front_end/messages/zh.json +++ b/front_end/messages/zh.json @@ -1793,5 +1793,10 @@ "tournamentsHeroSeriesShown": "{count, plural, one {显示#个问题系列} other {显示#个问题系列}}", "tournamentsHeroIndexesTitle": "发现复杂话题,

监控其进展。", "tournamentsHeroIndexesShown": "{count, plural, one {显示#个指数} other {显示#个指数}}", + "tournamentsInfoAria": "比赛信息", + "tournamentsInfoTitle": "免费参加;赢取现金奖励并提高预测技能。", + "tournamentsInfoScoringLink": "评分机制如何运作?", + "tournamentsInfoPrizesLink": "奖品如何分发?", + "tournamentsInfoCta": "注册参赛", "othersCount": "其他({count})" } diff --git a/front_end/src/app/(main)/(tournaments)/tournaments/components/new/tournaments_container.tsx b/front_end/src/app/(main)/(tournaments)/tournaments/components/new/tournaments_container.tsx index f973f683b8..26bde3e07a 100644 --- a/front_end/src/app/(main)/(tournaments)/tournaments/components/new/tournaments_container.tsx +++ b/front_end/src/app/(main)/(tournaments)/tournaments/components/new/tournaments_container.tsx @@ -2,7 +2,10 @@ import React, { PropsWithChildren } from "react"; const TournamentsContainer: React.FC = ({ children }) => { return ( -
+
{children}
); diff --git a/front_end/src/app/(main)/(tournaments)/tournaments/components/new/tournaments_grid/tournaments_grid.tsx b/front_end/src/app/(main)/(tournaments)/tournaments/components/new/tournaments_grid/tournaments_grid.tsx index 5db386e255..effc0a14c9 100644 --- a/front_end/src/app/(main)/(tournaments)/tournaments/components/new/tournaments_grid/tournaments_grid.tsx +++ b/front_end/src/app/(main)/(tournaments)/tournaments/components/new/tournaments_grid/tournaments_grid.tsx @@ -7,7 +7,13 @@ import { TournamentPreview } from "@/types/projects"; type Props = { items: TournamentPreview[] }; const TournamentsGrid: React.FC = ({ items }) => { - return
{items.length}
; + return ( +
+ {items.map((item) => ( +
+ ))} +
+ ); }; export default TournamentsGrid; diff --git a/front_end/src/app/(main)/(tournaments)/tournaments/components/new/tournaments_header.tsx b/front_end/src/app/(main)/(tournaments)/tournaments/components/new/tournaments_header.tsx index 6b35f3624d..715ea98b30 100644 --- a/front_end/src/app/(main)/(tournaments)/tournaments/components/new/tournaments_header.tsx +++ b/front_end/src/app/(main)/(tournaments)/tournaments/components/new/tournaments_header.tsx @@ -7,12 +7,12 @@ import useSearchInputState from "@/hooks/use_search_input_state"; import TournamentsTabs from "./tournament_tabs"; import TournamentsFilter from "./tournaments_filter"; -import TournamentsHero from "./tournaments_hero"; +import TournamentsInfoPopover from "./tournaments_info_popover"; import { useTournamentsSection } from "./tournaments_provider"; import { TOURNAMENTS_SEARCH } from "../../constants/query_params"; const TournamentsHeader: React.FC = () => { - const { current } = useTournamentsSection(); + const { current, infoOpen, toggleInfo, closeInfo } = useTournamentsSection(); const [searchQuery, setSearchQuery] = useSearchInputState( TOURNAMENTS_SEARCH, @@ -24,7 +24,6 @@ const TournamentsHeader: React.FC = () => { ); const [draftQuery, setDraftQuery] = useState(searchQuery); - useEffect(() => setDraftQuery(searchQuery), [searchQuery]); const handleSearchChange = (event: ChangeEvent) => { @@ -38,23 +37,33 @@ const TournamentsHeader: React.FC = () => { setSearchQuery(""); }; + const showInfo = true; + return (
+
+ + + {showInfo ? ( + (next ? toggleInfo() : closeInfo())} + /> + ) : null}
- -
); }; diff --git a/front_end/src/app/(main)/(tournaments)/tournaments/components/new/tournaments_info_popover.tsx b/front_end/src/app/(main)/(tournaments)/tournaments/components/new/tournaments_info_popover.tsx new file mode 100644 index 0000000000..6bd390972a --- /dev/null +++ b/front_end/src/app/(main)/(tournaments)/tournaments/components/new/tournaments_info_popover.tsx @@ -0,0 +1,305 @@ +"use client"; + +import { + autoUpdate, + flip, + FloatingPortal, + offset, + shift, + useClick, + useDismiss, + useFloating, + useInteractions, + useRole, +} from "@floating-ui/react"; +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, { + useCallback, + useEffect, + useLayoutEffect, + useState, +} from "react"; + +import Button from "@/components/ui/button"; +import { useAuth } from "@/contexts/auth_context"; +import { useModal } from "@/contexts/modal_context"; +import cn from "@/utils/core/cn"; + +type Props = { + open: boolean; + onOpenChange: (next: boolean) => void; + disabled?: boolean; +}; + +const PINNED_TOP = 72; +const STABLE_FRAMES_REQUIRED = 2; +const MAX_STABILIZE_FRAMES = 20; + +type RectSnapshot = { + top: number; + left: number; + right: number; + bottom: number; + width: number; + height: number; +}; + +const TournamentsInfoPopover: React.FC = ({ + open, + onOpenChange, + disabled, +}) => { + const t = useTranslations(); + const { setCurrentModal } = useModal(); + + const { user } = useAuth(); + const isLoggedOut = !user; + + const [referenceNode, setReferenceNode] = useState(null); + const { refs, floatingStyles, context, update, isPositioned } = useFloating({ + open, + onOpenChange, + placement: "bottom-end", + strategy: "fixed", + whileElementsMounted: autoUpdate, + middleware: [offset(10), flip(), shift({ padding: 12 })], + }); + + const setReference = useCallback( + (node: HTMLElement | null) => { + refs.setReference(node); + setReferenceNode(node); + }, + [refs] + ); + + const [ready, setReady] = useState(false); + const [pinned, setPinned] = useState(false); + const [pinnedRight, setPinnedRight] = useState(0); + + // wait until the reference element's rect stabilizes before showing. + useLayoutEffect(() => { + if (!open || !referenceNode) { + setReady(false); + return; + } + + let cancelled = false; + let rafId = 0; + let last: RectSnapshot | null = null; + let stableCount = 0; + let frames = 0; + + const measure = () => { + if (cancelled) return; + + const r = referenceNode.getBoundingClientRect(); + const next: RectSnapshot = { + top: r.top, + left: r.left, + right: r.right, + bottom: r.bottom, + width: r.width, + height: r.height, + }; + + if (last && rectCloseEnough(last, next)) stableCount += 1; + else stableCount = 0; + + last = next; + frames += 1; + + if ( + stableCount >= STABLE_FRAMES_REQUIRED - 1 || + frames >= MAX_STABILIZE_FRAMES + ) { + recomputePinned(referenceNode, setPinned, setPinnedRight); + update(); + + rafId = requestAnimationFrame(() => { + if (cancelled) return; + update(); + setReady(true); + }); + + return; + } + + rafId = requestAnimationFrame(measure); + }; + + rafId = requestAnimationFrame(measure); + return () => { + cancelled = true; + cancelAnimationFrame(rafId); + }; + }, [open, referenceNode, update]); + + // keep pin state/right offset in sync on scroll/resize. + useEffect(() => { + if (!open || !referenceNode) return; + + const handler = () => + recomputePinned(referenceNode, setPinned, setPinnedRight); + + window.addEventListener("scroll", handler, { passive: true }); + window.addEventListener("resize", handler, { passive: true }); + + return () => { + window.removeEventListener("scroll", handler); + window.removeEventListener("resize", handler); + }; + }, [open, referenceNode]); + + 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, + ]); + + const pinnedStyles: React.CSSProperties = pinned + ? { + position: "fixed", + top: PINNED_TOP, + right: pinnedRight, + left: "auto", + bottom: "auto", + transform: "none", + } + : {}; + + const handleSignup = () => { + setCurrentModal({ type: "signup", data: {} }); + }; + + return ( + <> + + + {open && referenceNode && ready ? ( + +
+
+ {t("tournamentsInfoTitle")} +
+ +
+ + {t("tournamentsInfoScoringLink")} + + + {t("tournamentsInfoPrizesLink")} + +
+ + {isLoggedOut && ( + + )} + + +
+
+ ) : null} + + ); +}; + +function rectCloseEnough(a: RectSnapshot, b: RectSnapshot) { + const eps = 0.5; + return ( + Math.abs(a.top - b.top) < eps && + Math.abs(a.left - b.left) < eps && + Math.abs(a.right - b.right) < eps && + Math.abs(a.bottom - b.bottom) < eps && + Math.abs(a.width - b.width) < eps && + Math.abs(a.height - b.height) < eps + ); +} + +function isElementVisible(el: HTMLElement) { + const r = el.getBoundingClientRect(); + return ( + r.bottom > 0 && + r.top < window.innerHeight && + r.right > 0 && + r.left < window.innerWidth + ); +} + +function recomputePinned( + referenceNode: HTMLElement, + setPinned: React.Dispatch>, + setPinnedRight: React.Dispatch> +) { + const nextPinned = !isElementVisible(referenceNode); + setPinned(nextPinned); + + if (nextPinned) { + setPinnedRight(getPinnedRightOffset("tournamentsContainer")); + } +} + +function getPinnedRightOffset(containerId: string) { + const el = document.getElementById(containerId); + if (!el) return 0; + + const rect = el.getBoundingClientRect(); + const styles = window.getComputedStyle(el); + const paddingRight = parseFloat(styles.paddingRight || "0"); + const innerRight = rect.right - paddingRight; + + return Math.max(0, window.innerWidth - innerRight); +} + +export default TournamentsInfoPopover; diff --git a/front_end/src/app/(main)/(tournaments)/tournaments/components/new/tournaments_provider.tsx b/front_end/src/app/(main)/(tournaments)/tournaments/components/new/tournaments_provider.tsx index 94b71f56d2..f67b140c8e 100644 --- a/front_end/src/app/(main)/(tournaments)/tournaments/components/new/tournaments_provider.tsx +++ b/front_end/src/app/(main)/(tournaments)/tournaments/components/new/tournaments_provider.tsx @@ -1,6 +1,6 @@ "use client"; -import React, { createContext, useContext, useMemo } from "react"; +import React, { createContext, useContext, useMemo, useState } from "react"; import { TournamentPreview } from "@/types/projects"; @@ -12,6 +12,9 @@ type TournamentsSectionCtxValue = { current: TournamentsSection; items: TournamentPreview[]; count: number; + infoOpen: boolean; + toggleInfo: () => void; + closeInfo: () => void; }; const TournamentsSectionCtx = createContext( @@ -24,6 +27,7 @@ export function TournamentsSectionProvider(props: { children: React.ReactNode; }) { const { tournaments, current, children } = props; + const [infoOpen, setInfoOpen] = useState(true); const sectionItems = useMemo( () => selectTournamentsForSection(tournaments, current), @@ -33,8 +37,15 @@ export function TournamentsSectionProvider(props: { const { filtered } = useTournamentFilters(sectionItems); const value = useMemo( - () => ({ current, items: filtered, count: filtered.length }), - [current, filtered] + () => ({ + current, + items: filtered, + count: filtered.length, + infoOpen, + toggleInfo: () => setInfoOpen((v) => !v), + closeInfo: () => setInfoOpen(false), + }), + [current, filtered, infoOpen] ); return ( diff --git a/front_end/src/app/(main)/(tournaments)/tournaments/components/new/tournaments_screen.tsx b/front_end/src/app/(main)/(tournaments)/tournaments/components/new/tournaments_screen.tsx index a198ba3bcb..6e69f580fa 100644 --- a/front_end/src/app/(main)/(tournaments)/tournaments/components/new/tournaments_screen.tsx +++ b/front_end/src/app/(main)/(tournaments)/tournaments/components/new/tournaments_screen.tsx @@ -4,6 +4,7 @@ import { TournamentPreview } from "@/types/projects"; import TournamentsContainer from "./tournaments_container"; import TournamentsHeader from "./tournaments_header"; +import TournamentsHero from "./tournaments_hero"; import { TournamentsSectionProvider } from "./tournaments_provider"; import { TournamentsSection } from "../../types"; @@ -22,7 +23,10 @@ const TournamentsScreen: React.FC = ({ -
{children}
+
+ +
{children}
+
); diff --git a/front_end/src/components/expandable_search_input.tsx b/front_end/src/components/expandable_search_input.tsx index 0d2e067f77..eec7d384cc 100644 --- a/front_end/src/components/expandable_search_input.tsx +++ b/front_end/src/components/expandable_search_input.tsx @@ -25,6 +25,7 @@ type Props = { keepOpenWhenHasValue?: boolean; collapseOnBlur?: boolean; className?: string; + buttonClassName?: string; inputClassName?: string; }; @@ -38,6 +39,7 @@ const ExpandableSearchInput: FC = ({ keepOpenWhenHasValue = true, collapseOnBlur = true, className, + buttonClassName, inputClassName, }) => { const [open, setOpen] = useState(false); @@ -95,7 +97,8 @@ const ExpandableSearchInput: FC = ({ aria-label="Open search" className={cn( "h-9 w-9 rounded-full border border-gray-300 bg-gray-0", - "dark:border-gray-500-dark dark:bg-gray-0-dark" + "dark:border-gray-500-dark dark:bg-gray-0-dark", + buttonClassName )} onClick={() => { setOpen(true); diff --git a/front_end/src/hooks/use_is_in_viewport.ts b/front_end/src/hooks/use_is_in_viewport.ts new file mode 100644 index 0000000000..e451141b7d --- /dev/null +++ b/front_end/src/hooks/use_is_in_viewport.ts @@ -0,0 +1,19 @@ +import { useEffect, useState } from "react"; + +export function useIsInViewport(el: HTMLElement | null) { + const [inView, setInView] = useState(true); + + useEffect(() => { + if (!el) return; + + const obs = new IntersectionObserver( + ([entry]) => setInView(entry?.isIntersecting ?? false), + { threshold: 0.01 } + ); + + obs.observe(el); + return () => obs.disconnect(); + }, [el]); + + return inView; +} From eae1f67bb994dbf418f2be96db9e5cc191bb49b2 Mon Sep 17 00:00:00 2001 From: Nikita Date: Mon, 22 Dec 2025 19:13:29 +0200 Subject: [PATCH 05/25] feat: make header sticky --- .../components/new/tournaments-tabs-shell.tsx | 4 +- .../components/new/tournaments_header.tsx | 94 ++++++-- .../new/tournaments_info_popover.tsx | 209 +++--------------- 3 files changed, 105 insertions(+), 202 deletions(-) diff --git a/front_end/src/app/(main)/(tournaments)/tournaments/components/new/tournaments-tabs-shell.tsx b/front_end/src/app/(main)/(tournaments)/tournaments/components/new/tournaments-tabs-shell.tsx index 04732017b1..3c47b19159 100644 --- a/front_end/src/app/(main)/(tournaments)/tournaments/components/new/tournaments-tabs-shell.tsx +++ b/front_end/src/app/(main)/(tournaments)/tournaments/components/new/tournaments-tabs-shell.tsx @@ -14,8 +14,8 @@ type Props = { const TournamentsTabsShell: React.FC = ({ current, sections }) => { return ( - - + + {sections.map((tab) => ( { const { current, infoOpen, toggleInfo, closeInfo } = useTournamentsSection(); @@ -37,35 +41,77 @@ const TournamentsHeader: React.FC = () => { setSearchQuery(""); }; + const sentinelRef = useRef(null); + const [stuck, setStuck] = useState(false); + + useEffect(() => { + const el = sentinelRef.current; + if (!el) 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(); + }, []); + const showInfo = true; return ( -
-
- - -
- - - - - {showInfo ? ( - (next ? toggleInfo() : closeInfo())} - /> - ) : null} + <> +
+ +
+
+
+ + +
+ + + + + {showInfo ? ( + (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/new/tournaments_info_popover.tsx b/front_end/src/app/(main)/(tournaments)/tournaments/components/new/tournaments_info_popover.tsx index 6bd390972a..8cceb77ee7 100644 --- a/front_end/src/app/(main)/(tournaments)/tournaments/components/new/tournaments_info_popover.tsx +++ b/front_end/src/app/(main)/(tournaments)/tournaments/components/new/tournaments_info_popover.tsx @@ -16,12 +16,7 @@ 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, { - useCallback, - useEffect, - useLayoutEffect, - useState, -} from "react"; +import React from "react"; import Button from "@/components/ui/button"; import { useAuth } from "@/contexts/auth_context"; @@ -32,128 +27,51 @@ type Props = { open: boolean; onOpenChange: (next: boolean) => void; disabled?: boolean; -}; - -const PINNED_TOP = 72; -const STABLE_FRAMES_REQUIRED = 2; -const MAX_STABILIZE_FRAMES = 20; - -type RectSnapshot = { - top: number; - left: number; - right: number; - bottom: number; - width: number; - height: number; + offsetPx?: number; + stickyTopPx?: number; }; const TournamentsInfoPopover: React.FC = ({ open, onOpenChange, disabled, + offsetPx = 12, + stickyTopPx = 0, }) => { const t = useTranslations(); const { setCurrentModal } = useModal(); - const { user } = useAuth(); const isLoggedOut = !user; - const [referenceNode, setReferenceNode] = useState(null); - const { refs, floatingStyles, context, update, isPositioned } = useFloating({ + const { refs, floatingStyles, context, isPositioned } = useFloating({ open, onOpenChange, placement: "bottom-end", strategy: "fixed", whileElementsMounted: autoUpdate, - middleware: [offset(10), flip(), shift({ padding: 12 })], + 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 setReference = useCallback( - (node: HTMLElement | null) => { - refs.setReference(node); - setReferenceNode(node); - }, - [refs] - ); - - const [ready, setReady] = useState(false); - const [pinned, setPinned] = useState(false); - const [pinnedRight, setPinnedRight] = useState(0); - - // wait until the reference element's rect stabilizes before showing. - useLayoutEffect(() => { - if (!open || !referenceNode) { - setReady(false); - return; - } - - let cancelled = false; - let rafId = 0; - let last: RectSnapshot | null = null; - let stableCount = 0; - let frames = 0; - - const measure = () => { - if (cancelled) return; - - const r = referenceNode.getBoundingClientRect(); - const next: RectSnapshot = { - top: r.top, - left: r.left, - right: r.right, - bottom: r.bottom, - width: r.width, - height: r.height, - }; - - if (last && rectCloseEnough(last, next)) stableCount += 1; - else stableCount = 0; - - last = next; - frames += 1; - - if ( - stableCount >= STABLE_FRAMES_REQUIRED - 1 || - frames >= MAX_STABILIZE_FRAMES - ) { - recomputePinned(referenceNode, setPinned, setPinnedRight); - update(); - - rafId = requestAnimationFrame(() => { - if (cancelled) return; - update(); - setReady(true); - }); - - return; - } - - rafId = requestAnimationFrame(measure); - }; - - rafId = requestAnimationFrame(measure); - return () => { - cancelled = true; - cancelAnimationFrame(rafId); - }; - }, [open, referenceNode, update]); - - // keep pin state/right offset in sync on scroll/resize. - useEffect(() => { - if (!open || !referenceNode) return; - - const handler = () => - recomputePinned(referenceNode, setPinned, setPinnedRight); - - window.addEventListener("scroll", handler, { passive: true }); - window.addEventListener("resize", handler, { passive: true }); - - return () => { - window.removeEventListener("scroll", handler); - window.removeEventListener("resize", handler); - }; - }, [open, referenceNode]); - const click = useClick(context, { enabled: !disabled }); const dismiss = useDismiss(context, { outsidePress: false }); const role = useRole(context, { role: "dialog" }); @@ -164,25 +82,12 @@ const TournamentsInfoPopover: React.FC = ({ role, ]); - const pinnedStyles: React.CSSProperties = pinned - ? { - position: "fixed", - top: PINNED_TOP, - right: pinnedRight, - left: "auto", - bottom: "auto", - transform: "none", - } - : {}; - - const handleSignup = () => { - setCurrentModal({ type: "signup", data: {} }); - }; + const handleSignup = () => setCurrentModal({ type: "signup", data: {} }); return ( <> - {open && referenceNode && ready ? ( + {open ? (
@@ -255,51 +159,4 @@ const TournamentsInfoPopover: React.FC = ({ ); }; -function rectCloseEnough(a: RectSnapshot, b: RectSnapshot) { - const eps = 0.5; - return ( - Math.abs(a.top - b.top) < eps && - Math.abs(a.left - b.left) < eps && - Math.abs(a.right - b.right) < eps && - Math.abs(a.bottom - b.bottom) < eps && - Math.abs(a.width - b.width) < eps && - Math.abs(a.height - b.height) < eps - ); -} - -function isElementVisible(el: HTMLElement) { - const r = el.getBoundingClientRect(); - return ( - r.bottom > 0 && - r.top < window.innerHeight && - r.right > 0 && - r.left < window.innerWidth - ); -} - -function recomputePinned( - referenceNode: HTMLElement, - setPinned: React.Dispatch>, - setPinnedRight: React.Dispatch> -) { - const nextPinned = !isElementVisible(referenceNode); - setPinned(nextPinned); - - if (nextPinned) { - setPinnedRight(getPinnedRightOffset("tournamentsContainer")); - } -} - -function getPinnedRightOffset(containerId: string) { - const el = document.getElementById(containerId); - if (!el) return 0; - - const rect = el.getBoundingClientRect(); - const styles = window.getComputedStyle(el); - const paddingRight = parseFloat(styles.paddingRight || "0"); - const innerRight = rect.right - paddingRight; - - return Math.max(0, window.innerWidth - innerRight); -} - export default TournamentsInfoPopover; From 2ed4e7ec57214dc6e1afa1ae58822e67aab7e44c Mon Sep 17 00:00:00 2001 From: Nikita Date: Tue, 23 Dec 2025 19:02:22 +0200 Subject: [PATCH 06/25] feat: add mobile adaptation --- .../components/new/tournaments-tabs-shell.tsx | 8 +- .../components/new/tournaments_filter.tsx | 4 + .../components/new/tournaments_header.tsx | 62 ++----- .../components/new/tournaments_hero.tsx | 2 +- .../new/tournaments_info_popover.tsx | 162 ------------------ .../new/tournaments_mobile_ctrl.tsx | 32 ++++ .../tournaments_popover/tournaments_info.tsx | 63 +++++++ .../tournaments_info_button.tsx | 43 +++++ .../tournaments_info_popover.tsx | 104 +++++++++++ .../components/new/tournaments_screen.tsx | 4 +- .../components/new/tournaments_search.tsx | 46 +++++ 11 files changed, 318 insertions(+), 212 deletions(-) delete mode 100644 front_end/src/app/(main)/(tournaments)/tournaments/components/new/tournaments_info_popover.tsx create mode 100644 front_end/src/app/(main)/(tournaments)/tournaments/components/new/tournaments_mobile_ctrl.tsx create mode 100644 front_end/src/app/(main)/(tournaments)/tournaments/components/new/tournaments_popover/tournaments_info.tsx create mode 100644 front_end/src/app/(main)/(tournaments)/tournaments/components/new/tournaments_popover/tournaments_info_button.tsx create mode 100644 front_end/src/app/(main)/(tournaments)/tournaments/components/new/tournaments_popover/tournaments_info_popover.tsx create mode 100644 front_end/src/app/(main)/(tournaments)/tournaments/components/new/tournaments_search.tsx diff --git a/front_end/src/app/(main)/(tournaments)/tournaments/components/new/tournaments-tabs-shell.tsx b/front_end/src/app/(main)/(tournaments)/tournaments/components/new/tournaments-tabs-shell.tsx index 3c47b19159..62c7f257f1 100644 --- a/front_end/src/app/(main)/(tournaments)/tournaments/components/new/tournaments-tabs-shell.tsx +++ b/front_end/src/app/(main)/(tournaments)/tournaments/components/new/tournaments-tabs-shell.tsx @@ -15,13 +15,15 @@ type Props = { 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"}` + ? `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} diff --git a/front_end/src/app/(main)/(tournaments)/tournaments/components/new/tournaments_filter.tsx b/front_end/src/app/(main)/(tournaments)/tournaments/components/new/tournaments_filter.tsx index 02d721921b..7ae870a155 100644 --- a/front_end/src/app/(main)/(tournaments)/tournaments/components/new/tournaments_filter.tsx +++ b/front_end/src/app/(main)/(tournaments)/tournaments/components/new/tournaments_filter.tsx @@ -2,6 +2,7 @@ import { useTranslations } from "next-intl"; import Listbox, { SelectOption } from "@/components/ui/listbox"; +import { useBreakpoint } from "@/hooks/tailwind"; import useSearchParams from "@/hooks/use_search_params"; import { TournamentsSortBy } from "@/types/projects"; @@ -33,12 +34,15 @@ const TournamentsFilter: React.FC = () => { shallowNavigateToSearchParams(); }; + const isLg = useBreakpoint("lg"); + return ( ); }; diff --git a/front_end/src/app/(main)/(tournaments)/tournaments/components/new/tournaments_header.tsx b/front_end/src/app/(main)/(tournaments)/tournaments/components/new/tournaments_header.tsx index 02df0c3e4a..31ca1a43d2 100644 --- a/front_end/src/app/(main)/(tournaments)/tournaments/components/new/tournaments_header.tsx +++ b/front_end/src/app/(main)/(tournaments)/tournaments/components/new/tournaments_header.tsx @@ -1,16 +1,15 @@ "use client"; -import React, { ChangeEvent, useEffect, useRef, useState } from "react"; +import React, { useEffect, useRef, useState } from "react"; -import ExpandableSearchInput from "@/components/expandable_search_input"; -import useSearchInputState from "@/hooks/use_search_input_state"; +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_info_popover"; +import TournamentsInfoPopover from "./tournaments_popover/tournaments_info_popover"; import { useTournamentsSection } from "./tournaments_provider"; -import { TOURNAMENTS_SEARCH } from "../../constants/query_params"; +import TournamentsSearch from "./tournaments_search"; const STICKY_TOP = 48; const POPOVER_GAP = 10; @@ -18,35 +17,13 @@ const POPOVER_GAP = 10; const TournamentsHeader: React.FC = () => { const { current, infoOpen, toggleInfo, closeInfo } = useTournamentsSection(); - const [searchQuery, setSearchQuery] = useSearchInputState( - TOURNAMENTS_SEARCH, - { - mode: "client", - debounceTime: 300, - modifySearchParams: true, - } - ); - - const [draftQuery, setDraftQuery] = useState(searchQuery); - useEffect(() => setDraftQuery(searchQuery), [searchQuery]); - - const handleSearchChange = (event: ChangeEvent) => { - const next = event.target.value; - setDraftQuery(next); - setSearchQuery(next); - }; - - const handleSearchErase = () => { - setDraftQuery(""); - setSearchQuery(""); - }; - const sentinelRef = useRef(null); - const [stuck, setStuck] = useState(false); + const isLg = useBreakpoint("lg"); + const [stuck, setStuck] = useState(!isLg); useEffect(() => { const el = sentinelRef.current; - if (!el) return; + if (!el || !isLg) return; const obs = new IntersectionObserver( ([entry]) => setStuck(!entry?.isIntersecting), @@ -59,7 +36,7 @@ const TournamentsHeader: React.FC = () => { obs.observe(el); return () => obs.disconnect(); - }, []); + }, [isLg]); const showInfo = true; @@ -72,27 +49,22 @@ const TournamentsHeader: React.FC = () => { className={cn( "sticky z-40", "ml-[calc(50%-50dvw)] w-[100dvw]", - stuck ? popoverSafeGlassClasses : "bg-transparent" + !isLg && "-mt-[52px]", + stuck || !isLg ? popoverSafeGlassClasses : "bg-transparent" )} style={{ top: STICKY_TOP }} > -
+
- +
+ +
-
+
+ - - - {showInfo ? ( + {showInfo && isLg ? ( (next ? toggleInfo() : closeInfo())} diff --git a/front_end/src/app/(main)/(tournaments)/tournaments/components/new/tournaments_hero.tsx b/front_end/src/app/(main)/(tournaments)/tournaments/components/new/tournaments_hero.tsx index eea00fad13..729531a253 100644 --- a/front_end/src/app/(main)/(tournaments)/tournaments/components/new/tournaments_hero.tsx +++ b/front_end/src/app/(main)/(tournaments)/tournaments/components/new/tournaments_hero.tsx @@ -36,7 +36,7 @@ const TournamentsHero: React.FC = () => { type PlainKey = Parameters[0]; return ( -
+

{t.rich(keys.titleKey as RichKey, { br: () =>
, diff --git a/front_end/src/app/(main)/(tournaments)/tournaments/components/new/tournaments_info_popover.tsx b/front_end/src/app/(main)/(tournaments)/tournaments/components/new/tournaments_info_popover.tsx deleted file mode 100644 index 8cceb77ee7..0000000000 --- a/front_end/src/app/(main)/(tournaments)/tournaments/components/new/tournaments_info_popover.tsx +++ /dev/null @@ -1,162 +0,0 @@ -"use client"; - -import { - autoUpdate, - flip, - FloatingPortal, - offset, - shift, - useClick, - useDismiss, - useFloating, - useInteractions, - useRole, -} from "@floating-ui/react"; -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"; -import cn from "@/utils/core/cn"; - -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 t = useTranslations(); - const { setCurrentModal } = useModal(); - const { user } = useAuth(); - const isLoggedOut = !user; - - 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, - ]); - - const handleSignup = () => setCurrentModal({ type: "signup", data: {} }); - - return ( - <> - - - {open ? ( - -
-
- {t("tournamentsInfoTitle")} -
- -
- - {t("tournamentsInfoScoringLink")} - - - {t("tournamentsInfoPrizesLink")} - -
- - {isLoggedOut && ( - - )} - - -
-
- ) : null} - - ); -}; - -export default TournamentsInfoPopover; diff --git a/front_end/src/app/(main)/(tournaments)/tournaments/components/new/tournaments_mobile_ctrl.tsx b/front_end/src/app/(main)/(tournaments)/tournaments/components/new/tournaments_mobile_ctrl.tsx new file mode 100644 index 0000000000..dda37d5780 --- /dev/null +++ b/front_end/src/app/(main)/(tournaments)/tournaments/components/new/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/new/tournaments_popover/tournaments_info.tsx b/front_end/src/app/(main)/(tournaments)/tournaments/components/new/tournaments_popover/tournaments_info.tsx new file mode 100644 index 0000000000..520864dab0 --- /dev/null +++ b/front_end/src/app/(main)/(tournaments)/tournaments/components/new/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/new/tournaments_popover/tournaments_info_button.tsx b/front_end/src/app/(main)/(tournaments)/tournaments/components/new/tournaments_popover/tournaments_info_button.tsx new file mode 100644 index 0000000000..36efbc5caa --- /dev/null +++ b/front_end/src/app/(main)/(tournaments)/tournaments/components/new/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/new/tournaments_popover/tournaments_info_popover.tsx b/front_end/src/app/(main)/(tournaments)/tournaments/components/new/tournaments_popover/tournaments_info_popover.tsx new file mode 100644 index 0000000000..6db8436125 --- /dev/null +++ b/front_end/src/app/(main)/(tournaments)/tournaments/components/new/tournaments_popover/tournaments_info_popover.tsx @@ -0,0 +1,104 @@ +"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"; + +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 { 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, + ]); + + return ( + <> + + + {open ? ( + +
+ onOpenChange(false)} /> +
+
+ ) : null} + + ); +}; + +export default TournamentsInfoPopover; diff --git a/front_end/src/app/(main)/(tournaments)/tournaments/components/new/tournaments_screen.tsx b/front_end/src/app/(main)/(tournaments)/tournaments/components/new/tournaments_screen.tsx index 6e69f580fa..9e32771fd0 100644 --- a/front_end/src/app/(main)/(tournaments)/tournaments/components/new/tournaments_screen.tsx +++ b/front_end/src/app/(main)/(tournaments)/tournaments/components/new/tournaments_screen.tsx @@ -5,6 +5,7 @@ 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"; @@ -25,7 +26,8 @@ const TournamentsScreen: React.FC = ({
-
{children}
+ +
{children}
diff --git a/front_end/src/app/(main)/(tournaments)/tournaments/components/new/tournaments_search.tsx b/front_end/src/app/(main)/(tournaments)/tournaments/components/new/tournaments_search.tsx new file mode 100644 index 0000000000..f4360618b8 --- /dev/null +++ b/front_end/src/app/(main)/(tournaments)/tournaments/components/new/tournaments_search.tsx @@ -0,0 +1,46 @@ +import { ChangeEvent, useEffect, useState } 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 [searchQuery, setSearchQuery] = useSearchInputState( + TOURNAMENTS_SEARCH, + { + mode: "client", + debounceTime: 300, + modifySearchParams: true, + } + ); + + const [draftQuery, setDraftQuery] = useState(searchQuery); + useEffect(() => setDraftQuery(searchQuery), [searchQuery]); + + const handleSearchChange = (event: ChangeEvent) => { + const next = event.target.value; + setDraftQuery(next); + setSearchQuery(next); + }; + + const handleSearchErase = () => { + setDraftQuery(""); + setSearchQuery(""); + }; + + return ( +
+ +
+ ); +}; + +export default TournamentsSearch; From ac9984c0f0976cf0841b69295af52484f3da8c8b Mon Sep 17 00:00:00 2001 From: Nikita Date: Wed, 24 Dec 2025 14:34:50 +0200 Subject: [PATCH 07/25] feat: add live tournament cards --- front_end/messages/cs.json | 14 + front_end/messages/en.json | 17 + front_end/messages/es.json | 14 + front_end/messages/pt.json | 14 + front_end/messages/zh-TW.json | 14 + front_end/messages/zh.json | 14 + .../tournaments_grid/live_tournament_card.tsx | 369 ++++++++++++++++++ .../live_tournaments_grid.tsx | 13 +- .../new/tournaments_grid/tournaments_grid.tsx | 27 +- .../components/new/tournaments_provider.tsx | 7 +- .../components/new/tournaments_screen.tsx | 8 +- .../(main)/(tournaments)/tournaments/page.tsx | 3 +- front_end/src/types/projects.ts | 1 + projects/serializers/common.py | 8 +- projects/services/common.py | 13 + projects/views/common.py | 12 +- 16 files changed, 526 insertions(+), 22 deletions(-) create mode 100644 front_end/src/app/(main)/(tournaments)/tournaments/components/new/tournaments_grid/live_tournament_card.tsx diff --git a/front_end/messages/cs.json b/front_end/messages/cs.json index 9047be0ef7..4ff3d87046 100644 --- a/front_end/messages/cs.json +++ b/front_end/messages/cs.json @@ -1796,5 +1796,19 @@ "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í}}", "othersCount": "Ostatní ({count})" } diff --git a/front_end/messages/en.json b/front_end/messages/en.json index cb482d5d1b..881b5b89e7 100644 --- a/front_end/messages/en.json +++ b/front_end/messages/en.json @@ -1790,5 +1790,22 @@ "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}}", "none": "none" } diff --git a/front_end/messages/es.json b/front_end/messages/es.json index 7c61253619..56a00e4081 100644 --- a/front_end/messages/es.json +++ b/front_end/messages/es.json @@ -1796,5 +1796,19 @@ "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}}", "othersCount": "Otros ({count})" } diff --git a/front_end/messages/pt.json b/front_end/messages/pt.json index f4471b0d97..0060fcd967 100644 --- a/front_end/messages/pt.json +++ b/front_end/messages/pt.json @@ -1794,5 +1794,19 @@ "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}}", "othersCount": "Outros ({count})" } diff --git a/front_end/messages/zh-TW.json b/front_end/messages/zh-TW.json index 21931c8369..95cfc4b4fd 100644 --- a/front_end/messages/zh-TW.json +++ b/front_end/messages/zh-TW.json @@ -1793,5 +1793,19 @@ "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 {天}}", "withdrawAfterPercentSetting2": "問題總生命周期後撤回" } diff --git a/front_end/messages/zh.json b/front_end/messages/zh.json index fcf5202979..ad86f960dc 100644 --- a/front_end/messages/zh.json +++ b/front_end/messages/zh.json @@ -1798,5 +1798,19 @@ "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 {天}}", "othersCount": "其他({count})" } diff --git a/front_end/src/app/(main)/(tournaments)/tournaments/components/new/tournaments_grid/live_tournament_card.tsx b/front_end/src/app/(main)/(tournaments)/tournaments/components/new/tournaments_grid/live_tournament_card.tsx new file mode 100644 index 0000000000..d2df810213 --- /dev/null +++ b/front_end/src/app/(main)/(tournaments)/tournaments/components/new/tournaments_grid/live_tournament_card.tsx @@ -0,0 +1,369 @@ +"use client"; + +import { faUsers } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import Link from "next/link"; +import { useTranslations } from "next-intl"; +import React, { useMemo } from "react"; + +import { TournamentPreview, TournamentTimeline } from "@/types/projects"; +import cn from "@/utils/core/cn"; +import { getProjectLink } from "@/utils/navigation"; + +type Props = { + item: TournamentPreview; + nowTs?: number; +}; + +const LiveTournamentCard: React.FC = ({ item, nowTs = 0 }) => { + const t = useTranslations(); + const prize = useMemo( + () => formatMoneyUSD(item.prize_pool ?? null), + [item.prize_pool] + ); + + const href = getProjectLink(item); + + return ( + +
+ {item.header_image ? ( + // eslint-disable-next-line @next/next/no-img-element + {item.name} + ) : null} +
+ +
+
+ {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"); + p = 0; + } else if (nowTs < startTs) { + label = t("tournamentTimelineStarts", { + when: formatRelative(t, startTs - nowTs), + }); + p = 0; + } 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 +) { + if (!Number.isFinite(deltaMs) || deltaMs <= 0) + return t("tournamentRelativeSoon"); + + const sec = 1000; + const min = 60 * sec; + const hour = 60 * min; + const day = 24 * hour; + const week = 7 * day; + const month = 30 * day; + const year = 365 * day; + + if (deltaMs > 20 * year) return t("tournamentRelativeFarFuture"); + if (deltaMs < min) return t("tournamentRelativeUnderMinute"); + + const pick = () => { + if (deltaMs < hour) + return { n: Math.round(deltaMs / min), unit: "minute" as const }; + if (deltaMs < day) + return { n: Math.round(deltaMs / hour), unit: "hour" as const }; + if (deltaMs < week) + return { n: Math.round(deltaMs / day), unit: "day" as const }; + if (deltaMs < month) + return { n: Math.round(deltaMs / week), unit: "week" as const }; + if (deltaMs < year) + return { n: Math.round(deltaMs / month), unit: "month" as const }; + return { n: Math.round(deltaMs / year), unit: "year" as const }; + }; + + const { n, unit } = pick(); + 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/new/tournaments_grid/live_tournaments_grid.tsx b/front_end/src/app/(main)/(tournaments)/tournaments/components/new/tournaments_grid/live_tournaments_grid.tsx index c6a36ff987..82c7129614 100644 --- a/front_end/src/app/(main)/(tournaments)/tournaments/components/new/tournaments_grid/live_tournaments_grid.tsx +++ b/front_end/src/app/(main)/(tournaments)/tournaments/components/new/tournaments_grid/live_tournaments_grid.tsx @@ -4,15 +4,18 @@ 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 } = useTournamentsSection(); + const { items, nowTs } = useTournamentsSection(); return ( -
-
Live Tournaments ({items.length})
- -
+ ( + + )} + /> ); }; diff --git a/front_end/src/app/(main)/(tournaments)/tournaments/components/new/tournaments_grid/tournaments_grid.tsx b/front_end/src/app/(main)/(tournaments)/tournaments/components/new/tournaments_grid/tournaments_grid.tsx index effc0a14c9..20289fa0c9 100644 --- a/front_end/src/app/(main)/(tournaments)/tournaments/components/new/tournaments_grid/tournaments_grid.tsx +++ b/front_end/src/app/(main)/(tournaments)/tournaments/components/new/tournaments_grid/tournaments_grid.tsx @@ -4,14 +4,29 @@ import React from "react"; import { TournamentPreview } from "@/types/projects"; -type Props = { items: TournamentPreview[] }; +type Props = { + items: TournamentPreview[]; + renderItem?: (item: TournamentPreview) => React.ReactNode; +}; -const TournamentsGrid: React.FC = ({ items }) => { +const TournamentsGrid: React.FC = ({ items, renderItem }) => { return ( -
- {items.map((item) => ( -
- ))} +
+ {items.map((item) => + renderItem ? ( + renderItem(item) + ) : ( +
+ ) + )}
); }; diff --git a/front_end/src/app/(main)/(tournaments)/tournaments/components/new/tournaments_provider.tsx b/front_end/src/app/(main)/(tournaments)/tournaments/components/new/tournaments_provider.tsx index f67b140c8e..a667633d18 100644 --- a/front_end/src/app/(main)/(tournaments)/tournaments/components/new/tournaments_provider.tsx +++ b/front_end/src/app/(main)/(tournaments)/tournaments/components/new/tournaments_provider.tsx @@ -12,6 +12,7 @@ type TournamentsSectionCtxValue = { current: TournamentsSection; items: TournamentPreview[]; count: number; + nowTs?: number; infoOpen: boolean; toggleInfo: () => void; closeInfo: () => void; @@ -25,8 +26,9 @@ export function TournamentsSectionProvider(props: { tournaments: TournamentPreview[]; current: TournamentsSection; children: React.ReactNode; + nowTs?: number; }) { - const { tournaments, current, children } = props; + const { tournaments, current, children, nowTs } = props; const [infoOpen, setInfoOpen] = useState(true); const sectionItems = useMemo( @@ -42,10 +44,11 @@ export function TournamentsSectionProvider(props: { items: filtered, count: filtered.length, infoOpen, + nowTs, toggleInfo: () => setInfoOpen((v) => !v), closeInfo: () => setInfoOpen(false), }), - [current, filtered, infoOpen] + [current, filtered, infoOpen, nowTs] ); return ( diff --git a/front_end/src/app/(main)/(tournaments)/tournaments/components/new/tournaments_screen.tsx b/front_end/src/app/(main)/(tournaments)/tournaments/components/new/tournaments_screen.tsx index 9e32771fd0..c17a7a7810 100644 --- a/front_end/src/app/(main)/(tournaments)/tournaments/components/new/tournaments_screen.tsx +++ b/front_end/src/app/(main)/(tournaments)/tournaments/components/new/tournaments_screen.tsx @@ -13,16 +13,22 @@ type Props = { current: TournamentsSection; tournaments: TournamentPreview[]; children: React.ReactNode; + nowTs?: number; }; const TournamentsScreen: React.FC = ({ current, tournaments, children, + nowTs, }) => { return ( - +
diff --git a/front_end/src/app/(main)/(tournaments)/tournaments/page.tsx b/front_end/src/app/(main)/(tournaments)/tournaments/page.tsx index 193f4c4ca8..e7bd3994d6 100644 --- a/front_end/src/app/(main)/(tournaments)/tournaments/page.tsx +++ b/front_end/src/app/(main)/(tournaments)/tournaments/page.tsx @@ -17,6 +17,7 @@ const LiveTournamentsPage: React.FC = async () => { const tournaments = await ServerProjectsApi.getTournaments(); const { activeTournaments, archivedTournaments, questionSeries, indexes } = extractTournamentLists(tournaments); + const nowTs = Date.now(); if (isOldScreen) { return ( @@ -30,7 +31,7 @@ const LiveTournamentsPage: React.FC = async () => { } return ( - + ); diff --git a/front_end/src/types/projects.ts b/front_end/src/types/projects.ts index e97689858d..689e3fa0f7 100644 --- a/front_end/src/types/projects.ts +++ b/front_end/src/types/projects.ts @@ -69,6 +69,7 @@ export type TournamentPreview = Project & { default_permission: ProjectPermissions | null; score_type: string; followers_count?: number; + timeline: TournamentTimeline; }; export type TournamentTimeline = { diff --git a/projects/serializers/common.py b/projects/serializers/common.py index 428f3af1b6..ee3ba80567 100644 --- a/projects/serializers/common.py +++ b/projects/serializers/common.py @@ -1,5 +1,5 @@ from collections import defaultdict -from typing import Any, Callable +from typing import Any, Callable, Iterable from django.db.models import Q, QuerySet from rest_framework import serializers @@ -253,12 +253,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/common.py b/projects/services/common.py index cbbae72497..67497db5bf 100644 --- a/projects/services/common.py +++ b/projects/services/common.py @@ -5,6 +5,7 @@ from django.db import IntegrityError from django.utils import timezone from django.utils.timezone import make_aware +from django.core.cache import cache from posts.models import Post from projects.models import Project, ProjectUserPermission @@ -210,6 +211,18 @@ def get_max(data: list): "all_questions_closed": all_questions_closed, } +PROJECT_TIMELINE_TTL_SECONDS = 5 * 360 + +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} def get_questions_count_for_projects(project_ids: list[int]) -> dict[int, int]: """ diff --git a/projects/views/common.py b/projects/views/common.py index 2ec16457b1..4d56b1321c 100644 --- a/projects/views/common.py +++ b/projects/views/common.py @@ -28,6 +28,7 @@ invite_user_to_project, get_site_main_project, get_project_timeline_data, + get_projects_timeline_cached, ) from projects.services.subscriptions import subscribe_project, unsubscribe_project from questions.models import Question @@ -132,12 +133,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) From 7707f78cbfcce2e1bf66cb67b6078a3e3b664142 Mon Sep 17 00:00:00 2001 From: Nikita Date: Wed, 24 Dec 2025 17:51:51 +0200 Subject: [PATCH 08/25] feat: add cards mobile adaptation --- .../tournaments_grid/live_tournament_card.tsx | 16 ++++++++-------- .../new/tournaments_grid/tournaments_grid.tsx | 4 +++- 2 files changed, 11 insertions(+), 9 deletions(-) diff --git a/front_end/src/app/(main)/(tournaments)/tournaments/components/new/tournaments_grid/live_tournament_card.tsx b/front_end/src/app/(main)/(tournaments)/tournaments/components/new/tournaments_grid/live_tournament_card.tsx index d2df810213..00b6f7ce7d 100644 --- a/front_end/src/app/(main)/(tournaments)/tournaments/components/new/tournaments_grid/live_tournament_card.tsx +++ b/front_end/src/app/(main)/(tournaments)/tournaments/components/new/tournaments_grid/live_tournament_card.tsx @@ -29,13 +29,13 @@ const LiveTournamentCard: React.FC = ({ item, nowTs = 0 }) => { href={href} className={cn( "group block no-underline", - "rounded border border-blue-400 dark:border-blue-400-dark", + "rounded-lg border border-blue-400 dark:border-blue-400-dark lg:rounded", "bg-gray-0/50 dark:bg-gray-0-dark/50", "shadow-sm transition-shadow hover:shadow-md", "overflow-hidden" )} > -
+
{item.header_image ? ( // eslint-disable-next-line @next/next/no-img-element = ({ item, nowTs = 0 }) => { ) : null}
-
+
{prize && ( = ({ item, nowTs = 0 }) => { {prize ? ` ${t("tournamentPrizePool")}` : t("tournamentNoPrizePool")}
-
+
{item.forecasters_count ?? 0} = ({ item, nowTs = 0 }) => {
-
-
+
+
{item.name}
@@ -168,7 +168,7 @@ function ActiveMiniBar({ return (
-

+

{label}

@@ -231,7 +231,7 @@ function ClosedMiniBar({ return (
-

+

{label}

diff --git a/front_end/src/app/(main)/(tournaments)/tournaments/components/new/tournaments_grid/tournaments_grid.tsx b/front_end/src/app/(main)/(tournaments)/tournaments/components/new/tournaments_grid/tournaments_grid.tsx index 20289fa0c9..376dc0b9f1 100644 --- a/front_end/src/app/(main)/(tournaments)/tournaments/components/new/tournaments_grid/tournaments_grid.tsx +++ b/front_end/src/app/(main)/(tournaments)/tournaments/components/new/tournaments_grid/tournaments_grid.tsx @@ -15,7 +15,9 @@ const TournamentsGrid: React.FC = ({ items, renderItem }) => { className=" grid grid-cols-2 - gap-3 sm:gap-5 + gap-3 + sm:grid-cols-3 + sm:gap-5 md:grid-cols-4 lg:grid-cols-3 xl:grid-cols-4 " From 6fbe9476b0c67d210e2fedabc10a89fe25ed2220 Mon Sep 17 00:00:00 2001 From: Nikita Date: Thu, 25 Dec 2025 09:01:57 +0200 Subject: [PATCH 09/25] feat: handle filter/info overlapping --- .../components/new/tournaments_filter.tsx | 11 +++++++++++ front_end/src/components/ui/listbox.tsx | 16 ++++++++++++++++ 2 files changed, 27 insertions(+) diff --git a/front_end/src/app/(main)/(tournaments)/tournaments/components/new/tournaments_filter.tsx b/front_end/src/app/(main)/(tournaments)/tournaments/components/new/tournaments_filter.tsx index 7ae870a155..06eb52020f 100644 --- a/front_end/src/app/(main)/(tournaments)/tournaments/components/new/tournaments_filter.tsx +++ b/front_end/src/app/(main)/(tournaments)/tournaments/components/new/tournaments_filter.tsx @@ -1,15 +1,18 @@ "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) ?? @@ -36,10 +39,18 @@ const TournamentsFilter: React.FC = () => { const isLg = useBreakpoint("lg"); + const handleOpenChange = useCallback( + (open: boolean) => { + if (open) closeInfo(); + }, + [closeInfo] + ); + return ( = { 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; From 5536ec5b9be96e39af856c426d975d750e7bc2d6 Mon Sep 17 00:00:00 2001 From: Nikita Date: Thu, 25 Dec 2025 13:33:28 +0200 Subject: [PATCH 10/25] feat: question series cards --- front_end/messages/cs.json | 1 + front_end/messages/en.json | 4 +- front_end/messages/es.json | 1 + front_end/messages/pt.json | 1 + front_end/messages/zh-TW.json | 1 + front_end/messages/zh.json | 1 + .../tournaments_grid/live_tournament_card.tsx | 2 +- .../tournaments_grid/question_series_card.tsx | 65 +++++++++++++++++++ .../series_tournaments_grid.tsx | 11 ++-- 9 files changed, 77 insertions(+), 10 deletions(-) create mode 100644 front_end/src/app/(main)/(tournaments)/tournaments/components/new/tournaments_grid/question_series_card.tsx diff --git a/front_end/messages/cs.json b/front_end/messages/cs.json index 4ff3d87046..12539dd691 100644 --- a/front_end/messages/cs.json +++ b/front_end/messages/cs.json @@ -1810,5 +1810,6 @@ "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}}", "othersCount": "Ostatní ({count})" } diff --git a/front_end/messages/en.json b/front_end/messages/en.json index 881b5b89e7..9e5f595ccf 100644 --- a/front_end/messages/en.json +++ b/front_end/messages/en.json @@ -1792,20 +1792,18 @@ "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}}", "none": "none" } diff --git a/front_end/messages/es.json b/front_end/messages/es.json index 56a00e4081..90f5b6b682 100644 --- a/front_end/messages/es.json +++ b/front_end/messages/es.json @@ -1810,5 +1810,6 @@ "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}}", "othersCount": "Otros ({count})" } diff --git a/front_end/messages/pt.json b/front_end/messages/pt.json index 0060fcd967..d333a2fc64 100644 --- a/front_end/messages/pt.json +++ b/front_end/messages/pt.json @@ -1808,5 +1808,6 @@ "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}}", "othersCount": "Outros ({count})" } diff --git a/front_end/messages/zh-TW.json b/front_end/messages/zh-TW.json index 95cfc4b4fd..89e9e2a96c 100644 --- a/front_end/messages/zh-TW.json +++ b/front_end/messages/zh-TW.json @@ -1807,5 +1807,6 @@ "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 {# 個問題}}", "withdrawAfterPercentSetting2": "問題總生命周期後撤回" } diff --git a/front_end/messages/zh.json b/front_end/messages/zh.json index ad86f960dc..df08f4d6a3 100644 --- a/front_end/messages/zh.json +++ b/front_end/messages/zh.json @@ -1812,5 +1812,6 @@ "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 {# 个问题}}", "othersCount": "其他({count})" } diff --git a/front_end/src/app/(main)/(tournaments)/tournaments/components/new/tournaments_grid/live_tournament_card.tsx b/front_end/src/app/(main)/(tournaments)/tournaments/components/new/tournaments_grid/live_tournament_card.tsx index 00b6f7ce7d..bce6975cdc 100644 --- a/front_end/src/app/(main)/(tournaments)/tournaments/components/new/tournaments_grid/live_tournament_card.tsx +++ b/front_end/src/app/(main)/(tournaments)/tournaments/components/new/tournaments_grid/live_tournament_card.tsx @@ -73,7 +73,7 @@ const LiveTournamentCard: React.FC = ({ item, nowTs = 0 }) => {
-
+
{item.name}
diff --git a/front_end/src/app/(main)/(tournaments)/tournaments/components/new/tournaments_grid/question_series_card.tsx b/front_end/src/app/(main)/(tournaments)/tournaments/components/new/tournaments_grid/question_series_card.tsx new file mode 100644 index 0000000000..75fa11b819 --- /dev/null +++ b/front_end/src/app/(main)/(tournaments)/tournaments/components/new/tournaments_grid/question_series_card.tsx @@ -0,0 +1,65 @@ +"use client"; + +import Link from "next/link"; +import { useTranslations } from "next-intl"; +import React from "react"; + +import { TournamentPreview } from "@/types/projects"; +import cn from "@/utils/core/cn"; +import { getProjectLink } from "@/utils/navigation"; + +type Props = { + item: TournamentPreview; +}; + +const QuestionSeriesCard: React.FC = ({ item }) => { + const t = useTranslations(); + + const href = getProjectLink(item); + const questionsCount = item.questions_count ?? 0; + + return ( + +
+ {item.header_image ? ( + // eslint-disable-next-line @next/next/no-img-element + {item.name} + ) : null} +
+ +
+

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

+ +
+ {item.name} +
+
+ + ); +}; + +export default QuestionSeriesCard; diff --git a/front_end/src/app/(main)/(tournaments)/tournaments/components/new/tournaments_grid/series_tournaments_grid.tsx b/front_end/src/app/(main)/(tournaments)/tournaments/components/new/tournaments_grid/series_tournaments_grid.tsx index 18fde3cbbc..47c5daf826 100644 --- a/front_end/src/app/(main)/(tournaments)/tournaments/components/new/tournaments_grid/series_tournaments_grid.tsx +++ b/front_end/src/app/(main)/(tournaments)/tournaments/components/new/tournaments_grid/series_tournaments_grid.tsx @@ -4,17 +4,16 @@ 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 ( -
-
- Question Series Tournaments ({items.length}) -
- -
+ } + /> ); }; From cc9f6b165db3cd669950e3b08bce9a20ce6cba38 Mon Sep 17 00:00:00 2001 From: Nikita Date: Thu, 25 Dec 2025 16:37:40 +0200 Subject: [PATCH 11/25] feat: add index cards --- front_end/messages/cs.json | 1 + front_end/messages/en.json | 1 + front_end/messages/es.json | 1 + front_end/messages/pt.json | 1 + front_end/messages/zh-TW.json | 1 + front_end/messages/zh.json | 1 + .../index_tournament_card.tsx | 111 ++++++++++++++++++ .../index_tournaments_grid.tsx | 10 +- .../new/tournaments_grid/tournaments_grid.tsx | 11 +- front_end/src/types/projects.ts | 1 + projects/serializers/common.py | 9 ++ 11 files changed, 141 insertions(+), 7 deletions(-) create mode 100644 front_end/src/app/(main)/(tournaments)/tournaments/components/new/tournaments_grid/index_tournament_card.tsx diff --git a/front_end/messages/cs.json b/front_end/messages/cs.json index 12539dd691..a89d8004b8 100644 --- a/front_end/messages/cs.json +++ b/front_end/messages/cs.json @@ -1811,5 +1811,6 @@ "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", "othersCount": "Ostatní ({count})" } diff --git a/front_end/messages/en.json b/front_end/messages/en.json index 9e5f595ccf..73a41be5d4 100644 --- a/front_end/messages/en.json +++ b/front_end/messages/en.json @@ -1805,5 +1805,6 @@ "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", "none": "none" } diff --git a/front_end/messages/es.json b/front_end/messages/es.json index 90f5b6b682..f77a97e64b 100644 --- a/front_end/messages/es.json +++ b/front_end/messages/es.json @@ -1811,5 +1811,6 @@ "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", "othersCount": "Otros ({count})" } diff --git a/front_end/messages/pt.json b/front_end/messages/pt.json index d333a2fc64..c709c38fdd 100644 --- a/front_end/messages/pt.json +++ b/front_end/messages/pt.json @@ -1809,5 +1809,6 @@ "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", "othersCount": "Outros ({count})" } diff --git a/front_end/messages/zh-TW.json b/front_end/messages/zh-TW.json index 89e9e2a96c..4663154de8 100644 --- a/front_end/messages/zh-TW.json +++ b/front_end/messages/zh-TW.json @@ -1808,5 +1808,6 @@ "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} 題問題", "withdrawAfterPercentSetting2": "問題總生命周期後撤回" } diff --git a/front_end/messages/zh.json b/front_end/messages/zh.json index df08f4d6a3..1e588be559 100644 --- a/front_end/messages/zh.json +++ b/front_end/messages/zh.json @@ -1813,5 +1813,6 @@ "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} 题目", "othersCount": "其他({count})" } diff --git a/front_end/src/app/(main)/(tournaments)/tournaments/components/new/tournaments_grid/index_tournament_card.tsx b/front_end/src/app/(main)/(tournaments)/tournaments/components/new/tournaments_grid/index_tournament_card.tsx new file mode 100644 index 0000000000..690df19e4b --- /dev/null +++ b/front_end/src/app/(main)/(tournaments)/tournaments/components/new/tournaments_grid/index_tournament_card.tsx @@ -0,0 +1,111 @@ +"use client"; + +import Link from "next/link"; +import { useTranslations } from "next-intl"; +import React, { useMemo } from "react"; + +import { TournamentPreview } from "@/types/projects"; +import cn from "@/utils/core/cn"; +import { getProjectLink } from "@/utils/navigation"; + +type Props = { + item: TournamentPreview; +}; + +const IndexTournamentCard: React.FC = ({ item }) => { + const t = useTranslations(); + const href = getProjectLink(item); + + 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} + ) : ( +
+ )} +
+ +
+

+ {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/new/tournaments_grid/index_tournaments_grid.tsx b/front_end/src/app/(main)/(tournaments)/tournaments/components/new/tournaments_grid/index_tournaments_grid.tsx index 0ba93d4982..8ca7d8a87a 100644 --- a/front_end/src/app/(main)/(tournaments)/tournaments/components/new/tournaments_grid/index_tournaments_grid.tsx +++ b/front_end/src/app/(main)/(tournaments)/tournaments/components/new/tournaments_grid/index_tournaments_grid.tsx @@ -4,15 +4,17 @@ 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 ( -
-
Indexes ({items.length})
- -
+ } + className="grid-cols-1 md:grid-cols-3 xl:grid-cols-3" + /> ); }; diff --git a/front_end/src/app/(main)/(tournaments)/tournaments/components/new/tournaments_grid/tournaments_grid.tsx b/front_end/src/app/(main)/(tournaments)/tournaments/components/new/tournaments_grid/tournaments_grid.tsx index 376dc0b9f1..6bf508c0cf 100644 --- a/front_end/src/app/(main)/(tournaments)/tournaments/components/new/tournaments_grid/tournaments_grid.tsx +++ b/front_end/src/app/(main)/(tournaments)/tournaments/components/new/tournaments_grid/tournaments_grid.tsx @@ -3,16 +3,19 @@ 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 }) => { +const TournamentsGrid: React.FC = ({ items, renderItem, className }) => { return (
{items.map((item) => renderItem ? ( diff --git a/front_end/src/types/projects.ts b/front_end/src/types/projects.ts index 689e3fa0f7..5291f4fa23 100644 --- a/front_end/src/types/projects.ts +++ b/front_end/src/types/projects.ts @@ -70,6 +70,7 @@ export type TournamentPreview = Project & { score_type: string; followers_count?: number; timeline: TournamentTimeline; + description_preview?: string; }; export type TournamentTimeline = { diff --git a/projects/serializers/common.py b/projects/serializers/common.py index ee3ba80567..533a33eda7 100644 --- a/projects/serializers/common.py +++ b/projects/serializers/common.py @@ -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,16 @@ 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 From 1a8db1d722d4d3c67c0d1622201f1da244cbc319 Mon Sep 17 00:00:00 2001 From: Nikita Date: Thu, 25 Dec 2025 18:40:17 +0200 Subject: [PATCH 12/25] refactor: tournament cards --- .../index_tournament_card.tsx | 18 ++------- .../tournaments_grid/live_tournament_card.tsx | 19 ++------- .../tournaments_grid/question_series_card.tsx | 24 +++--------- .../tournament_card_shell.tsx | 39 +++++++++++++++++++ 4 files changed, 52 insertions(+), 48 deletions(-) create mode 100644 front_end/src/app/(main)/(tournaments)/tournaments/components/new/tournaments_grid/tournament_card_shell.tsx diff --git a/front_end/src/app/(main)/(tournaments)/tournaments/components/new/tournaments_grid/index_tournament_card.tsx b/front_end/src/app/(main)/(tournaments)/tournaments/components/new/tournaments_grid/index_tournament_card.tsx index 690df19e4b..c63782c98c 100644 --- a/front_end/src/app/(main)/(tournaments)/tournaments/components/new/tournaments_grid/index_tournament_card.tsx +++ b/front_end/src/app/(main)/(tournaments)/tournaments/components/new/tournaments_grid/index_tournament_card.tsx @@ -1,12 +1,12 @@ "use client"; -import Link from "next/link"; import { useTranslations } from "next-intl"; import React, { useMemo } from "react"; import { TournamentPreview } from "@/types/projects"; import cn from "@/utils/core/cn"; -import { getProjectLink } from "@/utils/navigation"; + +import TournamentCardShell from "./tournament_card_shell"; type Props = { item: TournamentPreview; @@ -14,23 +14,13 @@ type Props = { const IndexTournamentCard: React.FC = ({ item }) => { const t = useTranslations(); - const href = getProjectLink(item); const description = useMemo(() => { return htmlBoldToText(item.description_preview || ""); }, [item.description_preview]); return ( - +
{item.header_image ? ( // eslint-disable-next-line @next/next/no-img-element @@ -74,7 +64,7 @@ const IndexTournamentCard: React.FC = ({ item }) => {

) : null}
- +
); }; diff --git a/front_end/src/app/(main)/(tournaments)/tournaments/components/new/tournaments_grid/live_tournament_card.tsx b/front_end/src/app/(main)/(tournaments)/tournaments/components/new/tournaments_grid/live_tournament_card.tsx index bce6975cdc..f9a9d5f59a 100644 --- a/front_end/src/app/(main)/(tournaments)/tournaments/components/new/tournaments_grid/live_tournament_card.tsx +++ b/front_end/src/app/(main)/(tournaments)/tournaments/components/new/tournaments_grid/live_tournament_card.tsx @@ -2,13 +2,13 @@ import { faUsers } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; -import Link from "next/link"; import { useTranslations } from "next-intl"; import React, { useMemo } from "react"; import { TournamentPreview, TournamentTimeline } from "@/types/projects"; import cn from "@/utils/core/cn"; -import { getProjectLink } from "@/utils/navigation"; + +import TournamentCardShell from "./tournament_card_shell"; type Props = { item: TournamentPreview; @@ -22,19 +22,8 @@ const LiveTournamentCard: React.FC = ({ item, nowTs = 0 }) => { [item.prize_pool] ); - const href = getProjectLink(item); - return ( - +
{item.header_image ? ( // eslint-disable-next-line @next/next/no-img-element @@ -86,7 +75,7 @@ const LiveTournamentCard: React.FC = ({ item, nowTs = 0 }) => { isOngoing={Boolean(item.is_ongoing)} />
- +
); }; diff --git a/front_end/src/app/(main)/(tournaments)/tournaments/components/new/tournaments_grid/question_series_card.tsx b/front_end/src/app/(main)/(tournaments)/tournaments/components/new/tournaments_grid/question_series_card.tsx index 75fa11b819..d3781a6fb4 100644 --- a/front_end/src/app/(main)/(tournaments)/tournaments/components/new/tournaments_grid/question_series_card.tsx +++ b/front_end/src/app/(main)/(tournaments)/tournaments/components/new/tournaments_grid/question_series_card.tsx @@ -1,34 +1,20 @@ "use client"; -import Link from "next/link"; import { useTranslations } from "next-intl"; import React from "react"; import { TournamentPreview } from "@/types/projects"; -import cn from "@/utils/core/cn"; -import { getProjectLink } from "@/utils/navigation"; -type Props = { - item: TournamentPreview; -}; +import TournamentCardShell from "./tournament_card_shell"; + +type Props = { item: TournamentPreview }; const QuestionSeriesCard: React.FC = ({ item }) => { const t = useTranslations(); - - const href = getProjectLink(item); const questionsCount = item.questions_count ?? 0; return ( - +
{item.header_image ? ( // eslint-disable-next-line @next/next/no-img-element @@ -58,7 +44,7 @@ const QuestionSeriesCard: React.FC = ({ item }) => { {item.name}
- + ); }; diff --git a/front_end/src/app/(main)/(tournaments)/tournaments/components/new/tournaments_grid/tournament_card_shell.tsx b/front_end/src/app/(main)/(tournaments)/tournaments/components/new/tournaments_grid/tournament_card_shell.tsx new file mode 100644 index 0000000000..554001c6f5 --- /dev/null +++ b/front_end/src/app/(main)/(tournaments)/tournaments/components/new/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; From cbe8ebc320260d6a6e0d8cfbeb32c2799a16a5ed Mon Sep 17 00:00:00 2001 From: Nikita Date: Fri, 26 Dec 2025 10:35:10 +0200 Subject: [PATCH 13/25] feat: add archived cards rendering --- .../tournaments/archived/page.tsx | 7 +++++- .../archived_tournaments_grid.tsx | 22 ++++++++++++++----- 2 files changed, 22 insertions(+), 7 deletions(-) diff --git a/front_end/src/app/(main)/(tournaments)/tournaments/archived/page.tsx b/front_end/src/app/(main)/(tournaments)/tournaments/archived/page.tsx index 05db394724..c8eb48bba4 100644 --- a/front_end/src/app/(main)/(tournaments)/tournaments/archived/page.tsx +++ b/front_end/src/app/(main)/(tournaments)/tournaments/archived/page.tsx @@ -5,8 +5,13 @@ import TournamentsScreen from "../components/new/tournaments_screen"; const ArchivedPage: React.FC = async () => { const tournaments = await ServerProjectsApi.getTournaments(); + const nowTs = Date.now(); return ( - + ); diff --git a/front_end/src/app/(main)/(tournaments)/tournaments/components/new/tournaments_grid/archived_tournaments_grid.tsx b/front_end/src/app/(main)/(tournaments)/tournaments/components/new/tournaments_grid/archived_tournaments_grid.tsx index b6df91073f..ba65fea356 100644 --- a/front_end/src/app/(main)/(tournaments)/tournaments/components/new/tournaments_grid/archived_tournaments_grid.tsx +++ b/front_end/src/app/(main)/(tournaments)/tournaments/components/new/tournaments_grid/archived_tournaments_grid.tsx @@ -2,17 +2,27 @@ import React from "react"; -import TournamentsGrid from "./tournaments_grid"; +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 } = useTournamentsSection(); + const { items, nowTs } = useTournamentsSection(); return ( -
-
Archived Tournaments ({items.length})
- -
+ { + if (item.type === TournamentType.QuestionSeries) { + return ; + } + + return ; + }} + /> ); }; From d0263232d1733e6a5a6c4829e3764a544da1ee8c Mon Sep 17 00:00:00 2001 From: Nikita Date: Fri, 26 Dec 2025 13:45:12 +0200 Subject: [PATCH 14/25] feat: adapt index cards & add img fallbacks --- .../index_tournament_card.tsx | 135 +++++++++++++----- .../tournaments_grid/live_tournament_card.tsx | 11 +- .../tournaments_grid/question_series_card.tsx | 13 +- 3 files changed, 117 insertions(+), 42 deletions(-) diff --git a/front_end/src/app/(main)/(tournaments)/tournaments/components/new/tournaments_grid/index_tournament_card.tsx b/front_end/src/app/(main)/(tournaments)/tournaments/components/new/tournaments_grid/index_tournament_card.tsx index c63782c98c..77ca62ee6e 100644 --- a/front_end/src/app/(main)/(tournaments)/tournaments/components/new/tournaments_grid/index_tournament_card.tsx +++ b/front_end/src/app/(main)/(tournaments)/tournaments/components/new/tournaments_grid/index_tournament_card.tsx @@ -1,5 +1,7 @@ "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"; @@ -21,48 +23,105 @@ const IndexTournamentCard: React.FC = ({ item }) => { return ( -
- {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} + {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} + + ), + })}

- ) : null} + +
+ {item.name} +
+ + {description ? ( +

+ {description} +

+ ) : null} +
); diff --git a/front_end/src/app/(main)/(tournaments)/tournaments/components/new/tournaments_grid/live_tournament_card.tsx b/front_end/src/app/(main)/(tournaments)/tournaments/components/new/tournaments_grid/live_tournament_card.tsx index f9a9d5f59a..0059bbb1b7 100644 --- a/front_end/src/app/(main)/(tournaments)/tournaments/components/new/tournaments_grid/live_tournament_card.tsx +++ b/front_end/src/app/(main)/(tournaments)/tournaments/components/new/tournaments_grid/live_tournament_card.tsx @@ -1,6 +1,6 @@ "use client"; -import { faUsers } from "@fortawesome/free-solid-svg-icons"; +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"; @@ -34,7 +34,14 @@ const LiveTournamentCard: React.FC = ({ item, nowTs = 0 }) => { decoding="async" className="h-full w-full object-cover" /> - ) : null} + ) : ( +
+ +
+ )}
diff --git a/front_end/src/app/(main)/(tournaments)/tournaments/components/new/tournaments_grid/question_series_card.tsx b/front_end/src/app/(main)/(tournaments)/tournaments/components/new/tournaments_grid/question_series_card.tsx index d3781a6fb4..4dc4db2e41 100644 --- a/front_end/src/app/(main)/(tournaments)/tournaments/components/new/tournaments_grid/question_series_card.tsx +++ b/front_end/src/app/(main)/(tournaments)/tournaments/components/new/tournaments_grid/question_series_card.tsx @@ -1,5 +1,7 @@ "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"; @@ -23,9 +25,16 @@ const QuestionSeriesCard: React.FC = ({ item }) => { alt={item.name} loading="lazy" decoding="async" - className="h-full w-full rounded object-cover" + className="h-full w-full object-cover" /> - ) : null} + ) : ( +
+ +
+ )}
From ba53ec070bc15dced5a271fe093faba0cbfc49a4 Mon Sep 17 00:00:00 2001 From: Nikita Date: Fri, 26 Dec 2025 15:26:43 +0200 Subject: [PATCH 15/25] chore: remove old screen --- .../tournaments/archived/page.tsx | 4 +- .../components/old/old_tournaments_screen.tsx | 92 ----------- .../components/old/tournament_filters.tsx | 39 ----- .../components/old/tournaments_list.tsx | 145 ------------------ .../components/{new => }/tournament_tabs.tsx | 2 +- .../{new => }/tournaments-tabs-shell.tsx | 2 +- .../{new => }/tournaments_container.tsx | 0 .../{new => }/tournaments_filter.tsx | 2 +- .../archived_tournaments_grid.tsx | 0 .../index_tournament_card.tsx | 0 .../index_tournaments_grid.tsx | 0 .../tournaments_grid/live_tournament_card.tsx | 0 .../live_tournaments_grid.tsx | 0 .../tournaments_grid/question_series_card.tsx | 0 .../series_tournaments_grid.tsx | 0 .../tournament_card_shell.tsx | 0 .../tournaments_grid/tournaments_grid.tsx | 0 .../{new => }/tournaments_header.tsx | 0 .../components/{new => }/tournaments_hero.tsx | 2 +- .../{new => }/tournaments_mobile_ctrl.tsx | 0 .../tournaments_popover/tournaments_info.tsx | 0 .../tournaments_info_button.tsx | 0 .../tournaments_info_popover.tsx | 0 .../{new => }/tournaments_provider.tsx | 6 +- .../{new => }/tournaments_screen.tsx | 2 +- .../{new => }/tournaments_search.tsx | 2 +- .../tournaments/helpers/index.ts | 24 --- .../tournaments/indexes/page.tsx | 4 +- .../(main)/(tournaments)/tournaments/page.tsx | 21 +-- .../tournaments/question-series/page.tsx | 4 +- 30 files changed, 17 insertions(+), 334 deletions(-) delete mode 100644 front_end/src/app/(main)/(tournaments)/tournaments/components/old/old_tournaments_screen.tsx delete mode 100644 front_end/src/app/(main)/(tournaments)/tournaments/components/old/tournament_filters.tsx delete mode 100644 front_end/src/app/(main)/(tournaments)/tournaments/components/old/tournaments_list.tsx rename front_end/src/app/(main)/(tournaments)/tournaments/components/{new => }/tournament_tabs.tsx (92%) rename front_end/src/app/(main)/(tournaments)/tournaments/components/{new => }/tournaments-tabs-shell.tsx (95%) rename front_end/src/app/(main)/(tournaments)/tournaments/components/{new => }/tournaments_container.tsx (100%) rename front_end/src/app/(main)/(tournaments)/tournaments/components/{new => }/tournaments_filter.tsx (96%) rename front_end/src/app/(main)/(tournaments)/tournaments/components/{new => }/tournaments_grid/archived_tournaments_grid.tsx (100%) rename front_end/src/app/(main)/(tournaments)/tournaments/components/{new => }/tournaments_grid/index_tournament_card.tsx (100%) rename front_end/src/app/(main)/(tournaments)/tournaments/components/{new => }/tournaments_grid/index_tournaments_grid.tsx (100%) rename front_end/src/app/(main)/(tournaments)/tournaments/components/{new => }/tournaments_grid/live_tournament_card.tsx (100%) rename front_end/src/app/(main)/(tournaments)/tournaments/components/{new => }/tournaments_grid/live_tournaments_grid.tsx (100%) rename front_end/src/app/(main)/(tournaments)/tournaments/components/{new => }/tournaments_grid/question_series_card.tsx (100%) rename front_end/src/app/(main)/(tournaments)/tournaments/components/{new => }/tournaments_grid/series_tournaments_grid.tsx (100%) rename front_end/src/app/(main)/(tournaments)/tournaments/components/{new => }/tournaments_grid/tournament_card_shell.tsx (100%) rename front_end/src/app/(main)/(tournaments)/tournaments/components/{new => }/tournaments_grid/tournaments_grid.tsx (100%) rename front_end/src/app/(main)/(tournaments)/tournaments/components/{new => }/tournaments_header.tsx (100%) rename front_end/src/app/(main)/(tournaments)/tournaments/components/{new => }/tournaments_hero.tsx (96%) rename front_end/src/app/(main)/(tournaments)/tournaments/components/{new => }/tournaments_mobile_ctrl.tsx (100%) rename front_end/src/app/(main)/(tournaments)/tournaments/components/{new => }/tournaments_popover/tournaments_info.tsx (100%) rename front_end/src/app/(main)/(tournaments)/tournaments/components/{new => }/tournaments_popover/tournaments_info_button.tsx (100%) rename front_end/src/app/(main)/(tournaments)/tournaments/components/{new => }/tournaments_popover/tournaments_info_popover.tsx (100%) rename front_end/src/app/(main)/(tournaments)/tournaments/components/{new => }/tournaments_provider.tsx (89%) rename front_end/src/app/(main)/(tournaments)/tournaments/components/{new => }/tournaments_screen.tsx (95%) rename front_end/src/app/(main)/(tournaments)/tournaments/components/{new => }/tournaments_search.tsx (95%) diff --git a/front_end/src/app/(main)/(tournaments)/tournaments/archived/page.tsx b/front_end/src/app/(main)/(tournaments)/tournaments/archived/page.tsx index c8eb48bba4..009aeac714 100644 --- a/front_end/src/app/(main)/(tournaments)/tournaments/archived/page.tsx +++ b/front_end/src/app/(main)/(tournaments)/tournaments/archived/page.tsx @@ -1,7 +1,7 @@ import ServerProjectsApi from "@/services/api/projects/projects.server"; -import ArchivedTournamentsGrid from "../components/new/tournaments_grid/archived_tournaments_grid"; -import TournamentsScreen from "../components/new/tournaments_screen"; +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(); diff --git a/front_end/src/app/(main)/(tournaments)/tournaments/components/old/old_tournaments_screen.tsx b/front_end/src/app/(main)/(tournaments)/tournaments/components/old/old_tournaments_screen.tsx deleted file mode 100644 index 28e21c5b56..0000000000 --- a/front_end/src/app/(main)/(tournaments)/tournaments/components/old/old_tournaments_screen.tsx +++ /dev/null @@ -1,92 +0,0 @@ -import Link from "next/link"; -import { getTranslations } from "next-intl/server"; -import React from "react"; - -import { TournamentPreview } from "@/types/projects"; -import { getPublicSettings } from "@/utils/public_settings.server"; - -import TournamentFilters from "./tournament_filters"; -import TournamentsList from "./tournaments_list"; - -type Props = { - activeTournaments: TournamentPreview[]; - archivedTournaments: TournamentPreview[]; - questionSeries: TournamentPreview[]; - indexes: TournamentPreview[]; -}; - -const OldTournamentsScreen: React.FC = async ({ - activeTournaments, - archivedTournaments, - questionSeries, - indexes, -}) => { - const t = await getTranslations(); - const { PUBLIC_MINIMAL_UI } = getPublicSettings(); - - 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 && ( -
- -
- )} - - -
- ); -}; - -export default OldTournamentsScreen; diff --git a/front_end/src/app/(main)/(tournaments)/tournaments/components/old/tournament_filters.tsx b/front_end/src/app/(main)/(tournaments)/tournaments/components/old/tournament_filters.tsx deleted file mode 100644 index 3abe3f1732..0000000000 --- a/front_end/src/app/(main)/(tournaments)/tournaments/components/old/tournament_filters.tsx +++ /dev/null @@ -1,39 +0,0 @@ -"use client"; -import { ChangeEvent, FC } from "react"; - -import SearchInput from "@/components/search_input"; -import useSearchInputState from "@/hooks/use_search_input_state"; - -import { TOURNAMENTS_SEARCH } from "../../constants/query_params"; -import TournamentsFilter from "../new/tournaments_filter"; - -const TournamentFilters: FC = () => { - 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 TournamentFilters; diff --git a/front_end/src/app/(main)/(tournaments)/tournaments/components/old/tournaments_list.tsx b/front_end/src/app/(main)/(tournaments)/tournaments/components/old/tournaments_list.tsx deleted file mode 100644 index 90d1dd711d..0000000000 --- a/front_end/src/app/(main)/(tournaments)/tournaments/components/old/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/new/tournament_tabs.tsx b/front_end/src/app/(main)/(tournaments)/tournaments/components/tournament_tabs.tsx similarity index 92% rename from front_end/src/app/(main)/(tournaments)/tournaments/components/new/tournament_tabs.tsx rename to front_end/src/app/(main)/(tournaments)/tournaments/components/tournament_tabs.tsx index 6c98a3629b..f5a01a3a46 100644 --- a/front_end/src/app/(main)/(tournaments)/tournaments/components/new/tournament_tabs.tsx +++ b/front_end/src/app/(main)/(tournaments)/tournaments/components/tournament_tabs.tsx @@ -1,7 +1,7 @@ import React from "react"; import TournamentsTabsShell from "./tournaments-tabs-shell"; -import { Section, TournamentsSection } from "../../types"; +import { Section, TournamentsSection } from "../types"; type Props = { current: TournamentsSection }; diff --git a/front_end/src/app/(main)/(tournaments)/tournaments/components/new/tournaments-tabs-shell.tsx b/front_end/src/app/(main)/(tournaments)/tournaments/components/tournaments-tabs-shell.tsx similarity index 95% rename from front_end/src/app/(main)/(tournaments)/tournaments/components/new/tournaments-tabs-shell.tsx rename to front_end/src/app/(main)/(tournaments)/tournaments/components/tournaments-tabs-shell.tsx index 62c7f257f1..218086b073 100644 --- a/front_end/src/app/(main)/(tournaments)/tournaments/components/new/tournaments-tabs-shell.tsx +++ b/front_end/src/app/(main)/(tournaments)/tournaments/components/tournaments-tabs-shell.tsx @@ -5,7 +5,7 @@ import React from "react"; import { Tabs, TabsList, TabsTab } from "@/components/ui/tabs"; import cn from "@/utils/core/cn"; -import { Section, TournamentsSection } from "../../types"; +import { Section, TournamentsSection } from "../types"; type Props = { current: TournamentsSection; diff --git a/front_end/src/app/(main)/(tournaments)/tournaments/components/new/tournaments_container.tsx b/front_end/src/app/(main)/(tournaments)/tournaments/components/tournaments_container.tsx similarity index 100% rename from front_end/src/app/(main)/(tournaments)/tournaments/components/new/tournaments_container.tsx rename to front_end/src/app/(main)/(tournaments)/tournaments/components/tournaments_container.tsx diff --git a/front_end/src/app/(main)/(tournaments)/tournaments/components/new/tournaments_filter.tsx b/front_end/src/app/(main)/(tournaments)/tournaments/components/tournaments_filter.tsx similarity index 96% rename from front_end/src/app/(main)/(tournaments)/tournaments/components/new/tournaments_filter.tsx rename to front_end/src/app/(main)/(tournaments)/tournaments/components/tournaments_filter.tsx index 06eb52020f..cd5ad9175f 100644 --- a/front_end/src/app/(main)/(tournaments)/tournaments/components/new/tournaments_filter.tsx +++ b/front_end/src/app/(main)/(tournaments)/tournaments/components/tournaments_filter.tsx @@ -8,7 +8,7 @@ import useSearchParams from "@/hooks/use_search_params"; import { TournamentsSortBy } from "@/types/projects"; import { useTournamentsSection } from "./tournaments_provider"; -import { TOURNAMENTS_SORT } from "../../constants/query_params"; +import { TOURNAMENTS_SORT } from "../constants/query_params"; const TournamentsFilter: React.FC = () => { const t = useTranslations(); diff --git a/front_end/src/app/(main)/(tournaments)/tournaments/components/new/tournaments_grid/archived_tournaments_grid.tsx b/front_end/src/app/(main)/(tournaments)/tournaments/components/tournaments_grid/archived_tournaments_grid.tsx similarity index 100% rename from front_end/src/app/(main)/(tournaments)/tournaments/components/new/tournaments_grid/archived_tournaments_grid.tsx rename to front_end/src/app/(main)/(tournaments)/tournaments/components/tournaments_grid/archived_tournaments_grid.tsx diff --git a/front_end/src/app/(main)/(tournaments)/tournaments/components/new/tournaments_grid/index_tournament_card.tsx b/front_end/src/app/(main)/(tournaments)/tournaments/components/tournaments_grid/index_tournament_card.tsx similarity index 100% rename from front_end/src/app/(main)/(tournaments)/tournaments/components/new/tournaments_grid/index_tournament_card.tsx rename to front_end/src/app/(main)/(tournaments)/tournaments/components/tournaments_grid/index_tournament_card.tsx diff --git a/front_end/src/app/(main)/(tournaments)/tournaments/components/new/tournaments_grid/index_tournaments_grid.tsx b/front_end/src/app/(main)/(tournaments)/tournaments/components/tournaments_grid/index_tournaments_grid.tsx similarity index 100% rename from front_end/src/app/(main)/(tournaments)/tournaments/components/new/tournaments_grid/index_tournaments_grid.tsx rename to front_end/src/app/(main)/(tournaments)/tournaments/components/tournaments_grid/index_tournaments_grid.tsx diff --git a/front_end/src/app/(main)/(tournaments)/tournaments/components/new/tournaments_grid/live_tournament_card.tsx b/front_end/src/app/(main)/(tournaments)/tournaments/components/tournaments_grid/live_tournament_card.tsx similarity index 100% rename from front_end/src/app/(main)/(tournaments)/tournaments/components/new/tournaments_grid/live_tournament_card.tsx rename to front_end/src/app/(main)/(tournaments)/tournaments/components/tournaments_grid/live_tournament_card.tsx diff --git a/front_end/src/app/(main)/(tournaments)/tournaments/components/new/tournaments_grid/live_tournaments_grid.tsx b/front_end/src/app/(main)/(tournaments)/tournaments/components/tournaments_grid/live_tournaments_grid.tsx similarity index 100% rename from front_end/src/app/(main)/(tournaments)/tournaments/components/new/tournaments_grid/live_tournaments_grid.tsx rename to front_end/src/app/(main)/(tournaments)/tournaments/components/tournaments_grid/live_tournaments_grid.tsx diff --git a/front_end/src/app/(main)/(tournaments)/tournaments/components/new/tournaments_grid/question_series_card.tsx b/front_end/src/app/(main)/(tournaments)/tournaments/components/tournaments_grid/question_series_card.tsx similarity index 100% rename from front_end/src/app/(main)/(tournaments)/tournaments/components/new/tournaments_grid/question_series_card.tsx rename to front_end/src/app/(main)/(tournaments)/tournaments/components/tournaments_grid/question_series_card.tsx diff --git a/front_end/src/app/(main)/(tournaments)/tournaments/components/new/tournaments_grid/series_tournaments_grid.tsx b/front_end/src/app/(main)/(tournaments)/tournaments/components/tournaments_grid/series_tournaments_grid.tsx similarity index 100% rename from front_end/src/app/(main)/(tournaments)/tournaments/components/new/tournaments_grid/series_tournaments_grid.tsx rename to front_end/src/app/(main)/(tournaments)/tournaments/components/tournaments_grid/series_tournaments_grid.tsx diff --git a/front_end/src/app/(main)/(tournaments)/tournaments/components/new/tournaments_grid/tournament_card_shell.tsx b/front_end/src/app/(main)/(tournaments)/tournaments/components/tournaments_grid/tournament_card_shell.tsx similarity index 100% rename from front_end/src/app/(main)/(tournaments)/tournaments/components/new/tournaments_grid/tournament_card_shell.tsx rename to front_end/src/app/(main)/(tournaments)/tournaments/components/tournaments_grid/tournament_card_shell.tsx diff --git a/front_end/src/app/(main)/(tournaments)/tournaments/components/new/tournaments_grid/tournaments_grid.tsx b/front_end/src/app/(main)/(tournaments)/tournaments/components/tournaments_grid/tournaments_grid.tsx similarity index 100% rename from front_end/src/app/(main)/(tournaments)/tournaments/components/new/tournaments_grid/tournaments_grid.tsx rename to front_end/src/app/(main)/(tournaments)/tournaments/components/tournaments_grid/tournaments_grid.tsx diff --git a/front_end/src/app/(main)/(tournaments)/tournaments/components/new/tournaments_header.tsx b/front_end/src/app/(main)/(tournaments)/tournaments/components/tournaments_header.tsx similarity index 100% rename from front_end/src/app/(main)/(tournaments)/tournaments/components/new/tournaments_header.tsx rename to front_end/src/app/(main)/(tournaments)/tournaments/components/tournaments_header.tsx diff --git a/front_end/src/app/(main)/(tournaments)/tournaments/components/new/tournaments_hero.tsx b/front_end/src/app/(main)/(tournaments)/tournaments/components/tournaments_hero.tsx similarity index 96% rename from front_end/src/app/(main)/(tournaments)/tournaments/components/new/tournaments_hero.tsx rename to front_end/src/app/(main)/(tournaments)/tournaments/components/tournaments_hero.tsx index 729531a253..7f74af5966 100644 --- a/front_end/src/app/(main)/(tournaments)/tournaments/components/new/tournaments_hero.tsx +++ b/front_end/src/app/(main)/(tournaments)/tournaments/components/tournaments_hero.tsx @@ -4,7 +4,7 @@ import { useTranslations } from "next-intl"; import React from "react"; import { useTournamentsSection } from "./tournaments_provider"; -import { TournamentsSection } from "../../types"; +import { TournamentsSection } from "../types"; const HERO_KEYS = { live: { diff --git a/front_end/src/app/(main)/(tournaments)/tournaments/components/new/tournaments_mobile_ctrl.tsx b/front_end/src/app/(main)/(tournaments)/tournaments/components/tournaments_mobile_ctrl.tsx similarity index 100% rename from front_end/src/app/(main)/(tournaments)/tournaments/components/new/tournaments_mobile_ctrl.tsx rename to front_end/src/app/(main)/(tournaments)/tournaments/components/tournaments_mobile_ctrl.tsx diff --git a/front_end/src/app/(main)/(tournaments)/tournaments/components/new/tournaments_popover/tournaments_info.tsx b/front_end/src/app/(main)/(tournaments)/tournaments/components/tournaments_popover/tournaments_info.tsx similarity index 100% rename from front_end/src/app/(main)/(tournaments)/tournaments/components/new/tournaments_popover/tournaments_info.tsx rename to front_end/src/app/(main)/(tournaments)/tournaments/components/tournaments_popover/tournaments_info.tsx diff --git a/front_end/src/app/(main)/(tournaments)/tournaments/components/new/tournaments_popover/tournaments_info_button.tsx b/front_end/src/app/(main)/(tournaments)/tournaments/components/tournaments_popover/tournaments_info_button.tsx similarity index 100% rename from front_end/src/app/(main)/(tournaments)/tournaments/components/new/tournaments_popover/tournaments_info_button.tsx rename to front_end/src/app/(main)/(tournaments)/tournaments/components/tournaments_popover/tournaments_info_button.tsx diff --git a/front_end/src/app/(main)/(tournaments)/tournaments/components/new/tournaments_popover/tournaments_info_popover.tsx b/front_end/src/app/(main)/(tournaments)/tournaments/components/tournaments_popover/tournaments_info_popover.tsx similarity index 100% rename from front_end/src/app/(main)/(tournaments)/tournaments/components/new/tournaments_popover/tournaments_info_popover.tsx rename to front_end/src/app/(main)/(tournaments)/tournaments/components/tournaments_popover/tournaments_info_popover.tsx diff --git a/front_end/src/app/(main)/(tournaments)/tournaments/components/new/tournaments_provider.tsx b/front_end/src/app/(main)/(tournaments)/tournaments/components/tournaments_provider.tsx similarity index 89% rename from front_end/src/app/(main)/(tournaments)/tournaments/components/new/tournaments_provider.tsx rename to front_end/src/app/(main)/(tournaments)/tournaments/components/tournaments_provider.tsx index a667633d18..410592fd3d 100644 --- a/front_end/src/app/(main)/(tournaments)/tournaments/components/new/tournaments_provider.tsx +++ b/front_end/src/app/(main)/(tournaments)/tournaments/components/tournaments_provider.tsx @@ -4,9 +4,9 @@ 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"; +import { selectTournamentsForSection } from "../helpers"; +import { useTournamentFilters } from "../hooks/use_tournament_filters"; +import { TournamentsSection } from "../types"; type TournamentsSectionCtxValue = { current: TournamentsSection; diff --git a/front_end/src/app/(main)/(tournaments)/tournaments/components/new/tournaments_screen.tsx b/front_end/src/app/(main)/(tournaments)/tournaments/components/tournaments_screen.tsx similarity index 95% rename from front_end/src/app/(main)/(tournaments)/tournaments/components/new/tournaments_screen.tsx rename to front_end/src/app/(main)/(tournaments)/tournaments/components/tournaments_screen.tsx index c17a7a7810..bb8ef43e96 100644 --- a/front_end/src/app/(main)/(tournaments)/tournaments/components/new/tournaments_screen.tsx +++ b/front_end/src/app/(main)/(tournaments)/tournaments/components/tournaments_screen.tsx @@ -7,7 +7,7 @@ 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 { TournamentsSection } from "../types"; type Props = { current: TournamentsSection; diff --git a/front_end/src/app/(main)/(tournaments)/tournaments/components/new/tournaments_search.tsx b/front_end/src/app/(main)/(tournaments)/tournaments/components/tournaments_search.tsx similarity index 95% rename from front_end/src/app/(main)/(tournaments)/tournaments/components/new/tournaments_search.tsx rename to front_end/src/app/(main)/(tournaments)/tournaments/components/tournaments_search.tsx index f4360618b8..61e98381bf 100644 --- a/front_end/src/app/(main)/(tournaments)/tournaments/components/new/tournaments_search.tsx +++ b/front_end/src/app/(main)/(tournaments)/tournaments/components/tournaments_search.tsx @@ -3,7 +3,7 @@ import { ChangeEvent, useEffect, useState } from "react"; import ExpandableSearchInput from "@/components/expandable_search_input"; import useSearchInputState from "@/hooks/use_search_input_state"; -import { TOURNAMENTS_SEARCH } from "../../constants/query_params"; +import { TOURNAMENTS_SEARCH } from "../constants/query_params"; const TournamentsSearch: React.FC = () => { const [searchQuery, setSearchQuery] = useSearchInputState( diff --git a/front_end/src/app/(main)/(tournaments)/tournaments/helpers/index.ts b/front_end/src/app/(main)/(tournaments)/tournaments/helpers/index.ts index dd90caa01f..d668c6ab5b 100644 --- a/front_end/src/app/(main)/(tournaments)/tournaments/helpers/index.ts +++ b/front_end/src/app/(main)/(tournaments)/tournaments/helpers/index.ts @@ -11,30 +11,6 @@ const archiveEndTs = (t: TournamentPreview) => .find((d) => d && isValid(d)) ?.getTime() ?? 0; -export 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 function selectTournamentsForSection( tournaments: TournamentPreview[], section: TournamentsSection diff --git a/front_end/src/app/(main)/(tournaments)/tournaments/indexes/page.tsx b/front_end/src/app/(main)/(tournaments)/tournaments/indexes/page.tsx index ab920b3f29..fd9b837adb 100644 --- a/front_end/src/app/(main)/(tournaments)/tournaments/indexes/page.tsx +++ b/front_end/src/app/(main)/(tournaments)/tournaments/indexes/page.tsx @@ -1,7 +1,7 @@ import ServerProjectsApi from "@/services/api/projects/projects.server"; -import IndexTournamentsGrid from "../components/new/tournaments_grid/index_tournaments_grid"; -import TournamentsScreen from "../components/new/tournaments_screen"; +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(); diff --git a/front_end/src/app/(main)/(tournaments)/tournaments/page.tsx b/front_end/src/app/(main)/(tournaments)/tournaments/page.tsx index e7bd3994d6..889b2dbdb5 100644 --- a/front_end/src/app/(main)/(tournaments)/tournaments/page.tsx +++ b/front_end/src/app/(main)/(tournaments)/tournaments/page.tsx @@ -1,9 +1,7 @@ import ServerProjectsApi from "@/services/api/projects/projects.server"; -import LiveTournamentsGrid from "./components/new/tournaments_grid/live_tournaments_grid"; -import TournamentsScreen from "./components/new/tournaments_screen"; -import OldTournamentsScreen from "./components/old/old_tournaments_screen"; -import { extractTournamentLists } from "./helpers"; +import LiveTournamentsGrid from "./components/tournaments_grid/live_tournaments_grid"; +import TournamentsScreen from "./components/tournaments_screen"; export const metadata = { title: "Tournaments | Metaculus", @@ -11,25 +9,10 @@ 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.", }; -const isOldScreen = false; - const LiveTournamentsPage: React.FC = async () => { const tournaments = await ServerProjectsApi.getTournaments(); - const { activeTournaments, archivedTournaments, questionSeries, indexes } = - extractTournamentLists(tournaments); const nowTs = Date.now(); - if (isOldScreen) { - return ( - - ); - } - return ( 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 index 7b55eb3d82..c7a00455c0 100644 --- a/front_end/src/app/(main)/(tournaments)/tournaments/question-series/page.tsx +++ b/front_end/src/app/(main)/(tournaments)/tournaments/question-series/page.tsx @@ -1,7 +1,7 @@ import ServerProjectsApi from "@/services/api/projects/projects.server"; -import SeriesTournamentsGrid from "../components/new/tournaments_grid/series_tournaments_grid"; -import TournamentsScreen from "../components/new/tournaments_screen"; +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(); From aae585aee934de6604326e86136872845b4b4a28 Mon Sep 17 00:00:00 2001 From: Nikita Date: Fri, 26 Dec 2025 16:13:11 +0200 Subject: [PATCH 16/25] chore: move cached fns into cached file --- projects/services/cache.py | 14 +++++++++++++- projects/services/common.py | 13 ------------- projects/views/common.py | 3 +-- 3 files changed, 14 insertions(+), 16 deletions(-) diff --git a/projects/services/cache.py b/projects/services/cache.py index 2200a84d71..54caac8bd9 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,14 @@ 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} \ No newline at end of file diff --git a/projects/services/common.py b/projects/services/common.py index 67497db5bf..1b3b74e1d2 100644 --- a/projects/services/common.py +++ b/projects/services/common.py @@ -211,19 +211,6 @@ def get_max(data: list): "all_questions_closed": all_questions_closed, } -PROJECT_TIMELINE_TTL_SECONDS = 5 * 360 - -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} - def get_questions_count_for_projects(project_ids: list[int]) -> dict[int, int]: """ Returns a dict mapping each project_id to its questions_count diff --git a/projects/views/common.py b/projects/views/common.py index 4d56b1321c..a056550dd3 100644 --- a/projects/views/common.py +++ b/projects/views/common.py @@ -21,14 +21,13 @@ 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, invite_user_to_project, get_site_main_project, get_project_timeline_data, - get_projects_timeline_cached, ) from projects.services.subscriptions import subscribe_project, unsubscribe_project from questions.models import Question From f6bf72e99adbd3c9e42c105c16f27acc85855a9e Mon Sep 17 00:00:00 2001 From: Nikita Date: Fri, 26 Dec 2025 16:32:56 +0200 Subject: [PATCH 17/25] feat: add not found screen --- front_end/messages/cs.json | 4 ++ front_end/messages/en.json | 4 ++ front_end/messages/es.json | 4 ++ front_end/messages/pt.json | 4 ++ front_end/messages/zh-TW.json | 4 ++ front_end/messages/zh.json | 4 ++ .../components/tournaments_results.tsx | 60 +++++++++++++++++++ .../components/tournaments_screen.tsx | 5 +- 8 files changed, 88 insertions(+), 1 deletion(-) create mode 100644 front_end/src/app/(main)/(tournaments)/tournaments/components/tournaments_results.tsx diff --git a/front_end/messages/cs.json b/front_end/messages/cs.json index a89d8004b8..6bd9a276b6 100644 --- a/front_end/messages/cs.json +++ b/front_end/messages/cs.json @@ -1812,5 +1812,9 @@ "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.", "othersCount": "Ostatní ({count})" } diff --git a/front_end/messages/en.json b/front_end/messages/en.json index 73a41be5d4..ea362cb8f2 100644 --- a/front_end/messages/en.json +++ b/front_end/messages/en.json @@ -1806,5 +1806,9 @@ "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.", "none": "none" } diff --git a/front_end/messages/es.json b/front_end/messages/es.json index f77a97e64b..b9384306e1 100644 --- a/front_end/messages/es.json +++ b/front_end/messages/es.json @@ -1812,5 +1812,9 @@ "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.", "othersCount": "Otros ({count})" } diff --git a/front_end/messages/pt.json b/front_end/messages/pt.json index c709c38fdd..43d270cd24 100644 --- a/front_end/messages/pt.json +++ b/front_end/messages/pt.json @@ -1810,5 +1810,9 @@ "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.", "othersCount": "Outros ({count})" } diff --git a/front_end/messages/zh-TW.json b/front_end/messages/zh-TW.json index 4663154de8..d1d016d54f 100644 --- a/front_end/messages/zh-TW.json +++ b/front_end/messages/zh-TW.json @@ -1809,5 +1809,9 @@ "tournamentUnitPlural": "{unit, select, minute {分鐘} hour {小時} day {天} week {週} month {月} year {年} other {天}}", "tournamentQuestionsCount": "{count, plural, one {# 個問題} other {# 個問題}}", "tournamentQuestionsCountUpper": "{count} 題問題", + "tournamentsEmptySearchTitle": "找不到結果", + "tournamentsEmptySearchBody": "嘗試使用不同的搜索詞,或清除搜索。", + "tournamentsEmptyDefaultTitle": "顯示 {count} 個比賽", + "tournamentsEmptyDefaultBody": "稍後再查看或嘗試其他標籤。", "withdrawAfterPercentSetting2": "問題總生命周期後撤回" } diff --git a/front_end/messages/zh.json b/front_end/messages/zh.json index 1e588be559..e22c0a069c 100644 --- a/front_end/messages/zh.json +++ b/front_end/messages/zh.json @@ -1814,5 +1814,9 @@ "tournamentUnitPlural": "{unit, select, minute {分钟} hour {小时} day {天} week {周} month {个月} year {年} other {天}}", "tournamentQuestionsCount": "{count, plural, one {# 个问题} other {# 个问题}}", "tournamentQuestionsCountUpper": "{count} 题目", + "tournamentsEmptySearchTitle": "未找到结果", + "tournamentsEmptySearchBody": "尝试不同的搜索词,或清除搜索。", + "tournamentsEmptyDefaultTitle": "显示了 {count} 场比赛", + "tournamentsEmptyDefaultBody": "稍后再查看或尝试其他选项卡。", "othersCount": "其他({count})" } 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 index bb8ef43e96..bee2fd8163 100644 --- a/front_end/src/app/(main)/(tournaments)/tournaments/components/tournaments_screen.tsx +++ b/front_end/src/app/(main)/(tournaments)/tournaments/components/tournaments_screen.tsx @@ -8,6 +8,7 @@ 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; @@ -33,7 +34,9 @@ const TournamentsScreen: React.FC = ({
-
{children}
+ + {children} +
From fc5ba887e5295ed94331e95258c804aa0079a58f Mon Sep 17 00:00:00 2001 From: Nikita Date: Fri, 26 Dec 2025 16:59:53 +0200 Subject: [PATCH 18/25] feat: hide components for specific pages --- .../components/tournaments_header.tsx | 28 ++++++++++--------- .../tournaments_info_popover.tsx | 6 ++++ projects/serializers/common.py | 3 +- projects/services/cache.py | 4 ++- projects/services/common.py | 2 +- projects/views/common.py | 5 +++- 6 files changed, 30 insertions(+), 18 deletions(-) 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 index 31ca1a43d2..5961785681 100644 --- a/front_end/src/app/(main)/(tournaments)/tournaments/components/tournaments_header.tsx +++ b/front_end/src/app/(main)/(tournaments)/tournaments/components/tournaments_header.tsx @@ -60,19 +60,21 @@ const TournamentsHeader: React.FC = () => {
-
- - - - {showInfo && isLg ? ( - (next ? toggleInfo() : closeInfo())} - offsetPx={POPOVER_GAP} - stickyTopPx={STICKY_TOP} - /> - ) : null} -
+ {current !== "indexes" && ( +
+ + + + {showInfo && isLg ? ( + (next ? toggleInfo() : closeInfo())} + offsetPx={POPOVER_GAP} + stickyTopPx={STICKY_TOP} + /> + ) : null} +
+ )}
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 index 6db8436125..38af03acc6 100644 --- 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 @@ -18,6 +18,7 @@ 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; @@ -34,6 +35,7 @@ const TournamentsInfoPopover: React.FC = ({ offsetPx = 12, stickyTopPx = 0, }) => { + const { current } = useTournamentsSection(); const { refs, floatingStyles, context, isPositioned } = useFloating({ open, onOpenChange, @@ -73,6 +75,10 @@ const TournamentsInfoPopover: React.FC = ({ role, ]); + if (current === "series" || current === "indexes") { + return null; + } + return ( <> str: raw = (project.description or "").strip() if not raw: diff --git a/projects/services/cache.py b/projects/services/cache.py index 54caac8bd9..63479fa4cf 100644 --- a/projects/services/cache.py +++ b/projects/services/cache.py @@ -48,6 +48,7 @@ def invalidate_projects_questions_count_cache(projects: list[Project]) -> None: ] 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( @@ -56,5 +57,6 @@ def get_project_timeline_data_cached(project: 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} \ No newline at end of file + return {p.id: get_project_timeline_data_cached(p) for p in projects} diff --git a/projects/services/common.py b/projects/services/common.py index 1b3b74e1d2..cbbae72497 100644 --- a/projects/services/common.py +++ b/projects/services/common.py @@ -5,7 +5,6 @@ from django.db import IntegrityError from django.utils import timezone from django.utils.timezone import make_aware -from django.core.cache import cache from posts.models import Post from projects.models import Project, ProjectUserPermission @@ -211,6 +210,7 @@ def get_max(data: list): "all_questions_closed": all_questions_closed, } + def get_questions_count_for_projects(project_ids: list[int]) -> dict[int, int]: """ Returns a dict mapping each project_id to its questions_count diff --git a/projects/views/common.py b/projects/views/common.py index a056550dd3..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, get_projects_timeline_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, From f4902b3209abfbc6bd1b702529782cd02ddef15a Mon Sep 17 00:00:00 2001 From: Nikita Date: Mon, 29 Dec 2025 11:06:56 +0200 Subject: [PATCH 19/25] feat: pr updates --- front_end/messages/cs.json | 4 + front_end/messages/en.json | 4 + front_end/messages/es.json | 4 + front_end/messages/pt.json | 4 + front_end/messages/zh-TW.json | 4 + front_end/messages/zh.json | 4 + .../components/tournament_tabs.tsx | 21 +++- .../components/tournaments_filter.tsx | 2 +- .../index_tournament_card.tsx | 2 +- .../tournaments_grid/live_tournament_card.tsx | 50 +++------ .../tournaments_grid/question_series_card.tsx | 2 +- .../tournaments_grid/tournaments_grid.tsx | 12 +-- .../components/tournaments_search.tsx | 18 ++-- .../components/expandable_search_input.tsx | 49 ++++----- front_end/src/components/search_input.tsx | 5 +- front_end/src/hooks/use_is_in_viewport.ts | 19 ---- front_end/src/utils/formatters/date.ts | 102 ++++++++++++++---- 17 files changed, 176 insertions(+), 130 deletions(-) delete mode 100644 front_end/src/hooks/use_is_in_viewport.ts diff --git a/front_end/messages/cs.json b/front_end/messages/cs.json index 6bd9a276b6..c702cd95e9 100644 --- a/front_end/messages/cs.json +++ b/front_end/messages/cs.json @@ -1816,5 +1816,9 @@ "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 ea362cb8f2..7e4100effe 100644 --- a/front_end/messages/en.json +++ b/front_end/messages/en.json @@ -1810,5 +1810,9 @@ "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 b9384306e1..2756f36c0d 100644 --- a/front_end/messages/es.json +++ b/front_end/messages/es.json @@ -1816,5 +1816,9 @@ "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 43d270cd24..c88608415e 100644 --- a/front_end/messages/pt.json +++ b/front_end/messages/pt.json @@ -1814,5 +1814,9 @@ "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 d1d016d54f..a8c6d84ed1 100644 --- a/front_end/messages/zh-TW.json +++ b/front_end/messages/zh-TW.json @@ -1813,5 +1813,9 @@ "tournamentsEmptySearchBody": "嘗試使用不同的搜索詞,或清除搜索。", "tournamentsEmptyDefaultTitle": "顯示 {count} 個比賽", "tournamentsEmptyDefaultBody": "稍後再查看或嘗試其他標籤。", + "tournamentsTabLive": "現場錦標賽", + "tournamentsTabSeries": "問答系列", + "tournamentsTabIndexes": "指數", + "tournamentsTabArchived": "已存檔", "withdrawAfterPercentSetting2": "問題總生命周期後撤回" } diff --git a/front_end/messages/zh.json b/front_end/messages/zh.json index e22c0a069c..25d3b116f6 100644 --- a/front_end/messages/zh.json +++ b/front_end/messages/zh.json @@ -1818,5 +1818,9 @@ "tournamentsEmptySearchBody": "尝试不同的搜索词,或清除搜索。", "tournamentsEmptyDefaultTitle": "显示了 {count} 场比赛", "tournamentsEmptyDefaultBody": "稍后再查看或尝试其他选项卡。", + "tournamentsTabLive": "直播锦标赛", + "tournamentsTabSeries": "问题系列", + "tournamentsTabIndexes": "索引", + "tournamentsTabArchived": "已归档", "othersCount": "其他({count})" } 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 index f5a01a3a46..2c604cf9a5 100644 --- a/front_end/src/app/(main)/(tournaments)/tournaments/components/tournament_tabs.tsx +++ b/front_end/src/app/(main)/(tournaments)/tournaments/components/tournament_tabs.tsx @@ -1,3 +1,6 @@ +"use client"; + +import { useTranslations } from "next-intl"; import React from "react"; import TournamentsTabsShell from "./tournaments-tabs-shell"; @@ -5,27 +8,37 @@ 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: "Live Tournaments", + label: t(TAB_KEYS.live as PlainKey), }, { value: "series", href: "/tournaments/question-series", - label: "Question Series", + label: t(TAB_KEYS.series as PlainKey), }, { value: "indexes", href: "/tournaments/indexes", - label: "Indexes", + label: t(TAB_KEYS.indexes as PlainKey), }, { value: "archived", href: "/tournaments/archived", - label: "Archived", + label: t(TAB_KEYS.archived as PlainKey), }, ]; 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 index cd5ad9175f..b2d207e3a2 100644 --- a/front_end/src/app/(main)/(tournaments)/tournaments/components/tournaments_filter.tsx +++ b/front_end/src/app/(main)/(tournaments)/tournaments/components/tournaments_filter.tsx @@ -48,7 +48,7 @@ const TournamentsFilter: React.FC = () => { return ( = ({ item }) => {
-
+
{item.header_image ? ( // eslint-disable-next-line @next/next/no-img-element = ({ item, nowTs = 0 }) => { const t = useTranslations(); const prize = useMemo( - () => formatMoneyUSD(item.prize_pool ?? null), + () => formatMoneyUSD(item.prize_pool), [item.prize_pool] ); return ( -
+
{item.header_image ? ( // eslint-disable-next-line @next/next/no-img-element = ({ item, nowTs = 0 }) => {
-
+
{item.name}
@@ -141,12 +142,10 @@ function ActiveMiniBar({ if (nowTs == null) { label = t("tournamentTimelineOngoing"); - p = 0; } else if (nowTs < startTs) { label = t("tournamentTimelineStarts", { when: formatRelative(t, startTs - nowTs), }); - p = 0; } else { const sinceStart = nowTs - startTs; label = @@ -168,7 +167,7 @@ function ActiveMiniBar({ {label}

-
+
-
+
, deltaMs: number ) { - if (!Number.isFinite(deltaMs) || deltaMs <= 0) - return t("tournamentRelativeSoon"); - - const sec = 1000; - const min = 60 * sec; - const hour = 60 * min; - const day = 24 * hour; - const week = 7 * day; - const month = 30 * day; - const year = 365 * day; - - if (deltaMs > 20 * year) return t("tournamentRelativeFarFuture"); - if (deltaMs < min) return t("tournamentRelativeUnderMinute"); - - const pick = () => { - if (deltaMs < hour) - return { n: Math.round(deltaMs / min), unit: "minute" as const }; - if (deltaMs < day) - return { n: Math.round(deltaMs / hour), unit: "hour" as const }; - if (deltaMs < week) - return { n: Math.round(deltaMs / day), unit: "day" as const }; - if (deltaMs < month) - return { n: Math.round(deltaMs / week), unit: "week" as const }; - if (deltaMs < year) - return { n: Math.round(deltaMs / month), unit: "month" as const }; - return { n: Math.round(deltaMs / year), unit: "year" as const }; - }; - - const { n, unit } = pick(); + 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 }) 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 index 4dc4db2e41..30b5765f2b 100644 --- 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 @@ -17,7 +17,7 @@ const QuestionSeriesCard: React.FC = ({ item }) => { return ( -
+
{item.header_image ? ( // eslint-disable-next-line @next/next/no-img-element = ({ items, renderItem, className }) => { return (
@@ -31,7 +23,7 @@ const TournamentsGrid: React.FC = ({ items, renderItem, className }) => { renderItem ? ( renderItem(item) ) : ( -
+
) )}
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 index 61e98381bf..605278efd6 100644 --- a/front_end/src/app/(main)/(tournaments)/tournaments/components/tournaments_search.tsx +++ b/front_end/src/app/(main)/(tournaments)/tournaments/components/tournaments_search.tsx @@ -1,4 +1,7 @@ -import { ChangeEvent, useEffect, useState } from "react"; +"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"; @@ -6,6 +9,7 @@ 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, { @@ -15,27 +19,21 @@ const TournamentsSearch: React.FC = () => { } ); - const [draftQuery, setDraftQuery] = useState(searchQuery); - useEffect(() => setDraftQuery(searchQuery), [searchQuery]); - const handleSearchChange = (event: ChangeEvent) => { - const next = event.target.value; - setDraftQuery(next); - setSearchQuery(next); + setSearchQuery(event.target.value); }; const handleSearchErase = () => { - setDraftQuery(""); setSearchQuery(""); }; return (
diff --git a/front_end/src/components/expandable_search_input.tsx b/front_end/src/components/expandable_search_input.tsx index eec7d384cc..d3accaf603 100644 --- a/front_end/src/components/expandable_search_input.tsx +++ b/front_end/src/components/expandable_search_input.tsx @@ -5,8 +5,8 @@ import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import React, { ChangeEventHandler, FC, - useCallback, useEffect, + useMemo, useRef, useState, } from "react"; @@ -24,6 +24,7 @@ type Props = { expandedWidthClassName?: string; keepOpenWhenHasValue?: boolean; collapseOnBlur?: boolean; + collapseOnErase?: boolean; className?: string; buttonClassName?: string; inputClassName?: string; @@ -38,31 +39,23 @@ const ExpandableSearchInput: FC = ({ expandedWidthClassName = "w-[220px]", keepOpenWhenHasValue = true, collapseOnBlur = true, + collapseOnErase = true, className, buttonClassName, inputClassName, }) => { const [open, setOpen] = useState(false); const rootRef = useRef(null); - const isExpanded = open || (keepOpenWhenHasValue && !!value); - - const getInputEl = useCallback( - () => - rootRef.current?.querySelector('input[type="search"]'), - [] - ); - - const focusInput = useCallback(() => { - requestAnimationFrame(() => getInputEl()?.focus()); - }, [getInputEl]); - - const blurInput = useCallback(() => { - requestAnimationFrame(() => getInputEl()?.blur()); - }, [getInputEl]); + const inputRef = useRef(null); + const isExpanded = useMemo(() => { + if (open) return true; + if (keepOpenWhenHasValue && value) return true; + return false; + }, [open, keepOpenWhenHasValue, value]); useEffect(() => { - if (isExpanded) focusInput(); - }, [isExpanded, focusInput]); + if (isExpanded) inputRef.current?.focus(); + }, [isExpanded]); const collapseIfAllowed = () => { if (!collapseOnBlur) return; @@ -70,6 +63,12 @@ const ExpandableSearchInput: FC = ({ setOpen(false); }; + const handleErase = () => { + onErase(); + if (collapseOnErase) setOpen(false); + inputRef.current?.blur(); + }; + return (
= ({ }} onKeyDownCapture={(e) => { if (e.key === "Escape") { - if (!value) setOpen(false); + if (!(keepOpenWhenHasValue && value)) setOpen(false); (e.target as HTMLElement)?.blur?.(); } }} @@ -100,10 +99,7 @@ const ExpandableSearchInput: FC = ({ "dark:border-gray-500-dark dark:bg-gray-0-dark", buttonClassName )} - onClick={() => { - setOpen(true); - focusInput(); - }} + onClick={() => setOpen(true)} > = ({ ) : ( { - onErase(); - if (keepOpenWhenHasValue) setOpen(false); - blurInput(); - }} + onErase={handleErase} placeholder={placeholder} className="h-9 w-full" iconPosition="left" diff --git a/front_end/src/components/search_input.tsx b/front_end/src/components/search_input.tsx index 82c66aba43..b1129689a0 100644 --- a/front_end/src/components/search_input.tsx +++ b/front_end/src/components/search_input.tsx @@ -1,7 +1,7 @@ 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"; @@ -24,6 +24,7 @@ type Props = { iconPosition?: IconPosition; rightControlsClassName?: string; rightButtonClassName?: string; + inputRef?: React.Ref; }; const SearchInput: FC = ({ @@ -41,6 +42,7 @@ const SearchInput: FC = ({ iconPosition = "right", rightControlsClassName, rightButtonClassName, + inputRef, }) => { const isForm = !!onSubmit; const isLeft = iconPosition === "left"; @@ -72,6 +74,7 @@ const SearchInput: FC = ({ )} { - if (!el) return; - - const obs = new IntersectionObserver( - ([entry]) => setInView(entry?.isIntersecting ?? false), - { threshold: 0.01 } - ); - - obs.observe(el); - return () => obs.disconnect(); - }, [el]); - - return inView; -} 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; }; From df28828e5ac310c5866cafed6eec1806e130d279 Mon Sep 17 00:00:00 2001 From: Hlib Date: Mon, 29 Dec 2025 17:30:32 +0100 Subject: [PATCH 20/25] Tournaments discovery timeline backend (#3981) * Improved projects timeline caching * Added unit tests --- projects/serializers/common.py | 35 ++++- projects/services/cache.py | 15 +- projects/services/common.py | 133 +++++++++++++----- projects/views/common.py | 7 +- .../test_services/test_timeline.py | 116 +++++++++++++++ 5 files changed, 245 insertions(+), 61 deletions(-) create mode 100644 tests/unit/test_projects/test_services/test_timeline.py diff --git a/projects/serializers/common.py b/projects/serializers/common.py index d03bd68909..f74dbbde06 100644 --- a/projects/serializers/common.py +++ b/projects/serializers/common.py @@ -1,3 +1,4 @@ +import logging from collections import defaultdict from typing import Any, Callable, Iterable @@ -8,9 +9,12 @@ from projects.models import Project, ProjectUserPermission, ProjectIndex from projects.serializers.communities import CommunitySerializer from projects.services.cache import get_projects_questions_count_cached +from projects.services.common import get_timeline_data_for_projects from projects.services.indexes import get_multi_year_index_data, get_default_index_data from users.serializers import UserPublicSerializer +logger = logging.getLogger(__name__) + class ProjectSerializer(serializers.ModelSerializer): class Meta: @@ -261,18 +265,39 @@ def serialize_index_data(index: ProjectIndex): def serialize_tournaments_with_counts( - projects: Iterable[Project], sort_key: Callable[[dict], Any] + projects: Iterable[Project], + sort_key: Callable[[dict], Any] = None, + with_timeline: bool = False, ) -> list[dict]: projects = list(projects) questions_count_map = get_projects_questions_count_cached([p.id for p in projects]) + projects_timeline_map = {} + + if with_timeline: + try: + projects_timeline_map = get_timeline_data_for_projects( + [x.id for x in projects] + ) + except Exception: + logger.exception("Failed to get projects timeline 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 - serialized_tournament["forecasts_count"] = obj.forecasts_count - serialized_tournament["forecasters_count"] = obj.forecasters_count + + serialized_tournament.update( + { + "questions_count": questions_count_map.get(obj.id) or 0, + "forecasts_count": obj.forecasts_count, + "forecasters_count": obj.forecasters_count, + "timeline": projects_timeline_map.get(obj.id), + } + ) + data.append(serialized_tournament) - data.sort(key=sort_key, reverse=True) + if sort_key: + data.sort(key=sort_key, reverse=True) + return data diff --git a/projects/services/cache.py b/projects/services/cache.py index 63479fa4cf..eacc7aca88 100644 --- a/projects/services/cache.py +++ b/projects/services/cache.py @@ -1,7 +1,7 @@ from django.core.cache import cache from projects.models import Project -from .common import get_questions_count_for_projects, get_project_timeline_data +from .common import get_questions_count_for_projects QUESTIONS_COUNT_CACHE_PREFIX = "project_questions_count:v1" QUESTIONS_COUNT_CACHE_TIMEOUT = 1 * 3600 # 3 hour @@ -47,16 +47,3 @@ 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/services/common.py b/projects/services/common.py index cbbae72497..ce8842a812 100644 --- a/projects/services/common.py +++ b/projects/services/common.py @@ -1,15 +1,19 @@ from collections import defaultdict from datetime import datetime +from itertools import chain from typing import Iterable from django.db import IntegrityError +from django.db.models import F from django.utils import timezone from django.utils.timezone import make_aware from posts.models import Post from projects.models import Project, ProjectUserPermission from projects.permissions import ObjectPermission +from questions.models import Question from users.models import User +from utils.cache import cache_per_object from utils.dtypes import generate_map_from_list @@ -152,7 +156,7 @@ def move_project_forecasting_end_date(project: Project, post: Post): project.save(update_fields=["forecasting_end_date"]) -def get_project_timeline_data(project: Project): +def _calculate_timeline_data(project: Project, questions: Iterable[Question]) -> dict: all_questions_resolved = True all_questions_closed = True @@ -160,44 +164,36 @@ def get_project_timeline_data(project: Project): actual_resolve_times = [] scheduled_resolve_times = [] - posts = ( - Post.objects.filter_projects(project) - .filter_questions() - .prefetch_questions() - .filter(curation_status=Post.CurationStatus.APPROVED) - ) - project_close_date = project.close_date or make_aware(datetime.max) project_forecasting_end_date = project.forecasting_end_date or project_close_date - for post in posts: - for question in post.get_questions(): - if all_questions_resolved: - all_questions_resolved = ( - question.actual_resolve_time - # Or treat as resolved as scheduled resolution is in the future - or question.scheduled_resolve_time > project_close_date - ) - - # Determine questions closure - if all_questions_closed: - close_time = question.actual_close_time or question.scheduled_close_time - all_questions_closed = ( - close_time <= timezone.now() - or close_time > project_forecasting_end_date - ) - - if question.cp_reveal_time: - cp_reveal_times.append(question.cp_reveal_time) - - if question.actual_resolve_time: - actual_resolve_times.append(question.actual_resolve_time) - - if question.scheduled_resolve_time: - scheduled_resolve_time = ( - question.actual_resolve_time or question.scheduled_resolve_time - ) - scheduled_resolve_times.append(scheduled_resolve_time) + for question in questions: + if all_questions_resolved: + all_questions_resolved = ( + question.actual_resolve_time + # Or treat as resolved as scheduled resolution is in the future + or question.scheduled_resolve_time > project_close_date + ) + + # Determine questions closure + if all_questions_closed: + close_time = question.actual_close_time or question.scheduled_close_time + all_questions_closed = ( + close_time <= timezone.now() + or close_time > project_forecasting_end_date + ) + + if question.cp_reveal_time: + cp_reveal_times.append(question.cp_reveal_time) + + if question.actual_resolve_time: + actual_resolve_times.append(question.actual_resolve_time) + + if question.scheduled_resolve_time: + scheduled_resolve_time = ( + question.actual_resolve_time or question.scheduled_resolve_time + ) + scheduled_resolve_times.append(scheduled_resolve_time) def get_max(data: list): return max([x for x in data if x <= project_close_date], default=None) @@ -211,6 +207,71 @@ def get_max(data: list): } +def get_project_timeline_data(project: Project): + # Fetch questions directly as per new requirement + questions = Question.objects.filter( + related_posts__post__default_project=project, + related_posts__post__curation_status=Post.CurationStatus.APPROVED, + ).distinct("id") + + return _calculate_timeline_data(project, questions) + + +@cache_per_object(timeout=60 * 15) +def get_timeline_data_for_projects(project_ids: list[int]) -> dict[int, dict]: + projects = Project.objects.in_bulk(project_ids) + + # 1. Map Project -> Post IDs + project_posts = defaultdict(set) + + # Default projects + qs_default = Post.objects.filter( + default_project_id__in=project_ids, + curation_status=Post.CurationStatus.APPROVED, + ).values_list("id", "default_project_id") + + # M2M projects + qs_m2m = Post.projects.through.objects.filter( + project_id__in=project_ids, + post__curation_status=Post.CurationStatus.APPROVED, + ).values_list("post_id", "project_id") + + for post_id, project_id in chain(qs_default, qs_m2m): + project_posts[project_id].add(post_id) + + # 2. Fetch Questions + all_post_ids = set().union(*project_posts.values()) + + questions = ( + Question.objects.filter(related_posts__post_id__in=all_post_ids) + .annotate(post_id=F("related_posts__post_id")) + .only( + "id", + "cp_reveal_time", + "actual_resolve_time", + "scheduled_resolve_time", + "actual_close_time", + "scheduled_close_time", + ) + ) + + # Group by post + questions_by_post = defaultdict(list) + for q in questions: + questions_by_post[q.post_id].append(q) + + # 3. Aggregate + return { + pid: _calculate_timeline_data( + project, + chain.from_iterable( + questions_by_post[post_id] for post_id in project_posts.get(pid, []) + ), + ) + for pid, project in projects.items() + } + + def get_questions_count_for_projects(project_ids: list[int]) -> dict[int, int]: """ Returns a dict mapping each project_id to its questions_count diff --git a/projects/views/common.py b/projects/views/common.py index c02dc334d3..d212952f72 100644 --- a/projects/views/common.py +++ b/projects/views/common.py @@ -23,7 +23,6 @@ ) from projects.services.cache import ( get_projects_questions_count_cached, - get_projects_timeline_cached, ) from projects.services.common import ( get_projects_qs, @@ -139,13 +138,9 @@ def tournaments_list_api_view(request: Request): ) projects = list(qs) data = serialize_tournaments_with_counts( - projects, sort_key=lambda r: r["questions_count"] + projects, sort_key=lambda r: r["questions_count"], with_timeline=True ) - timeline_map = get_projects_timeline_cached(projects) - for row in data: - row["timeline"] = timeline_map.get(row["id"]) - return Response(data) diff --git a/tests/unit/test_projects/test_services/test_timeline.py b/tests/unit/test_projects/test_services/test_timeline.py new file mode 100644 index 0000000000..2d03df7d52 --- /dev/null +++ b/tests/unit/test_projects/test_services/test_timeline.py @@ -0,0 +1,116 @@ +from datetime import timedelta + +from django.utils import timezone + +from posts.models import Post +from projects.services.common import ( + get_project_timeline_data, + get_timeline_data_for_projects, +) +from questions.models import Question +from tests.unit.test_posts.factories import factory_post +from tests.unit.test_projects.factories import factory_project +from tests.unit.test_questions.factories import create_question + + +def test_get_project_timeline_data(user1): + project = factory_project() + now = timezone.now() + + # Create posts with questions + factory_post( + author=user1, + default_project=project, + curation_status=Post.CurationStatus.APPROVED, + question=create_question( + question_type=Question.QuestionType.BINARY, + actual_resolve_time=now - timedelta(days=5), + actual_close_time=now - timedelta(days=5), + ), + ) + post2 = factory_post( + author=user1, + default_project=project, + curation_status=Post.CurationStatus.APPROVED, + question=create_question( + question_type=Question.QuestionType.BINARY, + actual_resolve_time=now - timedelta(days=2), + actual_close_time=now - timedelta(days=2), + ), + ) + + # Create a post that shouldn't be included (not approved) + factory_post( + author=user1, + default_project=project, + curation_status=Post.CurationStatus.DRAFT, + question=create_question(question_type=Question.QuestionType.BINARY), + ) + + data = get_project_timeline_data(project=project) + + assert data["latest_actual_resolve_time"] == post2.question.actual_resolve_time + assert data["all_questions_resolved"] + assert data["all_questions_closed"] + + +def test_get_timeline_data_for_projects(user1, django_assert_num_queries): + project1 = factory_project() + project2 = factory_project() + now = timezone.now() + + # Project 1: One resolved question + post1 = factory_post( + author=user1, + default_project=project1, + curation_status=Post.CurationStatus.APPROVED, + question=create_question( + question_type=Question.QuestionType.BINARY, + actual_resolve_time=now - timedelta(days=10), + actual_close_time=now - timedelta(days=10), + ), + ) + + # Project 2: One open question + post2 = factory_post( + author=user1, + default_project=project2, + curation_status=Post.CurationStatus.APPROVED, + question=create_question( + question_type=Question.QuestionType.BINARY, + actual_resolve_time=None, + scheduled_resolve_time=now + timedelta(days=10), + scheduled_close_time=now + timedelta(days=5), + ), + ) + + # Shared Post: In Project 1 (default) and Project 2 (m2m) + # Resolved recently + post3 = factory_post( + author=user1, + default_project=project1, + projects=[project2], + curation_status=Post.CurationStatus.APPROVED, + question=create_question( + question_type=Question.QuestionType.BINARY, + actual_resolve_time=now - timedelta(days=1), + actual_close_time=now - timedelta(days=1), + ), + ) + + # Call function and ensure queries count + with django_assert_num_queries(4): + data = get_timeline_data_for_projects([project1.pk, project2.pk]) + + # Check Project 1 Data + # Should include post1 and post3 + p1_data = data[project1.pk] + assert p1_data["latest_actual_resolve_time"] == post3.question.actual_resolve_time + assert p1_data["all_questions_resolved"] + + # Check Project 2 Data + # Should include post2 and post3 + p2_data = data[project2.pk] + # post3 is resolved, but post2 is not + assert not p2_data["all_questions_resolved"] + assert p2_data["latest_actual_resolve_time"] == post3.question.actual_resolve_time From 8b20dcbfd00382e77acf42e5a084c37a63b43ae2 Mon Sep 17 00:00:00 2001 From: Nikita Date: Mon, 29 Dec 2025 18:34:41 +0200 Subject: [PATCH 21/25] feat: qa updates --- .../archived_tournaments_grid.tsx | 9 ++++++- .../index_tournament_card.tsx | 4 +-- .../tournaments_grid/live_tournament_card.tsx | 25 ++++++++++++------- front_end/src/hooks/use_search_params.ts | 4 +-- 4 files changed, 28 insertions(+), 14 deletions(-) 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 index ba65fea356..c8bcc389b1 100644 --- 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 @@ -20,7 +20,14 @@ const ArchivedTournamentsGrid: React.FC = () => { return ; } - return ; + return ( + + ); }} /> ); 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 index 9273596674..b7a2cf8de0 100644 --- 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 @@ -57,7 +57,7 @@ const IndexTournamentCard: React.FC = ({ item }) => { "my-0", "text-[18px] font-semibold leading-[125%]", "text-blue-800 dark:text-blue-800-dark", - "line-clamp-2" + "line-clamp-2 text-balance" )} > {item.name} @@ -106,7 +106,7 @@ const IndexTournamentCard: React.FC = ({ item }) => { })}

-
+
{item.name}
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 index af403b2a62..a742d7db6b 100644 --- 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 @@ -14,9 +14,14 @@ import TournamentCardShell from "./tournament_card_shell"; type Props = { item: TournamentPreview; nowTs?: number; + hideTimeline?: boolean; }; -const LiveTournamentCard: React.FC = ({ item, nowTs = 0 }) => { +const LiveTournamentCard: React.FC = ({ + item, + nowTs = 0, + hideTimeline = false, +}) => { const t = useTranslations(); const prize = useMemo( () => formatMoneyUSD(item.prize_pool), @@ -74,14 +79,16 @@ const LiveTournamentCard: React.FC = ({ item, nowTs = 0 }) => { {item.name}

- + {!hideTimeline && ( + + )}
); diff --git a/front_end/src/hooks/use_search_params.ts b/front_end/src/hooks/use_search_params.ts index c0a08784f8..f43a1382a7 100644 --- a/front_end/src/hooks/use_search_params.ts +++ b/front_end/src/hooks/use_search_params.ts @@ -21,8 +21,8 @@ const useSearchParams = () => { // allows pushing search params to the url without page reload const shallowNavigateToSearchParams = useCallback(() => { - window.history.pushState(null, "", `?${params.toString()}`); - }, [params]); + router.replace(pathname + "?" + params.toString(), { scroll: false }); + }, [params, pathname, router]); const setParam = useCallback( (name: string, val: string | string[], withNavigation = true) => { From e1061c5a178a8c5103617bdfb9386b6d98118065 Mon Sep 17 00:00:00 2001 From: hlbmtc Date: Mon, 29 Dec 2025 19:40:06 +0100 Subject: [PATCH 22/25] Small fix --- tests/unit/test_projects/test_services/test_timeline.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/unit/test_projects/test_services/test_timeline.py b/tests/unit/test_projects/test_services/test_timeline.py index 2d03df7d52..4e92bb1abc 100644 --- a/tests/unit/test_projects/test_services/test_timeline.py +++ b/tests/unit/test_projects/test_services/test_timeline.py @@ -60,7 +60,7 @@ def test_get_timeline_data_for_projects(user1, django_assert_num_queries): now = timezone.now() # Project 1: One resolved question - post1 = factory_post( + factory_post( author=user1, default_project=project1, curation_status=Post.CurationStatus.APPROVED, @@ -72,7 +72,7 @@ def test_get_timeline_data_for_projects(user1, django_assert_num_queries): ) # Project 2: One open question - post2 = factory_post( + factory_post( author=user1, default_project=project2, curation_status=Post.CurationStatus.APPROVED, From 02bd461f1a56210300cfc6839fc88b0c5f638264 Mon Sep 17 00:00:00 2001 From: Nikita Date: Tue, 30 Dec 2025 13:08:43 +0200 Subject: [PATCH 23/25] feat: qa updates --- front_end/messages/cs.json | 2 +- front_end/messages/en.json | 2 +- front_end/messages/es.json | 2 +- front_end/messages/pt.json | 2 +- front_end/messages/zh-TW.json | 2 +- front_end/messages/zh.json | 2 +- .../components/active_tournament_timeline.tsx | 38 +++--------- .../components/gradient_progress_line.tsx | 58 +++++++++++++++++++ .../components/tournaments_container.tsx | 2 +- .../tournaments_grid/live_tournament_card.tsx | 32 ++-------- .../components/tournaments_header.tsx | 27 ++++++++- .../components/tournaments_hero.tsx | 10 +++- .../components/tournaments_mobile_ctrl.tsx | 37 +++++++++--- .../tournaments_info_popover.tsx | 10 +++- .../components/tournaments_screen.tsx | 4 +- .../hooks/use_tournaments_info_dismissed.ts | 36 ++++++++++++ .../components/expandable_search_input.tsx | 2 +- 17 files changed, 185 insertions(+), 83 deletions(-) create mode 100644 front_end/src/app/(main)/(tournaments)/tournament/components/gradient_progress_line.tsx create mode 100644 front_end/src/app/(main)/(tournaments)/tournaments/hooks/use_tournaments_info_dismissed.ts diff --git a/front_end/messages/cs.json b/front_end/messages/cs.json index c702cd95e9..12c7c93063 100644 --- a/front_end/messages/cs.json +++ b/front_end/messages/cs.json @@ -1802,7 +1802,6 @@ "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", @@ -1820,5 +1819,6 @@ "tournamentsTabSeries": "Série otázek", "tournamentsTabIndexes": "Indexy", "tournamentsTabArchived": "Archivováno", + "tournamentTimelineClosed": "Čekání na vyřešení", "othersCount": "Ostatní ({count})" } diff --git a/front_end/messages/en.json b/front_end/messages/en.json index 7e4100effe..8304d3231d 100644 --- a/front_end/messages/en.json +++ b/front_end/messages/en.json @@ -1796,7 +1796,7 @@ "tournamentTimelineJustStarted": "Just started", "tournamentTimelineStarts": "Starts {when}", "tournamentTimelineEnds": "Ends {when}", - "tournamentTimelineClosed": "Closed", + "tournamentTimelineClosed": "Waiting resolutions", "tournamentTimelineAllResolved": "All questions resolved", "tournamentRelativeSoon": "soon", "tournamentRelativeUnderMinute": "in under a minute", diff --git a/front_end/messages/es.json b/front_end/messages/es.json index 2756f36c0d..2ad4ae0688 100644 --- a/front_end/messages/es.json +++ b/front_end/messages/es.json @@ -1802,7 +1802,6 @@ "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", @@ -1820,5 +1819,6 @@ "tournamentsTabSeries": "Series de Preguntas", "tournamentsTabIndexes": "Índices", "tournamentsTabArchived": "Archivado", + "tournamentTimelineClosed": "Esperando resoluciones", "othersCount": "Otros ({count})" } diff --git a/front_end/messages/pt.json b/front_end/messages/pt.json index c88608415e..280b9e07ae 100644 --- a/front_end/messages/pt.json +++ b/front_end/messages/pt.json @@ -1800,7 +1800,6 @@ "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", @@ -1818,5 +1817,6 @@ "tournamentsTabSeries": "Série de Perguntas", "tournamentsTabIndexes": "Índices", "tournamentsTabArchived": "Arquivado", + "tournamentTimelineClosed": "Aguardando resoluções", "othersCount": "Outros ({count})" } diff --git a/front_end/messages/zh-TW.json b/front_end/messages/zh-TW.json index a8c6d84ed1..9e90db7dc3 100644 --- a/front_end/messages/zh-TW.json +++ b/front_end/messages/zh-TW.json @@ -1799,7 +1799,6 @@ "tournamentTimelineJustStarted": "剛剛開始", "tournamentTimelineStarts": "{when} 開始", "tournamentTimelineEnds": "{when} 結束", - "tournamentTimelineClosed": "已結束", "tournamentTimelineAllResolved": "所有問題已解決", "tournamentRelativeSoon": "即將", "tournamentRelativeUnderMinute": "在不到一分鐘內", @@ -1817,5 +1816,6 @@ "tournamentsTabSeries": "問答系列", "tournamentsTabIndexes": "指數", "tournamentsTabArchived": "已存檔", + "tournamentTimelineClosed": "等待裁定", "withdrawAfterPercentSetting2": "問題總生命周期後撤回" } diff --git a/front_end/messages/zh.json b/front_end/messages/zh.json index 25d3b116f6..b686bfb68d 100644 --- a/front_end/messages/zh.json +++ b/front_end/messages/zh.json @@ -1804,7 +1804,6 @@ "tournamentTimelineJustStarted": "刚刚开始", "tournamentTimelineStarts": "开始于{when}", "tournamentTimelineEnds": "结束于{when}", - "tournamentTimelineClosed": "已关闭", "tournamentTimelineAllResolved": "所有问题已解决", "tournamentRelativeSoon": "很快", "tournamentRelativeUnderMinute": "不到一分钟", @@ -1822,5 +1821,6 @@ "tournamentsTabSeries": "问题系列", "tournamentsTabIndexes": "索引", "tournamentsTabArchived": "已归档", + "tournamentTimelineClosed": "等待解决", "othersCount": "其他({count})" } diff --git a/front_end/src/app/(main)/(tournaments)/tournament/components/active_tournament_timeline.tsx b/front_end/src/app/(main)/(tournaments)/tournament/components/active_tournament_timeline.tsx index 54cbb435ca..35089dbf24 100644 --- a/front_end/src/app/(main)/(tournaments)/tournament/components/active_tournament_timeline.tsx +++ b/front_end/src/app/(main)/(tournaments)/tournament/components/active_tournament_timeline.tsx @@ -8,6 +8,8 @@ import { BotLeaderboardStatus, Tournament } from "@/types/projects"; import cn from "@/utils/core/cn"; import { formatDate } from "@/utils/formatters/date"; +import GradientProgressLine from "./gradient_progress_line"; + type Props = { tournament: Tournament; latestScheduledCloseTimestamp: number; @@ -66,15 +68,13 @@ const ActiveTournamentTimeline: FC = async ({ {t("closes")}

-
- {!isUpcoming && ( -
- -
+
+ {!isUpcoming ? ( + + ) : ( +
)} + {lastParticipationDayTimestamp && lastParticipationPosition && ( = ({ progressPercentage }) => ( -
-
-
-
-); - function calculateLastParticipationPosition( lastParticipationDayTimestamp: number | null, startDate: string, diff --git a/front_end/src/app/(main)/(tournaments)/tournament/components/gradient_progress_line.tsx b/front_end/src/app/(main)/(tournaments)/tournament/components/gradient_progress_line.tsx new file mode 100644 index 0000000000..5e7f544525 --- /dev/null +++ b/front_end/src/app/(main)/(tournaments)/tournament/components/gradient_progress_line.tsx @@ -0,0 +1,58 @@ +"use client"; + +import React from "react"; + +import cn from "@/utils/core/cn"; + +type Props = { + pct: number; + className?: string; + trackClassName?: string; + fillClassName?: string; + dotClassName?: string; + edgeInsetPx?: number; +}; + +const GradientProgressLine: React.FC = ({ + pct, + className, + trackClassName, + fillClassName, + dotClassName, + edgeInsetPx = 5, +}) => { + const clamped = Math.max(0, Math.min(100, pct)); + const left = `${clamped}%`; + const thumbLeft = `clamp(${edgeInsetPx}px, ${left}, calc(100% - ${edgeInsetPx}px))`; + + return ( +
+
+ +
+
+ ); +}; + +export default GradientProgressLine; 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 index 26bde3e07a..470dc4a6d4 100644 --- a/front_end/src/app/(main)/(tournaments)/tournaments/components/tournaments_container.tsx +++ b/front_end/src/app/(main)/(tournaments)/tournaments/components/tournaments_container.tsx @@ -3,7 +3,7 @@ import React, { PropsWithChildren } from "react"; const TournamentsContainer: React.FC = ({ children }) => { return (
{children} 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 index a742d7db6b..0caba43854 100644 --- 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 @@ -10,6 +10,7 @@ import cn from "@/utils/core/cn"; import { bucketRelativeMs } from "@/utils/formatters/date"; import TournamentCardShell from "./tournament_card_shell"; +import GradientProgressLine from "../../../tournament/components/gradient_progress_line"; type Props = { item: TournamentPreview; @@ -75,7 +76,7 @@ const LiveTournamentCard: React.FC = ({
-
+
{item.name}
@@ -173,38 +174,13 @@ function ActiveMiniBar({

{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, 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 index 5961785681..fae35dbc13 100644 --- a/front_end/src/app/(main)/(tournaments)/tournaments/components/tournaments_header.tsx +++ b/front_end/src/app/(main)/(tournaments)/tournaments/components/tournaments_header.tsx @@ -10,12 +10,27 @@ import TournamentsFilter from "./tournaments_filter"; import TournamentsInfoPopover from "./tournaments_popover/tournaments_info_popover"; import { useTournamentsSection } from "./tournaments_provider"; import TournamentsSearch from "./tournaments_search"; +import { useTournamentsInfoDismissed } from "../hooks/use_tournaments_info_dismissed"; const STICKY_TOP = 48; const POPOVER_GAP = 10; const TournamentsHeader: React.FC = () => { const { current, infoOpen, toggleInfo, closeInfo } = useTournamentsSection(); + const { + dismissed, + dismiss: infoDismiss, + ready, + } = useTournamentsInfoDismissed(); + + const didInitDismissCheck = useRef(false); + useEffect(() => { + if (!ready) return; + if (didInitDismissCheck.current) return; + didInitDismissCheck.current = true; + + if (dismissed && infoOpen) closeInfo(); + }, [ready, dismissed, infoOpen, closeInfo]); const sentinelRef = useRef(null); const isLg = useBreakpoint("lg"); @@ -54,7 +69,7 @@ const TournamentsHeader: React.FC = () => { )} style={{ top: STICKY_TOP }} > -
+
@@ -68,7 +83,15 @@ const TournamentsHeader: React.FC = () => { {showInfo && isLg ? ( (next ? toggleInfo() : closeInfo())} + onOpenChange={(next) => { + if (next) { + toggleInfo(); + return; + } + + infoDismiss(); + closeInfo(); + }} offsetPx={POPOVER_GAP} stickyTopPx={STICKY_TOP} /> 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 index 7f74af5966..eec180eee4 100644 --- a/front_end/src/app/(main)/(tournaments)/tournaments/components/tournaments_hero.tsx +++ b/front_end/src/app/(main)/(tournaments)/tournaments/components/tournaments_hero.tsx @@ -3,6 +3,8 @@ import { useTranslations } from "next-intl"; import React from "react"; +import { useBreakpoint } from "@/hooks/tailwind"; + import { useTournamentsSection } from "./tournaments_provider"; import { TournamentsSection } from "../types"; @@ -27,23 +29,25 @@ const HERO_KEYS = { const TournamentsHero: React.FC = () => { const t = useTranslations(); + const isLg = useBreakpoint("lg"); const { current, count } = useTournamentsSection(); const keys = HERO_KEYS[current]; if (!keys) return null; + if (!isLg && current === "live") return null; type RichKey = Parameters[0]; type PlainKey = Parameters[0]; return ( -
-

+
+

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

-

+

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

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 index dda37d5780..d6e100668c 100644 --- 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 @@ -1,18 +1,36 @@ "use client"; -import React, { useState } from "react"; +import React, { useEffect, useState } from "react"; import TournamentsFilter from "./tournaments_filter"; import TournamentsInfo from "./tournaments_popover/tournaments_info"; import TournamentsInfoButton from "./tournaments_popover/tournaments_info_button"; +import { useTournamentsSection } from "./tournaments_provider"; import TournamentsSearch from "./tournaments_search"; +import { useTournamentsInfoDismissed } from "../hooks/use_tournaments_info_dismissed"; const TournamentsMobileCtrl: React.FC = () => { - const [isInfoOpen, setIsInfoOpen] = useState(true); + const { current } = useTournamentsSection(); + const { dismissed, dismiss, ready } = useTournamentsInfoDismissed(); + const [isInfoOpen, setIsInfoOpen] = useState(false); + + const showInfo = current === "live"; + + useEffect(() => { + if (!ready) return; + setIsInfoOpen(showInfo && !dismissed); + }, [ready, dismissed, showInfo]); return (
- {isInfoOpen && setIsInfoOpen(false)} />} + {showInfo && isInfoOpen && ( + { + dismiss(); + setIsInfoOpen(false); + }} + /> + )}
@@ -20,10 +38,15 @@ const TournamentsMobileCtrl: React.FC = () => {
- setIsInfoOpen((p) => !p)} - /> + {showInfo && ( + { + dismiss(); + setIsInfoOpen((p) => !p); + }} + /> + )}

); 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 index 38af03acc6..f7a990283f 100644 --- 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 @@ -16,9 +16,9 @@ import React from "react"; import cn from "@/utils/core/cn"; +import { useTournamentsSection } from "../tournaments_provider"; import TournamentsInfo from "./tournaments_info"; import TournamentsInfoButton from "./tournaments_info_button"; -import { useTournamentsSection } from "../tournaments_provider"; type Props = { open: boolean; @@ -75,7 +75,7 @@ const TournamentsInfoPopover: React.FC = ({ role, ]); - if (current === "series" || current === "indexes") { + if (current !== "live") { return null; } @@ -99,7 +99,11 @@ const TournamentsInfoPopover: React.FC = ({ }} className={cn("z-[60] w-[365px]")} > - onOpenChange(false)} /> + { + onOpenChange(false); + }} + />
) : null} 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 index bee2fd8163..dea3848dbf 100644 --- a/front_end/src/app/(main)/(tournaments)/tournaments/components/tournaments_screen.tsx +++ b/front_end/src/app/(main)/(tournaments)/tournaments/components/tournaments_screen.tsx @@ -31,10 +31,10 @@ const TournamentsScreen: React.FC = ({ current={current} > -
+
- + {children}
diff --git a/front_end/src/app/(main)/(tournaments)/tournaments/hooks/use_tournaments_info_dismissed.ts b/front_end/src/app/(main)/(tournaments)/tournaments/hooks/use_tournaments_info_dismissed.ts new file mode 100644 index 0000000000..fa014e592d --- /dev/null +++ b/front_end/src/app/(main)/(tournaments)/tournaments/hooks/use_tournaments_info_dismissed.ts @@ -0,0 +1,36 @@ +"use client"; + +import { useCallback, useEffect, useMemo, useState } from "react"; + +import { useAuth } from "@/contexts/auth_context"; + +const STORAGE_PREFIX = "tournamentsInfoDismissed:v1"; + +export function useTournamentsInfoDismissed() { + const { user } = useAuth(); + + const key = useMemo(() => { + return `${STORAGE_PREFIX}:${user?.id ?? "anon"}`; + }, [user?.id]); + + const [dismissed, setDismissed] = useState(false); + const [ready, setReady] = useState(false); + + useEffect(() => { + try { + setDismissed(localStorage.getItem(key) === "1"); + } catch { + } finally { + setReady(true); + } + }, [key]); + + const dismiss = useCallback(() => { + setDismissed(true); + try { + localStorage.setItem(key, "1"); + } catch {} + }, [key]); + + return { dismissed, dismiss, ready }; +} diff --git a/front_end/src/components/expandable_search_input.tsx b/front_end/src/components/expandable_search_input.tsx index d3accaf603..f09370f8cc 100644 --- a/front_end/src/components/expandable_search_input.tsx +++ b/front_end/src/components/expandable_search_input.tsx @@ -116,7 +116,7 @@ const ExpandableSearchInput: FC = ({ className="h-9 w-full" iconPosition="left" inputClassName={cn( - "h-9 border border-gray-300 bg-gray-0 text-sm font-medium", + "h-9 border border-gray-300 bg-gray-0 sm:text-base font-medium", "placeholder:text-gray-600 dark:placeholder:text-gray-600-dark", "focus:outline-none focus:border-blue-500", "dark:border-gray-500-dark dark:bg-gray-0-dark", From 0a446a081fc392ff557681a959fbf035ecb220a0 Mon Sep 17 00:00:00 2001 From: Nikita Date: Tue, 30 Dec 2025 14:49:06 +0200 Subject: [PATCH 24/25] feat: update info --- front_end/messages/cs.json | 3 --- front_end/messages/en.json | 4 ++-- front_end/messages/es.json | 3 --- front_end/messages/pt.json | 3 --- front_end/messages/zh-TW.json | 3 --- front_end/messages/zh.json | 3 --- .../tournaments_popover/tournaments_info.tsx | 13 ++++++++++++- .../tournaments_info_popover.tsx | 2 +- 8 files changed, 15 insertions(+), 19 deletions(-) diff --git a/front_end/messages/cs.json b/front_end/messages/cs.json index 12c7c93063..288063a1a5 100644 --- a/front_end/messages/cs.json +++ b/front_end/messages/cs.json @@ -1792,9 +1792,6 @@ "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", diff --git a/front_end/messages/en.json b/front_end/messages/en.json index 8304d3231d..c36a7c5484 100644 --- a/front_end/messages/en.json +++ b/front_end/messages/en.json @@ -1786,8 +1786,8 @@ "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?", + "tournamentsInfoTitle": "We are not a prediction market. You can participate for free and win cash prizes for being accurate.", + "tournamentsInfoScoringLink": "What are forecasting scores?", "tournamentsInfoPrizesLink": "How are prizes distributed?", "tournamentsInfoCta": "Sign up to compete", "tournamentPrizePool": "PRIZE POOL", diff --git a/front_end/messages/es.json b/front_end/messages/es.json index 2ad4ae0688..9cadd0b07a 100644 --- a/front_end/messages/es.json +++ b/front_end/messages/es.json @@ -1792,9 +1792,6 @@ "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", diff --git a/front_end/messages/pt.json b/front_end/messages/pt.json index 280b9e07ae..33c799cfcb 100644 --- a/front_end/messages/pt.json +++ b/front_end/messages/pt.json @@ -1790,9 +1790,6 @@ "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", diff --git a/front_end/messages/zh-TW.json b/front_end/messages/zh-TW.json index 9e90db7dc3..b994fd4759 100644 --- a/front_end/messages/zh-TW.json +++ b/front_end/messages/zh-TW.json @@ -1789,9 +1789,6 @@ "tournamentsHeroIndexesTitle": "探索複雜議題,

監控其進展。", "tournamentsHeroIndexesShown": "{count, plural, one {顯示 # 個指數} other {顯示 # 個指數}}", "tournamentsInfoAria": "錦標賽資訊", - "tournamentsInfoTitle": "免費參加;獲得現金獎勵並練習預測。", - "tournamentsInfoScoringLink": "計分方式如何運作?", - "tournamentsInfoPrizesLink": "獎品如何分配?", "tournamentsInfoCta": "註冊參賽", "tournamentPrizePool": "獎金池", "tournamentNoPrizePool": "無獎金池", diff --git a/front_end/messages/zh.json b/front_end/messages/zh.json index b686bfb68d..6d67cd2b35 100644 --- a/front_end/messages/zh.json +++ b/front_end/messages/zh.json @@ -1794,9 +1794,6 @@ "tournamentsHeroIndexesTitle": "发现复杂话题,

监控其进展。", "tournamentsHeroIndexesShown": "{count, plural, one {显示#个指数} other {显示#个指数}}", "tournamentsInfoAria": "比赛信息", - "tournamentsInfoTitle": "免费参加;赢取现金奖励并提高预测技能。", - "tournamentsInfoScoringLink": "评分机制如何运作?", - "tournamentsInfoPrizesLink": "奖品如何分发?", "tournamentsInfoCta": "注册参赛", "tournamentPrizePool": "奖金池", "tournamentNoPrizePool": "无奖金池", 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 index 520864dab0..f091a8797f 100644 --- 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 @@ -21,10 +21,21 @@ const TournamentsInfo: React.FC = ({ onClose }) => { const { setCurrentModal } = useModal(); const handleSignup = () => setCurrentModal({ type: "signup", data: {} }); + const title = t.rich("tournamentsInfoTitle", { + predmarket: (chunks) => ( + + {chunks} + + ), + }); + return (
- {t("tournamentsInfoTitle")} + {title}
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 index f7a990283f..70476eaaa4 100644 --- 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 @@ -97,7 +97,7 @@ const TournamentsInfoPopover: React.FC = ({ ...floatingStyles, visibility: isPositioned ? "visible" : "hidden", }} - className={cn("z-[60] w-[365px]")} + className={cn("z-[60] w-[390px]")} > { From 02935ea5c5953ce623105ba91e16ead507e950e9 Mon Sep 17 00:00:00 2001 From: Nikita Date: Tue, 30 Dec 2025 16:50:37 +0200 Subject: [PATCH 25/25] feat: udpate links --- .../tournaments_popover/tournaments_info.tsx | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) 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 index f091a8797f..7df2f7b9fa 100644 --- 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 @@ -39,10 +39,16 @@ const TournamentsInfo: React.FC = ({ onClose }) => {
- + {t("tournamentsInfoScoringLink")} - + {t("tournamentsInfoPrizesLink")}