From 68aa12414a31225b24c66404d57a3d62128538d8 Mon Sep 17 00:00:00 2001 From: Kalle <38327916+Sendouc@users.noreply.github.com> Date: Sun, 20 Oct 2024 09:01:22 +0300 Subject: [PATCH] New front page (#1938) * Initial * Progress * Recent winners * Add button * Progress * Mobile nav initial * UI tweaks * Overflow * AnythingAdder links to places * Remove color for tournament showcase * Adjust SQ top banner based on if season is on right or not * Tournament participant count fixed * Log out * todo * Progress * Nav complete * Done? * Fix lint * Translate settings --- .env.example | 2 + .gitignore | 2 + app/components/Avatar.tsx | 10 +- app/components/Catcher.tsx | 42 +- app/components/Main.tsx | 11 +- app/components/Menu.tsx | 21 +- app/components/NewTabs.tsx | 11 +- app/components/form/MyForm.tsx | 2 +- app/components/icons/Globe.tsx | 32 - app/components/icons/Hamburger.tsx | 23 + app/components/icons/Key.tsx | 17 + app/components/icons/Moon.tsx | 32 - app/components/icons/Sun.tsx | 33 - app/components/icons/SunAndMoon.tsx | 32 - app/components/layout/AnythingAdder.tsx | 96 +++ app/components/layout/Footer.tsx | 2 +- app/components/layout/LanguageChanger.tsx | 59 -- .../layout/LogInButtonContainer.tsx | 2 +- app/components/layout/NavDialog.tsx | 113 ++++ app/components/layout/SelectedThemeIcon.tsx | 28 - app/components/layout/SideNav.tsx | 32 - app/components/layout/ThemeChanger.tsx | 58 -- app/components/layout/TopRightButtons.tsx | 17 +- app/components/layout/UserItem.tsx | 2 +- app/components/layout/index.tsx | 47 +- app/components/layout/nav-items.json | 113 +--- app/db/tables.ts | 1 + app/features/admin/actions/admin.server.ts | 2 +- app/features/admin/routes/admin.tsx | 2 +- app/features/api-private/routes/patrons.tsx | 2 +- app/features/api-private/routes/seed.tsx | 2 +- .../api-public/routes/calendar.$year.$week.ts | 2 +- app/features/api-public/routes/org.$id.ts | 2 +- .../api-public/routes/tournament-match.$id.ts | 2 +- ...tournament.$id.brackets.$bidx.standings.ts | 2 +- .../routes/tournament.$id.brackets.$bidx.ts | 2 +- .../api-public/routes/tournament.$id.teams.ts | 2 +- .../api-public/routes/tournament.$id.ts | 2 +- .../api-public/routes/user.$identifier.ts | 2 +- app/features/art/routes/art.new.tsx | 13 +- app/features/art/routes/art.tsx | 2 +- app/features/articles/routes/a.$slug.tsx | 4 +- app/features/articles/routes/a.tsx | 2 +- app/features/auth/core/routes.server.ts | 2 +- app/features/auth/core/user.ts | 2 - .../badges/routes/badges.$id.edit.tsx | 2 +- app/features/badges/routes/badges.tsx | 2 +- .../build-analyzer/routes/analyzer.tsx | 2 +- .../routes/builds.$slug.popular.tsx | 5 +- .../build-stats/routes/builds.$slug.stats.tsx | 5 +- app/features/builds/routes/builds.$slug.tsx | 5 +- app/features/builds/routes/builds.tsx | 2 +- .../calendar/CalendarRepository.server.ts | 15 +- .../calendar/actions/calendar.new.server.ts | 19 +- .../calendar/loaders/calendar.new.server.ts | 7 +- .../routes/calendar.$id.report-winners.tsx | 2 +- app/features/calendar/routes/calendar.$id.tsx | 5 +- app/features/calendar/routes/calendar.new.tsx | 171 ++--- app/features/calendar/routes/calendar.tsx | 41 +- .../core/ShowcaseTournaments.server.ts | 322 +++++++++ .../front-page/loaders/index.server.ts | 112 +++- app/features/front-page/routes/index.tsx | 628 ++++++++++++------ .../img-upload/actions/upload.server.ts | 2 +- .../img-upload/routes/upload.admin.tsx | 2 +- app/features/info/routes/contributions.tsx | 2 +- app/features/info/routes/faq.tsx | 2 +- .../leaderboards/routes/leaderboards.tsx | 2 +- app/features/lfg/actions/lfg.new.server.ts | 2 +- app/features/lfg/actions/lfg.server.ts | 2 +- app/features/lfg/loaders/lfg.new.server.ts | 2 +- app/features/lfg/routes/lfg.new.tsx | 2 +- app/features/lfg/routes/lfg.tsx | 18 +- app/features/links/routes/links.tsx | 2 +- .../map-list-generator/routes/maps.tsx | 2 +- .../map-planner/components/Planner.tsx | 16 +- app/features/map-planner/routes/plans.tsx | 2 +- app/features/mmr/season.ts | 2 +- .../routes/object-damage-calculator.tsx | 2 +- ...plus.suggestions.comment.$tier.$userId.tsx | 4 +- .../routes/plus.suggestions.new.tsx | 6 +- .../routes/plus.suggestions.tsx | 19 +- app/features/plus-suggestions/routes/plus.tsx | 2 +- .../plus-voting/routes/plus.voting.tsx | 2 +- .../sendouq-settings/routes/q.settings.tsx | 5 +- .../sendouq-streams/routes/q.streams.tsx | 2 +- app/features/sendouq/routes/q.looking.tsx | 2 +- app/features/sendouq/routes/q.match.$id.tsx | 4 +- app/features/sendouq/routes/q.preparing.tsx | 4 +- app/features/sendouq/routes/q.tsx | 2 +- app/features/sendouq/routes/tiers.tsx | 2 +- app/features/sendouq/routes/weapon-usage.tsx | 2 +- app/features/settings/routes/settings.tsx | 96 +++ .../team/actions/t.$customUrl.server.ts | 6 +- app/features/team/actions/t.server.ts | 2 +- .../team/loaders/t.$customUrl.server.ts | 2 +- .../team/routes/t.$customUrl.edit.tsx | 2 +- .../team/routes/t.$customUrl.join.tsx | 2 +- .../team/routes/t.$customUrl.roster.tsx | 8 +- app/features/team/routes/t.$customUrl.tsx | 2 +- app/features/team/routes/t.tsx | 47 +- .../top-search/routes/xsearch.player.$id.tsx | 2 +- app/features/top-search/routes/xsearch.tsx | 2 +- .../core/Tournament.server.ts | 2 +- .../tournament-bracket/core/Tournament.ts | 14 +- .../routes/to.$id.brackets.tsx | 2 +- .../routes/to.$id.matches.$mid.subscribe.tsx | 2 +- .../routes/to.$id.matches.$mid.tsx | 2 +- .../actions/org.$slug.edit.server.ts | 6 +- .../loaders/org.$slug.edit.server.ts | 2 +- .../loaders/org.$slug.server.ts | 2 +- .../routes/org.$slug.tsx | 2 +- .../tournament-organization-utils.server.ts | 2 +- .../routes/to.$id.subs.new.tsx | 2 +- .../tournament-subs/routes/to.$id.subs.tsx | 2 +- .../tournament/TournamentRepository.server.ts | 150 ++++- .../actions/to.$id.register.server.ts | 31 +- .../tournament/routes/to.$id.admin.tsx | 48 +- .../tournament/routes/to.$id.join.tsx | 14 +- .../tournament/routes/to.$id.seeds.tsx | 2 +- .../tournament/routes/to.$id.teams.$tid.tsx | 2 +- app/features/tournament/routes/to.$id.tsx | 2 +- .../tournament/tournament-utils.server.ts | 2 +- app/features/tournament/tournament-utils.ts | 140 +--- .../u.$identifier.builds.new.server.ts | 2 +- .../actions/u.$identifier.builds.server.ts | 2 +- .../loaders/u.$identifier.builds.server.ts | 2 +- .../loaders/u.$identifier.index.server.ts | 2 +- .../loaders/u.$identifier.results.server.ts | 2 +- .../loaders/u.$identifier.vods.server.ts | 2 +- .../user-page/routes/u.$identifier.art.tsx | 30 +- .../routes/u.$identifier.builds.new.tsx | 24 +- .../user-page/routes/u.$identifier.builds.tsx | 32 +- .../user-page/routes/u.$identifier.edit.tsx | 5 +- .../user-page/routes/u.$identifier.index.tsx | 2 +- .../u.$identifier.results.highlights.tsx | 2 +- .../routes/u.$identifier.seasons.tsx | 2 +- .../user-page/routes/u.$identifier.tsx | 8 +- .../user-page/routes/u.$identifier.vods.tsx | 37 +- app/features/user-search/routes/u.tsx | 5 +- app/features/vods/actions/vods.$id.server.ts | 2 +- app/features/vods/routes/vods.$id.tsx | 2 +- app/features/vods/routes/vods.new.tsx | 13 +- app/features/vods/routes/vods.tsx | 2 +- app/modules/i18n/resources.server.ts | 34 +- app/permissions.ts | 4 + app/root.tsx | 35 +- app/styles/calendar-new.css | 13 - app/styles/common.css | 18 +- app/styles/front.css | 399 +++++++---- app/styles/layout.css | 182 +++-- app/styles/utils.css | 20 +- app/utils/cache.server.ts | 2 +- app/utils/remix.server.ts | 300 +++++++++ app/utils/remix.ts | 301 +-------- app/utils/urls.ts | 59 +- e2e/builds.spec.ts | 2 +- e2e/lfg.spec.ts | 5 +- e2e/team.spec.ts | 6 +- locales/da/art.json | 1 - locales/da/common.json | 2 - locales/da/front.json | 1 + locales/da/team.json | 1 - locales/da/vods.json | 1 - locales/de/common.json | 2 - locales/de/front.json | 1 + locales/de/team.json | 1 - locales/en/art.json | 1 - locales/en/common.json | 18 +- locales/en/front.json | 21 + locales/en/team.json | 1 - locales/en/vods.json | 1 - locales/es-ES/art.json | 1 - locales/es-ES/common.json | 8 +- locales/es-ES/front.json | 1 + locales/es-ES/team.json | 1 - locales/es-ES/vods.json | 1 - locales/es-US/art.json | 1 - locales/es-US/common.json | 8 +- locales/es-US/front.json | 1 + locales/es-US/team.json | 1 - locales/es-US/vods.json | 1 - locales/fr-CA/art.json | 1 - locales/fr-CA/common.json | 2 - locales/fr-CA/front.json | 1 + locales/fr-CA/team.json | 1 - locales/fr-EU/art.json | 1 - locales/fr-EU/common.json | 2 - locales/fr-EU/front.json | 1 + locales/fr-EU/team.json | 1 - locales/he/art.json | 1 - locales/he/common.json | 2 - locales/he/front.json | 1 + locales/he/team.json | 1 - locales/it/front.json | 1 + locales/ja/art.json | 1 - locales/ja/common.json | 8 +- locales/ja/front.json | 1 + locales/ja/team.json | 1 - locales/ja/vods.json | 1 - locales/ko/common.json | 2 - locales/ko/front.json | 1 + locales/nl/front.json | 1 + locales/pl/common.json | 2 - locales/pl/front.json | 1 + locales/pl/team.json | 1 - locales/pt-BR/art.json | 1 - locales/pt-BR/common.json | 8 +- locales/pt-BR/front.json | 1 + locales/pt-BR/team.json | 1 - locales/pt-BR/vods.json | 1 - locales/ru/art.json | 1 - locales/ru/common.json | 2 - locales/ru/front.json | 1 + locales/ru/team.json | 1 - locales/zh/art.json | 1 - locales/zh/common.json | 8 +- locales/zh/front.json | 1 + locales/zh/team.json | 1 - locales/zh/vods.json | 1 - .../img/layout/front-boy-bg.avif | Bin 15541 -> 0 bytes .../static-assets/img/layout/front-boy-bg.png | Bin 36391 -> 0 bytes .../static-assets/img/layout/front-boy.avif | Bin 60443 -> 0 bytes public/static-assets/img/layout/front-boy.png | Bin 783956 -> 0 bytes .../img/layout/front-girl-bg.avif | Bin 17059 -> 0 bytes .../img/layout/front-girl-bg.png | Bin 38362 -> 0 bytes .../static-assets/img/layout/front-girl.avif | Bin 50193 -> 0 bytes .../static-assets/img/layout/front-girl.png | Bin 650128 -> 0 bytes public/static-assets/img/layout/log_in.avif | Bin 0 -> 2820 bytes public/static-assets/img/layout/log_in.png | Bin 0 -> 24390 bytes public/static-assets/img/layout/medal.avif | Bin 0 -> 4362 bytes public/static-assets/img/layout/medal.png | Bin 0 -> 34521 bytes public/static-assets/img/sq-header/5.avif | Bin 0 -> 8409 bytes public/static-assets/img/sq-header/5.png | Bin 0 -> 99527 bytes scripts/hex-to-filter.ts | 362 ---------- vite.config.ts | 2 + 235 files changed, 2750 insertions(+), 2267 deletions(-) delete mode 100644 app/components/icons/Globe.tsx create mode 100644 app/components/icons/Hamburger.tsx create mode 100644 app/components/icons/Key.tsx delete mode 100644 app/components/icons/Moon.tsx delete mode 100644 app/components/icons/Sun.tsx delete mode 100644 app/components/icons/SunAndMoon.tsx create mode 100644 app/components/layout/AnythingAdder.tsx delete mode 100644 app/components/layout/LanguageChanger.tsx create mode 100644 app/components/layout/NavDialog.tsx delete mode 100644 app/components/layout/SelectedThemeIcon.tsx delete mode 100644 app/components/layout/SideNav.tsx delete mode 100644 app/components/layout/ThemeChanger.tsx create mode 100644 app/features/front-page/core/ShowcaseTournaments.server.ts create mode 100644 app/features/settings/routes/settings.tsx create mode 100644 app/utils/remix.server.ts create mode 100644 locales/da/front.json create mode 100644 locales/de/front.json create mode 100644 locales/en/front.json create mode 100644 locales/es-ES/front.json create mode 100644 locales/es-US/front.json create mode 100644 locales/fr-CA/front.json create mode 100644 locales/fr-EU/front.json create mode 100644 locales/he/front.json create mode 100644 locales/it/front.json create mode 100644 locales/ja/front.json create mode 100644 locales/ko/front.json create mode 100644 locales/nl/front.json create mode 100644 locales/pl/front.json create mode 100644 locales/pt-BR/front.json create mode 100644 locales/ru/front.json create mode 100644 locales/zh/front.json delete mode 100644 public/static-assets/img/layout/front-boy-bg.avif delete mode 100644 public/static-assets/img/layout/front-boy-bg.png delete mode 100644 public/static-assets/img/layout/front-boy.avif delete mode 100644 public/static-assets/img/layout/front-boy.png delete mode 100644 public/static-assets/img/layout/front-girl-bg.avif delete mode 100644 public/static-assets/img/layout/front-girl-bg.png delete mode 100644 public/static-assets/img/layout/front-girl.avif delete mode 100644 public/static-assets/img/layout/front-girl.png create mode 100644 public/static-assets/img/layout/log_in.avif create mode 100644 public/static-assets/img/layout/log_in.png create mode 100644 public/static-assets/img/layout/medal.avif create mode 100644 public/static-assets/img/layout/medal.png create mode 100644 public/static-assets/img/sq-header/5.avif create mode 100644 public/static-assets/img/sq-header/5.png delete mode 100644 scripts/hex-to-filter.ts diff --git a/.env.example b/.env.example index 3cce938f21..ad2711a47d 100644 --- a/.env.example +++ b/.env.example @@ -30,3 +30,5 @@ VITE_SKALOP_WS_URL=ws://localhost:5900 // trunc, full or none (default: none) SQL_LOG=trunc + +USE_TEST_SEASONS=true diff --git a/.gitignore b/.gitignore index 387cd86d82..a0f89c5e14 100644 --- a/.gitignore +++ b/.gitignore @@ -24,3 +24,5 @@ dump /playwright/.cache/ .vscode + +notepad.txt diff --git a/app/components/Avatar.tsx b/app/components/Avatar.tsx index 044e74499c..47812a3089 100644 --- a/app/components/Avatar.tsx +++ b/app/components/Avatar.tsx @@ -1,7 +1,7 @@ import clsx from "clsx"; import * as React from "react"; import type { User } from "~/db/types"; -import { BLANK_IMAGE_URL } from "~/utils/urls"; +import { BLANK_IMAGE_URL, discordAvatarUrl } from "~/utils/urls"; const dimensions = { xxxs: 16, @@ -39,9 +39,11 @@ function _Avatar({ const src = url ?? (user?.discordAvatar && !isErrored - ? `https://cdn.discordapp.com/avatars/${user.discordId}/${ - user.discordAvatar - }.webp${size === "lg" ? "?size=240" : "?size=80"}` + ? discordAvatarUrl({ + discordAvatar: user.discordAvatar, + discordId: user.discordId, + size: size === "lg" ? "lg" : "sm", + }) : BLANK_IMAGE_URL); // avoid broken image placeholder return ( diff --git a/app/components/Catcher.tsx b/app/components/Catcher.tsx index edbfa0bd85..4a2728577b 100644 --- a/app/components/Catcher.tsx +++ b/app/components/Catcher.tsx @@ -1,4 +1,10 @@ -import { isRouteErrorResponse, useRouteError } from "@remix-run/react"; +import { + isRouteErrorResponse, + useRevalidator, + useRouteError, +} from "@remix-run/react"; +import * as React from "react"; +import { useCopyToClipboard, useLocation } from "react-use"; import { Button } from "~/components/Button"; import { useUser } from "~/features/auth/core/user"; import { @@ -12,8 +18,24 @@ import { Main } from "./Main"; export function Catcher() { const error = useRouteError(); const user = useUser(); + const { revalidate } = useRevalidator(); + const location = useLocation(); + const [, copyToClipboard] = useCopyToClipboard(); + + // refresh user data to make sure it's up to date (e.g. cookie might have been removed, let's show the prompt to log back in) + React.useEffect(() => { + if (!isRouteErrorResponse(error) || error.status !== 401) return; + + revalidate(); + }, [revalidate, error]); + + if (!isRouteErrorResponse(error)) { + const errorText = (() => { + if (!(error instanceof Error)) return; + + return `Time: ${new Date().toISOString()}\nURL: ${location.href}\nUser ID: ${user?.id ?? "Not logged in"}\n${error.stack ?? error.message}`; + })(); - if (!isRouteErrorResponse(error)) return (

Error happened

- It seems like you encountered a bug. Sorry about that! Please report - details (your browser? what were you doing?) on{" "} + It seems like you encountered a bug. Sorry about that! Please share + the diagnostics message below as well as other details (your browser? + what were you doing?) on{" "} our Discord so it can be fixed.

+ {errorText ? ( + <> +
+ + +
+ + ) : null}
); + } switch (error.status) { case 401: diff --git a/app/components/Main.tsx b/app/components/Main.tsx index 313131b760..d38e60388b 100644 --- a/app/components/Main.tsx +++ b/app/components/Main.tsx @@ -1,9 +1,4 @@ -import { - isRouteErrorResponse, - useLocation, - useRouteError, -} from "@remix-run/react"; -import { SideNav } from "app/components/layout/SideNav"; +import { isRouteErrorResponse, useRouteError } from "@remix-run/react"; import clsx from "clsx"; import type * as React from "react"; import { useUser } from "~/features/auth/core/user"; @@ -30,12 +25,8 @@ export const Main = ({ !user?.patronTier && !isRouteErrorResponse(error); - const location = useLocation(); - const isFrontPage = location.pathname === "/"; - return (
- {!isFrontPage ? : null}
void; disabled?: boolean; selected?: boolean; }[]; className?: string; scrolling?: boolean; + opensLeft?: boolean; } -export function Menu({ button, items, className, scrolling }: MenuProps) { +export function Menu({ + button, + items, + className, + scrolling, + opensLeft, +}: MenuProps) { return ( @@ -33,6 +42,7 @@ export function Menu({ button, items, className, scrolling }: MenuProps) { {items.map((item) => { @@ -52,6 +62,15 @@ export function Menu({ button, items, className, scrolling }: MenuProps) { {item.icon ? ( {item.icon} ) : null} + {item.imagePath ? ( + + ) : null} {item.text} )} diff --git a/app/components/NewTabs.tsx b/app/components/NewTabs.tsx index 1c7a873385..34218759ae 100644 --- a/app/components/NewTabs.tsx +++ b/app/components/NewTabs.tsx @@ -6,6 +6,7 @@ interface NewTabsProps { tabs: { label: string; number?: number; + icon?: React.ReactNode; hidden?: boolean; disabled?: boolean; }[]; @@ -20,6 +21,10 @@ interface NewTabsProps { setSelectedIndex?: (index: number) => void; /** Don't take space when no tabs to show? */ disappearing?: boolean; + /** Show padding between tabs and content + * @default true + */ + padded?: boolean; type?: "divider"; sticky?: boolean; } @@ -36,6 +41,7 @@ export function NewTabs(args: NewTabsProps) { selectedIndex, setSelectedIndex, disappearing = false, + padded = true, } = args; const cantSwitchTabs = tabs.filter((t) => !t.hidden).length <= 1; @@ -60,6 +66,7 @@ export function NewTabs(args: NewTabsProps) { data-testid={`tab-${tab.label}`} disabled={tab.disabled} > + {tab.icon} {tab.label} {typeof tab.number === "number" && tab.number !== 0 && ( {tab.number} @@ -69,7 +76,9 @@ export function NewTabs(args: NewTabsProps) { })} {content .filter((c) => !c.hidden) diff --git a/app/components/form/MyForm.tsx b/app/components/form/MyForm.tsx index 5dbf6e30f3..4b525a75f2 100644 --- a/app/components/form/MyForm.tsx +++ b/app/components/form/MyForm.tsx @@ -4,7 +4,7 @@ import * as React from "react"; import { FormProvider, useForm } from "react-hook-form"; import { useTranslation } from "react-i18next"; import type { z } from "zod"; -import type { ActionError } from "~/utils/remix"; +import type { ActionError } from "~/utils/remix.server"; import { SubmitButton } from "../SubmitButton"; export function MyForm({ diff --git a/app/components/icons/Globe.tsx b/app/components/icons/Globe.tsx deleted file mode 100644 index 47f93432e4..0000000000 --- a/app/components/icons/Globe.tsx +++ /dev/null @@ -1,32 +0,0 @@ -export function GlobeIcon({ - className, - alt, - size, -}: { - className?: string; - alt: string; - size?: number; -}) { - return ( - - {alt !== "" && {alt}} - - - ); -} diff --git a/app/components/icons/Hamburger.tsx b/app/components/icons/Hamburger.tsx new file mode 100644 index 0000000000..dc1228779d --- /dev/null +++ b/app/components/icons/Hamburger.tsx @@ -0,0 +1,23 @@ +export function HamburgerIcon({ + className, +}: { + className?: string; +}) { + return ( + + Hamburger icon + + + ); +} diff --git a/app/components/icons/Key.tsx b/app/components/icons/Key.tsx new file mode 100644 index 0000000000..add1720714 --- /dev/null +++ b/app/components/icons/Key.tsx @@ -0,0 +1,17 @@ +export function KeyIcon({ className }: { className?: string }) { + return ( + + Key Icon + + + ); +} diff --git a/app/components/icons/Moon.tsx b/app/components/icons/Moon.tsx deleted file mode 100644 index da21dbf448..0000000000 --- a/app/components/icons/Moon.tsx +++ /dev/null @@ -1,32 +0,0 @@ -export function MoonIcon({ - className, - alt, - size, -}: { - className?: string; - alt: string; - size?: number; -}) { - return ( - - {alt !== "" && {alt}} - - - ); -} diff --git a/app/components/icons/Sun.tsx b/app/components/icons/Sun.tsx deleted file mode 100644 index df443ca16d..0000000000 --- a/app/components/icons/Sun.tsx +++ /dev/null @@ -1,33 +0,0 @@ -export function SunIcon({ - className, - alt, - size, -}: { - className?: string; - alt: string; - title?: string; - size?: number; -}) { - return ( - - {alt !== "" && {alt}} - - - ); -} diff --git a/app/components/icons/SunAndMoon.tsx b/app/components/icons/SunAndMoon.tsx deleted file mode 100644 index 172104ce52..0000000000 --- a/app/components/icons/SunAndMoon.tsx +++ /dev/null @@ -1,32 +0,0 @@ -export function SunAndMoonIcon({ - className, - alt, - size, -}: { - className?: string; - alt?: string; - size?: number; -}) { - return ( - - {alt !== "" && {alt}} - - - ); -} diff --git a/app/components/layout/AnythingAdder.tsx b/app/components/layout/AnythingAdder.tsx new file mode 100644 index 0000000000..3856d17f70 --- /dev/null +++ b/app/components/layout/AnythingAdder.tsx @@ -0,0 +1,96 @@ +import { useNavigate } from "@remix-run/react"; +import * as React from "react"; +import { useTranslation } from "react-i18next"; +import { useUser } from "~/features/auth/core/user"; +import { + CALENDAR_NEW_PAGE, + NEW_TEAM_PAGE, + TOURNAMENT_NEW_PAGE, + lfgNewPostPage, + navIconUrl, + newArtPage, + newVodPage, + plusSuggestionsNewPage, + userNewBuildPage, +} from "~/utils/urls"; +import { Menu, type MenuProps } from "../Menu"; +import { PlusIcon } from "../icons/Plus"; + +const FilterMenuButton = React.forwardRef< + HTMLButtonElement, + React.ButtonHTMLAttributes +>((props, ref) => { + return ( + + ); +}); + +export function AnythingAdder() { + const { t } = useTranslation(["common"]); + const user = useUser(); + const navigate = useNavigate(); + + if (!user) { + return null; + } + + const items: MenuProps["items"] = [ + { + id: "tournament", + text: t("header.adder.tournament"), + imagePath: navIconUrl("medal"), + onClick: () => navigate(TOURNAMENT_NEW_PAGE), + }, + { + id: "calendarEvent", + text: t("header.adder.calendarEvent"), + imagePath: navIconUrl("calendar"), + onClick: () => navigate(CALENDAR_NEW_PAGE), + }, + { + id: "builds", + text: t("header.adder.build"), + imagePath: navIconUrl("builds"), + onClick: () => navigate(userNewBuildPage(user)), + }, + { + id: "team", + text: t("header.adder.team"), + imagePath: navIconUrl("t"), + onClick: () => navigate(NEW_TEAM_PAGE), + }, + { + id: "lfgPost", + text: t("header.adder.lfgPost"), + imagePath: navIconUrl("lfg"), + onClick: () => navigate(lfgNewPostPage()), + }, + { + id: "art", + text: t("header.adder.art"), + imagePath: navIconUrl("art"), + onClick: () => navigate(newArtPage()), + }, + { + id: "vods", + text: t("header.adder.vod"), + imagePath: navIconUrl("vods"), + onClick: () => navigate(newVodPage()), + }, + { + id: "plus", + text: t("header.adder.plusSuggestion"), + imagePath: navIconUrl("plus"), + onClick: () => navigate(plusSuggestionsNewPage()), + }, + ]; + + return ; +} diff --git a/app/components/layout/Footer.tsx b/app/components/layout/Footer.tsx index f1eb15ec99..637ca78e94 100644 --- a/app/components/layout/Footer.tsx +++ b/app/components/layout/Footer.tsx @@ -64,7 +64,7 @@ function _Footer() {

sendou.ink © Copyright of Sendou and contributors 2019-{currentYear}. - Original content & source code is licensed under the GPL-3.0 license. + Original content & source code is licensed under the AGPL-3.0 license.

Splatoon is trademark & © of Nintendo 2014-{currentYear}. sendou.ink diff --git a/app/components/layout/LanguageChanger.tsx b/app/components/layout/LanguageChanger.tsx deleted file mode 100644 index 09014112cc..0000000000 --- a/app/components/layout/LanguageChanger.tsx +++ /dev/null @@ -1,59 +0,0 @@ -import { useSearchParams } from "@remix-run/react"; -import type * as React from "react"; -import { useTranslation } from "react-i18next"; -import { languages } from "~/modules/i18n/config"; -import { LinkButton } from "../Button"; -import { Popover } from "../Popover"; -import { GlobeIcon } from "../icons/Globe"; - -const addUniqueParam = ( - oldParams: URLSearchParams, - name: string, - value: string, -): URLSearchParams => { - const paramsCopy = new URLSearchParams(oldParams); - paramsCopy.delete(name); - paramsCopy.append(name, value); - return paramsCopy; -}; - -export function LanguageChanger({ - children, - plain = false, -}: { - children?: React.ReactNode; - plain?: boolean; -}) { - const { t, i18n } = useTranslation(); - const [searchParams] = useSearchParams(); - - return ( - - ) - } - triggerClassName={plain ? undefined : "layout__header__button"} - > -

- {languages.map((lang) => ( - - {lang.name} - - ))} -
- - ); -} diff --git a/app/components/layout/LogInButtonContainer.tsx b/app/components/layout/LogInButtonContainer.tsx index 8473515f2e..ed13a43d4d 100644 --- a/app/components/layout/LogInButtonContainer.tsx +++ b/app/components/layout/LogInButtonContainer.tsx @@ -30,7 +30,7 @@ export function LogInButtonContainer({ isMounted && createPortal( -
+
+ +
+ ) : null} +
+ ); +} + +function LogInButton({ close }: { close: () => void }) { + const { t } = useTranslation(["common"]); + const user = useUser(); + + if (user) { + return ( + +
+ +
+ {t("common:pages.myPage")} + + ); + } + + return ( +
+ + + + {t("common:header.login")} +
+ ); +} diff --git a/app/components/layout/SelectedThemeIcon.tsx b/app/components/layout/SelectedThemeIcon.tsx deleted file mode 100644 index b525ccb91f..0000000000 --- a/app/components/layout/SelectedThemeIcon.tsx +++ /dev/null @@ -1,28 +0,0 @@ -import { useTranslation } from "react-i18next"; -import { Theme, useTheme } from "~/features/theme/core/provider"; -import { MoonIcon } from "../icons/Moon"; -import { SunIcon } from "../icons/Sun"; -import { SunAndMoonIcon } from "../icons/SunAndMoon"; - -const ThemeIcons = { - [Theme.LIGHT]: SunIcon, - [Theme.DARK]: MoonIcon, - auto: SunAndMoonIcon, -}; - -export function SelectedThemeIcon({ size }: { size?: number }) { - const { t } = useTranslation(); - const { userTheme } = useTheme(); - - if (!userTheme) return null; - - const SelectedIcon = ThemeIcons[userTheme]; - - return ( - - ); -} diff --git a/app/components/layout/SideNav.tsx b/app/components/layout/SideNav.tsx deleted file mode 100644 index 48c57295f4..0000000000 --- a/app/components/layout/SideNav.tsx +++ /dev/null @@ -1,32 +0,0 @@ -import { Link } from "@remix-run/react"; -import * as React from "react"; -import navItems from "~/components/layout/nav-items.json"; -import { navIconUrl } from "~/utils/urls"; -import { Image } from "../Image"; - -export function _SideNav() { - return ( - - ); -} - -export const SideNav = React.memo(_SideNav); diff --git a/app/components/layout/ThemeChanger.tsx b/app/components/layout/ThemeChanger.tsx deleted file mode 100644 index e8a2b8657a..0000000000 --- a/app/components/layout/ThemeChanger.tsx +++ /dev/null @@ -1,58 +0,0 @@ -import { useTranslation } from "react-i18next"; -import { Theme, useTheme } from "~/features/theme/core/provider"; -import { Button } from "../Button"; -import { Popover } from "../Popover"; -import { MoonIcon } from "../icons/Moon"; -import { SunIcon } from "../icons/Sun"; -import { SunAndMoonIcon } from "../icons/SunAndMoon"; -import { SelectedThemeIcon } from "./SelectedThemeIcon"; - -const ThemeIcons = { - [Theme.LIGHT]: SunIcon, - [Theme.DARK]: MoonIcon, - auto: SunAndMoonIcon, -}; - -export function ThemeChanger({ - children, - plain, -}: { - children?: React.ReactNode; - plain?: boolean; -}) { - const { userTheme, setUserTheme } = useTheme(); - const { t } = useTranslation(); - - if (!userTheme) { - return null; - } - - return ( - } - triggerClassName={plain ? undefined : "layout__header__button"} - > -
- {(["auto", Theme.DARK, Theme.LIGHT] as const).map((theme) => { - const Icon = ThemeIcons[theme]; - const selected = userTheme === theme; - return ( - - ); - })} -
-
- ); -} diff --git a/app/components/layout/TopRightButtons.tsx b/app/components/layout/TopRightButtons.tsx index 9431c17d2b..2c74309180 100644 --- a/app/components/layout/TopRightButtons.tsx +++ b/app/components/layout/TopRightButtons.tsx @@ -2,17 +2,19 @@ import * as React from "react"; import { useTranslation } from "react-i18next"; import { SUPPORT_PAGE } from "~/utils/urls"; import { LinkButton } from "../Button"; +import { HamburgerIcon } from "../icons/Hamburger"; import { HeartIcon } from "../icons/Heart"; -import { LanguageChanger } from "./LanguageChanger"; -import { ThemeChanger } from "./ThemeChanger"; +import { AnythingAdder } from "./AnythingAdder"; import { UserItem } from "./UserItem"; export function _TopRightButtons({ showSupport, isErrored, + openNavDialog, }: { showSupport: boolean; isErrored: boolean; + openNavDialog: () => void; }) { const { t } = useTranslation(["common"]); @@ -28,8 +30,15 @@ export function _TopRightButtons({ {t("common:pages.support")} ) : null} - - + + {!isErrored ? : null}
); diff --git a/app/components/layout/UserItem.tsx b/app/components/layout/UserItem.tsx index 0bd36e8568..1f7f53c3b2 100644 --- a/app/components/layout/UserItem.tsx +++ b/app/components/layout/UserItem.tsx @@ -12,7 +12,7 @@ export function UserItem() { if (user) { return ( - + + setNavDialogOpen(false)} /> + {isFrontPage ? ( +
- ); } @@ -725,14 +734,6 @@ function AvatarImageInput({ alt="" className="calendar-new__avatar-preview" /> - - )} @@ -753,64 +754,6 @@ function AvatarImageInput({ ); } -function TournamentLogoColorInputsWithShowcase({ - backgroundColor, - setBackgroundColor, - textColor, - setTextColor, - avatarUrl, -}: { - backgroundColor: string; - setBackgroundColor: (color: string) => void; - textColor: string; - setTextColor: (color: string) => void; - avatarUrl: string; -}) { - return ( -
-
- -
- Choose a combination that is easy to read -
- (otherwise will be excluded from front page promotion) -
-
-
- -
- - setBackgroundColor(e.target.value)} - /> - - setTextColor(e.target.value)} - /> -
-
- ); -} - function RankedToggle() { const baseEvent = useBaseEvent(); const [isRanked, setIsRanked] = React.useState( @@ -1091,32 +1034,6 @@ function TournamentMapPickingStyleSelect() { ); } -function TournamentEnabler({ - checked, - setChecked, -}: { - checked: boolean; - setChecked: (checked: boolean) => void; -}) { - const id = React.useId(); - - return ( -
- - - - Host the full event including bracket and sign ups on sendou.ink - -
- ); -} - function MapPoolSection() { const { t } = useTranslation(["game-misc", "common"]); diff --git a/app/features/calendar/routes/calendar.tsx b/app/features/calendar/routes/calendar.tsx index 73ca9b36dc..e782600520 100644 --- a/app/features/calendar/routes/calendar.tsx +++ b/app/features/calendar/routes/calendar.tsx @@ -19,11 +19,11 @@ import { Label } from "~/components/Label"; import { Main } from "~/components/Main"; import { Toggle } from "~/components/Toggle"; import { UsersIcon } from "~/components/icons/Users"; -import { useUser } from "~/features/auth/core/user"; import { getUserId } from "~/features/auth/core/user.server"; import { currentSeason } from "~/features/mmr/season"; import { HACKY_resolvePicture } from "~/features/tournament/tournament-utils"; import { useIsMounted } from "~/hooks/useIsMounted"; +import { useSearchParamState } from "~/hooks/useSearchParamState"; import { i18next } from "~/modules/i18n/i18next.server"; import { joinListToNaturalString } from "~/utils/arrays"; import { @@ -34,7 +34,7 @@ import { getWeekStartsAtMondayDay, weekNumberToDate, } from "~/utils/dates"; -import type { SendouRouteHandle } from "~/utils/remix"; +import type { SendouRouteHandle } from "~/utils/remix.server"; import { makeTitle } from "~/utils/strings"; import type { Unpacked } from "~/utils/types"; import { @@ -48,7 +48,6 @@ import { } from "~/utils/urls"; import { actualNumber } from "~/utils/zod"; import * as CalendarRepository from "../CalendarRepository.server"; -import { canAddNewEvent } from "../calendar-utils"; import { Tags } from "../components/Tags"; import "~/styles/calendar.css"; @@ -162,9 +161,12 @@ function fetchEventsOfWeek(args: { week: number; year: number }) { export default function CalendarPage() { const { t } = useTranslation("calendar"); const data = useLoaderData(); - const user = useUser(); const isMounted = useIsMounted(); - const [onlySendouInkEvents, setOnlySendouInkEvents] = React.useState(false); + const [onlySendouInkEvents, setOnlySendouInkEvents] = useSearchParamState({ + defaultValue: false, + name: "tournaments", + revive: (val) => val === "true", + }); const filteredEvents = onlySendouInkEvents ? data.events.filter((event) => event.tournamentId) @@ -185,8 +187,8 @@ export default function CalendarPage() {
-
-
+
+
- {user && canAddNewEvent(user) && ( - - {t("addNew")} - - )}
{isMounted ? ( <> @@ -405,7 +402,7 @@ function EventsList({ {t("pastEvents.dividerText")} ) : null} -
+
{daysDate.toLocaleDateString(i18n.language, { weekday: "long", day: "numeric", @@ -421,6 +418,9 @@ function EventsList({ weekday: "short", }); + const isOneVsOne = + calendarEvent.tournamentSettings?.minMembersPerTeam === 1; + const startTimeDate = databaseTimestampToDate( calendarEvent.startTime, ); @@ -438,7 +438,7 @@ function EventsList({ return (
@@ -525,10 +525,15 @@ function EventsList({ calendarEvent.participantCounts.teams > 0 ? (
{" "} - {t("count.teams", { - count: calendarEvent.participantCounts.teams, - })}{" "} - /{" "} + {!isOneVsOne ? ( + <> + {t("count.teams", { + count: + calendarEvent.participantCounts.teams, + })}{" "} + /{" "} + + ) : null} {t("count.players", { count: calendarEvent.participantCounts.players, diff --git a/app/features/front-page/core/ShowcaseTournaments.server.ts b/app/features/front-page/core/ShowcaseTournaments.server.ts new file mode 100644 index 0000000000..0f4e15dce4 --- /dev/null +++ b/app/features/front-page/core/ShowcaseTournaments.server.ts @@ -0,0 +1,322 @@ +import cachified from "@epic-web/cachified"; +import { ONE_HOUR_IN_MS, TWO_HOURS_IN_MS } from "~/constants"; +import type { Tables } from "~/db/tables"; +import * as TournamentRepository from "~/features/tournament/TournamentRepository.server"; +import { tournamentIsRanked } from "~/features/tournament/tournament-utils"; +import { cache, ttl } from "~/utils/cache.server"; +import { + databaseTimestampToDate, + dateToDatabaseTimestamp, +} from "~/utils/dates"; +import type { CommonUser } from "~/utils/kysely.server"; + +interface ShowcaseTournamentCollection { + participatingFor: ShowcaseTournament[]; + organizingFor: ShowcaseTournament[]; + showcase: ShowcaseTournament[]; + results: ShowcaseTournament[]; +} + +export interface ShowcaseTournament { + id: number; + name: string; + startTime: number; + teamsCount: number; + isRanked: boolean; + logoUrl: string | null; + organization: { + name: string; + slug: string; + } | null; + firstPlacer: { + teamName: string; + logoUrl: string | null; + members: (CommonUser & { country: Tables["User"]["country"] })[]; + notShownMembersCount: number; + } | null; +} + +interface ParticipationInfo { + participants: Set; + organizers: Set; +} + +export async function frontPageTournamentsByUserId( + userId: number | null, +): Promise { + const tournaments = await cachedTournaments(); + const participation = await cachedParticipationInfo( + userId, + tournaments.upcoming, + ); + + return { + organizingFor: tournaments.upcoming.filter((tournament) => + participation.organizers.has(tournament.id), + ), + participatingFor: tournaments.upcoming.filter((tournament) => + participation.participants.has(tournament.id), + ), + showcase: resolveShowcaseTournaments( + tournaments.upcoming.filter( + (tournament) => + !participation.organizers.has(tournament.id) && + !participation.participants.has(tournament.id), + ), + ), + results: tournaments.results, + }; +} + +let participationInfoMap: Map | null = + null; + +const emptyParticipationInfo = (): ParticipationInfo => ({ + participants: new Set(), + organizers: new Set(), +}); + +export function clearParticipationInfoMap() { + participationInfoMap = null; +} + +export function addToParticipationInfoMap({ + userId, + tournamentId, + type, +}: { + userId: number; + tournamentId: number; + type: "participant" | "organizer"; +}) { + if (!participationInfoMap) return; + + const participation = + participationInfoMap.get(userId) ?? emptyParticipationInfo(); + + if (type === "participant") { + participation.participants.add(tournamentId); + } else if (type === "organizer") { + participation.organizers.add(tournamentId); + } + + participationInfoMap.set(userId, participation); +} + +export function removeFromParticipationInfoMap({ + userId, + tournamentId, + type, +}: { + userId: number; + tournamentId: number; + type: "participant" | "organizer"; +}) { + if (!participationInfoMap) return; + + const participation = participationInfoMap.get(userId); + if (!participation) return; + + if (type === "participant") { + participation.participants.delete(tournamentId); + } else if (type === "organizer") { + participation.organizers.delete(tournamentId); + } + + participationInfoMap.set(userId, participation); +} + +async function cachedParticipationInfo( + userId: number | null, + tournaments: ShowcaseTournament[], +): Promise { + if (!userId) { + return emptyParticipationInfo(); + } + + if (participationInfoMap) { + return participationInfoMap.get(userId) ?? emptyParticipationInfo(); + } + + const participation = await tournamentsToParticipationInfoMap(tournaments); + participationInfoMap = participation; + + return participation.get(userId) ?? emptyParticipationInfo(); +} + +export const SHOWCASE_TOURNAMENTS_CACHE_KEY = "front-tournaments-list"; + +export const clearCachedTournaments = () => + cache.delete(SHOWCASE_TOURNAMENTS_CACHE_KEY); + +async function cachedTournaments() { + return cachified({ + key: SHOWCASE_TOURNAMENTS_CACHE_KEY, + cache, + ttl: ttl(ONE_HOUR_IN_MS), + staleWhileRevalidate: ttl(TWO_HOURS_IN_MS), + async getFreshValue() { + const tournaments = await TournamentRepository.forShowcase(); + + const mapped = tournaments.map(mapTournamentFromDB); + + return deleteExtraResults(mapped); + }, + }); +} + +function deleteExtraResults(tournaments: ShowcaseTournament[]) { + const nonResults = tournaments.filter( + (tournament) => !tournament.firstPlacer, + ); + + const rankedResults = tournaments + .filter((tournament) => tournament.firstPlacer && tournament.isRanked) + .sort((a, b) => b.teamsCount - a.teamsCount); + const nonRankedResults = tournaments + .filter((tournament) => tournament.firstPlacer && !tournament.isRanked) + .sort((a, b) => b.teamsCount - a.teamsCount); + + const rankedResultsToKeep = rankedResults.slice(0, 4); + // min 2, max 6 non ranked results + const nonRankedResultsToKeep = nonRankedResults.slice( + 0, + 6 - rankedResultsToKeep.length, + ); + + return { + results: [...rankedResultsToKeep, ...nonRankedResultsToKeep].sort( + (a, b) => b.startTime - a.startTime, + ), + upcoming: nonResults, + }; +} + +function resolveShowcaseTournaments( + tournaments: ShowcaseTournament[], +): ShowcaseTournament[] { + const happeningDuringNextWeek = tournaments.filter( + (tournament) => + tournament.startTime > databaseTimestampSixHoursAgo() && + tournament.startTime < databaseTimestampWeekFromNow(), + ); + const sorted = happeningDuringNextWeek.sort( + (a, b) => b.teamsCount - a.teamsCount, + ); + + const ranked = sorted.filter((tournament) => tournament.isRanked).slice(0, 3); + // min 3, max 6 non ranked + const nonRanked = sorted + .filter((tournament) => !tournament.isRanked) + .slice(0, 6 - ranked.length); + + return [...ranked, ...nonRanked].sort((a, b) => a.startTime - b.startTime); +} + +async function tournamentsToParticipationInfoMap( + tournaments: ShowcaseTournament[], +): Promise> { + const tournamentIds = tournaments.map((tournament) => tournament.id); + const tournamentsWithUsers = + await TournamentRepository.relatedUsersByTournamentIds(tournamentIds); + + const result: Map = new Map(); + + const addToMap = ( + userId: number, + tournamentId: number, + type: "participant" | "organizer", + ) => { + const participation = result.get(userId) ?? emptyParticipationInfo(); + + if (type === "participant") { + participation.participants.add(tournamentId); + } else if (type === "organizer") { + participation.organizers.add(tournamentId); + } + + result.set(userId, participation); + }; + + for (const tournament of tournamentsWithUsers) { + for (const { userId } of tournament.teamMembers) { + addToMap(userId, tournament.id, "participant"); + } + + for (const { userId } of tournament.staff) { + addToMap(userId, tournament.id, "organizer"); + } + + for (const { userId } of tournament.organizationMembers) { + addToMap(userId, tournament.id, "organizer"); + } + + addToMap(tournament.authorId, tournament.id, "organizer"); + } + + return result; +} + +const MEMBERS_TO_SHOW = 5; + +function mapTournamentFromDB( + tournament: TournamentRepository.ForShowcase, +): ShowcaseTournament { + return { + id: tournament.id, + name: tournament.name, + startTime: tournament.startTime, + teamsCount: tournament.teamsCount, + logoUrl: tournament.logoUrl, + organization: tournament.organization + ? { + name: tournament.organization.name, + slug: tournament.organization.slug, + } + : null, + isRanked: tournamentIsRanked({ + isSetAsRanked: tournament.settings.isRanked, + startTime: databaseTimestampToDate(tournament.startTime), + minMembersPerTeam: tournament.settings.minMembersPerTeam ?? 4, + }), + firstPlacer: + tournament.firstPlacers.length > 0 + ? { + teamName: tournament.firstPlacers[0].teamName, + logoUrl: + tournament.firstPlacers[0].teamLogoUrl ?? + tournament.firstPlacers[0].pickupAvatarUrl, + members: tournament.firstPlacers + .slice(0, MEMBERS_TO_SHOW) + .map((firstPlacer) => ({ + customUrl: firstPlacer.customUrl, + discordAvatar: firstPlacer.discordAvatar, + discordId: firstPlacer.discordId, + id: firstPlacer.id, + username: firstPlacer.username, + country: firstPlacer.country, + })), + notShownMembersCount: + tournament.firstPlacers.length > MEMBERS_TO_SHOW + ? tournament.firstPlacers.length - MEMBERS_TO_SHOW + : 0, + } + : null, + }; +} + +function databaseTimestampWeekFromNow() { + const now = new Date(); + + now.setDate(now.getDate() + 7); + + return dateToDatabaseTimestamp(now); +} + +function databaseTimestampSixHoursAgo() { + const now = new Date(); + + now.setHours(now.getHours() - 6); + + return dateToDatabaseTimestamp(now); +} diff --git a/app/features/front-page/loaders/index.server.ts b/app/features/front-page/loaders/index.server.ts index 9ac5f60bfa..aad0be4815 100644 --- a/app/features/front-page/loaders/index.server.ts +++ b/app/features/front-page/loaders/index.server.ts @@ -1,26 +1,28 @@ import cachified from "@epic-web/cachified"; -import { - ONE_HOUR_IN_MS, - TEN_MINUTES_IN_MS, - TWO_HOURS_IN_MS, -} from "~/constants"; +import type { LoaderFunctionArgs } from "@remix-run/node"; +import { ONE_HOUR_IN_MS, TWO_HOURS_IN_MS } from "~/constants"; +import type { Tables } from "~/db/tables"; +import { getUserId } from "~/features/auth/core/user.server"; import * as Changelog from "~/features/front-page/core/Changelog.server"; -import * as TournamentRepository from "~/features/tournament/TournamentRepository.server"; +import * as LeaderboardRepository from "~/features/leaderboards/LeaderboardRepository.server"; +import { cachedFullUserLeaderboard } from "~/features/leaderboards/core/leaderboards.server"; +import { currentOrPreviousSeason } from "~/features/mmr/season"; import { cache, ttl } from "~/utils/cache.server"; +import { + discordAvatarUrl, + teamPage, + userPage, + userSubmittedImage, +} from "~/utils/urls"; +import * as ShowcaseTournaments from "../core/ShowcaseTournaments.server"; -export const loader = async () => { - return { - tournaments: await cachified({ - key: "tournament-showcase", - cache, - ttl: ttl(TEN_MINUTES_IN_MS), - staleWhileRevalidate: ttl(ONE_HOUR_IN_MS), - async getFreshValue() { - return TournamentRepository.forShowcase(); - }, - }), - changelog: await cachified({ - key: "changelog", +export const loader = async ({ request }: LoaderFunctionArgs) => { + const user = await getUserId(request); + + const [tournaments, changelog, leaderboards] = await Promise.all([ + ShowcaseTournaments.frontPageTournamentsByUserId(user?.id ?? null), + cachified({ + key: "front-changelog", cache, ttl: ttl(ONE_HOUR_IN_MS), staleWhileRevalidate: ttl(TWO_HOURS_IN_MS), @@ -28,5 +30,77 @@ export const loader = async () => { return Changelog.get(); }, }), + cachedLeaderboards(), + ]); + + return { + tournaments, + changelog, + leaderboards, }; }; + +export interface LeaderboardEntry { + name: string; + url: string; + avatarUrl: string | null; + power: number; +} + +const ENTRIES_PER_LEADERBOARD = 5; + +function cachedLeaderboards(): Promise<{ + user: LeaderboardEntry[]; + team: LeaderboardEntry[]; +}> { + return cachified({ + key: "front-leaderboard", + cache, + ttl: ttl(ONE_HOUR_IN_MS), + staleWhileRevalidate: ttl(TWO_HOURS_IN_MS), + async getFreshValue() { + const season = currentOrPreviousSeason(new Date())?.nth ?? 1; + + const [team, user] = await Promise.all([ + LeaderboardRepository.teamLeaderboardBySeason({ + season, + onlyOneEntryPerUser: true, + }), + cachedFullUserLeaderboard(season), + ]); + + return { + user: user.slice(0, ENTRIES_PER_LEADERBOARD).map((entry) => ({ + power: entry.power, + name: entry.username, + url: userPage(entry), + avatarUrl: entry.discordAvatar + ? discordAvatarUrl({ + discordAvatar: entry.discordAvatar, + discordId: entry.discordId, + size: "sm", + }) + : null, + })), + team: team + .filter((entry) => entry.team) + .slice(0, ENTRIES_PER_LEADERBOARD) + .map((entry) => { + const team = entry.team as Pick< + Tables["Team"], + "id" | "name" | "customUrl" + > & { avatarUrl: string | null }; + + return { + power: entry.power, + name: team.name, + url: teamPage(team.customUrl), + avatarUrl: team.avatarUrl + ? userSubmittedImage(team.avatarUrl) + : null, + }; + }), + }; + }, + }); +} diff --git a/app/features/front-page/routes/index.tsx b/app/features/front-page/routes/index.tsx index 5487b7744e..f8f06854ec 100644 --- a/app/features/front-page/routes/index.tsx +++ b/app/features/front-page/routes/index.tsx @@ -1,4 +1,3 @@ -import type { SerializeFrom } from "@remix-run/node"; import { Link, useLoaderData } from "@remix-run/react"; import clsx from "clsx"; import * as React from "react"; @@ -6,254 +5,495 @@ import { useTranslation } from "react-i18next"; import { Avatar } from "~/components/Avatar"; import { Button } from "~/components/Button"; import { Divider } from "~/components/Divider"; +import { Flag } from "~/components/Flag"; import { Image } from "~/components/Image"; import { Main } from "~/components/Main"; -import { Placement } from "~/components/Placement"; +import { NewTabs } from "~/components/NewTabs"; +import { ArrowRightIcon } from "~/components/icons/ArrowRight"; import { BSKYLikeIcon } from "~/components/icons/BSKYLike"; import { BSKYReplyIcon } from "~/components/icons/BSKYReply"; import { BSKYRepostIcon } from "~/components/icons/BSKYRepost"; import { ExternalIcon } from "~/components/icons/External"; -import { GlobeIcon } from "~/components/icons/Globe"; -import { LogInIcon } from "~/components/icons/LogIn"; +import { KeyIcon } from "~/components/icons/Key"; import { LogOutIcon } from "~/components/icons/LogOut"; -import { LanguageChanger } from "~/components/layout/LanguageChanger"; -import { LogInButtonContainer } from "~/components/layout/LogInButtonContainer"; -import { SelectedThemeIcon } from "~/components/layout/SelectedThemeIcon"; -import { ThemeChanger } from "~/components/layout/ThemeChanger"; +import { SearchIcon } from "~/components/icons/Search"; +import { UsersIcon } from "~/components/icons/Users"; import navItems from "~/components/layout/nav-items.json"; import { useUser } from "~/features/auth/core/user"; import type * as Changelog from "~/features/front-page/core/Changelog.server"; -import { useTheme } from "~/features/theme/core/provider"; import { - HACKY_resolvePicture, - HACKY_resolveThemeColors, -} from "~/features/tournament/tournament-utils"; + currentOrPreviousSeason, + nextSeason, + previousSeason, +} from "~/features/mmr/season"; +import { HACKY_resolvePicture } from "~/features/tournament/tournament-utils"; import { useIsMounted } from "~/hooks/useIsMounted"; -import { languages } from "~/modules/i18n/config"; import { databaseTimestampToDate } from "~/utils/dates"; +import type { SendouRouteHandle } from "~/utils/remix.server"; import { - FRONT_BOY_BG_PATH, - FRONT_BOY_PATH, - FRONT_GIRL_BG_PATH, - FRONT_GIRL_PATH, + BLANK_IMAGE_URL, + CALENDAR_TOURNAMENTS_PAGE, LOG_OUT_URL, + SENDOUQ_PAGE, + leaderboardsPage, navIconUrl, + sqHeaderGuyImageUrl, tournamentPage, - userPage, userSubmittedImage, } from "~/utils/urls"; +import type * as ShowcaseTournaments from "../core/ShowcaseTournaments.server"; +import { type LeaderboardEntry, loader } from "../loaders/index.server"; -import { loader } from "../loaders/index.server"; export { loader }; import "~/styles/front.css"; +export const handle: SendouRouteHandle = { + i18n: ["front"], +}; + export default function FrontPage() { - const data = useLoaderData(); - const { userTheme } = useTheme(); - const [filters, setFilters] = React.useState<[string, string]>( - navItems[0]?.filters as [string, string], + return ( +
+ + + + + +
); - const { t, i18n } = useTranslation(["common"]); - const user = useUser(); +} - const selectedLanguage = languages.find( - (lang) => i18n.language === lang.code, - ); +function DesktopSideNav() { + const user = useUser(); + const { t } = useTranslation(["common"]); return ( -
-
- {data.tournaments.map((tournament) => ( - - ))} -
-
-
- -
- -
-
- {selectedLanguage?.name ?? ""} -
- -
- -
- -
-
- {t(`common:theme.${userTheme ?? "auto"}`)} -
- - {navItems.map((item) => ( +
+ ); + })} {user ? ( -
- - - -
+
+ +
) : null} - - -
+ + ); +} + +function SeasonBanner() { + const { t, i18n } = useTranslation(["front"]); + const season = nextSeason(new Date()) ?? currentOrPreviousSeason(new Date())!; + const _previousSeason = previousSeason(new Date()); + + const isInFuture = new Date() < season.starts; + const isShowingPreviousSeason = _previousSeason?.nth === season.nth; + + if (isShowingPreviousSeason) return null; + + return ( +
+ +
+ {t("front:sq.season", { nth: season.nth })} +
+
+ {season.starts.toLocaleDateString(i18n.language, { + month: "long", + day: "numeric", + })}{" "} + -{" "} + {season.ends.toLocaleDateString(i18n.language, { + month: "long", + day: "numeric", + })} +
+ + + +
+ + {isInFuture ? ( + <>{t("front:sq.prepare")} + ) : ( + <>{t("front:sq.participate")} + )} + +
+ +
+ ); +} + +function TournamentCards() { + const { t } = useTranslation(["front"]); + const data = useLoaderData(); + + if ( + data.tournaments.participatingFor.length === 0 && + data.tournaments.organizingFor.length === 0 && + data.tournaments.showcase.length === 0 + ) { + return null; + } + + return ( +
+ , + }, + { + label: t("front:showcase.tabs.organizer"), + hidden: data.tournaments.organizingFor.length === 0, + icon: , + }, + { + label: t("front:showcase.tabs.discover"), + hidden: data.tournaments.showcase.length === 0, + icon: , + }, + ]} + content={[ + { + key: "your", + hidden: data.tournaments.participatingFor.length === 0, + element: ( + + ), + }, + { + key: "organizer", + hidden: data.tournaments.organizingFor.length === 0, + element: ( + + ), + }, + { + key: "discover", + hidden: data.tournaments.showcase.length === 0, + element: ( + + ), + }, + ]} + /> +
+ ); +} + +function ShowcaseTournamentScroller({ + tournaments, +}: { tournaments: ShowcaseTournaments.ShowcaseTournament[] }) { + return ( +
+
+ {tournaments.map((tournament) => ( + + ))} +
+ +
+ ); +} + +function AllTournamentsLinkCard() { + const { t } = useTranslation(["front"]); + + return ( + + + {t("front:showcase.viewAll")} + ); } function TournamentCard({ tournament, + topSpaced, }: { - tournament: SerializeFrom["tournaments"][number]; + tournament: ShowcaseTournaments.ShowcaseTournament; + topSpaced?: boolean; }) { - const { t } = useTranslation(["common"]); const isMounted = useIsMounted(); - const { i18n } = useTranslation(); - const theme = - tournament.avatarMetadata ?? HACKY_resolveThemeColors(tournament); - - const happeningNow = - tournament.firstPlacers.length === 0 && - databaseTimestampToDate(tournament.startTime) < new Date(); - - const rtf = new Intl.RelativeTimeFormat(i18n.language, { numeric: "auto" }); + const { t, i18n } = useTranslation(["front", "common"]); const time = () => { if (!isMounted) return "Placeholder"; - if (happeningNow) return t("common:showcase.liveNow"); - - const date = databaseTimestampToDate(tournament.startTime); - const dayDifference = Math.floor( - (date.getTime() - new Date().getTime()) / (1000 * 60 * 60 * 24), - ); - - if (tournament.firstPlacers.length > 0) - return rtf.format(dayDifference, "day"); return databaseTimestampToDate(tournament.startTime).toLocaleString( i18n.language, { - month: "numeric", + month: "short", day: "numeric", hour: "numeric", + weekday: "short", }, ); }; return ( - -
- -
- {time()} + +
+
+ +
+ {tournament.organization ? ( +
+ {tournament.organization.name} +
+ ) : null}
+
+ {tournament.name}{" "} + +
+ {tournament.firstPlacer ? ( + + ) : null} + +
+
+ {tournament.teamsCount} +
+ {tournament.isRanked ? ( +
+ {t("front:showcase.card.ranked")} +
+ ) : ( +
+ {t("front:showcase.card.unranked")} +
+ )}
-
{tournament.name}
- {tournament.firstPlacers.length > 0 ? ( - <> -
-
- - {tournament.firstPlacers[0].teamName} +
+ ); +} + +function TournamentFirstPlacers({ + firstPlacer, +}: { + firstPlacer: NonNullable< + ShowcaseTournaments.ShowcaseTournament["firstPlacer"] + >; +}) { + const { t } = useTranslation(["front"]); + + return ( +
+
+ {firstPlacer.logoUrl ? ( + + ) : null}{" "} +
+ + {firstPlacer.teamName} + +
+ {t("front:showcase.card.winner")}
-
    - {tournament.firstPlacers.map((p) => ( -
  • {p.username}
  • - ))} -
- - ) : ( -
- {happeningNow - ? t("common:showcase.bracket") - : t("common:showcase.register")}
- )} - +
+
+ {firstPlacer.members.map((member) => ( +
+ {member.country ? : null} + {member.username}{" "} +
+ ))} + {firstPlacer.notShownMembersCount > 0 ? ( +
+ +{firstPlacer.notShownMembersCount} +
+ ) : null} +
+
); } -function LogInButton() { - const { t } = useTranslation(["common"]); - const user = useUser(); +function ResultHighlights() { + const { t } = useTranslation(["front"]); + const data = useLoaderData(); - if (user) { - return ( - - - {t("common:pages.myPage")} - - ); + // should not happen + if ( + !data.leaderboards.team.length || + !data.leaderboards.user.length || + !data.tournaments.results.length + ) { + return null; } + const season = currentOrPreviousSeason(new Date())!; + + const recentResults = ( + <> +

+ {t("front:showcase.results")} +

+
+ {data.tournaments.results.map((tournament) => ( + + ))} +
+ + ); + + return ( + <> +
+
+

+ {t("front:leaderboards.topPlayers")} +

+ +
+
+

+ {t("front:leaderboards.topTeams")} +

+ +
+
+ {recentResults} +
+
+
+
+ {recentResults} +
+
+ + ); +} + +function Leaderboard({ + entries, + fullLeaderboardUrl, +}: { entries: LeaderboardEntry[]; fullLeaderboardUrl: string }) { + const { t } = useTranslation(["front"]); + return ( -
- - - - {t("common:header.login")} +
+
+ {entries.map((entry, index) => ( + +
{index + 1}
+ +
+
{entry.name}
+
+ {entry.power.toFixed(2)} +
+
+ + ))} +
+ + + {t("front:leaderboards.viewFull")} +
); } function ChangelogList() { + const { t } = useTranslation(["front"]); const data = useLoaderData(); if (data.changelog.length === 0) return null; @@ -261,7 +501,7 @@ function ChangelogList() { return (
- Updates + {t("front:updates.header")} {data.changelog.map((item) => ( @@ -275,7 +515,8 @@ function ChangelogList() { rel="noopener noreferrer" className="stack horizontal sm mx-auto text-xs font-bold" > - View past updates + {t("front:updates.viewPast")}{" "} +
); @@ -349,40 +590,3 @@ function BSKYIconLink({ ); } - -function Drawings({ - filters, -}: { - filters: [boyFilter: string, girlFilter: string]; -}) { - return ( -
- - - - -
- ); -} diff --git a/app/features/img-upload/actions/upload.server.ts b/app/features/img-upload/actions/upload.server.ts index 8e2a410c58..a820678a94 100644 --- a/app/features/img-upload/actions/upload.server.ts +++ b/app/features/img-upload/actions/upload.server.ts @@ -18,7 +18,7 @@ import { parseSearchParams, unauthorizedIfFalsy, validate, -} from "~/utils/remix"; +} from "~/utils/remix.server"; import { teamPage, tournamentOrganizationPage } from "~/utils/urls"; import { addNewImage } from "../queries/addNewImage"; import { countUnvalidatedImg } from "../queries/countUnvalidatedImg.server"; diff --git a/app/features/img-upload/routes/upload.admin.tsx b/app/features/img-upload/routes/upload.admin.tsx index 1fc7a9f8f1..a2b64f45f1 100644 --- a/app/features/img-upload/routes/upload.admin.tsx +++ b/app/features/img-upload/routes/upload.admin.tsx @@ -10,7 +10,7 @@ import { notFoundIfFalsy, parseRequestPayload, validate, -} from "~/utils/remix"; +} from "~/utils/remix.server"; import { userSubmittedImage } from "~/utils/urls"; import * as ImageRepository from "../ImageRepository.server"; import { countAllUnvalidatedImg } from "../queries/countAllUnvalidatedImg.server"; diff --git a/app/features/info/routes/contributions.tsx b/app/features/info/routes/contributions.tsx index b53000c020..e8a7bf68a7 100644 --- a/app/features/info/routes/contributions.tsx +++ b/app/features/info/routes/contributions.tsx @@ -5,7 +5,7 @@ import { useTranslation } from "react-i18next"; import { Main } from "~/components/Main"; import { useSetTitle } from "~/hooks/useSetTitle"; import { languages } from "~/modules/i18n/config"; -import type { SendouRouteHandle } from "~/utils/remix"; +import type { SendouRouteHandle } from "~/utils/remix.server"; import { makeTitle } from "~/utils/strings"; import { ANTARISKA_TWITTER, diff --git a/app/features/info/routes/faq.tsx b/app/features/info/routes/faq.tsx index bd389b7a18..30958a1002 100644 --- a/app/features/info/routes/faq.tsx +++ b/app/features/info/routes/faq.tsx @@ -2,7 +2,7 @@ import type { MetaFunction } from "@remix-run/node"; import { useTranslation } from "react-i18next"; import { Main } from "~/components/Main"; import { useSetTitle } from "~/hooks/useSetTitle"; -import type { SendouRouteHandle } from "~/utils/remix"; +import type { SendouRouteHandle } from "~/utils/remix.server"; import { makeTitle } from "~/utils/strings"; import "~/styles/faq.css"; diff --git a/app/features/leaderboards/routes/leaderboards.tsx b/app/features/leaderboards/routes/leaderboards.tsx index 7b4f4f5cda..ad252a4406 100644 --- a/app/features/leaderboards/routes/leaderboards.tsx +++ b/app/features/leaderboards/routes/leaderboards.tsx @@ -28,7 +28,7 @@ import { } from "~/modules/in-game-lists"; import { rankedModesShort } from "~/modules/in-game-lists/modes"; import { cache, ttl } from "~/utils/cache.server"; -import type { SendouRouteHandle } from "~/utils/remix"; +import type { SendouRouteHandle } from "~/utils/remix.server"; import { makeTitle } from "~/utils/strings"; import { LEADERBOARDS_PAGE, diff --git a/app/features/lfg/actions/lfg.new.server.ts b/app/features/lfg/actions/lfg.new.server.ts index 2504c44a70..ec1af1026a 100644 --- a/app/features/lfg/actions/lfg.new.server.ts +++ b/app/features/lfg/actions/lfg.new.server.ts @@ -3,7 +3,7 @@ import { redirect } from "@remix-run/node"; import { z } from "zod"; import { requireUser } from "~/features/auth/core/user.server"; import * as UserRepository from "~/features/user-page/UserRepository.server"; -import { parseRequestPayload, validate } from "~/utils/remix"; +import { parseRequestPayload, validate } from "~/utils/remix.server"; import { LFG_PAGE } from "~/utils/urls"; import { falsyToNull, id } from "~/utils/zod"; import * as LFGRepository from "../LFGRepository.server"; diff --git a/app/features/lfg/actions/lfg.server.ts b/app/features/lfg/actions/lfg.server.ts index 6e522bf779..43e20a3daf 100644 --- a/app/features/lfg/actions/lfg.server.ts +++ b/app/features/lfg/actions/lfg.server.ts @@ -2,7 +2,7 @@ import type { ActionFunctionArgs } from "@remix-run/node"; import { z } from "zod"; import { requireUser } from "~/features/auth/core/user.server"; import { isAdmin } from "~/permissions"; -import { parseRequestPayload, validate } from "~/utils/remix"; +import { parseRequestPayload, validate } from "~/utils/remix.server"; import { _action, id } from "~/utils/zod"; import * as LFGRepository from "../LFGRepository.server"; diff --git a/app/features/lfg/loaders/lfg.new.server.ts b/app/features/lfg/loaders/lfg.new.server.ts index f29a3f091c..b0a5ad4577 100644 --- a/app/features/lfg/loaders/lfg.new.server.ts +++ b/app/features/lfg/loaders/lfg.new.server.ts @@ -3,7 +3,7 @@ import { z } from "zod"; import { requireUser } from "~/features/auth/core/user.server"; import * as QSettingsRepository from "~/features/sendouq-settings/QSettingsRepository.server"; import * as UserRepository from "~/features/user-page/UserRepository.server"; -import { parseSafeSearchParams } from "~/utils/remix"; +import { parseSafeSearchParams } from "~/utils/remix.server"; import type { Unpacked } from "~/utils/types"; import { id } from "~/utils/zod"; import * as LFGRepository from "../LFGRepository.server"; diff --git a/app/features/lfg/routes/lfg.new.tsx b/app/features/lfg/routes/lfg.new.tsx index c8ed1d86ec..0ba6a47fc0 100644 --- a/app/features/lfg/routes/lfg.new.tsx +++ b/app/features/lfg/routes/lfg.new.tsx @@ -10,7 +10,7 @@ import { SubmitButton } from "~/components/SubmitButton"; import { ArrowLeftIcon } from "~/components/icons/ArrowLeft"; import type { Tables } from "~/db/tables"; import { useUser } from "~/features/auth/core/user"; -import type { SendouRouteHandle } from "~/utils/remix"; +import type { SendouRouteHandle } from "~/utils/remix.server"; import { LFG_PAGE, SENDOUQ_SETTINGS_PAGE, diff --git a/app/features/lfg/routes/lfg.tsx b/app/features/lfg/routes/lfg.tsx index 127bbb3372..59710a26a0 100644 --- a/app/features/lfg/routes/lfg.tsx +++ b/app/features/lfg/routes/lfg.tsx @@ -4,16 +4,15 @@ import { add, sub } from "date-fns"; import React from "react"; import { useTranslation } from "react-i18next"; import { Alert } from "~/components/Alert"; -import { LinkButton } from "~/components/Button"; import { Main } from "~/components/Main"; import { SubmitButton } from "~/components/SubmitButton"; import { useUser } from "~/features/auth/core/user"; import { useSearchParamStateEncoder } from "~/hooks/useSearchParamState"; import { databaseTimestampToDate } from "~/utils/dates"; -import type { SendouRouteHandle } from "~/utils/remix"; +import type { SendouRouteHandle } from "~/utils/remix.server"; import { makeTitle } from "~/utils/strings"; import type { Unpacked } from "~/utils/types"; -import { LFG_PAGE, lfgNewPostPage, navIconUrl } from "~/utils/urls"; +import { LFG_PAGE, navIconUrl } from "~/utils/urls"; import { LFGAddFilterButton } from "../components/LFGAddFilterButton"; import { LFGFilters } from "../components/LFGFilters"; import { LFGPost } from "../components/LFGPost"; @@ -100,22 +99,11 @@ export default function LFGPage() { return (
-
+
setFilters([...filters, newFilter])} filters={filters} /> - {user && ( -
- - {t("common:actions.addNew")} - -
- )}
(stageIds[0]); const [mode, setMode] = React.useState("SZ"); const [backgroundStyle, setBackgroundStyle] = - React.useState("ITEMS"); - - const availableImageTypes = (stageId: number): StageBackgroundStyle[] => { - if (stageId > LAST_STAGE_ID_WITH_OBJECT_IMAGE) { - return ["MINI", "OVER"]; - } - - return ["ITEMS", "MINI", "OVER"]; - }; + React.useState("MINI"); const handleStageIdChange = (stageId: StageId) => { setStageId(stageId); - if (!availableImageTypes(stageId).includes(backgroundStyle)) { - setBackgroundStyle(availableImageTypes(stageId)[0]); - } }; return ( @@ -477,7 +465,7 @@ function StageBackgroundSelector({ setBackgroundStyle(e.target.value as StageBackgroundStyle) } > - {availableImageTypes(stageId).map((style) => { + {(["MINI", "OVER"] as const).map((style) => { return (