From 0c50b45cdb585f11cdd7ae2d11e3edbce0f9fc59 Mon Sep 17 00:00:00 2001 From: Kalle <38327916+Sendouc@users.noreply.github.com> Date: Thu, 6 Aug 2026 17:37:08 +0300 Subject: [PATCH] Unified filters (#3313) --- .../filter-bar/FilterBar.browser.test.tsx | 197 +++++++ .../filter-bar/FilterBar.module.css | 139 +++++ app/components/filter-bar/FilterBar.tsx | 183 ++++++ app/features/builds/builds-schemas.ts | 25 +- .../builds/builds-search-params.test.ts | 26 +- app/features/builds/builds-search-params.ts | 16 +- app/features/builds/builds-types.ts | 27 +- .../components/FilterSection.module.css | 38 -- .../builds/components/FilterSection.tsx | 259 --------- app/features/builds/core/filter.server.ts | 85 ++- app/features/builds/core/filter.test.ts | 85 +-- .../builds/loaders/builds.$slug.server.ts | 10 +- .../builds/routes/builds.$slug.module.css | 27 + app/features/builds/routes/builds.$slug.tsx | 459 +++++++++++---- app/features/calendar/calendar-schemas.ts | 127 +--- .../calendar/calendar-search-params.test.ts | 77 +-- .../calendar/calendar-search-params.ts | 68 ++- .../calendar/components/FiltersBar.tsx | 541 ++++++++++++++++++ .../calendar/components/FiltersDialog.tsx | 105 ---- .../calendar/core/CalendarEvent.test.ts | 37 ++ app/features/calendar/core/CalendarEvent.ts | 32 +- .../calendar/loaders/calendar.server.ts | 22 +- .../calendar/loaders/calendar[.]ics.server.ts | 21 +- .../calendar/routes/calendar.module.css | 9 +- app/features/calendar/routes/calendar.tsx | 70 +-- .../components-showcase/routes/components.tsx | 78 ++- .../lfg/components/LFGAddFilterButton.tsx | 50 -- .../lfg/components/LFGFilters.module.css | 5 - app/features/lfg/components/LFGFilters.tsx | 302 ---------- app/features/lfg/core/filtering.test.ts | 21 +- app/features/lfg/core/filtering.ts | 198 ++++--- app/features/lfg/lfg-constants.ts | 1 + app/features/lfg/lfg-search-params.test.ts | 43 +- app/features/lfg/lfg-search-params.ts | 47 +- app/features/lfg/lfg-types.ts | 168 +----- app/features/lfg/routes/lfg.module.css | 5 - app/features/lfg/routes/lfg.tsx | 261 ++++++++- .../scrims/components/ScrimFiltersDialog.tsx | 126 ---- app/features/scrims/loaders/scrims.server.ts | 19 +- app/features/scrims/routes/scrims.tsx | 221 ++++++- app/features/scrims/scrims-schemas.ts | 66 ++- .../scrims/scrims-search-params.test.ts | 75 +-- app/features/scrims/scrims-search-params.ts | 12 +- app/features/tournament/core/tiering.ts | 6 + .../user-page/UserRepository.server.ts | 432 +++++++++----- app/features/user-page/UserRepository.test.ts | 190 ++++++ .../components/ResultsFiltersBar.tsx | 398 +++++++++++++ .../user-page/components/UserResultsTable.tsx | 11 +- .../loaders/u.$identifier.results.server.ts | 52 +- .../routes/u.$identifier.results.tsx | 81 +-- app/features/user-page/user-page-constants.ts | 12 + .../user-page/user-page-search-params.test.ts | 49 +- .../user-page/user-page-search-params.ts | 65 ++- app/features/user-page/user-page.module.css | 29 +- app/features/vods/routes/vods.module.css | 4 - app/features/vods/routes/vods.tsx | 182 +++--- app/utils/urls.ts | 15 +- e2e/builds.spec.ts | 4 +- e2e/calendar.spec.ts | 18 +- e2e/pages/builds/weapon-builds-page.ts | 17 +- e2e/pages/calendar/calendar-page.ts | 75 +-- e2e/pages/lfg/lfg-page.ts | 4 +- e2e/pages/scrims/scrims-page.ts | 45 ++ e2e/pages/vods/vods-page.ts | 3 + e2e/scrims.spec.ts | 41 ++ locales/da/builds.json | 11 +- locales/da/calendar.json | 14 +- locales/da/common.json | 2 + locales/da/forms.json | 17 - locales/da/lfg.json | 2 - locales/da/scrims.json | 9 +- locales/da/user.json | 24 +- locales/de/builds.json | 11 +- locales/de/calendar.json | 14 +- locales/de/common.json | 2 + locales/de/forms.json | 17 - locales/de/lfg.json | 2 - locales/de/scrims.json | 9 +- locales/de/user.json | 24 +- locales/en/builds.json | 11 +- locales/en/calendar.json | 14 +- locales/en/common.json | 2 + locales/en/forms.json | 17 - locales/en/lfg.json | 2 - locales/en/scrims.json | 9 +- locales/en/user.json | 24 +- locales/es-ES/builds.json | 11 +- locales/es-ES/calendar.json | 14 +- locales/es-ES/common.json | 2 + locales/es-ES/forms.json | 17 - locales/es-ES/lfg.json | 2 - locales/es-ES/scrims.json | 9 +- locales/es-ES/user.json | 24 +- locales/es-US/builds.json | 11 +- locales/es-US/calendar.json | 14 +- locales/es-US/common.json | 2 + locales/es-US/forms.json | 17 - locales/es-US/lfg.json | 2 - locales/es-US/scrims.json | 9 +- locales/es-US/user.json | 24 +- locales/fr-CA/builds.json | 11 +- locales/fr-CA/calendar.json | 14 +- locales/fr-CA/common.json | 2 + locales/fr-CA/forms.json | 17 - locales/fr-CA/lfg.json | 2 - locales/fr-CA/scrims.json | 9 +- locales/fr-CA/user.json | 24 +- locales/fr-EU/builds.json | 11 +- locales/fr-EU/calendar.json | 14 +- locales/fr-EU/common.json | 2 + locales/fr-EU/forms.json | 17 - locales/fr-EU/lfg.json | 2 - locales/fr-EU/scrims.json | 9 +- locales/fr-EU/user.json | 24 +- locales/he/builds.json | 11 +- locales/he/calendar.json | 14 +- locales/he/common.json | 2 + locales/he/forms.json | 17 - locales/he/lfg.json | 2 - locales/he/scrims.json | 9 +- locales/he/user.json | 24 +- locales/it/builds.json | 11 +- locales/it/calendar.json | 14 +- locales/it/common.json | 2 + locales/it/forms.json | 17 - locales/it/lfg.json | 2 - locales/it/scrims.json | 9 +- locales/it/user.json | 24 +- locales/ja/builds.json | 11 +- locales/ja/calendar.json | 14 +- locales/ja/common.json | 2 + locales/ja/forms.json | 17 - locales/ja/lfg.json | 2 - locales/ja/scrims.json | 9 +- locales/ja/user.json | 24 +- locales/ko/builds.json | 11 +- locales/ko/calendar.json | 14 +- locales/ko/common.json | 2 + locales/ko/forms.json | 17 - locales/ko/lfg.json | 2 - locales/ko/scrims.json | 9 +- locales/ko/user.json | 24 +- locales/nl/builds.json | 11 +- locales/nl/calendar.json | 14 +- locales/nl/common.json | 2 + locales/nl/forms.json | 17 - locales/nl/lfg.json | 2 - locales/nl/scrims.json | 9 +- locales/nl/user.json | 24 +- locales/pl/builds.json | 11 +- locales/pl/calendar.json | 14 +- locales/pl/common.json | 2 + locales/pl/forms.json | 17 - locales/pl/lfg.json | 2 - locales/pl/scrims.json | 9 +- locales/pl/user.json | 24 +- locales/pt-BR/builds.json | 11 +- locales/pt-BR/calendar.json | 14 +- locales/pt-BR/common.json | 2 + locales/pt-BR/forms.json | 17 - locales/pt-BR/lfg.json | 2 - locales/pt-BR/scrims.json | 9 +- locales/pt-BR/user.json | 24 +- locales/ru/builds.json | 11 +- locales/ru/calendar.json | 14 +- locales/ru/common.json | 2 + locales/ru/forms.json | 17 - locales/ru/lfg.json | 2 - locales/ru/scrims.json | 9 +- locales/ru/user.json | 24 +- locales/zh/builds.json | 11 +- locales/zh/calendar.json | 14 +- locales/zh/common.json | 2 + locales/zh/forms.json | 17 - locales/zh/lfg.json | 2 - locales/zh/scrims.json | 9 +- locales/zh/user.json | 24 +- 177 files changed, 4494 insertions(+), 2886 deletions(-) create mode 100644 app/components/filter-bar/FilterBar.browser.test.tsx create mode 100644 app/components/filter-bar/FilterBar.module.css create mode 100644 app/components/filter-bar/FilterBar.tsx delete mode 100644 app/features/builds/components/FilterSection.module.css delete mode 100644 app/features/builds/components/FilterSection.tsx create mode 100644 app/features/calendar/components/FiltersBar.tsx delete mode 100644 app/features/calendar/components/FiltersDialog.tsx delete mode 100644 app/features/lfg/components/LFGAddFilterButton.tsx delete mode 100644 app/features/lfg/components/LFGFilters.module.css delete mode 100644 app/features/lfg/components/LFGFilters.tsx delete mode 100644 app/features/scrims/components/ScrimFiltersDialog.tsx create mode 100644 app/features/user-page/components/ResultsFiltersBar.tsx diff --git a/app/components/filter-bar/FilterBar.browser.test.tsx b/app/components/filter-bar/FilterBar.browser.test.tsx new file mode 100644 index 000000000..3546e8719 --- /dev/null +++ b/app/components/filter-bar/FilterBar.browser.test.tsx @@ -0,0 +1,197 @@ +import { useState } from "react"; +import { describe, expect, test } from "vitest"; +import { userEvent } from "vitest/browser"; +import { render } from "vitest-browser-react"; +import { SendouButton } from "../elements/Button"; +import { FilterBar } from "./FilterBar"; + +const MODES = ["SZ", "TC", "RM"]; + +function TestFilterBar(props: { + initialMode?: string | null; + initialWeapon?: string | null; + initialRank?: string | null; +}) { + const [mode, setMode] = useState(props.initialMode ?? null); + const [weapon, setWeapon] = useState( + props.initialWeapon ?? null, + ); + // unlike the other pills this one seeds a value when added from the menu + const [rank, setRank] = useState(props.initialRank ?? null); + + return ( + setMode(null), + popover: ( +
+ {MODES.map((value) => ( + + ))} +
+ ), + }, + { + key: "weapon", + name: "Weapon", + formattedValue: weapon, + onRemove: () => setWeapon(null), + popover: ( + + ), + }, + { + key: "rank", + name: "Rank", + formattedValue: rank, + onAdd: () => setRank("S+"), + onRemove: () => setRank(null), + popover: ( + + ), + }, + ]} + onReset={ + mode !== null || weapon !== null || rank !== null + ? () => { + setMode(null); + setWeapon(null); + setRank(null); + } + : undefined + } + actions={Save as default} + /> + ); +} + +describe("FilterBar", () => { + test("renders a set pill with its name and formatted value", async () => { + const screen = await render(); + + await expect + .element(screen.getByRole("button", { name: /Mode.*SZ/ })) + .toBeVisible(); + }); + + test("updates the pill value instantly when changed in the popover", async () => { + const screen = await render(); + + await screen.getByRole("button", { name: "Mode SZ" }).click(); + await screen.getByRole("button", { name: "Set TC" }).click(); + + await expect + .element(screen.getByRole("button", { name: "Mode TC" })) + .toBeVisible(); + }); + + test("hides a pill at its default value behind the add filter menu", async () => { + const screen = await render(); + + await expect + .element(screen.getByRole("button", { name: /Mode/ })) + .not.toBeInTheDocument(); + await expect + .element(screen.getByRole("button", { name: /Weapon/ })) + .not.toBeInTheDocument(); + + await screen.getByRole("button", { name: "Filter" }).click(); + + await expect + .element(screen.getByRole("menuitem", { name: "Weapon" })) + .toBeVisible(); + }); + + test("adding a pill opens its popover and keeps the pill visible while unset", async () => { + const screen = await render(); + + await screen.getByRole("button", { name: "Filter" }).click(); + await screen.getByRole("menuitem", { name: "Weapon" }).click(); + + await expect + .element(screen.getByRole("button", { name: "Set Splattershot" })) + .toBeVisible(); + + await screen.getByRole("button", { name: "Set Splattershot" }).click(); + + await expect + .element(screen.getByRole("button", { name: /Weapon.*Splattershot/ })) + .toBeVisible(); + }); + + test("removing a pill hides it again", async () => { + const screen = await render(); + + await screen.getByRole("button", { name: "Remove Weapon filter" }).click(); + + await expect + .element(screen.getByRole("button", { name: /Weapon/ })) + .not.toBeInTheDocument(); + }); + + test("adding a pill seeds its starting value via onAdd", async () => { + const screen = await render(); + + await screen.getByRole("button", { name: "Filter" }).click(); + await screen.getByRole("menuitem", { name: "Rank" }).click(); + + await expect + .element(screen.getByRole("button", { name: /Rank.*S\+/ })) + .toBeVisible(); + }); + + test("renders the reset button and the actions slot", async () => { + const screen = await render(); + + await expect + .element(screen.getByRole("button", { name: "Reset" })) + .toBeVisible(); + await expect + .element(screen.getByRole("button", { name: "Save as default" })) + .toBeVisible(); + }); + + test("resetting hides an added pill that was left unset", async () => { + const screen = await render(); + + await screen.getByRole("button", { name: "Filter", exact: true }).click(); + await screen.getByRole("menuitem", { name: "Weapon" }).click(); + + // adding a pill opens its popover, which blocks the reset button beneath it + await userEvent.keyboard("{Escape}"); + + await screen.getByRole("button", { name: "Reset" }).click(); + + await expect + .element(screen.getByRole("button", { name: /Weapon/ })) + .not.toBeInTheDocument(); + }); + + test("hides the add filter menu when every pill is visible", async () => { + const screen = await render( + , + ); + + await expect + .element(screen.getByRole("button", { name: "Filter", exact: true })) + .not.toBeInTheDocument(); + }); +}); diff --git a/app/components/filter-bar/FilterBar.module.css b/app/components/filter-bar/FilterBar.module.css new file mode 100644 index 000000000..d551e8e89 --- /dev/null +++ b/app/components/filter-bar/FilterBar.module.css @@ -0,0 +1,139 @@ +.popover { + min-width: 14rem; +} + +.bar { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: var(--s-1-5); +} + +.pill { + display: inline-flex; + align-items: center; + height: var(--selector-size); + border-radius: var(--radius-selector); + background-color: var(--color-bg-higher); + transition: background-color 0.15s; + + &:has(.trigger[data-hovered]) { + background-color: var(--color-bg-high); + } +} + +.trigger { + display: inline-flex; + align-items: center; + gap: var(--s-1); + height: 100%; + padding: 0 var(--s-2); + border: none; + border-radius: inherit; + background-color: transparent; + color: var(--color-text); + font-size: var(--font-xs); + font-weight: var(--weight-semi); + cursor: pointer; + + &[data-focus-visible] { + outline: var(--focus-ring); + outline-offset: 2px; + } + + .pill:has(.removeButton) & { + padding-right: var(--s-1); + } +} + +.removeButton { + display: inline-flex; + align-items: center; + height: 100%; + padding: 0 var(--s-1-5); + border: none; + border-radius: inherit; + background-color: transparent; + color: var(--color-text-high); + cursor: pointer; + + & > svg { + width: 14px; + height: 14px; + } + + &[data-hovered] { + color: var(--color-error); + } + + &[data-focus-visible] { + outline: var(--focus-ring); + outline-offset: 2px; + } +} + +.actions { + display: contents; +} + +.actions button { + display: inline-flex; + align-items: center; + gap: var(--s-1); + height: var(--selector-size); + padding: 0 var(--s-2); + border: var(--border-style-high); + border-radius: var(--radius-selector); + background-color: transparent; + color: var(--color-text-high); + font-size: var(--font-xs); + font-weight: var(--weight-semi); + white-space: nowrap; + cursor: pointer; + transition: + background-color 0.15s, + color 0.15s; + + &[data-hovered] { + background-color: var(--color-bg-high); + color: var(--color-text); + } + + &[data-focus-visible] { + outline: var(--focus-ring); + outline-offset: 2px; + } + + &[data-disabled] { + cursor: not-allowed; + opacity: 0.5; + } + + & svg { + width: 14px; + min-width: 14px; + max-width: 14px; + height: 14px; + margin-inline-end: 0; + } +} + +.icon { + display: inline-flex; + + & > svg { + width: 14px; + height: 14px; + } +} + +.value { + color: var(--color-text-accent); +} + +.chevron, +.plus { + width: 14px; + height: 14px; + color: var(--color-text-high); +} diff --git a/app/components/filter-bar/FilterBar.tsx b/app/components/filter-bar/FilterBar.tsx new file mode 100644 index 000000000..ba2105532 --- /dev/null +++ b/app/components/filter-bar/FilterBar.tsx @@ -0,0 +1,183 @@ +import clsx from "clsx"; +import { ChevronDown, Plus, RotateCcw, X } from "lucide-react"; +import * as React from "react"; +import { Button } from "react-aria-components"; +import { useTranslation } from "react-i18next"; +import { SendouButton } from "../elements/Button"; +import { SendouMenu, SendouMenuItem } from "../elements/Menu"; +import { SendouPopover } from "../elements/Popover"; +import styles from "./FilterBar.module.css"; + +export interface FilterBarPill { + key: string; + /** Translated filter name shown on the pill and in the add filter menu. */ + name: string; + /** Translated current value shown on the pill. Null when the filter is at its default. */ + formattedValue: React.ReactNode | null; + /** Popover content. Inputs inside write search params directly (instant apply). */ + popover: React.ReactNode; + /** Resets the pill's param(s) to defaults. Renders the remove button. */ + onRemove?: () => void; + /** Writes a starting value when the pill is added from the menu. */ + onAdd?: () => void; + icon?: React.ReactNode; + popoverClassName?: string; + testId?: string; +} + +export function FilterBar({ + pills, + onReset, + actions, +}: { + pills: FilterBarPill[]; + /** Resets every pill's param(s) to defaults. Renders the reset button. */ + onReset?: () => void; + actions?: React.ReactNode; +}) { + const { t } = useTranslation(); + const [justAddedKeys, setJustAddedKeys] = React.useState>( + new Set(), + ); + const [openPillKey, setOpenPillKey] = React.useState(null); + + const isVisible = (pill: FilterBarPill) => + pill.formattedValue !== null || justAddedKeys.has(pill.key); + + const hiddenPills = pills.filter((pill) => !isVisible(pill)); + + const addPill = (pill: FilterBarPill) => { + setJustAddedKeys((prev) => new Set(prev).add(pill.key)); + setOpenPillKey(pill.key); + pill.onAdd?.(); + }; + + const removePill = (pill: FilterBarPill) => { + setJustAddedKeys((prev) => { + const next = new Set(prev); + next.delete(pill.key); + return next; + }); + if (openPillKey === pill.key) { + setOpenPillKey(null); + } + pill.onRemove?.(); + }; + + const resetPills = () => { + setJustAddedKeys(new Set()); + setOpenPillKey(null); + onReset?.(); + }; + + return ( +
+ {pills.filter(isVisible).map((pill) => ( + setOpenPillKey(isOpen ? pill.key : null)} + onRemove={pill.onRemove ? () => removePill(pill) : undefined} + /> + ))} + {hiddenPills.length > 0 ? ( + + ) : null} + {onReset || actions ? ( +
+ {onReset ? ( + } onPress={resetPills}> + {t("actions.reset")} + + ) : null} + {actions} +
+ ) : null} +
+ ); +} + +function FilterPill({ + pill, + isOpen, + onOpenChange, + onRemove, +}: { + pill: FilterBarPill; + isOpen: boolean; + onOpenChange: (isOpen: boolean) => void; + onRemove?: () => void; +}) { + return ( +
+ + {pill.icon ? ( + {pill.icon} + ) : null} + {pill.name} + {pill.formattedValue !== null ? ( + {pill.formattedValue} + ) : null} + + + } + > + {pill.popover} + + {onRemove ? ( + + ) : null} +
+ ); +} + +function AddFilterMenu({ + pills, + onAdd, +}: { + pills: FilterBarPill[]; + onAdd: (pill: FilterBarPill) => void; +}) { + const { t } = useTranslation(); + + return ( + + + + } + > + {pills.map((pill) => ( + onAdd(pill)} + data-testid={pill.testId ? `menu-item-${pill.testId}` : undefined} + > + {pill.name} + + ))} + + ); +} diff --git a/app/features/builds/builds-schemas.ts b/app/features/builds/builds-schemas.ts index 3893e732b..c1326aebc 100644 --- a/app/features/builds/builds-schemas.ts +++ b/app/features/builds/builds-schemas.ts @@ -1,10 +1,10 @@ import { z } from "zod"; import { MAX_AP } from "~/features/build-analyzer/analyzer-constants"; -import { ability, modeShort } from "~/utils/zod"; +import { isValidDate } from "~/utils/dates"; +import { ability } from "~/utils/zod"; import { MAX_BUILD_FILTERS } from "./builds-constants"; -const abilityFilterSchema = z.object({ - type: z.literal("ability"), +const abilityConditionSchema = z.object({ ability: z.string().toUpperCase().pipe(ability), value: z.union([z.int().min(0).max(MAX_AP), z.boolean()]), comparison: z @@ -14,18 +14,11 @@ const abilityFilterSchema = z.object({ .optional(), }); -const modeFilterSchema = z.object({ - type: z.literal("mode"), - mode: z.string().toUpperCase().pipe(modeShort), -}); - -const dateFilterSchema = z.object({ - type: z.literal("date"), - date: z.iso.date(), -}); - -export const buildFiltersSchema = z - .array(z.union([abilityFilterSchema, modeFilterSchema, dateFilterSchema])) +export const abilityConditionsSchema = z + .array(abilityConditionSchema) .max(MAX_BUILD_FILTERS); -export type BuildFiltersFromSearchParams = z.infer; +export const buildsDateFilterSchema = z + .string() + .regex(/^\d{4}-\d{2}-\d{2}$/) + .refine((value) => isValidDate(new Date(value))); diff --git a/app/features/builds/builds-search-params.test.ts b/app/features/builds/builds-search-params.test.ts index d4b083641..48a3c111e 100644 --- a/app/features/builds/builds-search-params.test.ts +++ b/app/features/builds/builds-search-params.test.ts @@ -9,15 +9,17 @@ describe("buildsSearchParams", () => { it("round-trips", () => { assertRoundTrips(buildsSearchParams, { limit: [24, 48, 1, 240], - f: [ + abilities: [ [], [ - { type: "ability", ability: "ISM", comparison: "AT_LEAST", value: 3 }, - { type: "mode", mode: "SZ" }, - { type: "date", date: "2026-01-28" }, + { ability: "ISM", comparison: "AT_LEAST", value: 3 }, + { ability: "SSU", comparison: "AT_MOST", value: 12 }, ], - [{ type: "ability", ability: "LDE", value: true }], + [{ ability: "LDE", value: true }], + [{ ability: "CB", value: false }], ], + mode: [null, "SZ", "TW"], + date: [null, "2026-01-28"], }); }); @@ -28,11 +30,17 @@ describe("buildsSearchParams", () => { ["241"], ["abc"], ]); - assertDecodesToDefault(buildsSearchParams, "f", [ + assertDecodesToDefault(buildsSearchParams, "abilities", [ ["not-json"], - ['[{"type":"ability"}]'], - ['{"type":"mode","mode":"SZ"}'], - ['[{"type":"mode","mode":"XX"}]'], + ['[{"ability":"XXX","value":true}]'], + ['{"ability":"ISM","value":3}'], + ['[{"ability":"ISM","value":100,"comparison":"AT_LEAST"}]'], + ]); + assertDecodesToDefault(buildsSearchParams, "mode", [["XX"], ["zz"]]); + assertDecodesToDefault(buildsSearchParams, "date", [ + ["not-a-date"], + ["2026-13-99"], + ["2026-1-1"], ]); }); }); diff --git a/app/features/builds/builds-search-params.ts b/app/features/builds/builds-search-params.ts index e38ff475b..c9828bbe7 100644 --- a/app/features/builds/builds-search-params.ts +++ b/app/features/builds/builds-search-params.ts @@ -1,20 +1,32 @@ import { z } from "zod"; import * as SearchParams from "~/modules/search-params/search-params"; import { SP } from "~/modules/search-params/search-params"; +import { modeShort } from "~/utils/zod"; import { BUILDS_PAGE_BATCH_SIZE, BUILDS_PAGE_MAX_BUILDS, } from "./builds-constants"; -import { buildFiltersSchema } from "./builds-schemas"; +import { + abilityConditionsSchema, + buildsDateFilterSchema, +} from "./builds-schemas"; export const buildsSearchParams = SearchParams.define({ limit: SP.param(z.number().int().min(1).max(BUILDS_PAGE_MAX_BUILDS), { default: BUILDS_PAGE_BATCH_SIZE, loader: true, }), - f: SP.json(buildFiltersSchema, { + abilities: SP.json(abilityConditionsSchema, { default: [], resets: ["limit"], loader: true, }), + mode: SP.param(modeShort.nullable(), { + resets: ["limit"], + loader: true, + }), + date: SP.param(buildsDateFilterSchema.nullable(), { + resets: ["limit"], + loader: true, + }), }); diff --git a/app/features/builds/builds-types.ts b/app/features/builds/builds-types.ts index f1e3c1f0f..cc58f41c5 100644 --- a/app/features/builds/builds-types.ts +++ b/app/features/builds/builds-types.ts @@ -1,34 +1,13 @@ -import type { - Ability, - MainWeaponId, - ModeShort, -} from "~/modules/in-game-lists/types"; +import type { Ability, MainWeaponId } from "~/modules/in-game-lists/types"; export interface BuildWeaponWithTop500Info { weaponSplId: MainWeaponId; isTop500: number; } -export type AbilityBuildFilter = { - type: "ability"; +export interface AbilityCondition { ability: Ability; /** Ability points value or "has"/"doesn't have" */ value: number | boolean; comparison?: "AT_LEAST" | "AT_MOST"; -}; - -export type ModeBuildFilter = { - type: "mode"; - mode: ModeShort; -}; - -export type DateBuildFilter = { - type: "date"; - /** YYYY-MM-DD */ - date: string; -}; - -export type BuildFilter = - | AbilityBuildFilter - | ModeBuildFilter - | DateBuildFilter; +} diff --git a/app/features/builds/components/FilterSection.module.css b/app/features/builds/components/FilterSection.module.css deleted file mode 100644 index 5395f12b7..000000000 --- a/app/features/builds/components/FilterSection.module.css +++ /dev/null @@ -1,38 +0,0 @@ -.filter { - display: flex; - flex-direction: column; - padding: var(--s-3); - border-radius: var(--radius-box); - background-color: var(--color-bg-high); - gap: var(--s-2); -} - -.filterMode { - gap: var(--s-6); - flex-wrap: wrap; -} - -.filterDate { - display: flex; - align-items: center; -} - -@container (width >= 560px) { - .filter { - flex-direction: row; - } -} - -.abilityContainer { - display: flex; - width: 32px; - align-items: center; -} - -.apSelect { - width: 75px; -} - -.dateSelect { - width: 275px; -} diff --git a/app/features/builds/components/FilterSection.tsx b/app/features/builds/components/FilterSection.tsx deleted file mode 100644 index 308eafe96..000000000 --- a/app/features/builds/components/FilterSection.tsx +++ /dev/null @@ -1,259 +0,0 @@ -import clsx from "clsx"; -import { X } from "lucide-react"; -import { useTranslation } from "react-i18next"; -import { Ability } from "~/components/Ability"; -import { SendouButton } from "~/components/elements/Button"; -import { ModeImage } from "~/components/Image"; -import { possibleApValues } from "~/features/build-analyzer/analyzer-constants"; -import { useDateTimeFormat } from "~/hooks/intl/useDateTimeFormat"; -import { abilities } from "~/modules/in-game-lists/abilities"; -import { modesShort } from "~/modules/in-game-lists/modes"; -import type { - Ability as AbilityType, - ModeShort, -} from "~/modules/in-game-lists/types"; -import { dateToYYYYMMDD, isValidDate } from "~/utils/dates"; -import { RECENT_PATCHES } from "../builds-constants"; -import type { - AbilityBuildFilter, - BuildFilter, - DateBuildFilter, - ModeBuildFilter, -} from "../builds-types"; - -import styles from "./FilterSection.module.css"; - -export function FilterSection({ - number, - nthOfSame, - filter, - onChange, - remove, -}: { - number: number; - nthOfSame: number; - filter: BuildFilter; - onChange: (filter: Partial) => void; - remove: () => void; -}) { - const { t } = useTranslation(["builds"]); - - return ( -
-
-
- {t(`builds:filters.${filter.type}.title`)}{" "} - {nthOfSame > 1 ? nthOfSame : ""} -
-
- } - size="small" - variant="minimal-destructive" - onPress={remove} - aria-label="Delete filter" - data-testid="delete-filter-button" - /> -
-
- {filter.type === "ability" ? ( - - ) : null} - {filter.type === "mode" ? ( - - ) : null} - {filter.type === "date" ? ( - - ) : null} -
- ); -} - -function AbilityFilter({ - filter, - onChange, -}: { - filter: AbilityBuildFilter; - onChange: (filter: Partial) => void; -}) { - const { t } = useTranslation(["analyzer", "game-misc", "builds"]); - const abilityObject = abilities.find((a) => a.name === filter.ability)!; - - return ( -
-
- -
- - {abilityObject.type !== "STACKABLE" ? ( - - ) : null} - {abilityObject.type === "STACKABLE" ? ( - - ) : null} - {abilityObject.type === "STACKABLE" ? ( -
- -
{t("analyzer:abilityPoints.short")}
-
- ) : null} -
- ); -} - -function ModeFilter({ - filter, - onChange, - number, -}: { - filter: ModeBuildFilter; - onChange: (filter: Partial) => void; - number: number; -}) { - const { t } = useTranslation(["game-misc"]); - - const inputId = (mode: ModeShort) => `${number}-${mode}`; - - return ( -
- {modesShort.map((mode) => { - return ( -
- onChange({ mode })} - /> - -
- ); - })} -
- ); -} - -function DateFilter({ - filter, - onChange, -}: { - filter: DateBuildFilter; - onChange: (filter: Partial) => void; -}) { - const { t } = useTranslation(["builds"]); - const { formatter: patchDateFormatter } = useDateTimeFormat({ - day: "numeric", - month: "numeric", - year: "numeric", - }); - - const selectValue = () => - RECENT_PATCHES.some(({ date }) => date === filter.date) - ? filter.date - : "CUSTOM"; - - // on Saturday so it doesn't overlap with actual path dates (no patches on Saturdays) - const oneMonthAgoOnSaturday = new Date(); - oneMonthAgoOnSaturday.setUTCDate(oneMonthAgoOnSaturday.getUTCDate() - 30); - oneMonthAgoOnSaturday.setUTCDate( - oneMonthAgoOnSaturday.getUTCDate() - oneMonthAgoOnSaturday.getUTCDay() + 6, - ); - - const customDate = isValidDate(new Date(filter.date)) - ? new Date(filter.date) - : oneMonthAgoOnSaturday; - - return ( -
- - - {selectValue() === "CUSTOM" ? ( - onChange({ date: e.target.value })} - max={dateToYYYYMMDD(new Date())} - data-testid="date-input" - /> - ) : null} -
- ); -} diff --git a/app/features/builds/core/filter.server.ts b/app/features/builds/core/filter.server.ts index d379ec24f..6ae621b15 100644 --- a/app/features/builds/core/filter.server.ts +++ b/app/features/builds/core/filter.server.ts @@ -5,13 +5,7 @@ import type { ModeShort, } from "~/modules/in-game-lists/types"; import { databaseTimestampToDate } from "~/utils/dates"; -import { assertUnreachable } from "~/utils/types"; -import type { BuildFiltersFromSearchParams } from "../builds-schemas"; -import type { - AbilityBuildFilter, - DateBuildFilter, - ModeBuildFilter, -} from "../builds-types"; +import type { AbilityCondition } from "../builds-types"; type PartialBuild = { abilities: BuildAbilitiesTuple; @@ -19,17 +13,24 @@ type PartialBuild = { updatedAt: Tables["Build"]["updatedAt"]; }; +interface BuildFilters { + abilities: AbilityCondition[]; + mode: ModeShort | null; + date: string | null; +} + /** * Filters an array of builds based on the provided filter criteria and returns up to a specified count of matching builds. * * Filters are applied on "AND" basis, meaning all filters must match for a build to be included in the result. */ export function filterBuilds({ - filters, + abilities, + mode, + date, count, builds, -}: { - filters: BuildFiltersFromSearchParams; +}: BuildFilters & { count: number; builds: T[]; }) { @@ -38,7 +39,7 @@ export function filterBuilds({ for (const build of builds) { if (result.length === count) break; - if (buildMatchesFilters({ build, filters })) { + if (buildMatchesFilters({ build, abilities, mode, date })) { result.push(build); } } @@ -48,42 +49,38 @@ export function filterBuilds({ function buildMatchesFilters({ build, - filters, -}: { - build: T; - filters: BuildFiltersFromSearchParams; -}) { - for (const filter of filters) { - if (filter.type === "ability") { - if (!matchesAbilityFilter({ build, filter })) return false; - } else if (filter.type === "mode") { - if (!matchesModeFilter({ build, filter })) return false; - } else if (filter.type === "date") { - if (!matchesDateFilter({ build, filter })) return false; - } else { - assertUnreachable(filter); - } + abilities, + mode, + date, +}: BuildFilters & { build: T }) { + for (const condition of abilities) { + if (!matchesAbilityCondition({ build, condition })) return false; } + if (mode !== null && !matchesModeFilter({ build, mode })) return false; + if (date !== null && !matchesDateFilter({ build, date })) return false; + return true; } -function matchesAbilityFilter({ +function matchesAbilityCondition({ build, - filter, + condition, }: { build: PartialBuild; - filter: AbilityBuildFilter; + condition: AbilityCondition; }) { - if (typeof filter.value === "boolean") { - const hasAbility = build.abilities.flat().includes(filter.ability); - if (filter.value && !hasAbility) return false; - if (!filter.value && hasAbility) return false; - } else if (typeof filter.value === "number") { + if (typeof condition.value === "boolean") { + const hasAbility = build.abilities.flat().includes(condition.ability); + if (condition.value && !hasAbility) return false; + if (!condition.value && hasAbility) return false; + } else if (typeof condition.value === "number") { const abilityPoints = buildToAbilityPoints(build.abilities); - const ap = abilityPoints.get(filter.ability) ?? 0; - if (filter.comparison === "AT_LEAST" && ap < filter.value) return false; - if (filter.comparison === "AT_MOST" && ap > filter.value) return false; + const ap = abilityPoints.get(condition.ability) ?? 0; + if (condition.comparison === "AT_LEAST" && ap < condition.value) + return false; + if (condition.comparison === "AT_MOST" && ap > condition.value) + return false; } return true; @@ -91,24 +88,22 @@ function matchesAbilityFilter({ function matchesModeFilter({ build, - filter, + mode, }: { build: PartialBuild; - filter: ModeBuildFilter; + mode: ModeShort; }) { if (!build.modes) return false; - return build.modes.includes(filter.mode); + return build.modes.includes(mode); } function matchesDateFilter({ build, - filter, + date, }: { build: PartialBuild; - filter: DateBuildFilter; + date: string; }) { - const date = new Date(filter.date); - - return date < databaseTimestampToDate(build.updatedAt); + return new Date(date) < databaseTimestampToDate(build.updatedAt); } diff --git a/app/features/builds/core/filter.test.ts b/app/features/builds/core/filter.test.ts index e381b53b6..85f1a623f 100644 --- a/app/features/builds/core/filter.test.ts +++ b/app/features/builds/core/filter.test.ts @@ -33,6 +33,8 @@ const createBuild = ({ }; }; +const noFilters = { abilities: [], mode: null, date: null }; + describe("Filter builds", () => { test("returns correct build back based on abilities (AT_LEAST)", () => { const filtered = filterBuilds({ @@ -41,9 +43,9 @@ describe("Filter builds", () => { createBuild({ headAbilities: ["ISM", "ISM", "ISM", "ISM"] }), ], count: 2, - filters: [ + ...noFilters, + abilities: [ { - type: "ability", ability: "ISM", value: 10, comparison: "AT_LEAST", @@ -62,9 +64,9 @@ describe("Filter builds", () => { createBuild({ headAbilities: ["ISM", "ISM", "ISM", "ISM"] }), ], count: 2, - filters: [ + ...noFilters, + abilities: [ { - type: "ability", ability: "ISM", value: 6, comparison: "AT_MOST", @@ -83,9 +85,9 @@ describe("Filter builds", () => { createBuild({ headAbilities: ["ISS", "ISS", "ISM", "ISM"] }), ], count: 2, - filters: [ + ...noFilters, + abilities: [ { - type: "ability", ability: "T", value: true, }, @@ -103,9 +105,9 @@ describe("Filter builds", () => { createBuild({ headAbilities: ["ISS", "ISS", "ISM", "ISM"] }), ], count: 2, - filters: [ + ...noFilters, + abilities: [ { - type: "ability", ability: "T", value: false, }, @@ -130,45 +132,8 @@ describe("Filter builds", () => { createBuild({ headAbilities: ["ISM", "ISM", "ISM", "ISM"], modes: [] }), ], count: 3, - filters: [ - { - type: "mode", - mode: "SZ", - }, - ], - }); - - expect(filtered.length).toBe(1); - expect(filtered[0].abilities[0]).toEqual(["ISS", "ISM", "ISM", "ISM"]); - }); - - test("filters based on many modes", () => { - const filtered = filterBuilds({ - builds: [ - createBuild({ - headAbilities: ["ISS", "ISM", "ISM", "ISM"], - modes: ["SZ", "TC"], - }), - createBuild({ - headAbilities: ["ISM", "ISM", "ISM", "ISM"], - modes: ["SZ"], - }), - createBuild({ - headAbilities: ["ISM", "ISM", "ISM", "ISM"], - modes: ["TC"], - }), - ], - count: 3, - filters: [ - { - type: "mode", - mode: "SZ", - }, - { - type: "mode", - mode: "TC", - }, - ], + ...noFilters, + mode: "SZ", }); expect(filtered.length).toBe(1); @@ -188,19 +153,15 @@ describe("Filter builds", () => { }), ], count: 2, - filters: [ - { - type: "date", - date: "2022-01-01", - }, - ], + ...noFilters, + date: "2022-01-01", }); expect(filtered.length).toBe(1); expect(filtered[0].abilities[0]).toEqual(["ISS", "ISM", "ISM", "ISM"]); }); - test("combines filters of same type", () => { + test("combines multiple ability conditions", () => { const filtered = filterBuilds({ builds: [ createBuild({ headAbilities: ["T", "ISM", "ISM", "ISM"] }), @@ -208,14 +169,13 @@ describe("Filter builds", () => { createBuild({ headAbilities: ["ISS", "ISS", "ISM", "ISM"] }), ], count: 2, - filters: [ + ...noFilters, + abilities: [ { - type: "ability", ability: "T", value: true, }, { - type: "ability", ability: "ISM", value: 9, comparison: "AT_LEAST", @@ -247,13 +207,10 @@ describe("Filter builds", () => { }), ], count: 2, - filters: [ + ...noFilters, + date: "2022-01-01", + abilities: [ { - type: "date", - date: "2022-01-01", - }, - { - type: "ability", ability: "ISM", value: 9, comparison: "AT_LEAST", @@ -273,7 +230,7 @@ describe("Filter builds", () => { createBuild({ headAbilities: ["ISM", "ISM", "ISM", "ISM"] }), ], count: 2, - filters: [], + ...noFilters, }); expect(filtered.length).toBe(2); diff --git a/app/features/builds/loaders/builds.$slug.server.ts b/app/features/builds/loaders/builds.$slug.server.ts index 7df7e305f..350413512 100644 --- a/app/features/builds/loaders/builds.$slug.server.ts +++ b/app/features/builds/loaders/builds.$slug.server.ts @@ -18,13 +18,14 @@ export const loader = async ({ params, url }: LoaderFunctionArgs) => { throw new Response(null, { status: 404 }); } - const { limit, f: filters } = buildsSearchParams.parse(url); + const { limit, abilities, mode, date } = buildsSearchParams.parse(url); const weaponName = t(`weapons:MAIN_${weaponId}`); const slug = mySlugify(t(`weapons:MAIN_${weaponId}`, { lng: "en" })); - const hasActiveFilters = filters.length > 0; + const hasActiveFilters = + abilities.length > 0 || mode !== null || date !== null; const builds = await BuildRepository.findAllByWeaponId(weaponId, { limit: hasActiveFilters ? BUILDS_PAGE_MAX_BUILDS : limit + 1, @@ -34,7 +35,9 @@ export const loader = async ({ params, url }: LoaderFunctionArgs) => { const filteredBuilds = hasActiveFilters ? filterBuilds({ builds, - filters, + abilities, + mode, + date, count: limit + 1, }) : builds; @@ -55,6 +58,5 @@ export const loader = async ({ params, url }: LoaderFunctionArgs) => { limit, hasMoreBuilds, slug, - filters, }; }; diff --git a/app/features/builds/routes/builds.$slug.module.css b/app/features/builds/routes/builds.$slug.module.css index 0b180ea0c..0340a9e18 100644 --- a/app/features/builds/routes/builds.$slug.module.css +++ b/app/features/builds/routes/builds.$slug.module.css @@ -22,3 +22,30 @@ flex-direction: row; } } + +.abilityConditions { + display: flex; + flex-direction: column; + gap: var(--s-3); + width: 100%; + min-width: 14rem; +} + +.abilityConditionRow { + display: grid; + grid-template-columns: auto minmax(0, 1fr) auto; + align-items: center; + gap: var(--s-1-5); +} + +.abilityConditionValueRow { + display: grid; + grid-column: 2 / -1; + grid-template-columns: minmax(0, 1fr) auto auto; + align-items: center; + gap: var(--s-1-5); +} + +.abilityConditionApSelect { + min-width: 4.5rem; +} diff --git a/app/features/builds/routes/builds.$slug.tsx b/app/features/builds/routes/builds.$slug.tsx index 8efdddb78..6fd27a06f 100644 --- a/app/features/builds/routes/builds.$slug.tsx +++ b/app/features/builds/routes/builds.$slug.tsx @@ -3,17 +3,25 @@ import { ChartColumnBig, Flame, FlaskConical, - Funnel, Map as MapIcon, + X, } from "lucide-react"; import { useTranslation } from "react-i18next"; import type { MetaFunction } from "react-router"; import { useLoaderData } from "react-router"; +import { Ability } from "~/components/Ability"; import { BuildCard } from "~/components/BuildCard"; import { LinkButton, SendouButton } from "~/components/elements/Button"; -import { SendouMenu, SendouMenuItem } from "~/components/elements/Menu"; +import { FilterBar } from "~/components/filter-bar/FilterBar"; +import { ModeImage } from "~/components/Image"; import { Main } from "~/components/Main"; +import { possibleApValues } from "~/features/build-analyzer/analyzer-constants"; +import { useDateTimeFormat } from "~/hooks/intl/useDateTimeFormat"; +import { abilities } from "~/modules/in-game-lists/abilities"; +import { modesShort } from "~/modules/in-game-lists/modes"; +import type { Ability as AbilityType } from "~/modules/in-game-lists/types"; import { useSearchParamsTyped } from "~/modules/search-params/hooks"; +import { dateToYYYYMMDD, isValidDate } from "~/utils/dates"; import { metaTags, type SerializeFrom } from "~/utils/remix"; import type { SendouRouteHandle } from "~/utils/remix.server"; import { @@ -31,8 +39,7 @@ import { RECENT_PATCHES, } from "../builds-constants"; import { buildsSearchParams } from "../builds-search-params"; -import type { AbilityBuildFilter, BuildFilter } from "../builds-types"; -import { FilterSection } from "../components/FilterSection"; +import type { AbilityCondition } from "../builds-types"; import { loader } from "../loaders/builds.$slug.server"; @@ -95,110 +102,21 @@ export function BuildCards({ data }: { data: SerializeFrom }) { export default function WeaponsBuildsPage() { const data = useLoaderData(); const { t } = useTranslation(["common", "builds"]); - const [{ f: filters }, setParams] = useSearchParamsTyped(buildsSearchParams); - - const syncSearchParams = ( - newFilters: BuildFilter[], - opts?: { loader?: boolean }, - ) => { - setParams({ f: newFilters }, opts); - }; - - const handleFilterAdd = (type: BuildFilter["type"]) => { - const newFilter: BuildFilter = - type === "ability" - ? { - type: "ability", - ability: "ISM", - comparison: "AT_LEAST", - value: 0, - } - : type === "date" - ? { - type: "date", - date: RECENT_PATCHES[0].date, - } - : { - type: "mode", - mode: "SZ", - }; - - // a fresh "at least 0" ability filter matches every build, so no need to refetch - syncSearchParams( - [...filters, newFilter], - type === "ability" ? { loader: false } : undefined, - ); - }; - - const handleFilterChange = (i: number, newFilter: Partial) => { - const newFilters = filters.map((f, index) => - index === i - ? ({ - ...(f as AbilityBuildFilter), - ...(newFilter as AbilityBuildFilter), - } as BuildFilter) - : f, - ); - - syncSearchParams(newFilters); - }; - - const handleFilterDelete = (i: number) => { - syncSearchParams(filters.filter((_, index) => index !== i)); - }; + const [{ abilities: abilityConditions, mode, date }] = + useSearchParamsTyped(buildsSearchParams); const loadMoreLink = () => buildsSearchParams.href("", { limit: data.limit + BUILDS_PAGE_BATCH_SIZE, - f: filters, + abilities: abilityConditions, + mode, + date, }); - const nthOfSameFilter = (index: number) => { - const type = filters[index].type; - - return filters.slice(0, index).filter((f) => f.type === type).length + 1; - }; - return (
- } - isDisabled={filters.length >= MAX_BUILD_FILTERS} - data-testid="add-filter-button" - > - {t("builds:addFilter")} - - } - > - } - isDisabled={filters.length >= MAX_BUILD_FILTERS} - onAction={() => handleFilterAdd("ability")} - data-testid="menu-item-ability" - > - {t("builds:filters.type.ability")} - - } - onAction={() => handleFilterAdd("mode")} - data-testid="menu-item-mode" - > - {t("builds:filters.type.mode")} - - } - isDisabled={filters.some((filter) => filter.type === "date")} - onAction={() => handleFilterAdd("date")} - data-testid="menu-item-date" - > - {t("builds:filters.type.date")} - - +
- {filters.length > 0 ? ( -
- {filters.map((filter, i) => ( - handleFilterChange(i, newFilter)} - remove={() => handleFilterDelete(i)} - nthOfSame={nthOfSameFilter(i)} - /> - ))} -
- ) : null} {data.limit < BUILDS_PAGE_MAX_BUILDS && data.hasMoreBuilds ? ( ); } + +function Filters() { + const { t } = useTranslation(["builds", "game-misc"]); + const [{ abilities: abilityConditions, mode, date }, setParams] = + useSearchParamsTyped(buildsSearchParams); + + return ( + , + formattedValue: + abilityConditions.length > 0 + ? formatAbilityConditions(abilityConditions) + : null, + onRemove: () => setParams({ abilities: [] }), + testId: "ability", + popover: ( + + setParams({ abilities: newConditions }, opts) + } + /> + ), + }, + { + key: "mode", + name: t("builds:filters.mode"), + icon: , + formattedValue: + mode !== null ? t(`game-misc:MODE_SHORT_${mode}`) : null, + onAdd: () => setParams({ mode: "SZ" }), + onRemove: () => setParams({ mode: null }), + testId: "mode", + popover: ( +
+ {modesShort.map((option) => ( +
+ setParams({ mode: option })} + /> + +
+ ))} +
+ ), + }, + { + key: "date", + name: t("builds:filters.date"), + icon: , + formattedValue: date !== null ? : null, + onAdd: () => setParams({ date: RECENT_PATCHES[0].date }), + onRemove: () => setParams({ date: null }), + testId: "date", + popover: ( + setParams({ date: newDate })} + /> + ), + }, + ]} + /> + ); +} + +function formatAbilityConditions(conditions: AbilityCondition[]) { + const label = abilityConditionLabel(conditions[0]); + + return conditions.length > 1 ? `${label} +${conditions.length - 1}` : label; +} + +function abilityConditionLabel(condition: AbilityCondition) { + if (condition.value === true) return condition.ability; + if (condition.value === false) return `✗ ${condition.ability}`; + + return `${condition.ability} ${ + condition.comparison === "AT_MOST" ? "≤" : "≥" + } ${condition.value}`; +} + +function AbilityConditionsPopover({ + conditions, + onChange, +}: { + conditions: AbilityCondition[]; + onChange: ( + conditions: AbilityCondition[], + opts?: { loader: boolean }, + ) => void; +}) { + const { t } = useTranslation(["builds"]); + + const addCondition = () => { + const newCondition: AbilityCondition = { + ability: "ISM", + comparison: "AT_LEAST", + value: 0, + }; + + // a fresh "at least 0" ability condition matches every build, so no need to refetch + onChange([...conditions, newCondition], { loader: false }); + }; + + return ( +
+ {conditions.map((condition, i) => ( + + onChange( + conditions.map((c, index) => (index === i ? newCondition : c)), + ) + } + remove={() => onChange(conditions.filter((_, index) => index !== i))} + /> + ))} + = MAX_BUILD_FILTERS} + onPress={addCondition} + data-testid="add-ability-condition" + > + {t("builds:filters.addAbility")} + +
+ ); +} + +function AbilityConditionRow({ + condition, + onChange, + remove, +}: { + condition: AbilityCondition; + onChange: (condition: AbilityCondition) => void; + remove: () => void; +}) { + const { t } = useTranslation(["analyzer", "game-misc", "builds"]); + const abilityObject = abilities.find((a) => a.name === condition.ability)!; + + return ( +
+ + + } + size="miniscule" + variant="minimal-destructive" + onPress={remove} + aria-label="Delete ability condition" + data-testid="delete-ability-condition" + /> +
+ {abilityObject.type === "STACKABLE" ? ( + <> + + +
{t("analyzer:abilityPoints.short")}
+ + ) : ( + + )} +
+
+ ); +} + +function FormattedDate({ date }: { date: string }) { + const { formatter } = useDateTimeFormat({ + day: "numeric", + month: "numeric", + year: "numeric", + }); + + const patch = RECENT_PATCHES.find( + ({ date: patchDate }) => patchDate === date, + ); + if (patch) return <>{patch.patch}; + + return <>{formatter.format(new Date(date))}; +} + +function DatePopover({ + date, + onChange, +}: { + date: string | null; + onChange: (date: string) => void; +}) { + const { t } = useTranslation(["builds"]); + const { formatter: patchDateFormatter } = useDateTimeFormat({ + day: "numeric", + month: "numeric", + year: "numeric", + }); + + const selectValue = () => + RECENT_PATCHES.some(({ date: patchDate }) => patchDate === date) + ? date + : "CUSTOM"; + + // on Saturday so it doesn't overlap with actual path dates (no patches on Saturdays) + const oneMonthAgoOnSaturday = new Date(); + oneMonthAgoOnSaturday.setUTCDate(oneMonthAgoOnSaturday.getUTCDate() - 30); + oneMonthAgoOnSaturday.setUTCDate( + oneMonthAgoOnSaturday.getUTCDate() - oneMonthAgoOnSaturday.getUTCDay() + 6, + ); + + const customDate = + date !== null && isValidDate(new Date(date)) + ? new Date(date) + : oneMonthAgoOnSaturday; + + return ( +
+ + + {selectValue() === "CUSTOM" ? ( + onChange(e.target.value)} + max={dateToYYYYMMDD(new Date())} + data-testid="date-input" + /> + ) : null} +
+ ); +} diff --git a/app/features/calendar/calendar-schemas.ts b/app/features/calendar/calendar-schemas.ts index ef30f0c72..acea4c7a0 100644 --- a/app/features/calendar/calendar-schemas.ts +++ b/app/features/calendar/calendar-schemas.ts @@ -1,5 +1,9 @@ import { z } from "zod"; import type { CalendarEventTag } from "~/features/calendar/calendar-types"; +import { + BEST_TIER_NUMBER, + WORST_TIER_NUMBER, +} from "~/features/tournament/core/tiering"; import { TOURNAMENT, TOURNAMENT_STAGE_TYPES, @@ -8,16 +12,10 @@ import * as Swiss from "~/features/tournament-bracket/core/engine/swiss/team-sta import * as Progression from "~/features/tournament-bracket/core/Progression"; import { array, - checkboxGroup, customField, fieldset, numberField, - numberFieldOptional, - radioGroup, textField, - textFieldOptional, - toggle, - userSearchOptional, } from "~/form/fields"; import { gamesShort, versusShort } from "~/modules/in-game-lists/games"; import { modesShortWithSpecial } from "~/modules/in-game-lists/modes"; @@ -33,6 +31,10 @@ const calendarEventTagSchema = z .string() .refine((val) => CALENDAR_EVENT.TAGS.includes(val as CalendarEventTag)); +export const calendarFilterTagsArr = z + .array(calendarEventTagSchema) + .max(CALENDAR_EVENT.TAGS.length); + const calendarFiltersPlainStringArr = z.array(z.string().max(100)).max(10); const calendarFiltersIdsArr = z.array(id).max(10); const calendarFilterGamesArr = z.array(gamesShortSchema).min(1).max(3); @@ -45,11 +47,16 @@ const modeArr = z .array(modeShortWithSpecial) .min(1) .max(modesShortWithSpecial.length); +const tierNumber = z.coerce + .number() + .int() + .min(BEST_TIER_NUMBER) + .max(WORST_TIER_NUMBER); export const calendarFiltersSearchParamsSchema = z.object({ preferredStartTime: preferredStartTime.catch("ANY"), - tagsIncluded: z.array(calendarEventTagSchema).catch([]), - tagsExcluded: z.array(calendarEventTagSchema).catch([]), + tagsIncluded: calendarFilterTagsArr.catch([]), + tagsExcluded: calendarFilterTagsArr.catch([]), isSendou: z.boolean().catch(false), isRanked: z.boolean().catch(false), orgsIncluded: calendarFiltersPlainStringArr.catch([]), @@ -60,6 +67,8 @@ export const calendarFiltersSearchParamsSchema = z.object({ modes: modeArr.catch([...modesShortWithSpecial]), modesExact: z.boolean().catch(false), minTeamCount: z.coerce.number().int().nonnegative().catch(0), + minTier: tierNumber.catch(BEST_TIER_NUMBER), + maxTier: tierNumber.catch(WORST_TIER_NUMBER), }); const TAGS_TO_OMIT: CalendarEventTag[] = [ @@ -72,110 +81,10 @@ const TAGS_TO_OMIT: CalendarEventTag[] = [ "TRIOS", ]; -const filterTags = CALENDAR_EVENT.TAGS.filter( +export const calendarFilterTags = CALENDAR_EVENT.TAGS.filter( (tag) => !TAGS_TO_OMIT.includes(tag), ); -const tagItems = filterTags.map((tag) => ({ - label: `options.tag.${tag}` as const, - value: tag, -})); - -export const calendarFiltersFormSchema = z - .object({ - modes: checkboxGroup({ - label: "labels.buildModes", - items: [ - { label: "modes.TW", value: "TW" }, - { label: "modes.SZ", value: "SZ" }, - { label: "modes.TC", value: "TC" }, - { label: "modes.RM", value: "RM" }, - { label: "modes.CB", value: "CB" }, - { label: () => "Salmon Run", value: "SR" }, - { label: () => "Tricolor", value: "TB" }, - ], - minLength: 1, - }), - modesExact: toggle({ - label: "labels.modesExact", - bottomText: "bottomTexts.modesExact", - }), - games: checkboxGroup({ - label: "labels.games", - items: [ - { label: "options.game.S1", value: "S1" }, - { label: "options.game.S2", value: "S2" }, - { label: "options.game.S3", value: "S3" }, - ], - minLength: 1, - }), - preferredVersus: checkboxGroup({ - label: "labels.vs", - items: [ - { label: () => "4v4", value: "4v4" }, - { label: () => "3v3", value: "3v3" }, - { label: () => "2v2", value: "2v2" }, - { label: () => "1v1", value: "1v1" }, - ], - minLength: 1, - }), - preferredStartTime: radioGroup({ - label: "labels.startTime", - items: [ - { label: "options.startTime.any", value: "ANY" }, - { label: "options.startTime.eu", value: "EU" }, - { label: "options.startTime.na", value: "NA" }, - { label: "options.startTime.au", value: "AU" }, - ], - }), - tagsIncluded: checkboxGroup({ - label: "labels.tagsIncluded", - items: tagItems, - }), - tagsExcluded: checkboxGroup({ - label: "labels.tagsExcluded", - items: tagItems, - }), - isSendou: toggle({ label: "labels.onlySendouEvents" }), - isRanked: toggle({ label: "labels.onlyRankedEvents" }), - minTeamCount: numberFieldOptional({ - label: "labels.minTeamCount", - }), - orgsIncluded: array({ - label: "labels.orgsIncluded", - field: textFieldOptional({ maxLength: 100 }), - max: 10, - }), - orgsExcluded: array({ - label: "labels.orgsExcluded", - field: textFieldOptional({ maxLength: 100 }), - max: 10, - }), - authorIdsExcluded: array({ - label: "labels.authorIdsExcluded", - field: userSearchOptional({}), - max: 10, - }), - }) - .superRefine((filters, ctx) => { - if ( - filters.tagsIncluded.some((tag) => filters.tagsExcluded.includes(tag)) - ) { - ctx.addIssue({ - path: ["tagsExcluded"], - message: "Can't include and exclude the same tag", - code: z.ZodIssueCode.custom, - }); - } - - if (filters.orgsIncluded.length > 0 && filters.orgsExcluded.length > 0) { - ctx.addIssue({ - path: ["orgsExcluded"], - message: "Can't both include and exclude organizations", - code: z.ZodIssueCode.custom, - }); - } - }); const reportedPlayerSchema = z.discriminatedUnion("type", [ z.object({ type: z.literal("USER"), id: id.nullable() }), z.object({ diff --git a/app/features/calendar/calendar-search-params.test.ts b/app/features/calendar/calendar-search-params.test.ts index 482fc81fe..f5be37c71 100644 --- a/app/features/calendar/calendar-search-params.test.ts +++ b/app/features/calendar/calendar-search-params.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from "vitest"; +import { describe, it } from "vitest"; import { assertDecodesToDefault, assertRoundTrips, @@ -13,24 +13,26 @@ import * as CalendarEvent from "./core/CalendarEvent"; describe("calendarSearchParams", () => { it("round-trips", () => { assertRoundTrips(calendarSearchParams, { - filters: [ - CalendarEvent.defaultFilters(), - { - preferredStartTime: "EU", - tagsIncluded: ["ART"], - tagsExcluded: ["MONEY"], - isSendou: true, - isRanked: true, - orgsIncluded: ["Splat Org"], - orgsExcluded: [], - authorIdsExcluded: [1, 274], - games: ["S3"], - preferredVersus: ["4v4"], - modes: ["SZ", "TC"], - modesExact: true, - minTeamCount: 16, - }, + modes: [CalendarEvent.defaultFilters().modes, ["SZ", "TC"], ["TB"]], + modesExact: [false, true], + games: [CalendarEvent.defaultFilters().games, ["S3"], ["S1", "S2"]], + preferredVersus: [ + CalendarEvent.defaultFilters().preferredVersus, + ["4v4"], + ["1v1", "2v2"], ], + preferredStartTime: ["ANY", "EU", "NA", "AU"], + tagsIncluded: [[], ["ART"], ["ART", "MONEY"]], + tagsExcluded: [[], ["MONEY"]], + isSendou: [false, true], + isRanked: [false, true], + minTeamCount: [0, 16], + minTier: [1, 3, 9], + maxTier: [1, 5, 9], + orgsIncluded: [[], ["Splat Org"], ["A", "B"]], + orgsExcluded: [[], ["Bad Org"]], + authorIdsExcluded: [[], [1, 274]], + useDefaults: [true, false], day: [null, 1, 15, 31], month: [null, 0, 11], year: [null, 2015, 2026, 2100], @@ -38,12 +40,26 @@ describe("calendarSearchParams", () => { }); it("decodes garbage to defaults", () => { - assertDecodesToDefault(calendarSearchParams, "filters", [ - ["not-json"], - ["[1,2,3]"], - ['"foo"'], - ['{"preferredStartTime":"XX"}'], + assertDecodesToDefault(calendarSearchParams, "preferredStartTime", [ + ["XX"], + ["eu"], ]); + assertDecodesToDefault(calendarSearchParams, "modesExact", [ + ["1"], + ["yes"], + ]); + assertDecodesToDefault(calendarSearchParams, "minTeamCount", [ + ["-1"], + ["abc"], + ["1.5"], + ]); + assertDecodesToDefault(calendarSearchParams, "minTier", [ + ["0"], + ["10"], + ["abc"], + ]); + assertDecodesToDefault(calendarSearchParams, "maxTier", [["0"], ["10"]]); + assertDecodesToDefault(calendarSearchParams, "games", [["BAD"]]); assertDecodesToDefault(calendarSearchParams, "day", [ ["0"], ["32"], @@ -57,21 +73,6 @@ describe("calendarSearchParams", () => { ["nope"], ]); }); - - it("keeps valid fields when part of the filters blob is invalid", () => { - const parsed = calendarSearchParams.parse( - new URL( - `http://localhost/calendar?filters=${encodeURIComponent( - JSON.stringify({ isSendou: true, games: ["BAD"] }), - )}`, - ), - ); - - expect(parsed.filters).toEqual({ - ...CalendarEvent.defaultFilters(), - isSendou: true, - }); - }); }); describe("calendarEventsSearchParams", () => { diff --git a/app/features/calendar/calendar-search-params.ts b/app/features/calendar/calendar-search-params.ts index 3675ea54a..5dfb894b1 100644 --- a/app/features/calendar/calendar-search-params.ts +++ b/app/features/calendar/calendar-search-params.ts @@ -1,9 +1,18 @@ import { z } from "zod"; +import { + BEST_TIER_NUMBER, + WORST_TIER_NUMBER, +} from "~/features/tournament/core/tiering"; +import { gamesShort, versusShort } from "~/modules/in-game-lists/games"; +import { modesShortWithSpecial } from "~/modules/in-game-lists/modes"; import * as SearchParams from "~/modules/search-params/search-params"; import { SP } from "~/modules/search-params/search-params"; -import { dayMonthYear } from "~/utils/zod"; -import { calendarFiltersSearchParamsSchema } from "./calendar-schemas"; -import * as CalendarEvent from "./core/CalendarEvent"; +import { + dayMonthYear, + gamesShortSchema, + modeShortWithSpecial, +} from "~/utils/zod"; +import { calendarFilterTagsArr } from "./calendar-schemas"; export const VIEW_FILTERS = [ "registered", @@ -14,11 +23,60 @@ export const VIEW_FILTERS = [ ] as const; export type ViewFilter = (typeof VIEW_FILTERS)[number]; +const tierNumber = z + .number() + .int() + .min(BEST_TIER_NUMBER) + .max(WORST_TIER_NUMBER); + export const calendarSearchParams = SearchParams.define({ - filters: SP.json(calendarFiltersSearchParamsSchema, { - default: CalendarEvent.defaultFilters(), + modes: SP.param( + z.array(modeShortWithSpecial).min(1).max(modesShortWithSpecial.length), + { default: [...modesShortWithSpecial], loader: true }, + ), + modesExact: SP.param(z.boolean(), { default: false, loader: true }), + games: SP.param(z.array(gamesShortSchema).min(1).max(gamesShort.length), { + default: [...gamesShort], loader: true, }), + preferredVersus: SP.param( + z.array(z.enum(versusShort)).min(1).max(versusShort.length), + { default: [...versusShort], loader: true }, + ), + preferredStartTime: SP.param(z.enum(["ANY", "EU", "NA", "AU"]), { + default: "ANY", + loader: true, + }), + tagsIncluded: SP.param(calendarFilterTagsArr, { + default: [], + loader: true, + }), + tagsExcluded: SP.param(calendarFilterTagsArr, { + default: [], + loader: true, + }), + isSendou: SP.param(z.boolean(), { default: false, loader: true }), + isRanked: SP.param(z.boolean(), { default: false, loader: true }), + minTeamCount: SP.param(z.number().int().nonnegative(), { + default: 0, + loader: true, + }), + minTier: SP.param(tierNumber, { default: BEST_TIER_NUMBER, loader: true }), + maxTier: SP.param(tierNumber, { default: WORST_TIER_NUMBER, loader: true }), + orgsIncluded: SP.param(z.array(z.string().max(100)).max(10), { + default: [], + loader: true, + }), + orgsExcluded: SP.param(z.array(z.string().max(100)).max(10), { + default: [], + loader: true, + }), + authorIdsExcluded: SP.param(z.array(z.number().int().positive()).max(10), { + default: [], + loader: true, + }), + /** False once the user has edited the filters, making the URL win over their saved defaults. */ + useDefaults: SP.param(z.boolean(), { default: true, loader: true }), day: SP.param(dayMonthYear.shape.day.nullable(), { loader: true }), month: SP.param(dayMonthYear.shape.month.nullable(), { loader: true }), year: SP.param(dayMonthYear.shape.year.nullable(), { loader: true }), diff --git a/app/features/calendar/components/FiltersBar.tsx b/app/features/calendar/components/FiltersBar.tsx new file mode 100644 index 000000000..38040a790 --- /dev/null +++ b/app/features/calendar/components/FiltersBar.tsx @@ -0,0 +1,541 @@ +import { Star, X } from "lucide-react"; +import * as React from "react"; +import { useTranslation } from "react-i18next"; +import { useFetcher, useLoaderData } from "react-router"; +import { SendouButton } from "~/components/elements/Button"; +import { SendouSelect, SendouSelectItem } from "~/components/elements/Select"; +import { SendouSwitch } from "~/components/elements/Switch"; +import { UserSearch } from "~/components/elements/UserSearch"; +import { FilterBar } from "~/components/filter-bar/FilterBar"; +import { useUser } from "~/features/auth/core/user"; +import { calendarFilterTags } from "~/features/calendar/calendar-schemas"; +import { calendarSearchParams } from "~/features/calendar/calendar-search-params"; +import type { CalendarFilters } from "~/features/calendar/calendar-types"; +import { + TIER_NUMBERS, + tierNumberToName, +} from "~/features/tournament/core/tiering"; +import { + CheckboxGroupFormField, + RadioGroupFormField, +} from "~/form/fields/InputGroupFormField"; +import { gamesShort, versusShort } from "~/modules/in-game-lists/games"; +import { modesShortWithSpecial } from "~/modules/in-game-lists/modes"; +import { useSearchParamsTyped } from "~/modules/search-params/hooks"; +import * as CalendarEvent from "../core/CalendarEvent"; +import type { CalendarLoaderData } from "../loaders/calendar.server"; + +export function FiltersBar() { + const { t } = useTranslation(["calendar", "common", "forms"]); + const user = useUser(); + const data = useLoaderData(); + const [, setParams] = useSearchParamsTyped(calendarSearchParams); + const persistFetcher = useFetcher(); + + const filters = data.filters; + const defaults = CalendarEvent.defaultFilters(); + + const tagItems = calendarFilterTags.map((tag) => ({ + label: t(`forms:options.tag.${tag}`), + value: tag, + })); + + const writeFilters = (partial: Partial) => { + setParams({ ...filters, ...partial, useDefaults: false }); + }; + + const modesFormatted = () => { + const parts = []; + if (filters.modes.length < modesShortWithSpecial.length) { + parts.push(filters.modes.join(", ")); + } + if (filters.modesExact) { + parts.push(t("calendar:filter.exactModes")); + } + + return parts.length > 0 ? parts.join(" · ") : null; + }; + + const eventTypeFormatted = () => { + const parts = []; + if (filters.games.length < gamesShort.length) { + parts.push(filters.games.join(", ")); + } + if (filters.preferredVersus.length < versusShort.length) { + parts.push(filters.preferredVersus.join(", ")); + } + if (filters.isSendou) { + parts.push(t("calendar:filterBar.sendou")); + } + if (filters.isRanked) { + parts.push(t("calendar:filterBar.ranked")); + } + + return parts.length > 0 ? parts.join(" · ") : null; + }; + + const tierFormatted = () => { + if ( + filters.minTier === defaults.minTier && + filters.maxTier === defaults.maxTier + ) { + return null; + } + + const bestTier = tierNumberToName(filters.minTier); + const worstTier = tierNumberToName(filters.maxTier); + + return bestTier === worstTier ? bestTier : `${bestTier}–${worstTier}`; + }; + + const tagsFormatted = () => { + const parts = []; + if (filters.tagsIncluded.length > 0) { + parts.push(`+${filters.tagsIncluded.length}`); + } + if (filters.tagsExcluded.length > 0) { + parts.push(`−${filters.tagsExcluded.length}`); + } + + return parts.length > 0 ? parts.join(" · ") : null; + }; + + const organizersFormatted = () => { + const parts = []; + if (filters.orgsIncluded.length > 0) { + parts.push(`+${filters.orgsIncluded.length}`); + } + const excludedCount = + filters.orgsExcluded.length + filters.authorIdsExcluded.length; + if (excludedCount > 0) { + parts.push(`−${excludedCount}`); + } + + return parts.length > 0 ? parts.join(" · ") : null; + }; + + const timeAndSizeFormatted = () => { + const parts = []; + if (filters.preferredStartTime !== "ANY") { + parts.push( + t( + `calendar:filter.startTime.${filters.preferredStartTime.toLowerCase() as Lowercase>}`, + ), + ); + } + if (filters.minTeamCount > 0) { + parts.push(`${filters.minTeamCount}+`); + } + + return parts.length > 0 ? parts.join(" · ") : null; + }; + + return ( + + writeFilters({ modes: defaults.modes, modesExact: false }), + testId: "modes-filter", + popover: ( +
+ + modes.length > 0 ? writeFilters({ modes }) : undefined + } + minLength={1} + onBlur={() => {}} + /> + writeFilters({ modesExact })} + > + {t("calendar:filter.exactModes")} + +
+ ), + }, + { + key: "eventType", + name: t("calendar:filterBar.eventType"), + formattedValue: eventTypeFormatted(), + onRemove: () => + writeFilters({ + games: defaults.games, + preferredVersus: defaults.preferredVersus, + isSendou: false, + isRanked: false, + }), + testId: "event-type-filter", + popover: ( +
+ ({ + label: t(`forms:options.game.${game}`), + value: game, + }))} + value={filters.games} + onChange={(games) => + games.length > 0 ? writeFilters({ games }) : undefined + } + minLength={1} + onBlur={() => {}} + /> + ({ + label: versus, + value: versus, + }))} + value={filters.preferredVersus} + onChange={(preferredVersus) => + preferredVersus.length > 0 + ? writeFilters({ preferredVersus }) + : undefined + } + minLength={1} + onBlur={() => {}} + /> + writeFilters({ isSendou })} + > + {t("calendar:filter.isSendou")} + + writeFilters({ isRanked })} + > + {t("calendar:filter.isRanked")} + +
+ ), + }, + { + key: "tier", + name: t("calendar:filterBar.tier"), + formattedValue: tierFormatted(), + onRemove: () => + writeFilters({ + minTier: defaults.minTier, + maxTier: defaults.maxTier, + }), + testId: "tier-filter", + popover: ( +
+ + writeFilters({ + minTier, + maxTier: Math.max(minTier, filters.maxTier), + }) + } + /> + + writeFilters({ + maxTier, + minTier: Math.min(maxTier, filters.minTier), + }) + } + /> +
+ ), + }, + { + key: "tags", + name: t("calendar:filterBar.tags"), + formattedValue: tagsFormatted(), + onRemove: () => writeFilters({ tagsIncluded: [], tagsExcluded: [] }), + testId: "tags-filter", + popover: ( +
+ + writeFilters({ + tagsIncluded, + tagsExcluded: filters.tagsExcluded.filter( + (tag) => !tagsIncluded.includes(tag), + ), + }) + } + minLength={0} + onBlur={() => {}} + /> + + writeFilters({ + tagsExcluded, + tagsIncluded: filters.tagsIncluded.filter( + (tag) => !tagsExcluded.includes(tag), + ), + }) + } + minLength={0} + onBlur={() => {}} + /> +
+ ), + }, + { + key: "organizers", + name: t("calendar:filterBar.organizers"), + formattedValue: organizersFormatted(), + onRemove: () => + writeFilters({ + orgsIncluded: [], + orgsExcluded: [], + authorIdsExcluded: [], + }), + testId: "organizers-filter", + popover: ( +
+ writeFilters({ orgsIncluded })} + disabled={filters.orgsExcluded.length > 0} + /> + writeFilters({ orgsExcluded })} + disabled={filters.orgsIncluded.length > 0} + /> + + writeFilters({ authorIdsExcluded }) + } + /> +
+ ), + }, + { + key: "timeAndSize", + name: t("calendar:filterBar.timeAndSize"), + formattedValue: timeAndSizeFormatted(), + onRemove: () => + writeFilters({ preferredStartTime: "ANY", minTeamCount: 0 }), + testId: "time-and-size-filter", + popover: ( +
+ + writeFilters({ preferredStartTime }) + } + onBlur={() => {}} + /> + +
+ ), + }, + ]} + onReset={ + !CalendarEvent.isDefaultFilters(filters) + ? () => writeFilters(defaults) + : undefined + } + actions={ + user && data.canSaveAsDefault ? ( + } + isDisabled={persistFetcher.state !== "idle"} + onPress={() => + persistFetcher.submit(filters, { + method: "post", + encType: "application/json", + }) + } + data-testid="save-filters-as-default-button" + > + {t("common:filterBar.saveAsDefault")} + + ) : null + } + /> + ); +} + +function TierSelect({ + label, + value, + onChange, +}: { + label: string; + value: number; + onChange: (value: number) => void; +}) { + return ( + ({ id: tier }))} + selectedKey={value} + onSelectionChange={(key) => onChange(Number(key))} + > + {({ id }) => ( + + {tierNumberToName(id)} + + )} + + ); +} + +function OrgListEditor({ + label, + values, + onChange, + disabled, +}: { + label: string; + values: string[]; + onChange: (values: string[]) => void; + disabled: boolean; +}) { + const { t } = useTranslation(["common"]); + const [draft, setDraft] = React.useState(""); + + const addDraft = () => { + const org = draft.trim(); + if (!org || values.includes(org)) return; + + onChange([...values, org]); + setDraft(""); + }; + + return ( +
+ {label} + {values.map((org) => ( +
+ {org} + } + variant="minimal-destructive" + size="miniscule" + aria-label={`Remove ${org}`} + onPress={() => onChange(values.filter((value) => value !== org))} + /> +
+ ))} + {values.length < 10 ? ( +
+ setDraft(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") { + e.preventDefault(); + addDraft(); + } + }} + /> + + {t("common:actions.add")} + +
+ ) : null} +
+ ); +} + +function ExcludedAuthorsEditor({ + label, + values, + onChange, +}: { + label: string; + values: number[]; + onChange: (values: number[]) => void; +}) { + return ( +
+ {label} + {values.map((userId) => ( +
+ + } + variant="minimal-destructive" + size="miniscule" + aria-label="Remove excluded author" + onPress={() => onChange(values.filter((value) => value !== userId))} + /> +
+ ))} + {values.length < 10 ? ( + { + if (user && !values.includes(user.id)) { + onChange([...values, user.id]); + } + }} + /> + ) : null} +
+ ); +} diff --git a/app/features/calendar/components/FiltersDialog.tsx b/app/features/calendar/components/FiltersDialog.tsx deleted file mode 100644 index 8771ba6a1..000000000 --- a/app/features/calendar/components/FiltersDialog.tsx +++ /dev/null @@ -1,105 +0,0 @@ -import { Funnel } from "lucide-react"; -import * as React from "react"; -import { useTranslation } from "react-i18next"; -import type { z } from "zod"; -import { SendouButton } from "~/components/elements/Button"; -import { SendouDialog } from "~/components/elements/Dialog"; -import { useUser } from "~/features/auth/core/user"; -import { calendarFiltersFormSchema } from "~/features/calendar/calendar-schemas"; -import { calendarSearchParams } from "~/features/calendar/calendar-search-params"; -import type { CalendarFilters } from "~/features/calendar/calendar-types"; -import { SendouForm, useFormFieldContext } from "~/form/SendouForm"; -import { useSearchParamsTyped } from "~/modules/search-params/hooks"; - -type FormValues = z.infer; - -export function FiltersDialog({ filters }: { filters: CalendarFilters }) { - const { t } = useTranslation(["calendar"]); - const [isOpen, setIsOpen] = React.useState(false); - - return ( - <> - } - onPress={() => setIsOpen(true)} - data-testid="filter-events-button" - > - {t("calendar:filter.button")} - - setIsOpen(false)} - > - { - setIsOpen(false); - }} - /> - - - ); -} - -function FiltersForm({ - filters, - closeDialog, -}: { - filters: CalendarFilters; - closeDialog: () => void; -}) { - const user = useUser(); - const { t } = useTranslation(["calendar"]); - const [, setSearchParams] = useSearchParamsTyped(calendarSearchParams); - - const handleApply = (values: FormValues) => { - setSearchParams({ filters: values as unknown as CalendarFilters }); - closeDialog(); - }; - - return ( - : null} - > - {({ FormField }) => ( - <> - - - - - - - - - - - - - - - )} - - ); -} - -function ApplyAndPersistButton() { - const { t } = useTranslation(["calendar"]); - const { values, submitToServer, fetcherState } = useFormFieldContext(); - - return ( - submitToServer(values as CalendarFilters)} - isDisabled={fetcherState !== "idle"} - > - {t("calendar:filter.applyAndDefault")} - - ); -} diff --git a/app/features/calendar/core/CalendarEvent.test.ts b/app/features/calendar/core/CalendarEvent.test.ts index 8220ad7da..12dd4d207 100644 --- a/app/features/calendar/core/CalendarEvent.test.ts +++ b/app/features/calendar/core/CalendarEvent.test.ts @@ -200,6 +200,43 @@ describe("CalendarEvent.applyFilters", () => { expect(result[0].events.shown.map((e) => e.id)).toEqual([2]); }); + it("filters by tier range, taking the tentative tier into account", () => { + const events = [ + { + at: 123, + events: [ + makeEvent({ id: 1, tier: 1 }), + makeEvent({ id: 2, tier: 3 }), + makeEvent({ id: 3, tentativeTier: 4 }), + makeEvent({ id: 4, tier: 6 }), + makeEvent({ id: 5, tentativeTier: 8 }), + makeEvent({ id: 6 }), + ], + }, + ]; + const filters: CalendarFilters = { + ...CalendarEvent.defaultFilters(), + minTier: 2, + maxTier: 6, + }; + const result = CalendarEvent.applyFilters(events, filters); + expect(result[0].events.shown.map((e) => e.id)).toEqual([2, 3, 4]); + }); + + it("shows untiered events when the tier range is at its default", () => { + const events = [ + { + at: 123, + events: [makeEvent({ id: 1 }), makeEvent({ id: 2, tier: 5 })], + }, + ]; + const result = CalendarEvent.applyFilters( + events, + CalendarEvent.defaultFilters(), + ); + expect(result[0].events.shown.map((e) => e.id)).toEqual([1, 2]); + }); + it("filters by orgsIncluded", () => { const events = [ { diff --git a/app/features/calendar/core/CalendarEvent.ts b/app/features/calendar/core/CalendarEvent.ts index 004cf6ff2..5009ad0bc 100644 --- a/app/features/calendar/core/CalendarEvent.ts +++ b/app/features/calendar/core/CalendarEvent.ts @@ -1,5 +1,9 @@ import { TZDate } from "@date-fns/tz"; import { isWeekend } from "date-fns"; +import { + BEST_TIER_NUMBER, + WORST_TIER_NUMBER, +} from "~/features/tournament/core/tiering"; import { gamesShort, versusShort } from "~/modules/in-game-lists/games"; import { modesShortWithSpecial } from "~/modules/in-game-lists/modes"; import { assertType } from "~/utils/types"; @@ -9,7 +13,7 @@ import type { GroupedCalendarEvents, } from "../calendar-types"; -const FILTERS_KEYS = [ +export const FILTERS_KEYS = [ "preferredStartTime", "tagsIncluded", "tagsExcluded", @@ -22,6 +26,8 @@ const FILTERS_KEYS = [ "modes", "modesExact", "minTeamCount", + "minTier", + "maxTier", "preferredVersus", ] as const; @@ -46,6 +52,8 @@ export function defaultFilters(): CalendarFilters { orgsExcluded: [], authorIdsExcluded: [], minTeamCount: 0, + minTier: BEST_TIER_NUMBER, + maxTier: WORST_TIER_NUMBER, }; } @@ -58,10 +66,7 @@ export function isDefaultFilters(filters: CalendarFilters): boolean { return filtersToString(filters) === defaultFiltersString; } -/** - * Serializes the given calendar filters object into a string representation to be used as e.g. React key. - */ -export function filtersToString(filters: CalendarFilters): string { +function filtersToString(filters: CalendarFilters): string { let result = ""; for (const key of FILTERS_KEYS) { @@ -252,6 +257,23 @@ function matchesFilter( return event.teamsCount >= minTeamCount; } + case "minTier": { + const { minTier, maxTier } = filters; + if (minTier === BEST_TIER_NUMBER && maxTier === WORST_TIER_NUMBER) { + return true; + } + + const tier = event.tier ?? event.tentativeTier; + if (tier === null) { + return false; + } + + return tier >= minTier && tier <= maxTier; + } + case "maxTier": { + // handled in the minTier filter + return true; + } case "orgsIncluded": { const orgsIncluded = filters[key]; if (orgsIncluded.length === 0) { diff --git a/app/features/calendar/loaders/calendar.server.ts b/app/features/calendar/loaders/calendar.server.ts index a5dc52d96..bb178ca82 100644 --- a/app/features/calendar/loaders/calendar.server.ts +++ b/app/features/calendar/loaders/calendar.server.ts @@ -1,5 +1,6 @@ import { add, startOfWeek, sub } from "date-fns"; import type { LoaderFunctionArgs } from "react-router"; +import * as R from "remeda"; import type { UserPreferences } from "~/db/tables-json"; import { getUser } from "~/features/auth/core/user.server"; import { DAYS_SHOWN_AT_A_TIME } from "~/features/calendar/calendar-constants"; @@ -41,6 +42,17 @@ export const loader = async (args: LoaderFunctionArgs) => { const filters = resolveFilters(args.request, user?.preferences); const filtered = CalendarEvent.applyFilters(events, filters); + const canSaveAsDefault = + user != null && + !R.isDeepEqual( + filters, + user.preferences?.defaultCalendarFilters + ? calendarFiltersSearchParamsSchema.parse( + user.preferences.defaultCalendarFilters, + ) + : CalendarEvent.defaultFilters(), + ); + const eventTimes = canAccessTrophies(user) ? filtered : filtered.map((time) => ({ @@ -61,6 +73,7 @@ export const loader = async (args: LoaderFunctionArgs) => { eventTimes, dateViewed, filters, + canSaveAsDefault, }; }; @@ -68,7 +81,14 @@ function resolveFilters( request: Request, preferences?: UserPreferences | null, ) { - const parsed = calendarSearchParams.parse(request).filters; + const searchParams = calendarSearchParams.parse(request); + const parsed = R.pick(searchParams, [...CalendarEvent.FILTERS_KEYS]); + + // the user cleared or edited the filters, so the URL is the whole truth + // even when it ends up holding no filters at all + if (!searchParams.useDefaults) { + return parsed; + } if (!CalendarEvent.isDefaultFilters(parsed)) { return parsed; diff --git a/app/features/calendar/loaders/calendar[.]ics.server.ts b/app/features/calendar/loaders/calendar[.]ics.server.ts index 1e2b68a30..a4613de90 100644 --- a/app/features/calendar/loaders/calendar[.]ics.server.ts +++ b/app/features/calendar/loaders/calendar[.]ics.server.ts @@ -1,11 +1,14 @@ import type { LoaderFunctionArgs } from "react-router"; +import * as R from "remeda"; +import { safeJSONParse } from "~/utils/zod"; import * as CalendarRepository from "../CalendarRepository.server"; +import { calendarFiltersSearchParamsSchema } from "../calendar-schemas"; import { calendarSearchParams } from "../calendar-search-params"; import * as CalendarEvent from "../core/CalendarEvent"; import * as ICal from "../core/ICal.server"; export const loader = async ({ request }: LoaderFunctionArgs) => { - const { filters } = calendarSearchParams.parse(request); + const filters = resolveFilters(request); const startTime = new Date(); const endTime = new Date(startTime); @@ -39,3 +42,19 @@ export const loader = async ({ request }: LoaderFunctionArgs) => { }, }); }; + +/** Subscribed feed URLs may still carry the pre-FilterBar `filters` JSON param. */ +function resolveFilters(request: Request) { + // biome-ignore lint/plugin: legacy param no current route produces + const legacyFilters = new URL(request.url).searchParams.get("filters"); + if (legacyFilters !== null) { + const parsed = calendarFiltersSearchParamsSchema.safeParse( + safeJSONParse(legacyFilters), + ); + if (parsed.success) return parsed.data; + } + + return R.pick(calendarSearchParams.parse(request), [ + ...CalendarEvent.FILTERS_KEYS, + ]); +} diff --git a/app/features/calendar/routes/calendar.module.css b/app/features/calendar/routes/calendar.module.css index 2447dcca3..8fde8d771 100644 --- a/app/features/calendar/routes/calendar.module.css +++ b/app/features/calendar/routes/calendar.module.css @@ -9,15 +9,18 @@ ); } +.columnsWidthContainer { + width: 100%; + max-width: var(--columns-width); + margin-inline: auto; +} + .buttonsContainer { display: flex; justify-content: space-between; gap: var(--s-6); align-items: start; flex-wrap: wrap-reverse; - width: 100%; - max-width: var(--columns-width); - margin-inline: auto; } .navigateButtonsContainer { diff --git a/app/features/calendar/routes/calendar.tsx b/app/features/calendar/routes/calendar.tsx index fed0c30a7..cf3b581c4 100644 --- a/app/features/calendar/routes/calendar.tsx +++ b/app/features/calendar/routes/calendar.tsx @@ -25,21 +25,17 @@ import { LocaleTimeRange } from "~/components/LocaleTimeRange"; import { Main } from "~/components/Main"; import { DAYS_SHOWN_AT_A_TIME } from "~/features/calendar/calendar-constants"; import { useCollapsableEvents } from "~/features/calendar/calendar-hooks"; +import { calendarSearchParams } from "~/features/calendar/calendar-search-params"; +import { useSearchParamsTyped } from "~/modules/search-params/hooks"; import { dayMonthYearToDateValue } from "~/utils/dates"; import { metaTags } from "~/utils/remix"; import type { SendouRouteHandle } from "~/utils/remix.server"; -import { - CALENDAR_PAGE, - calendarIcalFeed, - calendarPage, - navIconUrl, -} from "~/utils/urls"; +import { CALENDAR_PAGE, calendarIcalFeed, navIconUrl } from "~/utils/urls"; import type { DayMonthYear } from "~/utils/zod"; import { action } from "../actions/calendar"; import { daysForCalendar } from "../calendar-utils"; -import { FiltersDialog } from "../components/FiltersDialog"; +import { FiltersBar } from "../components/FiltersBar"; import { TournamentCard } from "../components/TournamentCard"; -import * as CalendarEvent from "../core/CalendarEvent"; import { type CalendarLoaderData, loader } from "../loaders/calendar.server"; export { action, loader }; @@ -77,25 +73,18 @@ export default function CalendarPage() { className={clsx("stack lg", styles.container)} style={{ "--columns-count": DAYS_SHOWN_AT_A_TIME } as React.CSSProperties} > -
+
- } - daysInterval={previous} - filters={data.filters} - > + } daysInterval={previous}> {t("common:actions.previous")} - } - daysInterval={next} - filters={data.filters} - > + } daysInterval={next}> {t("common:actions.next")}
@@ -108,12 +97,11 @@ export default function CalendarPage() { } url={calendarIcalFeed(data.filters)} /> -
+
+ +
["shown"]; - filters?: CalendarLoaderData["filters"]; }) { + const dayHref = useCalendarDayHref(); + const lowestDate = daysInterval[0]; const highestDate = daysInterval[daysInterval.length - 1]; @@ -159,7 +147,7 @@ function NavigateButton({ return ( @@ -177,24 +165,16 @@ function NavigateButton({ ); } -function CalendarDatePicker({ - dayMonthYear, - filters, -}: { - dayMonthYear: DayMonthYear; - filters?: CalendarLoaderData["filters"]; -}) { +function CalendarDatePicker({ dayMonthYear }: { dayMonthYear: DayMonthYear }) { const navigate = useNavigate(); + const dayHref = useCalendarDayHref(); const onChange = (date: DateValue) => { navigate( - calendarPage({ - filters, - dayMonthYear: { - day: date.day, - month: date.month - 1, - year: date.year, - }, + dayHref({ + day: date.day, + month: date.month - 1, + year: date.year, }), ); }; @@ -216,6 +196,14 @@ function CalendarDatePicker({ ); } +/** Href to another day, carrying the current filter search params over unchanged. */ +function useCalendarDayHref() { + const [params] = useSearchParamsTyped(calendarSearchParams); + + return (dayMonthYear: DayMonthYear) => + calendarSearchParams.href(CALENDAR_PAGE, { ...params, ...dayMonthYear }); +} + /** Centers today's column, leaving weeks that don't contain today scrolled to their first day. */ function scrollTodayToCenter(container: HTMLDivElement | null) { if (!container) return; diff --git a/app/features/components-showcase/routes/components.tsx b/app/features/components-showcase/routes/components.tsx index 2c26d0fb5..e10168f46 100644 --- a/app/features/components-showcase/routes/components.tsx +++ b/app/features/components-showcase/routes/components.tsx @@ -1,6 +1,6 @@ import { parseDate } from "@internationalized/date"; import clsx from "clsx"; -import { Check, Plus, Search, SquarePen, Trash } from "lucide-react"; +import { Check, Plus, RotateCcw, Search, SquarePen, Trash } from "lucide-react"; import { useState } from "react"; import { Ability } from "~/components/Ability"; import { Alert } from "~/components/Alert"; @@ -29,6 +29,7 @@ import { import { toastQueue } from "~/components/elements/Toast"; import { Flag } from "~/components/Flag"; import { FormMessage } from "~/components/FormMessage"; +import { FilterBar } from "~/components/filter-bar/FilterBar"; import { ModeImage, SpecialWeaponImage, @@ -101,6 +102,7 @@ export const SECTIONS = [ { title: "Dialog", id: "dialog", component: DialogSection }, { title: "Popover", id: "popover", component: PopoverSection }, { title: "Menu", id: "menu", component: MenuSection }, + { title: "Filter Bar", id: "filter-bar", component: FilterBarSection }, { title: "Toast", id: "toast", component: ToastSection }, { title: "Divider", id: "divider", component: DividerSection }, { title: "Table", id: "table", component: TableSection }, @@ -1328,6 +1330,80 @@ function MenuSection({ id }: { id: string }) { ); } +const SHOWCASE_MODES = ["SZ", "TC", "RM", "CB"]; +const SHOWCASE_STAGES = ["Scorch Gorge", "Eeltail Alley", "Hagglefish Market"]; + +function FilterBarSection({ id }: { id: string }) { + const [mode, setMode] = useState("SZ"); + const [stage, setStage] = useState(null); + + return ( +
+ Filter Bar + + setMode(null), + popover: ( + + {SHOWCASE_MODES.map((value) => ( + + {value} + + ))} + + ), + }, + { + key: "stage", + name: "Stage", + formattedValue: stage, + onRemove: () => setStage(null), + popover: ( + + {SHOWCASE_STAGES.map((value) => ( + + {value} + + ))} + + ), + }, + ]} + actions={ + mode !== null || stage !== null ? ( + } + onPress={() => { + setMode(null); + setStage(null); + }} + > + Reset + + ) : null + } + /> +
+ ); +} + function ToastSection({ id }: { id: string }) { return (
diff --git a/app/features/lfg/components/LFGAddFilterButton.tsx b/app/features/lfg/components/LFGAddFilterButton.tsx deleted file mode 100644 index 84830f896..000000000 --- a/app/features/lfg/components/LFGAddFilterButton.tsx +++ /dev/null @@ -1,50 +0,0 @@ -import { Filter } from "lucide-react"; -import { useTranslation } from "react-i18next"; -import { SendouButton } from "~/components/elements/Button"; -import { SendouMenu, SendouMenuItem } from "~/components/elements/Menu"; -import type { LFGFilter } from "../lfg-types"; - -const defaultFilters: Record = { - Weapon: { _tag: "Weapon", weaponSplIds: [] }, - Type: { _tag: "Type", type: "PLAYER_FOR_TEAM" }, - Language: { _tag: "Language", language: "en" }, - PlusTier: { _tag: "PlusTier", tier: 3 }, - Timezone: { _tag: "Timezone", maxHourDifference: 3 }, - MinTier: { _tag: "MinTier", tier: "GOLD" }, - MaxTier: { _tag: "MaxTier", tier: "PLATINUM" }, -}; - -export function LFGAddFilterButton({ - filters, - addFilter, -}: { - filters: LFGFilter[]; - addFilter: (filter: LFGFilter) => void; -}) { - const { t } = useTranslation(["lfg"]); - - return ( - } - data-testid="add-filter-button" - > - {t("lfg:addFilter")} - - } - > - {Object.entries(defaultFilters).map(([tag, defaultFilter]) => ( - filter._tag === tag)} - onAction={() => addFilter(defaultFilter)} - > - {t(`lfg:filters.${tag as LFGFilter["_tag"]}`)} - - ))} - - ); -} diff --git a/app/features/lfg/components/LFGFilters.module.css b/app/features/lfg/components/LFGFilters.module.css deleted file mode 100644 index c3acf3c27..000000000 --- a/app/features/lfg/components/LFGFilters.module.css +++ /dev/null @@ -1,5 +0,0 @@ -.filter { - padding: var(--s-1-5) var(--s-2); - background-color: var(--bg-lighter); - border-radius: var(--rounded); -} diff --git a/app/features/lfg/components/LFGFilters.tsx b/app/features/lfg/components/LFGFilters.tsx deleted file mode 100644 index 7c289ed0a..000000000 --- a/app/features/lfg/components/LFGFilters.tsx +++ /dev/null @@ -1,302 +0,0 @@ -import { X } from "lucide-react"; -import { useTranslation } from "react-i18next"; -import * as R from "remeda"; -import { SendouButton } from "~/components/elements/Button"; -import { WeaponImage } from "~/components/Image"; -import { Label } from "~/components/Label"; -import { WeaponSelect } from "~/components/WeaponSelect"; -import type { Tables } from "~/db/tables"; -import type { TierName } from "~/features/mmr/mmr-constants"; -import { TIERS } from "~/features/mmr/mmr-constants"; -import { - languagesUnified, - type UnifiedLanguageCode, -} from "~/modules/i18n/config"; -import type { MainWeaponId } from "~/modules/in-game-lists/types"; -import { LFG } from "../lfg-constants"; -import type { LFGFilter } from "../lfg-types"; - -import styles from "./LFGFilters.module.css"; - -export function LFGFilters({ - filters, - changeFilter, - removeFilterByTag, -}: { - filters: LFGFilter[]; - changeFilter: (newFilter: LFGFilter) => void; - removeFilterByTag: (tag: string) => void; -}) { - if (filters.length === 0) { - return null; - } - - return ( -
- {filters.map((filter) => ( - removeFilterByTag(filter._tag)} - /> - ))} -
- ); -} - -function Filter({ - filter, - changeFilter, - removeFilter, -}: { - filter: LFGFilter; - changeFilter: (newFilter: LFGFilter) => void; - removeFilter: () => void; -}) { - const { t } = useTranslation(["lfg"]); - - return ( -
-
- - } - size="small" - variant="minimal-destructive" - onPress={removeFilter} - aria-label="Delete filter" - /> -
-
- {filter._tag === "Weapon" && ( - - )} - {filter._tag === "Type" && ( - - )} - {filter._tag === "Timezone" && ( - - )} - {filter._tag === "Language" && ( - - )} - {filter._tag === "PlusTier" && ( - - )} - {filter._tag === "MaxTier" && ( - - )} - {filter._tag === "MinTier" && ( - - )} -
-
- ); -} - -function WeaponFilterFields({ - value, - changeFilter, -}: { - value: MainWeaponId[]; - changeFilter: (newFilter: LFGFilter) => void; -}) { - return ( -
- - changeFilter({ - _tag: "Weapon", - weaponSplIds: - value.length >= 10 - ? [...value.slice(1, 10), weaponId] - : [...value, weaponId], - }) - } - key={value.join("-")} - /> - {value.map((weapon) => ( - - changeFilter({ - _tag: "Weapon", - weaponSplIds: value.filter((weaponId) => weaponId !== weapon), - }) - } - > - - - ))} -
- ); -} - -function TypeFilterFields({ - value, - changeFilter, -}: { - value: Tables["LFGPost"]["type"]; - changeFilter: (newFilter: LFGFilter) => void; -}) { - const { t } = useTranslation(["lfg"]); - - return ( -
- -
- ); -} - -function TimezoneFilterFields({ - value, - changeFilter, -}: { - value: number; - changeFilter: (newFilter: LFGFilter) => void; -}) { - return ( -
- { - changeFilter({ - _tag: "Timezone", - maxHourDifference: Number(e.target.value), - }); - }} - /> -
- ); -} - -function LanguageFilterFields({ - value, - changeFilter, -}: { - value: string; - changeFilter: (newFilter: LFGFilter) => void; -}) { - return ( -
- -
- ); -} - -function PlusTierFilterFields({ - value, - changeFilter, -}: { - value: number; - changeFilter: (newFilter: LFGFilter) => void; -}) { - const { t } = useTranslation(["lfg"]); - - return ( -
- -
- ); -} - -function TierFilterFields({ - _tag, - value, - changeFilter, -}: { - _tag: "MaxTier" | "MinTier"; - value: TierName; - changeFilter: (newFilter: LFGFilter) => void; -}) { - return ( -
- -
- ); -} diff --git a/app/features/lfg/core/filtering.test.ts b/app/features/lfg/core/filtering.test.ts index 2cdd2a5d6..40a295e47 100644 --- a/app/features/lfg/core/filtering.test.ts +++ b/app/features/lfg/core/filtering.test.ts @@ -1,4 +1,5 @@ import { afterEach, describe, expect, test, vi } from "vitest"; +import type { LFGFilterValues } from "../lfg-types"; import type { LFGLoaderPost } from "../routes/lfg"; import { filterPosts } from "./filtering"; @@ -9,15 +10,21 @@ const postOfType = (type: LFGLoaderPost["type"]) => team: null, }) as unknown as LFGLoaderPost; +const noFilters: LFGFilterValues = { + weapons: [], + type: null, + timezone: null, + language: null, + plusTier: null, + minTier: null, + maxTier: null, +}; + describe("filterPosts", () => { - test("a weapon filter with no weapons selected shows every post", () => { + test("no weapons selected shows every post", () => { const posts = [postOfType("PLAYER_FOR_TEAM"), postOfType("COACH_FOR_TEAM")]; - const filtered = filterPosts( - posts, - [{ _tag: "Weapon", weaponSplIds: [] }], - new Map(), - ); + const filtered = filterPosts(posts, noFilters, new Map()); expect(filtered).toHaveLength(2); }); @@ -45,7 +52,7 @@ describe("filterPosts", () => { const filtered = filterPosts( [post], - [{ _tag: "Timezone", maxHourDifference: 3 }], + { ...noFilters, timezone: 3 }, new Map(), ); diff --git a/app/features/lfg/core/filtering.ts b/app/features/lfg/core/filtering.ts index 432f1cffe..85990c75c 100644 --- a/app/features/lfg/core/filtering.ts +++ b/app/features/lfg/core/filtering.ts @@ -1,119 +1,135 @@ +import type { TierName } from "~/features/mmr/mmr-constants"; import { compareTwoTiers } from "~/features/mmr/mmr-utils"; import type { MainWeaponId } from "~/modules/in-game-lists/types"; import { mainWeaponIds, weaponIdToBaseWeaponId, } from "~/modules/in-game-lists/weapon-ids"; -import { assertUnreachable } from "~/utils/types"; -import type { LFGFilter } from "../lfg-types"; +import type { LFGFilterValues } from "../lfg-types"; import type { LFGLoaderData, LFGLoaderPost, TiersMap } from "../routes/lfg"; import { hourDifferenceBetweenTimezones } from "./timezone"; export function filterPosts( posts: LFGLoaderData["posts"], - filters: LFGFilter[], + filters: LFGFilterValues, tiersMap: TiersMap, ) { - return posts.filter((post) => { - for (const filter of filters) { - if (!filterMatchesPost(post, filter, tiersMap)) return false; + return posts.filter((post) => postMatchesFilters(post, filters, tiersMap)); +} + +function postMatchesFilters( + post: LFGLoaderPost, + filters: LFGFilterValues, + tiersMap: TiersMap, +) { + if ( + post.type === "COACH_FOR_TEAM" && + // not visible in the UI + (filters.weapons.length > 0 || + filters.minTier !== null || + filters.maxTier !== null) + ) { + return false; + } + + if (filters.weapons.length > 0 && !matchesWeapons(post, filters.weapons)) { + return false; + } + if (filters.type !== null && post.type !== filters.type) return false; + if (filters.timezone !== null && !matchesTimezone(post, filters.timezone)) { + return false; + } + if ( + filters.language !== null && + !post.languages?.includes(filters.language) + ) { + return false; + } + if (filters.plusTier !== null && !matchesPlusTier(post, filters.plusTier)) { + return false; + } + if ( + filters.maxTier !== null && + !matchesMaxTier(post, filters.maxTier, tiersMap) + ) { + return false; + } + if ( + filters.minTier !== null && + !matchesMinTier(post, filters.minTier, tiersMap) + ) { + return false; + } + + return true; +} + +function matchesWeapons(post: LFGLoaderPost, weapons: MainWeaponId[]) { + const weaponIdsWithRelated = weapons.flatMap(weaponIdToRelated); + + return checkMatchesSomeUserInPost(post, (user) => + user.weaponPool.some(({ weaponSplId }) => + weaponIdsWithRelated.includes(weaponSplId), + ), + ); +} + +function matchesTimezone(post: LFGLoaderPost, maxHourDifference: number) { + const userTimezone = Intl.DateTimeFormat().resolvedOptions().timeZone; + + return ( + Math.abs(hourDifferenceBetweenTimezones(post.timezone, userTimezone)) <= + maxHourDifference + ); +} + +function matchesPlusTier(post: LFGLoaderPost, plusTier: number) { + return checkMatchesSomeUserInPost( + post, + (user) => user.plusTier && user.plusTier <= plusTier, + ); +} + +function matchesMaxTier( + post: LFGLoaderPost, + maxTier: TierName, + tiersMap: TiersMap, +) { + return checkMatchesSomeUserInPost(post, (user) => { + const tiers = tiersMap.get(user.id); + if (!tiers) return false; + + if (tiers.latest && compareTwoTiers(tiers.latest.name, maxTier) >= 0) { + return true; } - return true; + if (tiers.previous && compareTwoTiers(tiers.previous.name, maxTier) >= 0) { + return true; + } + + return false; }); } -function filterMatchesPost( +function matchesMinTier( post: LFGLoaderPost, - filter: LFGFilter, + minTier: TierName, tiersMap: TiersMap, ) { - if (post.type === "COACH_FOR_TEAM") { - // not visible in the UI - if ( - (filter._tag === "Weapon" && filter.weaponSplIds.length > 0) || - filter._tag === "MaxTier" || - filter._tag === "MinTier" - ) { - return false; + return checkMatchesSomeUserInPost(post, (user) => { + const tiers = tiersMap.get(user.id); + if (!tiers) return false; + + if (tiers.latest && compareTwoTiers(tiers.latest.name, minTier) <= 0) { + return true; } - } - switch (filter._tag) { - case "Weapon": { - if (filter.weaponSplIds.length === 0) return true; - - const weaponIdsWithRelated = - filter.weaponSplIds.flatMap(weaponIdToRelated); - - return checkMatchesSomeUserInPost(post, (user) => - user.weaponPool.some(({ weaponSplId }) => - weaponIdsWithRelated.includes(weaponSplId), - ), - ); + if (tiers.previous && compareTwoTiers(tiers.previous.name, minTier) <= 0) { + return true; } - case "Type": - return post.type === filter.type; - case "Timezone": { - const userTimezone = Intl.DateTimeFormat().resolvedOptions().timeZone; - return ( - Math.abs(hourDifferenceBetweenTimezones(post.timezone, userTimezone)) <= - filter.maxHourDifference - ); - } - case "Language": - return !!post.languages?.includes(filter.language); - case "PlusTier": - return checkMatchesSomeUserInPost( - post, - (user) => user.plusTier && user.plusTier <= filter.tier, - ); - case "MaxTier": - return checkMatchesSomeUserInPost(post, (user) => { - const tiers = tiersMap.get(user.id); - if (!tiers) return false; - - if ( - tiers.latest && - compareTwoTiers(tiers.latest.name, filter.tier) >= 0 - ) { - return true; - } - - if ( - tiers.previous && - compareTwoTiers(tiers.previous.name, filter.tier) >= 0 - ) { - return true; - } - - return false; - }); - case "MinTier": - return checkMatchesSomeUserInPost(post, (user) => { - const tiers = tiersMap.get(user.id); - if (!tiers) return false; - - if ( - tiers.latest && - compareTwoTiers(tiers.latest.name, filter.tier) <= 0 - ) { - return true; - } - - if ( - tiers.previous && - compareTwoTiers(tiers.previous.name, filter.tier) <= 0 - ) { - return true; - } - - return false; - }); - default: - assertUnreachable(filter); - } + return false; + }); } const checkMatchesSomeUserInPost = ( diff --git a/app/features/lfg/lfg-constants.ts b/app/features/lfg/lfg-constants.ts index d5dfd4196..b9b05598e 100644 --- a/app/features/lfg/lfg-constants.ts +++ b/app/features/lfg/lfg-constants.ts @@ -15,6 +15,7 @@ export const LFG = { MIN_TEXT_LENGTH: 1, MAX_TEXT_LENGTH: 2_000, POST_FRESHNESS_DAYS: 30 as const, + MAX_WEAPON_FILTERS: 10, types: LFG_TYPES, }; diff --git a/app/features/lfg/lfg-search-params.test.ts b/app/features/lfg/lfg-search-params.test.ts index 3bc9d06ca..d609a0864 100644 --- a/app/features/lfg/lfg-search-params.test.ts +++ b/app/features/lfg/lfg-search-params.test.ts @@ -4,36 +4,31 @@ import { assertRoundTrips, } from "~/modules/search-params/search-params-test-utils"; import { lfgNewSearchParams, lfgSearchParams } from "./lfg-search-params"; -import type { LFGFilter } from "./lfg-types"; - -const weaponFilter: LFGFilter = { _tag: "Weapon", weaponSplIds: [0, 10] }; -const typeFilter: LFGFilter = { _tag: "Type", type: "PLAYER_FOR_TEAM" }; -const timezoneFilter: LFGFilter = { _tag: "Timezone", maxHourDifference: 3 }; -const languageFilter: LFGFilter = { _tag: "Language", language: "en" }; -const plusTierFilter: LFGFilter = { _tag: "PlusTier", tier: 1 }; -const maxTierFilter: LFGFilter = { _tag: "MaxTier", tier: "GOLD" }; -const minTierFilter: LFGFilter = { _tag: "MinTier", tier: "BRONZE" }; - -// the filter LFGAddFilterButton inserts when the user picks "Weapon" -const emptyWeaponFilter: LFGFilter = { _tag: "Weapon", weaponSplIds: [] }; describe("lfgSearchParams", () => { it("round-trips", () => { assertRoundTrips(lfgSearchParams, { - q: [ - [], - [weaponFilter], - [emptyWeaponFilter], - [typeFilter], - [timezoneFilter], - [languageFilter], - [plusTierFilter], - [maxTierFilter], - [minTierFilter], - [weaponFilter, typeFilter, minTierFilter], - ], + weapons: [[], [0], [0, 10, 4001]], + type: [null, "PLAYER_FOR_TEAM", "COACH_FOR_TEAM"], + timezone: [null, 0, 3, 12], + language: [null, "en", "ja"], + plusTier: [null, 1, 3], + minTier: [null, "GOLD", "LEVIATHAN"], + maxTier: [null, "PLATINUM", "IRON"], }); }); + + it("decodes garbage to defaults", () => { + assertDecodesToDefault(lfgSearchParams, "type", [["NOT_A_TYPE"], [""]]); + assertDecodesToDefault(lfgSearchParams, "timezone", [ + ["13"], + ["-1"], + ["abc"], + ]); + assertDecodesToDefault(lfgSearchParams, "language", [["xx"]]); + assertDecodesToDefault(lfgSearchParams, "plusTier", [["0"], ["4"]]); + assertDecodesToDefault(lfgSearchParams, "minTier", [["gold"], ["XX"]]); + }); }); describe("lfgNewSearchParams", () => { diff --git a/app/features/lfg/lfg-search-params.ts b/app/features/lfg/lfg-search-params.ts index 4d153bb1c..b1116f6ab 100644 --- a/app/features/lfg/lfg-search-params.ts +++ b/app/features/lfg/lfg-search-params.ts @@ -1,29 +1,36 @@ import { z } from "zod"; +import { TIERS, type TierName } from "~/features/mmr/mmr-constants"; +import { + languagesUnified, + type UnifiedLanguageCode, +} from "~/modules/i18n/config"; +import { mainWeaponIds } from "~/modules/in-game-lists/weapon-ids"; import * as SearchParams from "~/modules/search-params/search-params"; import { SP } from "~/modules/search-params/search-params"; -import { - filterToSmallStr, - type LFGFilter, - smallStrToFilter, -} from "./lfg-types"; +import { numericEnum } from "~/utils/zod"; +import { LFG, LFG_TYPES } from "./lfg-constants"; -const lfgFiltersCodec = z.codec( - z.string(), - z.custom((value) => Array.isArray(value)), - { - decode: (queryString) => - queryString === "" - ? [] - : queryString - .split("-") - .map(smallStrToFilter) - .filter((filter) => filter !== null), - encode: (filters) => filters.map(filterToSmallStr).join("-"), - }, -); +const LANGUAGE_CODES = languagesUnified.map((language) => language.code) as [ + UnifiedLanguageCode, + ...UnifiedLanguageCode[], +]; +const TIER_NAMES = TIERS.map((tier) => tier.name) as [TierName, ...TierName[]]; export const lfgSearchParams = SearchParams.define({ - q: SP.custom(lfgFiltersCodec, { default: [], loader: false }), + weapons: SP.param( + z.array(numericEnum(mainWeaponIds)).max(LFG.MAX_WEAPON_FILTERS), + { default: [], loader: false }, + ), + type: SP.param(z.enum(LFG_TYPES).nullable(), { loader: false }), + timezone: SP.param(z.number().int().min(0).max(12).nullable(), { + loader: false, + }), + language: SP.param(z.enum(LANGUAGE_CODES).nullable(), { loader: false }), + plusTier: SP.param(z.number().int().min(1).max(3).nullable(), { + loader: false, + }), + minTier: SP.param(z.enum(TIER_NAMES).nullable(), { loader: false }), + maxTier: SP.param(z.enum(TIER_NAMES).nullable(), { loader: false }), }); export const lfgNewSearchParams = SearchParams.define({ diff --git a/app/features/lfg/lfg-types.ts b/app/features/lfg/lfg-types.ts index 450afb334..3483ae4f2 100644 --- a/app/features/lfg/lfg-types.ts +++ b/app/features/lfg/lfg-types.ts @@ -1,160 +1,14 @@ -import { LFG_TYPES, type LFGType } from "~/features/lfg/lfg-constants"; -import { - languagesUnified, - type UnifiedLanguageCode, -} from "~/modules/i18n/config"; +import type { LFGType } from "~/features/lfg/lfg-constants"; +import type { TierName } from "~/features/mmr/mmr-constants"; +import type { UnifiedLanguageCode } from "~/modules/i18n/config"; import type { MainWeaponId } from "~/modules/in-game-lists/types"; -import { assertUnreachable } from "~/utils/types"; -import { TIERS, type TierName } from "../mmr/mmr-constants"; -export type LFGFilter = - | WeaponFilter - | TypeFilter - | TimezoneFilter - | LanguageFilter - | PlusTierFilter - | MaxTierFilter - | MinTierFilter; - -type WeaponFilter = { - _tag: "Weapon"; - weaponSplIds: MainWeaponId[]; -}; - -type TypeFilter = { - _tag: "Type"; - type: LFGType; -}; - -type TimezoneFilter = { - _tag: "Timezone"; - maxHourDifference: number; -}; - -type LanguageFilter = { - _tag: "Language"; - language: UnifiedLanguageCode; -}; - -type PlusTierFilter = { - _tag: "PlusTier"; - tier: number; -}; - -type MaxTierFilter = { - _tag: "MaxTier"; - tier: TierName; -}; - -type MinTierFilter = { - _tag: "MinTier"; - tier: TierName; -}; - -const typeToNum = new Map(LFG_TYPES.map((tier, index) => [tier, `${index}`])); - -const numToType = new Map( - Array.from(typeToNum).map(([type, num]) => [`${num}`, type]), -); - -const tierToNum = new Map( - TIERS.map((tier, index) => { - return [tier.name, `${index}`]; - }), -); - -const numToTier = new Map( - Array.from(tierToNum).map(([tier, num]) => [`${num}`, tier]), -); - -export function filterToSmallStr(filter: LFGFilter): string { - switch (filter._tag) { - case "Weapon": { - const weapons = filter.weaponSplIds.map((wid) => `${wid}`).join(","); - return `w.${weapons}`; - } - case "Type": - return `t.${typeToNum.get(filter.type)}`; - case "Timezone": - return `tz.${filter.maxHourDifference}`; - case "Language": - return `l.${filter.language}`; - case "PlusTier": - return `pt.${filter.tier}`; - case "MaxTier": - return `mx.${tierToNum.get(filter.tier)}`; - case "MinTier": - return `mn.${tierToNum.get(filter.tier)}`; - default: - assertUnreachable(filter); - } -} - -export function smallStrToFilter(s: string): LFGFilter | null { - const [tag, val] = s.split("."); - if (!tag || val === undefined) return null; - - switch (tag) { - case "w": { - // an empty weapon filter is valid, it's what the add filter button inserts - const weaponIds = val - .split(",") - .filter(Boolean) - .map((x) => Number.parseInt(x, 10) as MainWeaponId) - .filter((x) => !Number.isNaN(x)); - return { - _tag: "Weapon", - weaponSplIds: weaponIds, - }; - } - case "t": { - const filterType = numToType.get(val); - if (!filterType) return null; - return { - _tag: "Type", - type: filterType, - }; - } - case "tz": { - const n = Number.parseInt(val, 10); - if (Number.isNaN(n)) return null; - return { - _tag: "Timezone", - maxHourDifference: n, - }; - } - case "l": { - const language = languagesUnified.find((lang) => lang.code === val)?.code; - if (!language) return null; - return { - _tag: "Language", - language, - }; - } - case "pt": { - const n = Number.parseInt(val, 10); - if (Number.isNaN(n)) return null; - return { - _tag: "PlusTier", - tier: n, - }; - } - case "mx": { - const tier = numToTier.get(val); - if (!tier) return null; - return { - _tag: "MaxTier", - tier: tier, - }; - } - case "mn": { - const tier = numToTier.get(val); - if (!tier) return null; - return { - _tag: "MinTier", - tier: tier, - }; - } - } - return null; +export interface LFGFilterValues { + weapons: MainWeaponId[]; + type: LFGType | null; + timezone: number | null; + language: UnifiedLanguageCode | null; + plusTier: number | null; + minTier: TierName | null; + maxTier: TierName | null; } diff --git a/app/features/lfg/routes/lfg.module.css b/app/features/lfg/routes/lfg.module.css index 221073e6e..0ef45816d 100644 --- a/app/features/lfg/routes/lfg.module.css +++ b/app/features/lfg/routes/lfg.module.css @@ -1,8 +1,3 @@ -.topRow { - display: flex; - justify-content: flex-end; -} - .post { scroll-margin-top: 6rem; } diff --git a/app/features/lfg/routes/lfg.tsx b/app/features/lfg/routes/lfg.tsx index c8ae60913..a2fad104e 100644 --- a/app/features/lfg/routes/lfg.tsx +++ b/app/features/lfg/routes/lfg.tsx @@ -4,19 +4,25 @@ import React from "react"; import { useTranslation } from "react-i18next"; import type { MetaFunction } from "react-router"; import { useLoaderData } from "react-router"; +import * as R from "remeda"; import { ActionButton } from "~/components/ActionButton"; import { Alert } from "~/components/Alert"; +import { SendouButton } from "~/components/elements/Button"; +import { FilterBar } from "~/components/filter-bar/FilterBar"; +import { WeaponImage } from "~/components/Image"; import { Main } from "~/components/Main"; +import { WeaponSelect } from "~/components/WeaponSelect"; import { useUser } from "~/features/auth/core/user"; -import { useSearchParam } from "~/modules/search-params/hooks"; +import { TIERS } from "~/features/mmr/mmr-constants"; +import { languagesUnified } from "~/modules/i18n/config"; +import type { MainWeaponId } from "~/modules/in-game-lists/types"; +import { useSearchParamsTyped } from "~/modules/search-params/hooks"; import { databaseTimestampToDate } from "~/utils/dates"; import { metaTags, type SerializeFrom } from "~/utils/remix"; import type { SendouRouteHandle } from "~/utils/remix.server"; import type { Unpacked } from "~/utils/types"; import { LFG_PAGE, navIconUrl } from "~/utils/urls"; import { action } from "../actions/lfg.server"; -import { LFGAddFilterButton } from "../components/LFGAddFilterButton"; -import { LFGFilters } from "../components/LFGFilters"; import { LFGPost } from "../components/LFGPost"; import { filterPosts } from "../core/filtering"; import { LFG } from "../lfg-constants"; @@ -57,11 +63,11 @@ export default function LFGPage() { const { t } = useTranslation(["common", "lfg"]); const user = useUser(); const data = useLoaderData(); - const [filters, setFilters] = useSearchParam(lfgSearchParams, "q"); + const [filterValues] = useSearchParamsTyped(lfgSearchParams); const tiersMap = React.useMemo(() => unserializeTiers(data), [data]); - const filteredPosts = filterPosts(data.posts, filters, tiersMap); + const filteredPosts = filterPosts(data.posts, filterValues, tiersMap); const showExpiryAlert = (post: Unpacked) => { if (post.author.id !== user?.id) return false; @@ -78,25 +84,7 @@ export default function LFGPage() { return (
-
- setFilters([...filters, newFilter])} - filters={filters} - /> -
- - setFilters( - filters.map((filter) => - filter._tag === newFilter._tag ? newFilter : filter, - ), - ) - } - removeFilterByTag={(tag) => - setFilters(filters.filter((filter) => filter._tag !== tag)) - } - /> + {filteredPosts.map((post) => (
0 ? ( + + {weapons.map((weaponSplId) => ( + + ))} + + ) : null, + onRemove: () => setParams({ weapons: [] }), + popover: ( + setParams({ weapons: newWeapons })} + /> + ), + }, + { + key: "type", + name: t("lfg:filters.Type"), + formattedValue: type !== null ? t(`lfg:types.${type}`) : null, + onAdd: () => setParams({ type: "PLAYER_FOR_TEAM" }), + onRemove: () => setParams({ type: null }), + popover: ( + + ), + }, + { + key: "language", + name: t("lfg:filters.Language"), + formattedValue: + language !== null + ? (languagesUnified.find((lang) => lang.code === language) + ?.name ?? language) + : null, + onAdd: () => setParams({ language: "en" }), + onRemove: () => setParams({ language: null }), + popover: ( + + ), + }, + { + key: "plusTier", + name: t("lfg:filters.PlusTier"), + formattedValue: + plusTier !== null + ? plusTier === 1 + ? "+1" + : `+${plusTier} ${t("lfg:filters.orAbove")}` + : null, + onAdd: () => setParams({ plusTier: 3 }), + onRemove: () => setParams({ plusTier: null }), + popover: ( + + ), + }, + { + key: "timezone", + name: t("lfg:filters.Timezone"), + formattedValue: timezone !== null ? `±${timezone}h` : null, + onAdd: () => setParams({ timezone: 3 }), + onRemove: () => setParams({ timezone: null }), + popover: ( + setParams({ timezone: Number(e.target.value) })} + /> + ), + }, + { + key: "minTier", + name: t("lfg:filters.MinTier"), + formattedValue: + minTier !== null ? R.capitalize(minTier.toLowerCase()) : null, + onAdd: () => setParams({ minTier: "GOLD" }), + onRemove: () => setParams({ minTier: null }), + popover: ( + setParams({ minTier: tier })} + /> + ), + }, + { + key: "maxTier", + name: t("lfg:filters.MaxTier"), + formattedValue: + maxTier !== null ? R.capitalize(maxTier.toLowerCase()) : null, + onAdd: () => setParams({ maxTier: "PLATINUM" }), + onRemove: () => setParams({ maxTier: null }), + popover: ( + setParams({ maxTier: tier })} + /> + ), + }, + ]} + /> + ); +} + +function WeaponsPopover({ + weapons, + onChange, +}: { + weapons: MainWeaponId[]; + onChange: (weapons: MainWeaponId[]) => void; +}) { + return ( +
+ + onChange( + weapons.length >= LFG.MAX_WEAPON_FILTERS + ? [...weapons.slice(1, LFG.MAX_WEAPON_FILTERS), weaponId] + : [...weapons, weaponId], + ) + } + key={weapons.join("-")} + /> + {weapons.length > 0 ? ( +
+ {weapons.map((weapon) => ( + + onChange(weapons.filter((weaponId) => weaponId !== weapon)) + } + > + + + ))} +
+ ) : null} +
+ ); +} + +function TierSelect({ + label, + value, + onChange, +}: { + label: string; + value: (typeof TIERS)[number]["name"]; + onChange: (tier: (typeof TIERS)[number]["name"]) => void; +}) { + return ( + + ); +} + function PostExpiryAlert({ postId }: { postId: number }) { const { t } = useTranslation(["common", "lfg"]); diff --git a/app/features/scrims/components/ScrimFiltersDialog.tsx b/app/features/scrims/components/ScrimFiltersDialog.tsx deleted file mode 100644 index 07609c38e..000000000 --- a/app/features/scrims/components/ScrimFiltersDialog.tsx +++ /dev/null @@ -1,126 +0,0 @@ -import { Funnel } from "lucide-react"; -import * as React from "react"; -import { useTranslation } from "react-i18next"; -import type { z } from "zod"; -import { SendouButton } from "~/components/elements/Button"; -import { SendouDialog } from "~/components/elements/Dialog"; -import { useUser } from "~/features/auth/core/user"; -import type { ScrimFilters } from "~/features/scrims/scrims-types"; -import { SendouForm, useFormFieldContext } from "~/form/SendouForm"; -import { useSearchParamsTyped } from "~/modules/search-params/hooks"; -import { scrimsFiltersFormSchema } from "../scrims-schemas"; -import { scrimsSearchParams } from "../scrims-search-params"; -import type { LutiDiv } from "../scrims-types"; - -type FormValues = z.infer; - -export function ScrimFiltersDialog({ filters }: { filters: ScrimFilters }) { - const { t } = useTranslation(["scrims"]); - const [isOpen, setIsOpen] = React.useState(false); - - return ( - <> - } - onPress={() => setIsOpen(true)} - data-testid="filter-scrims-button" - > - {t("scrims:filters.button")} - - setIsOpen(false)} - > - { - setIsOpen(false); - }} - /> - - - ); -} - -function filtersToFormValues(filters: ScrimFilters): FormValues { - return { - weekdayTimes: filters.weekdayTimes, - weekendTimes: filters.weekendTimes, - divs: filters.divs ? [filters.divs.max, filters.divs.min] : [null, null], - }; -} - -function formValuesToFilters(values: FormValues): ScrimFilters { - const [max, min] = values.divs ?? [null, null]; - return { - weekdayTimes: values.weekdayTimes, - weekendTimes: values.weekendTimes, - divs: - max || min - ? { max: max as LutiDiv | null, min: min as LutiDiv | null } - : null, - }; -} - -function FiltersForm({ - filters, - closeDialog, -}: { - filters: ScrimFilters; - closeDialog: () => void; -}) { - const user = useUser(); - const { t } = useTranslation(["scrims"]); - const [, setSearchParams] = useSearchParamsTyped(scrimsSearchParams); - - const defaultValues = filtersToFormValues(filters); - - const handleApply = (values: FormValues) => { - setSearchParams({ filters: formValuesToFilters(values) }); - closeDialog(); - }; - - return ( - : null} - > - {({ FormField }) => ( - <> - - - - - )} - - ); -} - -function ApplyAndPersistButton() { - const { t } = useTranslation(["scrims"]); - const { values, submitToServer, fetcherState } = useFormFieldContext(); - - const handlePress = () => { - submitToServer({ - _action: "PERSIST_SCRIM_FILTERS", - filters: formValuesToFilters(values as FormValues), - }); - }; - - return ( - - {t("scrims:filters.applyAndDefault")} - - ); -} diff --git a/app/features/scrims/loaders/scrims.server.ts b/app/features/scrims/loaders/scrims.server.ts index 81a7dfea0..70d578157 100644 --- a/app/features/scrims/loaders/scrims.server.ts +++ b/app/features/scrims/loaders/scrims.server.ts @@ -17,11 +17,16 @@ export const loader = async ({ request }: LoaderFunctionArgs) => { ? await AssociationsRepository.findByMemberUserId(user?.id) : null; - const filtersFromSearchParams = scrimsSearchParams.parse(request).filters; + const { weekdayTimes, weekendTimes, divs, useDefaults } = + scrimsSearchParams.parse(request); + const filtersFromSearchParams = { weekdayTimes, weekendTimes, divs }; - const filters = Scrim.filtersAreDefault(filtersFromSearchParams) - ? (user?.preferences?.defaultScrimsFilters ?? Scrim.defaultFilters()) - : filtersFromSearchParams; + // when the user cleared or edited the filters the URL is the whole truth + // even when it ends up holding no filters at all + const filters = + useDefaults && Scrim.filtersAreDefault(filtersFromSearchParams) + ? (user?.preferences?.defaultScrimsFilters ?? Scrim.defaultFilters()) + : filtersFromSearchParams; const posts = (await ScrimPostRepository.findAllRelevant()) .filter( @@ -57,5 +62,11 @@ export const loader = async ({ request }: LoaderFunctionArgs) => { posts: dividePosts(posts, user?.id), teams: user ? await TeamRepository.findAllByMemberUserId(user.id) : [], filters, + canSaveAsDefault: + user != null && + !R.isDeepEqual( + filters, + user.preferences?.defaultScrimsFilters ?? Scrim.defaultFilters(), + ), }; }; diff --git a/app/features/scrims/routes/scrims.tsx b/app/features/scrims/routes/scrims.tsx index fdd62d4ee..04a5f5625 100644 --- a/app/features/scrims/routes/scrims.tsx +++ b/app/features/scrims/routes/scrims.tsx @@ -7,14 +7,22 @@ import { useLoaderData } from "react-router"; import * as R from "remeda"; import type { z } from "zod"; import { LinkButton, SendouButton } from "~/components/elements/Button"; +import { FilterBar } from "~/components/filter-bar/FilterBar"; import { LocaleTime } from "~/components/LocaleTime"; import { useUser } from "~/features/auth/core/user"; +import { DualSelectFormField } from "~/form/fields/DualSelectFormField"; +import { TimeRangeFormField } from "~/form/fields/TimeRangeFormField"; +import { useActionSubmit } from "~/hooks/useActionSubmit"; import { useHydrated } from "~/hooks/useHydrated"; -import { useSearchParam } from "~/modules/search-params/hooks"; +import { + useSearchParam, + useSearchParamsTyped, +} from "~/modules/search-params/hooks"; import { databaseTimestampToDate } from "~/utils/dates"; import { metaTags } from "~/utils/remix"; import type { SendouRouteHandle } from "~/utils/remix.server"; import { associationsPage, navIconUrl, scrimsPage } from "~/utils/urls"; +import { timeString } from "~/utils/zod"; import { SendouTab, SendouTabList, @@ -24,16 +32,16 @@ import { import { Main } from "../../../components/Main"; import { action } from "../actions/scrims.server"; import { ScrimPostCard, ScrimRequestCard } from "../components/ScrimCard"; -import { ScrimFiltersDialog } from "../components/ScrimFiltersDialog"; import * as Scrim from "../core/Scrim"; import { loader } from "../loaders/scrims.server"; -import type { newRequestSchema } from "../scrims-schemas"; +import { LUTI_DIVS } from "../scrims-constants"; +import { type newRequestSchema, scrimsActionSchema } from "../scrims-schemas"; import { scrimsSearchParams } from "../scrims-search-params"; -import type { ScrimFilters, ScrimPost } from "../scrims-types"; +import type { LutiDiv, ScrimFilters, ScrimPost } from "../scrims-types"; export { action, loader }; -import { Check, Download, Funnel, Megaphone } from "lucide-react"; +import { Check, Download, Funnel, Megaphone, Star } from "lucide-react"; import styles from "./scrims.module.css"; @@ -87,23 +95,16 @@ export default function ScrimsPage() { return (
-
-
- - {t("scrims:associations.title")} - - {user ? ( - - ) : null} -
+
+ + {t("scrims:associations.title")} + +
(); + const [, setParams] = useSearchParamsTyped(scrimsSearchParams); + const persistFilters = useActionSubmit(scrimsActionSchema, { + encType: "application/json", + }); + + const filters = data.filters; + + const writeFilters = (partial: Partial) => { + setParams({ ...filters, ...partial, useDefaults: false }); + }; + + return ( + writeFilters({ weekdayTimes: null }), + testId: "weekday-times-filter", + popover: ( + + writeFilters({ weekdayTimes: timeRange }) + } + /> + ), + }, + { + key: "weekendTimes", + name: t("scrims:filters.weekendTimes"), + formattedValue: filters.weekendTimes + ? `${filters.weekendTimes.start}–${filters.weekendTimes.end}` + : null, + onRemove: () => writeFilters({ weekendTimes: null }), + testId: "weekend-times-filter", + popover: ( + + writeFilters({ weekendTimes: timeRange }) + } + /> + ), + }, + { + key: "divs", + name: t("scrims:filters.divs"), + formattedValue: filters.divs + ? `${filters.divs.max}–${filters.divs.min}` + : null, + onRemove: () => writeFilters({ divs: null }), + testId: "divs-filter", + popover: ( + writeFilters({ divs })} + /> + ), + }, + ]} + onReset={ + !Scrim.filtersAreDefault(filters) + ? () => + writeFilters({ + weekdayTimes: null, + weekendTimes: null, + divs: null, + }) + : undefined + } + actions={ + data.canSaveAsDefault ? ( + } + isDisabled={persistFilters.state !== "idle"} + onPress={() => + persistFilters.submit("PERSIST_SCRIM_FILTERS", { filters }) + } + data-testid="save-filters-as-default-button" + > + {t("common:filterBar.saveAsDefault")} + + ) : null + } + /> + ); +} + +function TimeRangePopover({ + name, + value, + onChange, +}: { + name: string; + value: ScrimFilters["weekdayTimes"]; + onChange: (value: ScrimFilters["weekdayTimes"]) => void; +}) { + const { t } = useTranslation(["forms"]); + const [draft, setDraft] = React.useState(value); + + const handleChange = (timeRange: { start: string; end: string } | null) => { + setDraft(timeRange); + + if (timeRange === null) { + onChange(null); + return; + } + + if ( + timeString.safeParse(timeRange.start).success && + timeString.safeParse(timeRange.end).success + ) { + onChange(timeRange); + } + }; + + return ( + + ); +} + +function DivsPopover({ + value, + onChange, +}: { + value: ScrimFilters["divs"]; + onChange: (value: ScrimFilters["divs"]) => void; +}) { + const { t } = useTranslation(["forms"]); + const [draft, setDraft] = React.useState<[LutiDiv | null, LutiDiv | null]>([ + value?.max ?? null, + value?.min ?? null, + ]); + + const divItems = LUTI_DIVS.map((div) => ({ label: div, value: div })); + + const handleChange = (newValue: [LutiDiv | null, LutiDiv | null]) => { + setDraft(newValue); + + const [max, min] = newValue; + if (max !== null && min !== null) { + onChange({ max, min }); + } else if (max === null && min === null) { + onChange(null); + } + }; + + return ( + {}} + /> + ); +} + function ScrimsDaySeparatedCards({ posts, filters, diff --git a/app/features/scrims/scrims-schemas.ts b/app/features/scrims/scrims-schemas.ts index 62c68b73f..5ccd08000 100644 --- a/app/features/scrims/scrims-schemas.ts +++ b/app/features/scrims/scrims-schemas.ts @@ -14,7 +14,6 @@ import { textArea, textAreaOptional, textFieldOptional, - timeRangeOptional, toggle, tournamentSearchOptional, } from "~/form/fields"; @@ -90,7 +89,7 @@ const timeRangeSchema = z.object({ end: timeString, }); -export const divsSchema = z +const divsBaseSchema = z .object({ min: z.enum(LUTI_DIVS).nullable(), max: z.enum(LUTI_DIVS).nullable(), @@ -107,26 +106,53 @@ export const divsSchema = z { message: "forms:errors.divBothOrNeither", }, - ) - .transform((divs) => { - if (!divs.min || !divs.max) return divs; + ); - const minIndex = LUTI_DIVS.indexOf(divs.min); - const maxIndex = LUTI_DIVS.indexOf(divs.max); +export const divsSchema = divsBaseSchema.transform(normalizeDivs); - if (maxIndex > minIndex) { - return { min: divs.max, max: divs.min }; - } +function normalizeDivs( + divs: T, +): T { + if (!divs.min || !divs.max) return divs; - return divs; - }); + const minIndex = LUTI_DIVS.indexOf(divs.min as (typeof LUTI_DIVS)[number]); + const maxIndex = LUTI_DIVS.indexOf(divs.max as (typeof LUTI_DIVS)[number]); + if (minIndex === -1 || maxIndex === -1) return divs; -export const scrimsFiltersSchema = z.object({ + if (maxIndex > minIndex) { + return { ...divs, min: divs.max, max: divs.min }; + } + + return divs; +} + +const scrimsFiltersSchema = z.object({ weekdayTimes: timeRangeSchema.nullable().catch(null), weekendTimes: timeRangeSchema.nullable().catch(null), divs: divsSchema.nullable().catch(null), }); +export const timeRangeCodec = z.codec(z.string(), timeRangeSchema.nullable(), { + decode: (encoded) => { + if (encoded[5] !== "-") return null; + + return { start: encoded.slice(0, 5), end: encoded.slice(6) }; + }, + encode: (timeRange) => + timeRange === null ? "" : `${timeRange.start}-${timeRange.end}`, +}); + +export const divsCodec = z.codec(z.string(), divsBaseSchema.nullable(), { + decode: (encoded) => { + const [max, min] = encoded.split("-"); + + return normalizeDivs({ max: max ?? null, min: min ?? null }) as z.output< + typeof divsBaseSchema + >; + }, + encode: (divs) => (divs === null ? "" : `${divs.max}-${divs.min}`), +}); + const divsFormField = dualSelectOptional({ fields: [ { @@ -147,20 +173,6 @@ const divsFormField = dualSelectOptional({ }, }); -export const scrimsFiltersFormSchema = z.object({ - weekdayTimes: timeRangeOptional({ - label: "labels.weekdayTimes", - startLabel: "labels.start", - endLabel: "labels.end", - }), - weekendTimes: timeRangeOptional({ - label: "labels.weekendTimes", - startLabel: "labels.start", - endLabel: "labels.end", - }), - divs: divsFormField, -}); - const persistScrimFiltersSchema = z.object({ _action: _action("PERSIST_SCRIM_FILTERS"), filters: scrimsFiltersSchema, diff --git a/app/features/scrims/scrims-search-params.test.ts b/app/features/scrims/scrims-search-params.test.ts index 73df1a1ba..c3e291945 100644 --- a/app/features/scrims/scrims-search-params.test.ts +++ b/app/features/scrims/scrims-search-params.test.ts @@ -1,9 +1,8 @@ -import { describe, expect, it } from "vitest"; +import { describe, it } from "vitest"; import { assertDecodesToDefault, assertRoundTrips, } from "~/modules/search-params/search-params-test-utils"; -import * as Scrim from "./core/Scrim"; import { scrimsSearchParams } from "./scrims-search-params"; describe("scrimsSearchParams", () => { @@ -11,39 +10,38 @@ describe("scrimsSearchParams", () => { // divs examples are in the normalized shape the divsSchema transform // produces (max is the higher div) so decode(encode(x)) equals x assertRoundTrips(scrimsSearchParams, { - filters: [ - Scrim.defaultFilters(), - { - weekdayTimes: { start: "18:00", end: "22:30" }, - weekendTimes: { start: "10:00", end: "23:59" }, - divs: { min: "5", max: "1" }, - }, - { - weekdayTimes: null, - weekendTimes: { start: "00:00", end: "12:00" }, - divs: { min: "3", max: "3" }, - }, - { - weekdayTimes: null, - weekendTimes: null, - divs: { min: "11", max: "X" }, - }, - { - weekdayTimes: { start: "20:00", end: "02:00" }, - weekendTimes: null, - divs: { min: null, max: null }, - }, + weekdayTimes: [ + null, + { start: "18:00", end: "22:30" }, + { start: "00:00", end: "23:59" }, + { start: "20:00", end: "02:00" }, + ], + weekendTimes: [null, { start: "10:00", end: "23:59" }], + divs: [ + null, + { min: "5", max: "1" }, + { min: "3", max: "3" }, + { min: "11", max: "X" }, ], pendingRequestPostId: [null, 1, 987654], + useDefaults: [true, false], }); }); it("decodes garbage to defaults", () => { - assertDecodesToDefault(scrimsSearchParams, "filters", [ - ["not-json"], - ["[]"], - ['{"divs":{"min":"1","max":null}}'], - ['{"weekdayTimes":{"start":"25:00","end":"22:00"}}'], + assertDecodesToDefault(scrimsSearchParams, "weekdayTimes", [ + ["25:00-22:00"], + ["18:00x22:00"], + ["18:00"], + [""], + ["18:60-22:00"], + ]); + assertDecodesToDefault(scrimsSearchParams, "divs", [ + ["1-"], + ["-5"], + ["not-a-div-XX"], + ["12-13"], + [""], ]); assertDecodesToDefault(scrimsSearchParams, "pendingRequestPostId", [ ["abc"], @@ -52,23 +50,4 @@ describe("scrimsSearchParams", () => { ["1.5"], ]); }); - - it("keeps valid fields when part of the filters blob is invalid", () => { - const parsed = scrimsSearchParams.parse( - new URL( - `http://localhost/scrims?filters=${encodeURIComponent( - JSON.stringify({ - weekdayTimes: { start: "18:00", end: "20:00" }, - divs: "bad", - }), - )}`, - ), - ); - - expect(parsed.filters).toEqual({ - weekdayTimes: { start: "18:00", end: "20:00" }, - weekendTimes: null, - divs: null, - }); - }); }); diff --git a/app/features/scrims/scrims-search-params.ts b/app/features/scrims/scrims-search-params.ts index 5ac86997c..6a68dc811 100644 --- a/app/features/scrims/scrims-search-params.ts +++ b/app/features/scrims/scrims-search-params.ts @@ -1,14 +1,14 @@ import { z } from "zod"; import * as SearchParams from "~/modules/search-params/search-params"; import { SP } from "~/modules/search-params/search-params"; -import * as Scrim from "./core/Scrim"; -import { scrimsFiltersSchema } from "./scrims-schemas"; +import { divsCodec, timeRangeCodec } from "./scrims-schemas"; export const scrimsSearchParams = SearchParams.define({ - filters: SP.json(scrimsFiltersSchema, { - default: Scrim.defaultFilters(), - loader: true, - }), + weekdayTimes: SP.custom(timeRangeCodec, { loader: true }), + weekendTimes: SP.custom(timeRangeCodec, { loader: true }), + divs: SP.custom(divsCodec, { loader: true }), + /** False once the user has edited the filters, making the URL win over their saved defaults. */ + useDefaults: SP.param(z.boolean(), { default: true, loader: true }), pendingRequestPostId: SP.param(z.number().int().positive().nullable(), { loader: false, }), diff --git a/app/features/tournament/core/tiering.ts b/app/features/tournament/core/tiering.ts index 0fe39d06f..a191d538e 100644 --- a/app/features/tournament/core/tiering.ts +++ b/app/features/tournament/core/tiering.ts @@ -51,6 +51,12 @@ const NUMBER_TO_TIER = { export type TournamentTier = keyof typeof TIER_TO_NUMBER; export type TournamentTierNumber = (typeof TIER_TO_NUMBER)[TournamentTier]; +/** Every tier number, from the best tier (X) to the worst (C). */ +export const TIER_NUMBERS = Object.values(TIER_TO_NUMBER); + +export const BEST_TIER_NUMBER = TIER_TO_NUMBER.X; +export const WORST_TIER_NUMBER = TIER_TO_NUMBER.C; + export function calculateAdjustedScore( rawScore: number, teamCount: number, diff --git a/app/features/user-page/UserRepository.server.ts b/app/features/user-page/UserRepository.server.ts index c20821d53..42ad2247e 100644 --- a/app/features/user-page/UserRepository.server.ts +++ b/app/features/user-page/UserRepository.server.ts @@ -6,7 +6,15 @@ import { db } from "~/db/sql"; import type { DB, Tables, TablesInsertable } from "~/db/tables"; import type { CustomTheme, UserPreferences } from "~/db/tables-json"; import { actorId } from "~/features/auth/core/user.server"; -import type { BuildSort } from "~/features/user-page/user-page-constants"; +import { + BEST_TIER_NUMBER, + type TournamentTierNumber, + WORST_TIER_NUMBER, +} from "~/features/tournament/core/tiering"; +import type { + BuildSort, + ResultSource, +} from "~/features/user-page/user-page-constants"; import { userRoles } from "~/modules/permissions/mapper.server"; import { isSupporter } from "~/modules/permissions/utils"; import { databaseTimestampNow, dateToDatabaseTimestamp } from "~/utils/dates"; @@ -392,6 +400,16 @@ export function findByFriendCode(friendCode: string) { .execute(); } +export async function findUsernameById(id: number) { + const user = await db + .selectFrom("User") + .select("User.username") + .where("User.id", "=", id) + .executeTakeFirst(); + + return user?.username ?? null; +} + export async function findLeanById(id: number) { const user = await db .selectFrom("User") @@ -525,16 +543,61 @@ export async function findChatUsersByUserIds(userIds: number[]) { return result; } -const withMaxEventStartTime = (eb: ExpressionBuilder) => { - return eb +export interface ResultsFilters { + showHighlightsOnly?: boolean; + tournamentName?: string; + teamName?: string; + mateUserId?: number; + minTier?: TournamentTierNumber; + maxTier?: TournamentTierNumber; + maxPlacement?: number; + fromYear?: number; + toYear?: number; + source?: ResultSource; + minParticipantCount?: number; +} + +const withMaxEventStartTime = (eb: ExpressionBuilder) => + eb .selectFrom("CalendarEventDate") .select(({ fn }) => [fn.max("CalendarEventDate.startsAt").as("startsAt")]) .whereRef("CalendarEventDate.eventId", "=", "CalendarEvent.id") .as("startsAt"); -}; -const baseCalendarEventResultsQuery = (userId: number) => - db +const maxEventStartTimeExpr = sql`(select max(${sql.ref("CalendarEventDate.startsAt")}) from ${sql.table("CalendarEventDate")} where ${sql.ref("CalendarEventDate.eventId")} = ${sql.ref("CalendarEvent.id")})`; + +const maxEventStartTimeAtLeastExpr = (year: number) => + sql`${maxEventStartTimeExpr} >= ${yearStartsAt(year)}`; + +const maxEventStartTimeAtMostExpr = (year: number) => + sql`${maxEventStartTimeExpr} <= ${yearEndsAt(year)}`; + +const NEVER_MATCHES = sql`0`; + +const isTierFiltered = ({ + minTier = BEST_TIER_NUMBER, + maxTier = WORST_TIER_NUMBER, +}: ResultsFilters) => + minTier !== BEST_TIER_NUMBER || maxTier !== WORST_TIER_NUMBER; + +/** Results reported on a calendar event have no tier, so filtering by tier excludes them. */ +const includesCalendarEventResults = (filters: ResultsFilters) => + filters.source !== "SENDOU" && !isTierFiltered(filters); + +const includesTournamentResults = (filters: ResultsFilters) => + filters.source !== "EXTERNAL"; + +const yearStartsAt = (year: number) => + dateToDatabaseTimestamp(new Date(Date.UTC(year, 0, 1))); + +const yearEndsAt = (year: number) => + dateToDatabaseTimestamp(new Date(Date.UTC(year + 1, 0, 1))) - 1; + +const baseCalendarEventResultsQuery = ( + userId: number, + filters: ResultsFilters, +) => { + let query = db .selectFrom("CalendarEventResultPlayer") .innerJoin( "CalendarEventResultTeam", @@ -553,8 +616,71 @@ const baseCalendarEventResultsQuery = (userId: number) => ) .where("CalendarEventResultPlayer.userId", "=", userId); -const baseTournamentResultsQuery = (userId: number) => - db + if (!includesCalendarEventResults(filters)) { + return query.where(NEVER_MATCHES); + } + + if (filters.showHighlightsOnly) { + query = query.where("UserResultHighlight.userId", "is not", null); + } + + if (filters.tournamentName) { + query = query.where( + nameLikeExpr("CalendarEvent.name", filters.tournamentName), + ); + } + + if (filters.teamName) { + query = query.where( + nameLikeExpr("CalendarEventResultTeam.name", filters.teamName), + ); + } + + if (filters.mateUserId) { + const mateUserId = filters.mateUserId; + query = query.where((eb) => + eb.exists( + eb + .selectFrom("CalendarEventResultPlayer as MatePlayer") + .select("MatePlayer.userId") + .whereRef("MatePlayer.teamId", "=", "CalendarEventResultTeam.id") + .where("MatePlayer.userId", "=", mateUserId), + ), + ); + } + + if (filters.maxPlacement) { + query = query.where( + "CalendarEventResultTeam.placement", + "<=", + filters.maxPlacement, + ); + } + + if (filters.minParticipantCount) { + query = query.where( + "CalendarEvent.participantCount", + ">=", + filters.minParticipantCount, + ); + } + + if (filters.fromYear) { + query = query.where(maxEventStartTimeAtLeastExpr(filters.fromYear)); + } + + if (filters.toYear) { + query = query.where(maxEventStartTimeAtMostExpr(filters.toYear)); + } + + return query; +}; + +const baseTournamentResultsQuery = ( + userId: number, + filters: ResultsFilters, +) => { + let query = db .selectFrom("TournamentResult") .innerJoin( "TournamentTeam", @@ -569,124 +695,167 @@ const baseTournamentResultsQuery = (userId: number) => .innerJoin("Tournament", "Tournament.id", "TournamentResult.tournamentId") .where("TournamentResult.userId", "=", userId); + if (!includesTournamentResults(filters)) { + return query.where(NEVER_MATCHES); + } + + if (filters.showHighlightsOnly) { + query = query.where("TournamentResult.isHighlight", "=", 1); + } + + if (filters.tournamentName) { + query = query.where( + nameLikeExpr("CalendarEvent.name", filters.tournamentName), + ); + } + + if (filters.teamName) { + query = query.where(nameLikeExpr("TournamentTeam.name", filters.teamName)); + } + + if (filters.mateUserId) { + const mateUserId = filters.mateUserId; + query = query.where((eb) => + eb.exists( + eb + .selectFrom("TournamentResult as MateResult") + .select("MateResult.userId") + .whereRef( + "MateResult.tournamentTeamId", + "=", + "TournamentResult.tournamentTeamId", + ) + .where("MateResult.userId", "=", mateUserId), + ), + ); + } + + if (isTierFiltered(filters)) { + query = query + .where("Tournament.tier", ">=", filters.minTier ?? BEST_TIER_NUMBER) + .where("Tournament.tier", "<=", filters.maxTier ?? WORST_TIER_NUMBER); + } + + if (filters.maxPlacement) { + query = query.where( + "TournamentResult.placement", + "<=", + filters.maxPlacement, + ); + } + + if (filters.minParticipantCount) { + query = query.where( + "TournamentResult.participantCount", + ">=", + filters.minParticipantCount, + ); + } + + if (filters.fromYear) { + query = query.where(maxEventStartTimeAtLeastExpr(filters.fromYear)); + } + + if (filters.toYear) { + query = query.where(maxEventStartTimeAtMostExpr(filters.toYear)); + } + + return query; +}; + const escapeLikePattern = (value: string) => value.replace(/[\\%_]/g, (char) => `\\${char}`); -const tournamentNameLikeExpr = (tournamentName: string) => { - const pattern = `%${escapeLikePattern(tournamentName)}%`; - return sql`${sql.ref("CalendarEvent.name")} like ${pattern} escape '\\'`; +const nameLikeExpr = (column: string, name: string) => { + const pattern = `%${escapeLikePattern(name)}%`; + return sql`${sql.ref(column)} like ${pattern} escape '\\'`; }; export function findResultsByUserId( userId: number, { - showHighlightsOnly = false, limit, offset, - tournamentName, - }: { - showHighlightsOnly?: boolean; + ...filters + }: ResultsFilters & { limit?: number; offset?: number; - tournamentName?: string; } = {}, ) { - let calendarEventResultsQuery = baseCalendarEventResultsQuery(userId).select( - ({ eb, fn }) => [ - "CalendarEvent.id as eventId", - sql`null`.as("tournamentId"), - "CalendarEventResultTeam.placement", - "CalendarEvent.participantCount", - sql`null`.as("setResults"), - sql`null`.as("div"), - sql`null`.as("logoUrl"), - "CalendarEvent.name as eventName", - "CalendarEventResultTeam.id as teamId", - "CalendarEventResultTeam.name as teamName", - fn("iif", [ - "UserResultHighlight.userId", - sql`1`, - sql`0`, - ]).as("isHighlight"), - sql`null`.as("tier"), - withMaxEventStartTime(eb), - jsonArrayFrom( - eb - .selectFrom("CalendarEventResultPlayer") - .leftJoin("User", "User.id", "CalendarEventResultPlayer.userId") - .select((eb) => [ - ...commonUserSelect(eb), - "CalendarEventResultPlayer.name", - ]) - .whereRef( - "CalendarEventResultPlayer.teamId", - "=", - "CalendarEventResultTeam.id", - ) - .where((eb) => - eb.or([ - eb("CalendarEventResultPlayer.userId", "is", null), - eb("CalendarEventResultPlayer.userId", "!=", userId), - ]), - ), - ).as("mates"), - ], - ); + const calendarEventResultsQuery = baseCalendarEventResultsQuery( + userId, + filters, + ).select(({ eb, fn }) => [ + "CalendarEvent.id as eventId", + sql`null`.as("tournamentId"), + "CalendarEventResultTeam.placement", + "CalendarEvent.participantCount", + sql`null`.as("setResults"), + sql`null`.as("div"), + sql`null`.as("logoUrl"), + "CalendarEvent.name as eventName", + "CalendarEventResultTeam.id as teamId", + "CalendarEventResultTeam.name as teamName", + fn("iif", ["UserResultHighlight.userId", sql`1`, sql`0`]).as( + "isHighlight", + ), + sql`null`.as("tier"), + withMaxEventStartTime(eb), + jsonArrayFrom( + eb + .selectFrom("CalendarEventResultPlayer") + .leftJoin("User", "User.id", "CalendarEventResultPlayer.userId") + .select((eb) => [ + ...commonUserSelect(eb), + "CalendarEventResultPlayer.name", + ]) + .whereRef( + "CalendarEventResultPlayer.teamId", + "=", + "CalendarEventResultTeam.id", + ) + .where((eb) => + eb.or([ + eb("CalendarEventResultPlayer.userId", "is", null), + eb("CalendarEventResultPlayer.userId", "!=", userId), + ]), + ), + ).as("mates"), + ]); - let tournamentResultsQuery = baseTournamentResultsQuery(userId).select( - ({ eb }) => [ - sql`null`.as("eventId"), - "TournamentResult.tournamentId", - "TournamentResult.placement", - "TournamentResult.participantCount", - "TournamentResult.setResults", - "TournamentResult.div", - tournamentLogoOrNull(eb).as("logoUrl"), - "CalendarEvent.name as eventName", - "TournamentTeam.id as teamId", - "TournamentTeam.name as teamName", - "TournamentResult.isHighlight", - "Tournament.tier", - withMaxEventStartTime(eb), - jsonArrayFrom( - eb - .selectFrom("TournamentResult as TournamentResult2") - .innerJoin("User", "User.id", "TournamentResult2.userId") - .select((eb) => [ - ...commonUserSelect(eb), - sql`null`.as("name"), - ]) - .whereRef( - "TournamentResult2.tournamentTeamId", - "=", - "TournamentResult.tournamentTeamId", - ) - .where("TournamentResult2.userId", "!=", userId), - ).as("mates"), - ], - ); - - if (showHighlightsOnly) { - calendarEventResultsQuery = calendarEventResultsQuery.where( - "UserResultHighlight.userId", - "is not", - null, - ); - tournamentResultsQuery = tournamentResultsQuery.where( - "TournamentResult.isHighlight", - "=", - 1, - ); - } - - if (tournamentName) { - calendarEventResultsQuery = calendarEventResultsQuery.where( - tournamentNameLikeExpr(tournamentName), - ); - tournamentResultsQuery = tournamentResultsQuery.where( - tournamentNameLikeExpr(tournamentName), - ); - } + const tournamentResultsQuery = baseTournamentResultsQuery( + userId, + filters, + ).select(({ eb }) => [ + sql`null`.as("eventId"), + "TournamentResult.tournamentId", + "TournamentResult.placement", + "TournamentResult.participantCount", + "TournamentResult.setResults", + "TournamentResult.div", + tournamentLogoOrNull(eb).as("logoUrl"), + "CalendarEvent.name as eventName", + "TournamentTeam.id as teamId", + "TournamentTeam.name as teamName", + "TournamentResult.isHighlight", + "Tournament.tier", + withMaxEventStartTime(eb), + jsonArrayFrom( + eb + .selectFrom("TournamentResult as TournamentResult2") + .innerJoin("User", "User.id", "TournamentResult2.userId") + .select((eb) => [ + ...commonUserSelect(eb), + sql`null`.as("name"), + ]) + .whereRef( + "TournamentResult2.tournamentTeamId", + "=", + "TournamentResult.tournamentTeamId", + ) + .where("TournamentResult2.userId", "!=", userId), + ).as("mates"), + ]); let query = calendarEventResultsQuery .unionAll(tournamentResultsQuery) @@ -706,40 +875,17 @@ export function findResultsByUserId( export async function countResultsByUserId( userId: number, - { - showHighlightsOnly = false, - tournamentName, - }: { showHighlightsOnly?: boolean; tournamentName?: string } = {}, + filters: ResultsFilters = {}, ) { - let calendarEventResultsQuery = baseCalendarEventResultsQuery(userId).select( - ({ fn }) => [fn.countAll().as("count")], - ); + const calendarEventResultsQuery = baseCalendarEventResultsQuery( + userId, + filters, + ).select(({ fn }) => [fn.countAll().as("count")]); - let tournamentResultsQuery = baseTournamentResultsQuery(userId).select( - ({ fn }) => [fn.countAll().as("count")], - ); - - if (showHighlightsOnly) { - calendarEventResultsQuery = calendarEventResultsQuery.where( - "UserResultHighlight.userId", - "is not", - null, - ); - tournamentResultsQuery = tournamentResultsQuery.where( - "TournamentResult.isHighlight", - "=", - 1, - ); - } - - if (tournamentName) { - calendarEventResultsQuery = calendarEventResultsQuery.where( - tournamentNameLikeExpr(tournamentName), - ); - tournamentResultsQuery = tournamentResultsQuery.where( - tournamentNameLikeExpr(tournamentName), - ); - } + const tournamentResultsQuery = baseTournamentResultsQuery( + userId, + filters, + ).select(({ fn }) => [fn.countAll().as("count")]); const [calendarEventResults, tournamentResults] = await Promise.all([ calendarEventResultsQuery.executeTakeFirst(), diff --git a/app/features/user-page/UserRepository.test.ts b/app/features/user-page/UserRepository.test.ts index 0f8ba330e..36e52cfdd 100644 --- a/app/features/user-page/UserRepository.test.ts +++ b/app/features/user-page/UserRepository.test.ts @@ -1,5 +1,9 @@ import { describe, expect, test } from "vitest"; +import * as CalendarEventFactory from "~/db/seed/factories/CalendarEventFactory"; +import * as CalendarEventResultFactory from "~/db/seed/factories/CalendarEventResultFactory"; +import * as TournamentFactory from "~/db/seed/factories/TournamentFactory"; import * as UserFactory from "~/db/seed/factories/UserFactory"; +import { dateToDatabaseTimestamp } from "~/utils/dates"; import * as UserRepository from "./UserRepository.server"; describe("UserRepository", () => { @@ -100,6 +104,192 @@ describe("UserRepository", () => { ).toBe(1); }); + describe("findResultsByUserId filters", () => { + const startTimeOf = (year: number) => + dateToDatabaseTimestamp(new Date(Date.UTC(year, 5, 1))); + + const seedResults = async () => { + const user = await UserFactory.create(); + const mate = await UserFactory.create(); + const [firstOpponent, secondOpponent] = await UserFactory.createMany(2); + + const wonEvent = await CalendarEventFactory.create({ + name: "Gamma Open", + authorId: user.id, + startTimes: [startTimeOf(2024)], + }); + await CalendarEventResultFactory.create({ + eventId: wonEvent.id, + participantCount: 8, + results: [ + { + teamName: "Team Gamma", + placement: 1, + players: [ + { userId: user.id, name: null }, + { userId: mate.id, name: null }, + ], + }, + ], + }); + + const lostEvent = await CalendarEventFactory.create({ + name: "Delta Open", + authorId: user.id, + startTimes: [startTimeOf(2022)], + }); + await CalendarEventResultFactory.create({ + eventId: lostEvent.id, + participantCount: 50, + results: [ + { + teamName: "Team Delta", + placement: 5, + players: [ + { userId: user.id, name: null }, + { userId: firstOpponent.id, name: null }, + ], + }, + ], + }); + + await TournamentFactory.createPlayed( + { + name: "Alpha Invitational", + authorId: user.id, + startTimes: [startTimeOf(2023)], + minMembersPerTeam: 1, + }, + { + teamRosters: [[user.id], [secondOpponent.id]], + playedOut: "all", + tier: 1, + }, + ); + + return { userId: user.id, mateUserId: mate.id }; + }; + + const filteredResults = async ( + userId: number, + filters: Parameters[1], + ) => { + const [results, count] = await Promise.all([ + UserRepository.findResultsByUserId(userId, filters), + UserRepository.countResultsByUserId(userId, filters), + ]); + + expect(count).toBe(results.length); + + return results; + }; + + test("returns every result without filters", async () => { + const { userId } = await seedResults(); + + const results = await filteredResults(userId, {}); + + expect(results).toHaveLength(3); + }); + + test("filters by result source", async () => { + const { userId } = await seedResults(); + + const tournaments = await filteredResults(userId, { source: "SENDOU" }); + const reported = await filteredResults(userId, { source: "EXTERNAL" }); + + expect(tournaments).toHaveLength(1); + expect(tournaments[0].eventName).toBe("Alpha Invitational"); + expect(reported.map((result) => result.eventName).sort()).toEqual([ + "Delta Open", + "Gamma Open", + ]); + }); + + test("filters by tier, excluding results without one", async () => { + const { userId } = await seedResults(); + + const bestTiers = await filteredResults(userId, { + minTier: 1, + maxTier: 3, + }); + const worstTiers = await filteredResults(userId, { + minTier: 8, + maxTier: 9, + }); + + expect(bestTiers).toHaveLength(1); + expect(bestTiers[0].eventName).toBe("Alpha Invitational"); + expect(worstTiers).toHaveLength(0); + }); + + test("filters by placement", async () => { + const { userId } = await seedResults(); + + const wins = await filteredResults(userId, { maxPlacement: 1 }); + + expect(wins.every((result) => result.placement === 1)).toBe(true); + expect(wins.map((result) => result.eventName)).toContain("Gamma Open"); + expect(wins.map((result) => result.eventName)).not.toContain( + "Delta Open", + ); + }); + + test("filters by year range", async () => { + const { userId } = await seedResults(); + + const results = await filteredResults(userId, { + fromYear: 2023, + toYear: 2024, + }); + + expect(results.map((result) => result.eventName).sort()).toEqual([ + "Alpha Invitational", + "Gamma Open", + ]); + }); + + test("filters by teammate", async () => { + const { userId, mateUserId } = await seedResults(); + + const results = await filteredResults(userId, { mateUserId }); + + expect(results).toHaveLength(1); + expect(results[0].eventName).toBe("Gamma Open"); + }); + + test("filters by team name", async () => { + const { userId } = await seedResults(); + + const results = await filteredResults(userId, { teamName: "delta" }); + + expect(results).toHaveLength(1); + expect(results[0].eventName).toBe("Delta Open"); + }); + + test("filters by minimum participant count", async () => { + const { userId } = await seedResults(); + + const results = await filteredResults(userId, { + minParticipantCount: 16, + }); + + expect(results).toHaveLength(1); + expect(results[0].eventName).toBe("Delta Open"); + }); + + test("filters by tournament name", async () => { + const { userId } = await seedResults(); + + const results = await filteredResults(userId, { + tournamentName: "alpha", + }); + + expect(results).toHaveLength(1); + expect(results[0].eventName).toBe("Alpha Invitational"); + }); + }); + describe("userRoles", () => { test("returns empty array for basic user", async () => { await UserFactory.createAdmin(); diff --git a/app/features/user-page/components/ResultsFiltersBar.tsx b/app/features/user-page/components/ResultsFiltersBar.tsx new file mode 100644 index 000000000..3e7b5f455 --- /dev/null +++ b/app/features/user-page/components/ResultsFiltersBar.tsx @@ -0,0 +1,398 @@ +import * as React from "react"; +import type { Key } from "react-aria-components"; +import { useTranslation } from "react-i18next"; +import { useLoaderData } from "react-router"; +import { SendouSelect, SendouSelectItem } from "~/components/elements/Select"; +import { SendouSwitch } from "~/components/elements/Switch"; +import { UserSearch } from "~/components/elements/UserSearch"; +import type { FilterBarPill } from "~/components/filter-bar/FilterBar"; +import { FilterBar } from "~/components/filter-bar/FilterBar"; +import { + BEST_TIER_NUMBER, + TIER_NUMBERS, + type TournamentTierNumber, + tierNumberToName, + WORST_TIER_NUMBER, +} from "~/features/tournament/core/tiering"; +import { RadioGroupFormField } from "~/form/fields/InputGroupFormField"; +import { useDebounce } from "~/hooks/useDebounce"; +import { useSearchParamsTyped } from "~/modules/search-params/hooks"; +import type { UserResultsLoaderData } from "../loaders/u.$identifier.results.server"; +import { + RESULT_PLACEMENT_FILTERS, + RESULT_SOURCES, + RESULTS_FIRST_YEAR, + type ResultPlacementFilter, +} from "../user-page-constants"; +import { userResultsSearchParams } from "../user-page-search-params"; + +const DEFAULT_FILTERS = { + highlightsOnly: true, + tournament: null, + team: null, + mate: null, + minTier: BEST_TIER_NUMBER, + maxTier: WORST_TIER_NUMBER, + maxPlacement: null, + fromYear: null, + toYear: null, + source: "ALL", + minParticipantCount: 0, +} as const; + +export function ResultsFiltersBar() { + const { t } = useTranslation("user"); + const data = useLoaderData(); + const [filters, setFilters] = useSearchParamsTyped(userResultsSearchParams); + + const tierFormatted = () => { + if ( + filters.minTier === DEFAULT_FILTERS.minTier && + filters.maxTier === DEFAULT_FILTERS.maxTier + ) { + return null; + } + + const bestTier = tierNumberToName(filters.minTier); + const worstTier = tierNumberToName(filters.maxTier); + + return bestTier === worstTier ? bestTier : `${bestTier}–${worstTier}`; + }; + + const placementName = (maxPlacement: number) => + maxPlacement === 1 + ? t("results.filter.placement.first") + : t("results.filter.placement.top", { count: maxPlacement }); + + const yearsFormatted = () => { + if (!filters.fromYear && !filters.toYear) return null; + if (filters.fromYear === filters.toYear) return String(filters.fromYear); + + return `${filters.fromYear ?? ""}–${filters.toYear ?? ""}`; + }; + + const highlightsPill: FilterBarPill = { + key: "highlights", + name: t("results.highlights"), + formattedValue: filters.highlightsOnly ? t("results.filter.only") : null, + onRemove: () => setFilters({ highlightsOnly: false }), + onAdd: () => setFilters({ highlightsOnly: true }), + testId: "highlights-filter", + popover: ( + setFilters({ highlightsOnly })} + > + {t("results.filter.highlightsOnly")} + + ), + }; + + const pills: FilterBarPill[] = [ + ...(data.hasHighlightedResults ? [highlightsPill] : []), + { + key: "tournament", + name: t("results.filter.tournament"), + formattedValue: filters.tournament, + onRemove: () => setFilters({ tournament: null }), + testId: "tournament-filter", + popover: ( + setFilters({ tournament })} + /> + ), + }, + { + key: "mate", + name: t("results.filter.mate"), + formattedValue: filters.mate ? (data.mateUsername ?? "?") : null, + onRemove: () => setFilters({ mate: null }), + testId: "mate-filter", + popover: ( + setFilters({ mate: user?.id ?? null })} + /> + ), + }, + { + key: "team", + name: t("results.filter.team"), + formattedValue: filters.team, + onRemove: () => setFilters({ team: null }), + testId: "team-filter", + popover: ( + setFilters({ team })} + /> + ), + }, + { + key: "tier", + name: t("results.filter.tier"), + formattedValue: tierFormatted(), + onRemove: () => + setFilters({ + minTier: DEFAULT_FILTERS.minTier, + maxTier: DEFAULT_FILTERS.maxTier, + }), + testId: "tier-filter", + popover: ( +
+ ({ id: tier }))} + selectedKey={filters.minTier} + onSelectionChange={(key) => { + const minTier = toTierNumber(key); + setFilters({ + minTier, + maxTier: minTier > filters.maxTier ? minTier : filters.maxTier, + }); + }} + > + {({ id }) => ( + + {tierNumberToName(id)} + + )} + + ({ id: tier }))} + selectedKey={filters.maxTier} + onSelectionChange={(key) => { + const maxTier = toTierNumber(key); + setFilters({ + maxTier, + minTier: maxTier < filters.minTier ? maxTier : filters.minTier, + }); + }} + > + {({ id }) => ( + + {tierNumberToName(id)} + + )} + +
+ ), + }, + { + key: "placement", + name: t("results.filter.placement"), + formattedValue: filters.maxPlacement + ? placementName(filters.maxPlacement) + : null, + onRemove: () => setFilters({ maxPlacement: null }), + testId: "placement-filter", + popover: ( + ({ + id: placement, + }))} + selectedKey={filters.maxPlacement} + clearable + onSelectionChange={(key) => + setFilters({ + maxPlacement: + key === null ? null : (Number(key) as ResultPlacementFilter), + }) + } + > + {({ id }) => ( + + {placementName(id)} + + )} + + ), + }, + { + key: "years", + name: t("results.filter.years"), + formattedValue: yearsFormatted(), + onRemove: () => setFilters({ fromYear: null, toYear: null }), + testId: "years-filter", + popover: ( +
+ + setFilters({ + fromYear, + toYear: + fromYear && filters.toYear + ? Math.max(fromYear, filters.toYear) + : filters.toYear, + }) + } + /> + + setFilters({ + toYear, + fromYear: + toYear && filters.fromYear + ? Math.min(toYear, filters.fromYear) + : filters.fromYear, + }) + } + /> +
+ ), + }, + { + key: "source", + name: t("results.filter.source"), + formattedValue: + filters.source === DEFAULT_FILTERS.source + ? null + : t(`results.filter.source.${filters.source}`), + onRemove: () => setFilters({ source: DEFAULT_FILTERS.source }), + testId: "source-filter", + popover: ( + ({ + label: t(`results.filter.source.${source}`), + value: source, + }))} + value={filters.source} + onChange={(source) => setFilters({ source })} + onBlur={() => {}} + /> + ), + }, + { + key: "size", + name: t("results.filter.size"), + formattedValue: + filters.minParticipantCount > 0 + ? `${filters.minParticipantCount}+` + : null, + onRemove: () => setFilters({ minParticipantCount: 0 }), + testId: "size-filter", + popover: ( + + ), + }, + ]; + + return ( + setFilters(DEFAULT_FILTERS) + } + /> + ); +} + +function DebouncedNameFilter({ + label, + value, + onChange, +}: { + label: string; + value: string | null; + onChange: (value: string | null) => void; +}) { + const [draft, setDraft] = React.useState(value ?? ""); + + useDebounce( + () => { + if ((value ?? "") === draft.trim()) return; + onChange(draft.trim() || null); + }, + 300, + [draft], + ); + + return ( + + ); +} + +function YearSelect({ + label, + value, + onChange, +}: { + label: string; + value: number | null; + onChange: (value: number | null) => void; +}) { + const years = selectableYears(); + + return ( + ({ id: year }))} + selectedKey={value} + clearable + onSelectionChange={(key) => onChange(key === null ? null : Number(key))} + > + {({ id }) => ( + + {id} + + )} + + ); +} + +const selectableYears = () => { + const currentYear = new Date().getFullYear(); + + const result = []; + for (let year = currentYear; year >= RESULTS_FIRST_YEAR; year--) { + result.push(year); + } + + return result; +}; + +const toTierNumber = (key: Key | null) => Number(key) as TournamentTierNumber; + +const isDefaultFilters = ( + filters: Record, +) => + Object.entries(DEFAULT_FILTERS).every( + ([key, value]) => filters[key as keyof typeof DEFAULT_FILTERS] === value, + ); diff --git a/app/features/user-page/components/UserResultsTable.tsx b/app/features/user-page/components/UserResultsTable.tsx index bd6c14e9a..c892d6612 100644 --- a/app/features/user-page/components/UserResultsTable.tsx +++ b/app/features/user-page/components/UserResultsTable.tsx @@ -50,16 +50,21 @@ export function UserResultsTable({ {results.map((result, i) => { + // team ids of the two result types are from different tables and can collide + const rowId = result.tournamentId + ? `tournament-${result.teamId}` + : `event-${result.teamId}`; + // We are trying to construct a reasonable label for the checkbox // which shouldn't contain the whole information of the table row as // that can be also accessed when needed. // e.g. "20xx Placing 2nd", "Big House 10 Placing 20th" - const placementCellId = `${id}-${result.teamId}-placement`; - const nameCellId = `${id}-${result.teamId}-name`; + const placementCellId = `${id}-${rowId}-placement`; + const nameCellId = `${id}-${rowId}-name`; const checkboxLabelIds = `${nameCellId} ${placementHeaderId} ${placementCellId}`; return ( - + {hasHighlightCheckboxes && ( ; export const loader = async ({ params, request, url }: LoaderFunctionArgs) => { - const { all, page, tournament } = userResultsSearchParams.parse(request); + const { + highlightsOnly, + page, + tournament, + team, + mate, + minTier, + maxTier, + maxPlacement, + fromYear, + toYear, + source, + minParticipantCount, + } = userResultsSearchParams.parse(request); const userId = notFoundIfNullish( await UserRepository.findIdByIdentifier(params.identifier!), @@ -20,34 +33,50 @@ export const loader = async ({ params, request, url }: LoaderFunctionArgs) => { const hasHighlightedResults = await UserRepository.hasHighlightedResultsByUserId(userId); - let showHighlightsOnly = !all; + const isChoosingHighlights = url.pathname.includes("/results/highlights"); + const canFilter = !isChoosingHighlights && Boolean(getUser()); - if (!hasHighlightedResults) { + /** Logged out visitors are locked to the highlights, if there are any. */ + let showHighlightsOnly = hasHighlightedResults; + + if (canFilter && !highlightsOnly) { showHighlightsOnly = false; } - const isChoosingHighlights = url.pathname.includes("/results/highlights"); if (isChoosingHighlights) { showHighlightsOnly = false; } - const tournamentName = - !isChoosingHighlights && getUser() && tournament !== null - ? tournament - : undefined; + const filters = canFilter + ? { + tournamentName: tournament ?? undefined, + teamName: team ?? undefined, + mateUserId: mate ?? undefined, + minTier, + maxTier, + maxPlacement: maxPlacement ?? undefined, + fromYear: fromYear ?? undefined, + toYear: toYear ?? undefined, + source, + minParticipantCount, + } + : {}; - const [results, totalCount] = await Promise.all([ + const [results, totalCount, mateUsername] = await Promise.all([ UserRepository.findResultsByUserId(userId, { showHighlightsOnly, - tournamentName, + ...filters, ...(isChoosingHighlights ? { limit: HIGHLIGHTS_RESULTS_MAX } : { limit: RESULTS_PER_PAGE, offset: (page - 1) * RESULTS_PER_PAGE }), }), UserRepository.countResultsByUserId(userId, { showHighlightsOnly, - tournamentName, + ...filters, }), + filters.mateUserId + ? UserRepository.findUsernameById(filters.mateUserId) + : null, ]); return { @@ -56,5 +85,6 @@ export const loader = async ({ params, request, url }: LoaderFunctionArgs) => { ...paginate({ url, page, pageSize: RESULTS_PER_PAGE, totalCount }), }, hasHighlightedResults, + mateUsername, }; }; diff --git a/app/features/user-page/routes/u.$identifier.results.tsx b/app/features/user-page/routes/u.$identifier.results.tsx index 72ac79b2e..7ae1ae3d7 100644 --- a/app/features/user-page/routes/u.$identifier.results.tsx +++ b/app/features/user-page/routes/u.$identifier.results.tsx @@ -1,18 +1,13 @@ -import { Search } from "lucide-react"; -import * as React from "react"; import { useTranslation } from "react-i18next"; import { useLoaderData, useMatches } from "react-router"; import { LinkButton } from "~/components/elements/Button"; -import { Input } from "~/components/Input"; import { Pagination } from "~/components/Pagination"; import { useUser } from "~/features/auth/core/user"; import { UserResultsTable } from "~/features/user-page/components/UserResultsTable"; -import { useDebounce } from "~/hooks/useDebounce"; import { useSearchParamPagination } from "~/hooks/useSearchParamPagination"; -import { useSearchParamsTyped } from "~/modules/search-params/hooks"; import invariant from "~/utils/invariant"; import { userPage, userResultsEditHighlightsPage } from "~/utils/urls"; -import { SendouButton } from "../../../components/elements/Button"; +import { ResultsFiltersBar } from "../components/ResultsFiltersBar"; import { SubPageHeader } from "../components/SubPageHeader"; import { loader } from "../loaders/u.$identifier.results.server"; import type { UserPageLoaderData } from "../loaders/u.$identifier.server"; @@ -23,37 +18,13 @@ export { loader }; export default function UserResultsPage() { const user = useUser(); - const { t } = useTranslation("user"); + const { t } = useTranslation(["user", "common"]); const data = useLoaderData(); const [, parentRoute] = useMatches(); invariant(parentRoute); const layoutData = parentRoute.loaderData as UserPageLoaderData; - const [{ all: showAll, tournament }, setParams] = useSearchParamsTyped( - userResultsSearchParams, - ); - - const urlTournamentQuery = tournament ?? ""; - const [tournamentQuery, setTournamentQuery] = - React.useState(urlTournamentQuery); - const [prevUrlTournamentQuery, setPrevUrlTournamentQuery] = - React.useState(urlTournamentQuery); - - if (urlTournamentQuery !== prevUrlTournamentQuery) { - setPrevUrlTournamentQuery(urlTournamentQuery); - setTournamentQuery(urlTournamentQuery); - } - - useDebounce( - () => { - if (urlTournamentQuery === tournamentQuery) return; - setParams({ tournament: tournamentQuery || null }); - }, - 300, - [tournamentQuery], - ); - const pagination = useSearchParamPagination({ definition: userResultsSearchParams, currentPage: data.results.currentPage, @@ -66,43 +37,23 @@ export default function UserResultsPage() { user={layoutData.user} backTo={userPage(layoutData.user)} /> -
-

- {showAll || !data.hasHighlightedResults - ? t("results.title") - : t("results.highlights")} -

+ {user?.id === layoutData.user.id ? (
- {user ? ( - setTournamentQuery(e.target.value)} - placeholder={t("results.filter.placeholder")} - aria-label={t("results.filter.placeholder")} - icon={} - /> - ) : null} - {user?.id === layoutData.user.id ? ( - - {t("results.highlights.choose")} - - ) : null} + + {t("results.highlights.choose")} +
-
- - {data.results.pagesCount > 1 ? : null} - {data.hasHighlightedResults ? ( - setParams({ all: !showAll })} - > - {showAll - ? t("results.button.showHighlights") - : t("results.button.showAll")} - ) : null} + {user ? : null} + {data.results.value.length > 0 ? ( + + ) : ( +
{t("common:noResults")}
+ )} + {data.results.pagesCount > 1 ? : null}
); } diff --git a/app/features/user-page/user-page-constants.ts b/app/features/user-page/user-page-constants.ts index 4d8af2e42..4d4c6789f 100644 --- a/app/features/user-page/user-page-constants.ts +++ b/app/features/user-page/user-page-constants.ts @@ -21,6 +21,18 @@ export const SPL2_JOIN_ORDER_CUTOFF = 13_589; export const MATCHES_PER_SEASONS_PAGE = 8; export const RESULTS_PER_PAGE = 25; export const HIGHLIGHTS_RESULTS_MAX = 500; + +/** Year of the oldest event that can have results. */ +export const RESULTS_FIRST_YEAR = 2015; + +/** Placement thresholds that results can be filtered by e.g. 3 = top 3 only. */ +export const RESULT_PLACEMENT_FILTERS = [1, 3, 8, 16, 32] as const; + +export type ResultPlacementFilter = (typeof RESULT_PLACEMENT_FILTERS)[number]; + +export const RESULT_SOURCES = ["ALL", "SENDOU", "EXTERNAL"] as const; + +export type ResultSource = (typeof RESULT_SOURCES)[number]; export const BUILD_SORT_IDENTIFIERS = [ "UPDATED_AT", "TOP_500", diff --git a/app/features/user-page/user-page-search-params.test.ts b/app/features/user-page/user-page-search-params.test.ts index 6077cbc6a..df26e71e9 100644 --- a/app/features/user-page/user-page-search-params.test.ts +++ b/app/features/user-page/user-page-search-params.test.ts @@ -1,9 +1,18 @@ import { describe, it } from "vitest"; import * as Seasons from "~/features/mmr/core/Seasons"; +import { + BEST_TIER_NUMBER, + WORST_TIER_NUMBER, +} from "~/features/tournament/core/tiering"; import { assertDecodesToDefault, assertRoundTrips, } from "~/modules/search-params/search-params-test-utils"; +import { + RESULT_PLACEMENT_FILTERS, + RESULT_SOURCES, + RESULTS_FIRST_YEAR, +} from "./user-page-constants"; import { userArtSearchParams, userBuildsSearchParams, @@ -16,18 +25,31 @@ const startedSeasons = Seasons.allStarted(new Date()); const newestSeason = startedSeasons[0]; const oldestSeason = startedSeasons.at(-1)!; const notStartedSeason = newestSeason + 1000; +const currentYear = new Date().getFullYear(); describe("userResultsSearchParams", () => { it("round-trips", () => { assertRoundTrips(userResultsSearchParams, { - all: [false, true], + highlightsOnly: [false, true], page: [1, 2, 1000], tournament: ["In The Zone", "x", "a".repeat(100)], + team: [null, "Team Olive", "a".repeat(100)], + mate: [null, 1, 9999], + minTier: [BEST_TIER_NUMBER, 5, WORST_TIER_NUMBER], + maxTier: [BEST_TIER_NUMBER, 5, WORST_TIER_NUMBER], + maxPlacement: [null, ...RESULT_PLACEMENT_FILTERS], + fromYear: [null, RESULTS_FIRST_YEAR, currentYear], + toYear: [null, RESULTS_FIRST_YEAR, currentYear], + source: [...RESULT_SOURCES], + minParticipantCount: [0, 16, 9999], }); }); it("malformed values decode to defaults", () => { - assertDecodesToDefault(userResultsSearchParams, "all", [["1"], ["yes"]]); + assertDecodesToDefault(userResultsSearchParams, "highlightsOnly", [ + ["1"], + ["yes"], + ]); assertDecodesToDefault(userResultsSearchParams, "page", [ ["0"], ["1001"], @@ -38,6 +60,29 @@ describe("userResultsSearchParams", () => { [" "], ["a".repeat(101)], ]); + assertDecodesToDefault(userResultsSearchParams, "team", [ + [""], + ["a".repeat(101)], + ]); + assertDecodesToDefault(userResultsSearchParams, "mate", [["0"], ["abc"]]); + assertDecodesToDefault(userResultsSearchParams, "minTier", [ + ["0"], + ["10"], + ["abc"], + ]); + assertDecodesToDefault(userResultsSearchParams, "maxPlacement", [ + ["2"], + ["abc"], + ]); + assertDecodesToDefault(userResultsSearchParams, "fromYear", [ + [String(RESULTS_FIRST_YEAR - 1)], + [String(currentYear + 1)], + ]); + assertDecodesToDefault(userResultsSearchParams, "source", [["SOMETHING"]]); + assertDecodesToDefault(userResultsSearchParams, "minParticipantCount", [ + ["-1"], + ["10000"], + ]); }); }); diff --git a/app/features/user-page/user-page-search-params.ts b/app/features/user-page/user-page-search-params.ts index 69e21df4e..b5ba279cd 100644 --- a/app/features/user-page/user-page-search-params.ts +++ b/app/features/user-page/user-page-search-params.ts @@ -3,22 +3,81 @@ import { ART_SOURCES } from "~/features/art/art-types"; import { serializedBuildCodec } from "~/features/build-analyzer/analyzer-search-params"; import { EMPTY_BUILD } from "~/features/builds/builds-constants"; import * as Seasons from "~/features/mmr/core/Seasons"; +import { + BEST_TIER_NUMBER, + TIER_NUMBERS, + WORST_TIER_NUMBER, +} from "~/features/tournament/core/tiering"; import type { MainWeaponId } from "~/modules/in-game-lists/types"; import { mainWeaponIds } from "~/modules/in-game-lists/weapon-ids"; import * as SearchParams from "~/modules/search-params/search-params"; import { SP } from "~/modules/search-params/search-params"; import { numericEnum } from "~/utils/zod"; +import { + RESULT_PLACEMENT_FILTERS, + RESULT_SOURCES, + RESULTS_FIRST_YEAR, +} from "./user-page-constants"; const BUILD_FILTER_TABS = ["ALL", "PUBLIC", "PRIVATE"] as const; +const resultYear = z + .number() + .int() + .min(RESULTS_FIRST_YEAR) + .refine((year) => year <= new Date().getFullYear()); + +const resultsFilterName = z.string().trim().min(1).max(100).nullable(); + export const userResultsSearchParams = SearchParams.define({ - all: SP.param(z.boolean(), { - default: false, + /** Only applies to users who have highlighted results. */ + highlightsOnly: SP.param(z.boolean(), { + default: true, loader: true, resets: ["page"], }), page: SP.page(), - tournament: SP.param(z.string().trim().min(1).max(100).nullable(), { + tournament: SP.param(resultsFilterName, { + loader: true, + resets: ["page"], + }), + team: SP.param(resultsFilterName, { + loader: true, + resets: ["page"], + }), + mate: SP.param(z.number().int().positive().nullable(), { + loader: true, + resets: ["page"], + }), + minTier: SP.param(numericEnum(TIER_NUMBERS), { + default: BEST_TIER_NUMBER, + loader: true, + resets: ["page"], + }), + maxTier: SP.param(numericEnum(TIER_NUMBERS), { + default: WORST_TIER_NUMBER, + loader: true, + resets: ["page"], + }), + maxPlacement: SP.param(numericEnum(RESULT_PLACEMENT_FILTERS).nullable(), { + loader: true, + resets: ["page"], + }), + fromYear: SP.param(resultYear.nullable(), { + loader: true, + resets: ["page"], + }), + toYear: SP.param(resultYear.nullable(), { + loader: true, + resets: ["page"], + }), + source: SP.param(z.enum(RESULT_SOURCES), { + default: "ALL", + loader: true, + resets: ["page"], + }), + minParticipantCount: SP.param(z.number().int().nonnegative().max(9999), { + default: 0, loader: true, resets: ["page"], }), diff --git a/app/features/user-page/user-page.module.css b/app/features/user-page/user-page.module.css index 2da668030..e92c96540 100644 --- a/app/features/user-page/user-page.module.css +++ b/app/features/user-page/user-page.module.css @@ -126,40 +126,13 @@ overflow-x: auto; } -.resultsHeader { - display: flex; - align-items: center; - justify-content: space-between; - gap: var(--s-3); -} - .resultsHeaderActions { display: flex; align-items: center; + justify-content: flex-end; gap: var(--s-2); } -.resultsFilterInput { - width: 12rem; - max-width: 100%; -} - -@media screen and (max-width: 599px) { - .resultsHeader { - flex-direction: column; - align-items: stretch; - } - - .resultsHeaderActions { - justify-content: space-between; - } - - .resultsFilterInput { - flex: 1; - width: auto; - } -} - .resultsTableHighlights { border: var(--s-2) solid var(--color-bg-high); padding-inline: 0 !important; diff --git a/app/features/vods/routes/vods.module.css b/app/features/vods/routes/vods.module.css index 297e24158..792282ce5 100644 --- a/app/features/vods/routes/vods.module.css +++ b/app/features/vods/routes/vods.module.css @@ -4,7 +4,3 @@ gap: var(--s-6); justify-content: center; } - -.typeSelect { - width: 220px; -} diff --git a/app/features/vods/routes/vods.tsx b/app/features/vods/routes/vods.tsx index 24dc06e4e..133d4c3f4 100644 --- a/app/features/vods/routes/vods.tsx +++ b/app/features/vods/routes/vods.tsx @@ -1,7 +1,12 @@ import { useTranslation } from "react-i18next"; import type { MetaFunction } from "react-router"; import { useLoaderData } from "react-router"; -import { Label } from "~/components/Label"; +import { + SendouChipRadio, + SendouChipRadioGroup, +} from "~/components/elements/ChipRadio"; +import { SendouSelect, SendouSelectItem } from "~/components/elements/Select"; +import { FilterBar } from "~/components/filter-bar/FilterBar"; import { Main } from "~/components/Main"; import { Pagination } from "~/components/Pagination"; import { WeaponSelect } from "~/components/WeaponSelect"; @@ -70,90 +75,105 @@ export default function VodsSearchPage() { } function Filters() { - const { t } = useTranslation(["game-misc", "vods"]); + const { t } = useTranslation(["game-misc", "vods", "weapons"]); const [{ mode, stageId, weapon, type }, setParams] = useSearchParamsTyped(vodsSearchParams); return ( -
-
- - -
-
- - -
- - { - setParams({ weapon: weaponId ?? null }); - }} - clearable - /> - -
- - -
-
+ setParams({ mode: null }), + testId: "vods-mode-filter", + popover: ( + + {modesShort.map((option) => ( + setParams({ mode: value as ModeShort })} + > + {t(`game-misc:MODE_SHORT_${option}`)} + + ))} + + ), + }, + { + key: "stage", + name: t("vods:forms.title.stage"), + formattedValue: + stageId !== null ? t(`game-misc:STAGE_${stageId}`) : null, + onRemove: () => setParams({ stageId: null }), + testId: "vods-stage-filter", + popover: ( + ({ id }))} + selectedKey={stageId} + onSelectionChange={(key) => + setParams({ stageId: key as StageId }) + } + search={{}} + > + {({ id }) => ( + + {t(`game-misc:STAGE_${id}`)} + + )} + + ), + }, + { + key: "weapon", + name: t("vods:forms.title.weapon"), + formattedValue: weapon !== null ? t(`weapons:MAIN_${weapon}`) : null, + onRemove: () => setParams({ weapon: null }), + testId: "vods-weapon-filter", + popover: ( + { + setParams({ weapon: weaponId ?? null }); + }} + clearable + /> + ), + }, + { + key: "type", + name: t("vods:forms.title.type"), + formattedValue: type !== null ? t(`vods:type.${type}`) : null, + onRemove: () => setParams({ type: null }), + testId: "vods-type-filter", + popover: ( + + {videoMatchTypes.map((option) => ( + + setParams({ + type: value as (typeof videoMatchTypes)[number], + }) + } + > + {t(`vods:type.${option}`)} + + ))} + + ), + }, + ]} + /> ); } diff --git a/app/utils/urls.ts b/app/utils/urls.ts index f4db1941d..eaff31f90 100644 --- a/app/utils/urls.ts +++ b/app/utils/urls.ts @@ -44,7 +44,6 @@ import { userCardEditSearchParams } from "~/features/user-card/user-card-search- import { userArtSearchParams, userBuildsNewSearchParams, - userResultsSearchParams, userSeasonSummaryGraphicSearchParams, userSeasonsSearchParams, } from "~/features/user-page/user-page-search-params"; @@ -273,10 +272,8 @@ export const userEditProfilePage = (user: UserLinkArgs) => `${userPage(user)}/edit`; export const userBuildsPage = (user: UserLinkArgs) => `${userPage(user)}/builds`; -export const userResultsPage = (user: UserLinkArgs, showAll?: boolean) => - userResultsSearchParams.href(`${userPage(user)}/results`, { - all: Boolean(showAll), - }); +export const userResultsPage = (user: UserLinkArgs) => + `${userPage(user)}/results`; export const userVodsPage = (user: UserLinkArgs) => `${userPage(user)}/vods`; export const userCardEditPage = (args?: { returnTo?: string }) => userCardEditSearchParams.href(USER_CARD_EDIT_PAGE, { @@ -382,12 +379,8 @@ export const weaponBuildPopularPage = (weaponSlug: string) => `${weaponBuildPage(weaponSlug)}/popular`; export const weaponParamsPage = (weaponSlug: string) => `/params/${weaponSlug}`; -export const calendarPage = (args?: { - filters?: CalendarFilters; - dayMonthYear?: DayMonthYear; -}) => +export const calendarPage = (args?: { dayMonthYear?: DayMonthYear }) => calendarSearchParams.href(CALENDAR_PAGE, { - ...(args?.filters ? { filters: args.filters } : {}), ...(args?.dayMonthYear ? { day: args.dayMonthYear.day, @@ -399,7 +392,7 @@ export const calendarPage = (args?: { export const calendarIcalFeed = (filters?: CalendarFilters) => calendarSearchParams.href(`${SENDOU_INK_BASE_URL}/calendar.ics`, { - ...(filters ? { filters } : {}), + ...(filters ?? {}), }); export const calendarEventPage = (eventId: number) => `/calendar/${eventId}`; diff --git a/e2e/builds.spec.ts b/e2e/builds.spec.ts index 687d5f738..be3813309 100644 --- a/e2e/builds.spec.ts +++ b/e2e/builds.spec.ts @@ -113,14 +113,14 @@ test.describe("Builds", () => { // are all builds with ISM are hidden? await expect(weaponBuilds.ability("ISM")).toHaveCount(1); - await weaponBuilds.deleteFilter(); + await weaponBuilds.deleteFilter("ability"); await expect(weaponBuilds.ability("ISM").nth(1)).toBeVisible(); await weaponBuilds.addFilter("mode"); await weaponBuilds.modeFilterCheckbox("Tower Control").click(); await expect(weaponBuilds.modeBadge("TC")).toHaveCount(3); - await weaponBuilds.deleteFilter(); + await weaponBuilds.deleteFilter("mode"); await expect(weaponBuilds.locators.buildCards.first()).toBeVisible(); await weaponBuilds.addFilter("date"); diff --git a/e2e/calendar.spec.ts b/e2e/calendar.spec.ts index 05943393c..f3196928d 100644 --- a/e2e/calendar.spec.ts +++ b/e2e/calendar.spec.ts @@ -38,9 +38,7 @@ test.describe("Calendar", () => { const calendar = new CalendarPage(page); await calendar.goto(); - const filters = await calendar.openFilters(); - await filters.form.check("isSendou"); - await filters.apply(); + await calendar.toggleEventTypeFilter("isSendou"); await expect(calendar.locators.tournamentCards).toHaveCount( SENDOU_INK_TOURNAMENTS_COUNT, @@ -77,9 +75,8 @@ test.describe("Calendar", () => { await isNotVisible(calendar.locators.hiddenEventsButtons); - const filters = await calendar.openFilters(); - await filters.form.check("isRanked"); - await filters.applyAndMakeDefault(); + await calendar.toggleEventTypeFilter("isRanked"); + await calendar.saveFiltersAsDefault(); await expect(calendar.locators.hiddenEventsButtons.first()).toBeVisible(); @@ -87,6 +84,15 @@ test.describe("Calendar", () => { // remembers selection via user preferences await expect(calendar.locators.hiddenEventsButtons.first()).toBeVisible(); + + await calendar.removeEventTypeFilter(); + + // removing the filter sticks instead of falling back to the saved default + await isNotVisible(calendar.locators.hiddenEventsButtons); + + await calendar.reload(); + + await isNotVisible(calendar.locators.hiddenEventsButtons); }); test("navigates view more buttons", async ({ page }) => { diff --git a/e2e/pages/builds/weapon-builds-page.ts b/e2e/pages/builds/weapon-builds-page.ts index 605beb4ff..f91edfcf6 100644 --- a/e2e/pages/builds/weapon-builds-page.ts +++ b/e2e/pages/builds/weapon-builds-page.ts @@ -13,7 +13,6 @@ export class WeaponBuildsPage { this.locators = { buildCards: page.getByTestId("build-card"), addFilterButton: page.getByTestId("add-filter-button"), - deleteFilterButton: page.getByTestId("delete-filter-button"), comparisonSelect: page.getByTestId("comparison-select"), dateSelect: page.getByTestId("date-select"), dateInput: page.getByTestId("date-input"), @@ -41,11 +40,23 @@ export class WeaponBuildsPage { } async addFilter(type: "ability" | "mode" | "date") { + await this.page.keyboard.press("Escape"); + await this.locators.addFilterButton.click(); await this.page.getByTestId(`menu-item-${type}`).click(); + + if (type === "ability") { + await this.page.getByTestId("add-ability-condition").click(); + } } - async deleteFilter() { - await this.locators.deleteFilterButton.click(); + async deleteFilter(type: "ability" | "mode" | "date") { + if (type === "ability") { + await this.page.getByTestId("delete-ability-condition").click(); + return; + } + + await this.page.keyboard.press("Escape"); + await this.page.getByTestId(`${type}-remove`).click(); } } diff --git a/e2e/pages/calendar/calendar-page.ts b/e2e/pages/calendar/calendar-page.ts index e40f81a10..ab8558b0a 100644 --- a/e2e/pages/calendar/calendar-page.ts +++ b/e2e/pages/calendar/calendar-page.ts @@ -1,12 +1,10 @@ import type { Page } from "@playwright/test"; -import { calendarFiltersFormSchema } from "~/features/calendar/calendar-schemas"; import { calendarPage } from "~/utils/urls"; import { expectIsHydrated, navigate, waitForPOSTResponse, } from "../../helpers/playwright"; -import { createFormHelpers } from "../../helpers/playwright-form"; /** `/calendar` */ export class CalendarPage { @@ -20,7 +18,11 @@ export class CalendarPage { hiddenEventsButtons: page.getByTestId("hidden-events-button"), clockHeaderTimes: page.getByTestId("clock-header-time"), todayHeader: page.getByTestId("today-header"), - filterEventsButton: page.getByTestId("filter-events-button"), + eventTypeFilterPill: page.getByTestId("event-type-filter"), + addFilterButton: page.getByTestId("add-filter-button"), + saveFiltersAsDefaultButton: page.getByTestId( + "save-filters-as-default-button", + ), navigateButtons: page.getByTestId("calendar-navigate-button"), }; } @@ -46,9 +48,42 @@ export class CalendarPage { await expectIsHydrated(this.page); } - async openFilters() { - await this.locators.filterEventsButton.click(); - return new CalendarFiltersDialog(this.page); + /** Toggles one of the switches inside the "Event type" filter pill's popover. */ + async toggleEventTypeFilter(name: "isSendou" | "isRanked") { + await this.page.keyboard.press("Escape"); + await this.openEventTypeFilter(); + await this.page + .getByText( + name === "isSendou" + ? "Only events hosted on sendou.ink" + : "Only ranked events", + ) + .click(); + await this.page.keyboard.press("Escape"); + } + + /** Resets the "Event type" filter pill's filters, hiding the pill. */ + async removeEventTypeFilter() { + await this.page.keyboard.press("Escape"); + await this.page.getByTestId("event-type-filter-remove").click(); + } + + /** The pill is only rendered while its filters differ from the defaults. */ + private async openEventTypeFilter() { + if (await this.locators.eventTypeFilterPill.isVisible()) { + await this.locators.eventTypeFilterPill.click(); + return; + } + + await this.locators.addFilterButton.click(); + await this.page.getByTestId("menu-item-event-type-filter").click(); + } + + /** Persists the current filters as the user's default. */ + async saveFiltersAsDefault() { + await waitForPOSTResponse(this.page, () => + this.locators.saveFiltersAsDefaultButton.click(), + ); } /** Shows or hides the events the current filters hide, of the first time slot. */ @@ -64,31 +99,3 @@ export class CalendarPage { await this.locators.navigateButtons.nth(1).click(); } } - -class CalendarFiltersDialog { - private readonly page: Page; - readonly form; - readonly locators; - - constructor(page: Page) { - this.page = page; - this.form = createFormHelpers(page, calendarFiltersFormSchema); - this.locators = { - applyAndMakeDefaultButton: page.getByRole("button", { - name: "Apply & make default", - }), - }; - } - - /** Applies the filters for this visit only, via search params. */ - async apply() { - await this.form.submit(); - } - - /** Applies the filters and saves them as the user's default. */ - async applyAndMakeDefault() { - await waitForPOSTResponse(this.page, () => - this.locators.applyAndMakeDefaultButton.click(), - ); - } -} diff --git a/e2e/pages/lfg/lfg-page.ts b/e2e/pages/lfg/lfg-page.ts index 94c1bb3d2..a02b7ac0a 100644 --- a/e2e/pages/lfg/lfg-page.ts +++ b/e2e/pages/lfg/lfg-page.ts @@ -13,7 +13,9 @@ export class LFGPage { this.page = page; this.locators = { addFilterButton: page.getByTestId("add-filter-button"), - languageFilterSelect: page.getByLabel("Spoken language"), + languageFilterSelect: page.getByLabel("Spoken language", { + exact: true, + }), }; } diff --git a/e2e/pages/scrims/scrims-page.ts b/e2e/pages/scrims/scrims-page.ts index 4125938be..fd3150f6f 100644 --- a/e2e/pages/scrims/scrims-page.ts +++ b/e2e/pages/scrims/scrims-page.ts @@ -2,10 +2,12 @@ import type { Page } from "@playwright/test"; import { scrimRequestFormSchema } from "~/features/scrims/scrims-schemas"; import { scrimsPage } from "~/utils/urls"; import { + expectIsHydrated, modalClickConfirmButton, navigate, selectUser, submit, + waitForPOSTResponse, } from "../../helpers/playwright"; import { createFormHelpers } from "../../helpers/playwright-form"; import { AssociationsPage } from "../associations/associations-page"; @@ -38,6 +40,11 @@ export class ScrimsPage { limitedVisibilityPopover: page.getByTestId("limited-visibility-popover"), tournamentPopover: page.getByTestId("tournament-popover-trigger"), canceledLabel: page.getByText("Canceled"), + divsFilterPill: page.getByTestId("divs-filter"), + addFilterButton: page.getByTestId("add-filter-button"), + saveFiltersAsDefaultButton: page.getByTestId( + "save-filters-as-default-button", + ), }; } @@ -45,10 +52,48 @@ export class ScrimsPage { await navigate({ page: this.page, url: scrimsPage() }); } + async reload() { + await this.page.reload(); + await expectIsHydrated(this.page); + } + post(text: string) { return this.page.getByText(text); } + /** Sets both selects of the "Divs" filter pill's popover. */ + async filterByDivs({ max, min }: { max: string; min: string }) { + await this.page.keyboard.press("Escape"); + await this.openDivsFilter(); + await this.page.getByLabel("Max div").selectOption(max); + await this.page.getByLabel("Min div").selectOption(min); + await this.page.keyboard.press("Escape"); + } + + /** Resets the "Divs" filter, hiding the pill. */ + async removeDivsFilter() { + await this.page.keyboard.press("Escape"); + await this.page.getByTestId("divs-filter-remove").click(); + } + + /** Persists the current filters as the user's default. */ + async saveFiltersAsDefault() { + await waitForPOSTResponse(this.page, () => + this.locators.saveFiltersAsDefaultButton.click(), + ); + } + + /** The pill is only rendered while its filter differs from the default. */ + private async openDivsFilter() { + if (await this.locators.divsFilterPill.isVisible()) { + await this.locators.divsFilterPill.click(); + return; + } + + await this.locators.addFilterButton.click(); + await this.page.getByTestId("menu-item-divs-filter").click(); + } + async openTab(tab: Tab) { await this.page.getByRole("tab", { name: TAB_NAMES[tab] }).click(); } diff --git a/e2e/pages/vods/vods-page.ts b/e2e/pages/vods/vods-page.ts index 8defe882c..c14a4a9e7 100644 --- a/e2e/pages/vods/vods-page.ts +++ b/e2e/pages/vods/vods-page.ts @@ -11,6 +11,7 @@ export class VodsPage { this.page = page; this.locators = { noVodsText: this.page.getByText(/No videos found matching this filter/), + addFilterButton: page.getByTestId("add-filter-button"), }; } @@ -24,6 +25,8 @@ export class VodsPage { } async filterByWeapon(weaponName: string) { + await this.locators.addFilterButton.click(); + await this.page.getByTestId("menu-item-vods-weapon-filter").click(); await selectWeapon({ page: this.page, name: weaponName }); } } diff --git a/e2e/scrims.spec.ts b/e2e/scrims.spec.ts index 909c09e5d..062d46aab 100644 --- a/e2e/scrims.spec.ts +++ b/e2e/scrims.spec.ts @@ -1,6 +1,7 @@ import { addDays, addHours, setHours, setMinutes, startOfHour } from "date-fns"; import { NZAP_TEST_ID } from "~/db/seed/constants"; import { ADMIN_ID } from "~/features/admin/admin-constants"; +import { serializeLutiDiv } from "~/features/scrims/scrims-utils"; import type { ModeShort, StageId } from "~/modules/in-game-lists/types"; import { dateToDatabaseTimestamp } from "~/utils/dates"; import { toDBBoolean } from "~/utils/sql"; @@ -86,6 +87,46 @@ test.describe("Scrims", () => { await expect(scrims.locators.requestButtons).toHaveCount(2); }); + test("filters by div and sets the filter as default", async ({ + page, + factories, + }) => { + await factories.ScrimPostFactory.create({ + users: await createGroup(factories), + maxDiv: serializeLutiDiv("1"), + minDiv: serializeLutiDiv("2"), + }); + + await impersonate(page, NZAP_TEST_ID); + + const scrims = new ScrimsPage(page); + await scrims.goto(); + await scrims.openTab("available"); + + await expect(scrims.locators.requestButtons).toHaveCount(1); + + // a div range the post's own range falls outside of + await scrims.filterByDivs({ max: "5", min: "6" }); + + await expect(scrims.locators.requestButtons).toHaveCount(0); + + await scrims.saveFiltersAsDefault(); + await scrims.goto(); + await scrims.openTab("available"); + + // remembers selection via user preferences + await expect(scrims.locators.requestButtons).toHaveCount(0); + + await scrims.removeDivsFilter(); + + // removing the filter sticks instead of falling back to the saved default + await expect(scrims.locators.requestButtons).toHaveCount(1); + + await scrims.reload(); + + await expect(scrims.locators.requestButtons).toHaveCount(1); + }); + test("accepts a request", async ({ page, factories }) => { await createPostWithRequest(factories, { ownerUserId: ADMIN_ID }); diff --git a/locales/da/builds.json b/locales/da/builds.json index 9475bb138..69dbe459d 100644 --- a/locales/da/builds.json +++ b/locales/da/builds.json @@ -21,17 +21,14 @@ "stats.all": "Alle", "stats.public": "", "stats.private": "", - "addFilter": "Tilføj filter", "linkButton.abilityStats": "Egenskabsstatistikker", "linkButton.popularBuilds": "Populære sæt", "noPopularBuilds": "Der er på nuværende tidspunkt ingen populære sæt for det valgte våben.", "emptyAbilitySlot": "Tomt egenskabsfelt", - "filters.type.ability": "Efter egenskab", - "filters.type.mode": "Efter spiltilstand", - "filters.type.date": "Efter dato", - "filters.ability.title": "Egenskabsfilter", - "filters.mode.title": "Spiltilstandsfilter", - "filters.date.title": "Datofilter", + "filters.abilities": "", + "filters.mode": "", + "filters.date": "", + "filters.addAbility": "", "filters.has": "Indeholder", "filters.does.not.have": "Indeholder ikke", "filters.atLeast": "Mindst", diff --git a/locales/da/calendar.json b/locales/da/calendar.json index 0581b40ba..ff0b0f8a4 100644 --- a/locales/da/calendar.json +++ b/locales/da/calendar.json @@ -46,11 +46,8 @@ "tag.desc.SR": "Salmon Run begivenhed.", "tag.desc.CARDS": "Tableturf Battle begivenhed.", "icalFeed": "", - "filter.button": "", - "filter.heading": "", "filter.modes": "", "filter.exactModes": "", - "filter.exactModesBottom": "", "filter.games": "", "filter.vs": "", "filter.vs.4v4": "", @@ -67,11 +64,18 @@ "filter.isSendou": "", "filter.isRanked": "", "filter.minTeamCount": "", + "filter.minTier": "", + "filter.maxTier": "", "filter.orgsIncluded": "", "filter.orgsExcluded": "", "filter.authorIdsExcluded": "", - "filter.apply": "", - "filter.applyAndDefault": "", + "filterBar.eventType": "", + "filterBar.tier": "", + "filterBar.tags": "", + "filterBar.organizers": "", + "filterBar.timeAndSize": "", + "filterBar.sendou": "", + "filterBar.ranked": "", "forms.draft": "", "forms.draftInfo": "", "forms.draftBracketStartBlocked": "", diff --git a/locales/da/common.json b/locales/da/common.json index ffe1b03cc..acdcd693e 100644 --- a/locales/da/common.json +++ b/locales/da/common.json @@ -149,6 +149,8 @@ "actions.outlined": "", "actions.noOutline": "", "actions.join": "Bliv medlem", + "filterBar.addFilter": "", + "filterBar.saveAsDefault": "", "imageExport.export": "", "imageExport.download": "", "imageExport.theme.light": "", diff --git a/locales/da/forms.json b/locales/da/forms.json index 91e803569..4f2509fdc 100644 --- a/locales/da/forms.json +++ b/locales/da/forms.json @@ -140,7 +140,6 @@ "errors.canNotSetIfLookingNow": "", "errors.maxAssociationsReached": "", "labels.weekdayTimes": "", - "labels.weekendTimes": "", "labels.start": "", "labels.end": "", "labels.member": "", @@ -187,24 +186,8 @@ "vodTypes.SCRIM": "Testkamp", "vodTypes.MATCHMAKING": "Anarki/X-kamp/Turf-war", "vodTypes.SENDOUQ": "SendouQ", - "labels.modesExact": "", - "bottomTexts.modesExact": "", - "labels.games": "", - "labels.vs": "", "labels.startTime": "", - "labels.tagsIncluded": "", - "labels.tagsExcluded": "", - "labels.onlySendouEvents": "", - "labels.onlyRankedEvents": "", - "labels.minTeamCount": "", - "labels.orgsIncluded": "", - "labels.orgsExcluded": "", - "labels.authorIdsExcluded": "", "bottomTexts.authorIdsExcluded": "", - "options.startTime.any": "", - "options.startTime.eu": "", - "options.startTime.na": "", - "options.startTime.au": "", "options.game.S1": "", "options.game.S2": "", "options.game.S3": "", diff --git a/locales/da/lfg.json b/locales/da/lfg.json index 89a1f2ace..f14ec0ece 100644 --- a/locales/da/lfg.json +++ b/locales/da/lfg.json @@ -8,7 +8,6 @@ "post.lastActive": "sidst aktiv", "noPosts": "Ingen opslag passer dette filter", "expiring": "Opslag er ved at udløbe, stadigvæk interresteret?", - "addFilter": "", "filters.Weapon": "Våbenpulje", "filters.Type": "Opslagstype", "filters.Timezone": "Tidszoneforskel", @@ -16,7 +15,6 @@ "filters.PlusTier": "Plus tier", "filters.MaxTier": "Max tier", "filters.MinTier": "Min tier", - "filters.suffix": "filter", "filters.orAbove": "Eller over", "new.noMorePosts": "Du kan ikke lave flere opslag", "new.type.header": "Type", diff --git a/locales/da/scrims.json b/locales/da/scrims.json index 9c1ee23f1..1e6340b6a 100644 --- a/locales/da/scrims.json +++ b/locales/da/scrims.json @@ -12,16 +12,9 @@ "requestModal.at.label": "", "requestModal.at.explanation": "", "pickupBy": "", - "filters.button": "", - "filters.heading": "", "filters.weekdayTimes": "", - "filters.weekdayStart": "", - "filters.weekdayEnd": "", "filters.weekendTimes": "", - "filters.weekendStart": "", - "filters.weekendEnd": "", - "filters.apply": "", - "filters.applyAndDefault": "", + "filters.divs": "", "filters.showFiltered": "", "filters.hideFiltered": "", "filters.showPendingRequests": "", diff --git a/locales/da/user.json b/locales/da/user.json index f908c7e83..ad22d513e 100644 --- a/locales/da/user.json +++ b/locales/da/user.json @@ -148,7 +148,6 @@ "sens": "Følsomhed", "usesPronouns": "", "discordExplanation": "Brugernavn, Profilbillede, Youtube-, Bluesky- og Twitch-konter er hentet via din Discord-konto. Se <1>FAQ for yderligere information.", - "results.title": "Alle resultater", "results.placing": "Placering", "results.team": "Hold", "results.tournament": "Turnering", @@ -159,9 +158,26 @@ "results.highlights": "Højdepunkter", "results.highlights.choose": "Vælg højdepunkter", "results.highlights.explanation": "Vælg de resultater, som du vil fremhæve", - "results.button.showHighlights": "Vis højdepunkter", - "results.button.showAll": "Vis alt", - "results.filter.placeholder": "", + "results.filter.only": "", + "results.filter.highlightsOnly": "", + "results.filter.tournament": "", + "results.filter.team": "", + "results.filter.mate": "", + "results.filter.tier": "", + "results.filter.tier.min": "", + "results.filter.tier.max": "", + "results.filter.placement": "", + "results.filter.placement.first": "", + "results.filter.placement.top": "", + "results.filter.years": "", + "results.filter.years.from": "", + "results.filter.years.to": "", + "results.filter.source": "", + "results.filter.source.ALL": "", + "results.filter.source.SENDOU": "", + "results.filter.source.EXTERNAL": "", + "results.filter.size": "", + "results.filter.size.min": "", "forms.errors.maxWeapons": "Maks antal våben nået", "search.info": "", "search.noResults": "Søgningen ’{{query}}’ fandt ingen brugere", diff --git a/locales/de/builds.json b/locales/de/builds.json index 837961634..ef4242623 100644 --- a/locales/de/builds.json +++ b/locales/de/builds.json @@ -21,17 +21,14 @@ "stats.all": "", "stats.public": "", "stats.private": "", - "addFilter": "", "linkButton.abilityStats": "", "linkButton.popularBuilds": "", "noPopularBuilds": "", "emptyAbilitySlot": "", - "filters.type.ability": "", - "filters.type.mode": "", - "filters.type.date": "", - "filters.ability.title": "", - "filters.mode.title": "", - "filters.date.title": "", + "filters.abilities": "", + "filters.mode": "", + "filters.date": "", + "filters.addAbility": "", "filters.has": "", "filters.does.not.have": "", "filters.atLeast": "", diff --git a/locales/de/calendar.json b/locales/de/calendar.json index d091720cb..8f905c6d0 100644 --- a/locales/de/calendar.json +++ b/locales/de/calendar.json @@ -46,11 +46,8 @@ "tag.desc.SR": "Es wird Salmon Run gespielt.", "tag.desc.CARDS": "Es wird Revierdecks gespielt.", "icalFeed": "", - "filter.button": "", - "filter.heading": "", "filter.modes": "", "filter.exactModes": "", - "filter.exactModesBottom": "", "filter.games": "", "filter.vs": "", "filter.vs.4v4": "", @@ -67,11 +64,18 @@ "filter.isSendou": "", "filter.isRanked": "", "filter.minTeamCount": "", + "filter.minTier": "", + "filter.maxTier": "", "filter.orgsIncluded": "", "filter.orgsExcluded": "", "filter.authorIdsExcluded": "", - "filter.apply": "", - "filter.applyAndDefault": "", + "filterBar.eventType": "", + "filterBar.tier": "", + "filterBar.tags": "", + "filterBar.organizers": "", + "filterBar.timeAndSize": "", + "filterBar.sendou": "", + "filterBar.ranked": "", "forms.draft": "", "forms.draftInfo": "", "forms.draftBracketStartBlocked": "", diff --git a/locales/de/common.json b/locales/de/common.json index b17680f8a..804686334 100644 --- a/locales/de/common.json +++ b/locales/de/common.json @@ -149,6 +149,8 @@ "actions.outlined": "", "actions.noOutline": "", "actions.join": "Beitreten", + "filterBar.addFilter": "", + "filterBar.saveAsDefault": "", "imageExport.export": "", "imageExport.download": "", "imageExport.theme.light": "", diff --git a/locales/de/forms.json b/locales/de/forms.json index 1b0d41f4c..933a4ae87 100644 --- a/locales/de/forms.json +++ b/locales/de/forms.json @@ -140,7 +140,6 @@ "errors.canNotSetIfLookingNow": "", "errors.maxAssociationsReached": "", "labels.weekdayTimes": "", - "labels.weekendTimes": "", "labels.start": "", "labels.end": "", "labels.member": "", @@ -187,24 +186,8 @@ "vodTypes.SCRIM": "Scrim", "vodTypes.MATCHMAKING": "Anarchie/X Kampf/Revierkampf", "vodTypes.SENDOUQ": "", - "labels.modesExact": "", - "bottomTexts.modesExact": "", - "labels.games": "", - "labels.vs": "", "labels.startTime": "", - "labels.tagsIncluded": "", - "labels.tagsExcluded": "", - "labels.onlySendouEvents": "", - "labels.onlyRankedEvents": "", - "labels.minTeamCount": "", - "labels.orgsIncluded": "", - "labels.orgsExcluded": "", - "labels.authorIdsExcluded": "", "bottomTexts.authorIdsExcluded": "", - "options.startTime.any": "", - "options.startTime.eu": "", - "options.startTime.na": "", - "options.startTime.au": "", "options.game.S1": "", "options.game.S2": "", "options.game.S3": "", diff --git a/locales/de/lfg.json b/locales/de/lfg.json index 389605cdf..0dbdb1439 100644 --- a/locales/de/lfg.json +++ b/locales/de/lfg.json @@ -8,7 +8,6 @@ "post.lastActive": "", "noPosts": "", "expiring": "", - "addFilter": "", "filters.Weapon": "", "filters.Type": "", "filters.Timezone": "", @@ -16,7 +15,6 @@ "filters.PlusTier": "", "filters.MaxTier": "", "filters.MinTier": "", - "filters.suffix": "", "filters.orAbove": "", "new.noMorePosts": "", "new.type.header": "", diff --git a/locales/de/scrims.json b/locales/de/scrims.json index 38bb9b2b7..5b138c980 100644 --- a/locales/de/scrims.json +++ b/locales/de/scrims.json @@ -12,16 +12,9 @@ "requestModal.at.label": "", "requestModal.at.explanation": "", "pickupBy": "", - "filters.button": "", - "filters.heading": "", "filters.weekdayTimes": "", - "filters.weekdayStart": "", - "filters.weekdayEnd": "", "filters.weekendTimes": "", - "filters.weekendStart": "", - "filters.weekendEnd": "", - "filters.apply": "", - "filters.applyAndDefault": "", + "filters.divs": "", "filters.showFiltered": "", "filters.hideFiltered": "", "filters.showPendingRequests": "", diff --git a/locales/de/user.json b/locales/de/user.json index 842bbdbb7..c7e75ac15 100644 --- a/locales/de/user.json +++ b/locales/de/user.json @@ -148,7 +148,6 @@ "sens": "Empfindlichkeit", "usesPronouns": "", "discordExplanation": "Der Username, Profilbild, YouTube-, Bluesky- und Twitch-Konten stammen von deinem Discord-Konto. Mehr Infos in den <1>FAQ.", - "results.title": "", "results.placing": "Platzierung", "results.team": "Team", "results.tournament": "Turnier", @@ -159,9 +158,26 @@ "results.highlights": "Highlights", "results.highlights.choose": "Highlights wählen", "results.highlights.explanation": "Wähle Ergebnisse, die du hervorheben möchtest", - "results.button.showHighlights": "", - "results.button.showAll": "", - "results.filter.placeholder": "", + "results.filter.only": "", + "results.filter.highlightsOnly": "", + "results.filter.tournament": "", + "results.filter.team": "", + "results.filter.mate": "", + "results.filter.tier": "", + "results.filter.tier.min": "", + "results.filter.tier.max": "", + "results.filter.placement": "", + "results.filter.placement.first": "", + "results.filter.placement.top": "", + "results.filter.years": "", + "results.filter.years.from": "", + "results.filter.years.to": "", + "results.filter.source": "", + "results.filter.source.ALL": "", + "results.filter.source.SENDOU": "", + "results.filter.source.EXTERNAL": "", + "results.filter.size": "", + "results.filter.size.min": "", "forms.errors.maxWeapons": "Maximale Zahl an Waffen erreicht", "search.info": "", "search.noResults": "Keine Nutzer gefunden, die '{{query}}' entsprechen", diff --git a/locales/en/builds.json b/locales/en/builds.json index f08407ffe..2fb462035 100644 --- a/locales/en/builds.json +++ b/locales/en/builds.json @@ -21,17 +21,14 @@ "stats.all": "All", "stats.public": "Public", "stats.private": "Private", - "addFilter": "Add filter", "linkButton.abilityStats": "Ability stats", "linkButton.popularBuilds": "Popular builds", "noPopularBuilds": "It seems there are no popular builds for this weapon at this moment.", "emptyAbilitySlot": "Empty ability slot", - "filters.type.ability": "By ability", - "filters.type.mode": "By mode", - "filters.type.date": "By date", - "filters.ability.title": "Ability filter", - "filters.mode.title": "Mode filter", - "filters.date.title": "Date filter", + "filters.abilities": "Abilities", + "filters.mode": "Mode", + "filters.date": "Date", + "filters.addAbility": "Add ability", "filters.has": "Has", "filters.does.not.have": "Doesn't have", "filters.atLeast": "At least", diff --git a/locales/en/calendar.json b/locales/en/calendar.json index 249812045..fa8c84773 100644 --- a/locales/en/calendar.json +++ b/locales/en/calendar.json @@ -46,11 +46,8 @@ "tag.desc.SR": "Salmon Run event.", "tag.desc.CARDS": "Tableturf Battle event.", "icalFeed": "iCal", - "filter.button": "Filter", - "filter.heading": "Filter calendar events", "filter.modes": "Modes", "filter.exactModes": "Exact modes", - "filter.exactModesBottom": "Only show events that match all selected modes", "filter.games": "Games", "filter.vs": "Vs.", "filter.vs.4v4": "4v4", @@ -67,11 +64,18 @@ "filter.isSendou": "Only events hosted on sendou.ink", "filter.isRanked": "Only ranked events", "filter.minTeamCount": "Minimum team count", + "filter.minTier": "Highest tier", + "filter.maxTier": "Lowest tier", "filter.orgsIncluded": "Visible organizations", "filter.orgsExcluded": "Hidden organizations", "filter.authorIdsExcluded": "Authors excluded", - "filter.apply": "Apply", - "filter.applyAndDefault": "Apply & make default", + "filterBar.eventType": "Event type", + "filterBar.tier": "Tier", + "filterBar.tags": "Tags", + "filterBar.organizers": "Organizers", + "filterBar.timeAndSize": "Time & size", + "filterBar.sendou": "sendou.ink", + "filterBar.ranked": "Ranked", "forms.draft": "Draft", "forms.draftInfo": "Draft tournaments are hidden and only visible to organizers. The tournament must be opened (by disabling this toggle) before any bracket can be started.", "forms.draftBracketStartBlocked": "Tournament is in draft mode. Edit the tournament and disable the draft toggle before starting the bracket.", diff --git a/locales/en/common.json b/locales/en/common.json index 48fd66587..f48789227 100644 --- a/locales/en/common.json +++ b/locales/en/common.json @@ -149,6 +149,8 @@ "actions.outlined": "Outlined", "actions.noOutline": "No outline", "actions.join": "Join", + "filterBar.addFilter": "Filter", + "filterBar.saveAsDefault": "Save as default", "imageExport.export": "Export image", "imageExport.download": "Download", "imageExport.theme.light": "Light", diff --git a/locales/en/forms.json b/locales/en/forms.json index aed447b43..4e7bc63f2 100644 --- a/locales/en/forms.json +++ b/locales/en/forms.json @@ -140,7 +140,6 @@ "errors.canNotSetIfLookingNow": "Can not be set if looking for scrim now", "errors.maxAssociationsReached": "You have reached the maximum number of associations", "labels.weekdayTimes": "Weekday times", - "labels.weekendTimes": "Weekend times", "labels.start": "Start", "labels.end": "End", "labels.member": "Member", @@ -187,24 +186,8 @@ "vodTypes.SCRIM": "Scrim", "vodTypes.MATCHMAKING": "Anarchy/X Battle/Turf War", "vodTypes.SENDOUQ": "SendouQ", - "labels.modesExact": "Exact modes", - "bottomTexts.modesExact": "Only show events that match all selected modes", - "labels.games": "Games", - "labels.vs": "Vs.", "labels.startTime": "Start time", - "labels.tagsIncluded": "Tags included", - "labels.tagsExcluded": "Tags excluded", - "labels.onlySendouEvents": "Only events hosted on sendou.ink", - "labels.onlyRankedEvents": "Only ranked events", - "labels.minTeamCount": "Minimum team count", - "labels.orgsIncluded": "Visible organizations", - "labels.orgsExcluded": "Hidden organizations", - "labels.authorIdsExcluded": "Authors excluded", "bottomTexts.authorIdsExcluded": "You can find a user's id on their profile page", - "options.startTime.any": "Any", - "options.startTime.eu": "Europe friendly", - "options.startTime.na": "Americas friendly", - "options.startTime.au": "AU/NZ friendly", "options.game.S1": "Splatoon 1", "options.game.S2": "Splatoon 2", "options.game.S3": "Splatoon 3", diff --git a/locales/en/lfg.json b/locales/en/lfg.json index fc156ef11..360dab290 100644 --- a/locales/en/lfg.json +++ b/locales/en/lfg.json @@ -8,7 +8,6 @@ "post.lastActive": "updated", "noPosts": "No posts matching the filter", "expiring": "Post is expiring. Still looking?", - "addFilter": "Add filter", "filters.Weapon": "Weapon pool", "filters.Type": "Post type", "filters.Timezone": "Timezone hour difference", @@ -16,7 +15,6 @@ "filters.PlusTier": "Plus tier", "filters.MaxTier": "Max tier", "filters.MinTier": "Min tier", - "filters.suffix": "filter", "filters.orAbove": "or above", "new.noMorePosts": "You can't create any more posts", "new.type.header": "Type", diff --git a/locales/en/scrims.json b/locales/en/scrims.json index f9f8616f3..b2d76223a 100644 --- a/locales/en/scrims.json +++ b/locales/en/scrims.json @@ -12,16 +12,9 @@ "requestModal.at.label": "Start time", "requestModal.at.explanation": "Select a time within the post's time range", "pickupBy": "Pickup by", - "filters.button": "Filters", - "filters.heading": "Scrim Filters", "filters.weekdayTimes": "Weekday times", - "filters.weekdayStart": "Weekday start", - "filters.weekdayEnd": "Weekday end", "filters.weekendTimes": "Weekend times", - "filters.weekendStart": "Weekend start", - "filters.weekendEnd": "Weekend end", - "filters.apply": "Apply", - "filters.applyAndDefault": "Apply & Set as Default", + "filters.divs": "Divs", "filters.showFiltered": "Show filtered ({{count}})", "filters.hideFiltered": "Hide filtered ({{count}})", "filters.showPendingRequests": "Show pending requests ({{count}})", diff --git a/locales/en/user.json b/locales/en/user.json index 1b4775ea1..1d5f9ce56 100644 --- a/locales/en/user.json +++ b/locales/en/user.json @@ -148,7 +148,6 @@ "sens": "Sens", "usesPronouns": "Uses", "discordExplanation": "Username, profile picture, YouTube, Bluesky and Twitch accounts come from your Discord account. See <1>FAQ for more information.", - "results.title": "All results", "results.placing": "Placing", "results.team": "Team", "results.tournament": "Tournament", @@ -159,9 +158,26 @@ "results.highlights": "Highlights", "results.highlights.choose": "Choose highlights", "results.highlights.explanation": "Select the results you want to highlight", - "results.button.showHighlights": "Show highlights", - "results.button.showAll": "Show all", - "results.filter.placeholder": "Filter by tournament", + "results.filter.only": "Only", + "results.filter.highlightsOnly": "Only highlighted results", + "results.filter.tournament": "Tournament", + "results.filter.team": "Team", + "results.filter.mate": "Teammate", + "results.filter.tier": "Tier", + "results.filter.tier.min": "Highest tier", + "results.filter.tier.max": "Lowest tier", + "results.filter.placement": "Placement", + "results.filter.placement.first": "Winner", + "results.filter.placement.top": "Top {{count}}", + "results.filter.years": "Years", + "results.filter.years.from": "From", + "results.filter.years.to": "To", + "results.filter.source": "Source", + "results.filter.source.ALL": "All", + "results.filter.source.SENDOU": "Hosted on sendou.ink", + "results.filter.source.EXTERNAL": "Reported results", + "results.filter.size": "Size", + "results.filter.size.min": "Minimum teams", "forms.errors.maxWeapons": "Max weapon count reached", "search.info": "Search for users by Discord or Splatoon 3 name", "search.noResults": "No users found matching '{{query}}'", diff --git a/locales/es-ES/builds.json b/locales/es-ES/builds.json index ef543d4ad..d82029361 100644 --- a/locales/es-ES/builds.json +++ b/locales/es-ES/builds.json @@ -21,17 +21,14 @@ "stats.all": "Todas", "stats.public": "Público", "stats.private": "Privado", - "addFilter": "Añadir filtro", "linkButton.abilityStats": "Estadísticas de potenciadores", "linkButton.popularBuilds": "Builds populares", "noPopularBuilds": "Parece que no hay builds populares para esta arma al momento.", "emptyAbilitySlot": "Espacio de potenciador vacío", - "filters.type.ability": "Por potenciador", - "filters.type.mode": "Por estilo", - "filters.type.date": "Por fecha", - "filters.ability.title": "Filtro por potenciador", - "filters.mode.title": "Filtro por estilo", - "filters.date.title": "Filtro por fecha", + "filters.abilities": "", + "filters.mode": "", + "filters.date": "", + "filters.addAbility": "", "filters.has": "Tiene", "filters.does.not.have": "No tiene", "filters.atLeast": "Al menos", diff --git a/locales/es-ES/calendar.json b/locales/es-ES/calendar.json index ba063531a..7eabeee22 100644 --- a/locales/es-ES/calendar.json +++ b/locales/es-ES/calendar.json @@ -48,11 +48,8 @@ "tag.desc.SR": "Evento de Salmon Run", "tag.desc.CARDS": "Evento de Lucha carterritorial", "icalFeed": "iCal", - "filter.button": "Filtrar", - "filter.heading": "Filtrar eventos del calendario", "filter.modes": "Modos", "filter.exactModes": "Modos exactos", - "filter.exactModesBottom": "Mostrar solo eventos que coincidan con todos los modos seleccionados", "filter.games": "Juegos", "filter.vs": "Vs.", "filter.vs.4v4": "4v4", @@ -69,11 +66,18 @@ "filter.isSendou": "Solo eventos alojados en sendou.ink", "filter.isRanked": "Solo eventos clasificados", "filter.minTeamCount": "Mínimo de equipos", + "filter.minTier": "", + "filter.maxTier": "", "filter.orgsIncluded": "Organizaciones visibles", "filter.orgsExcluded": "Organizaciones ocultas", "filter.authorIdsExcluded": "Autores excluidos", - "filter.apply": "Aplicar", - "filter.applyAndDefault": "Aplicar y establecer por defecto", + "filterBar.eventType": "", + "filterBar.tier": "", + "filterBar.tags": "", + "filterBar.organizers": "", + "filterBar.timeAndSize": "", + "filterBar.sendou": "", + "filterBar.ranked": "", "forms.draft": "Borrador", "forms.draftInfo": "Los torneos en borrador están ocultos y solo son visibles para los organizadores. El torneo debe abrirse (desactivando esta opción) antes de que pueda iniciarse cualquier cuadro.", "forms.draftBracketStartBlocked": "El torneo está en modo borrador. Edita el torneo y desactiva la opción de borrador antes de iniciar el cuadro.", diff --git a/locales/es-ES/common.json b/locales/es-ES/common.json index 84b97bc04..db0728568 100644 --- a/locales/es-ES/common.json +++ b/locales/es-ES/common.json @@ -149,6 +149,8 @@ "actions.outlined": "Con borde", "actions.noOutline": "Sin borde", "actions.join": "Unirse", + "filterBar.addFilter": "", + "filterBar.saveAsDefault": "", "imageExport.export": "Exportar imagen", "imageExport.download": "Descargar", "imageExport.theme.light": "Claro", diff --git a/locales/es-ES/forms.json b/locales/es-ES/forms.json index 8440a5803..bde7f6bdd 100644 --- a/locales/es-ES/forms.json +++ b/locales/es-ES/forms.json @@ -140,7 +140,6 @@ "errors.canNotSetIfLookingNow": "No se puede establecer si se está buscando scrim ahora", "errors.maxAssociationsReached": "Has alcanzado el número máximo de asociaciones", "labels.weekdayTimes": "Horarios entre semana", - "labels.weekendTimes": "Horarios de fin de semana", "labels.start": "Inicio", "labels.end": "Fin", "labels.member": "Miembro", @@ -187,24 +186,8 @@ "vodTypes.SCRIM": "Práctica", "vodTypes.MATCHMAKING": "Combate caótico/X/territorial", "vodTypes.SENDOUQ": "SendouQ", - "labels.modesExact": "Modos exactos", - "bottomTexts.modesExact": "Mostrar solo eventos que coincidan con todos los modos seleccionados", - "labels.games": "Partidas", - "labels.vs": "Vs.", "labels.startTime": "Hora de inicio", - "labels.tagsIncluded": "Etiquetas incluidas", - "labels.tagsExcluded": "Etiquetas excluidas", - "labels.onlySendouEvents": "Solo eventos organizados en sendou.ink", - "labels.onlyRankedEvents": "Solo eventos competitivos", - "labels.minTeamCount": "Mínimo de equipos", - "labels.orgsIncluded": "Organizaciones visibles", - "labels.orgsExcluded": "Organizaciones ocultas", - "labels.authorIdsExcluded": "Autores excluidos", "bottomTexts.authorIdsExcluded": "Puedes encontrar el ID de un usuario en su página de perfil", - "options.startTime.any": "Cualquiera", - "options.startTime.eu": "Horario europeo", - "options.startTime.na": "Horario americano", - "options.startTime.au": "Horario AU/NZ", "options.game.S1": "Splatoon 1", "options.game.S2": "Splatoon 2", "options.game.S3": "Splatoon 3", diff --git a/locales/es-ES/lfg.json b/locales/es-ES/lfg.json index 39b6e1fd5..889925839 100644 --- a/locales/es-ES/lfg.json +++ b/locales/es-ES/lfg.json @@ -8,7 +8,6 @@ "post.lastActive": "hace", "noPosts": "Sin publicaciones que coincidan con el filtro", "expiring": "La publicación caduca. ¿Sigues buscando?", - "addFilter": "Añadir filtro", "filters.Weapon": "Grupo de armas", "filters.Type": "Tipo de publicación", "filters.Timezone": "Diferencia horaria", @@ -16,7 +15,6 @@ "filters.PlusTier": "Nivel Plus", "filters.MaxTier": "Nivel máximo", "filters.MinTier": "Nivel mínimo", - "filters.suffix": "filtro", "filters.orAbove": "o más", "new.noMorePosts": "No puedes crear más publicaciones", "new.type.header": "Tipo", diff --git a/locales/es-ES/scrims.json b/locales/es-ES/scrims.json index afba148f0..c787c0bb6 100644 --- a/locales/es-ES/scrims.json +++ b/locales/es-ES/scrims.json @@ -12,16 +12,9 @@ "requestModal.at.label": "Hora de inicio", "requestModal.at.explanation": "Selecciona una hora dentro del rango de tiempo de la publicación", "pickupBy": "Pickup de", - "filters.button": "Filtros", - "filters.heading": "Filtros de scrims", "filters.weekdayTimes": "Horarios entre semana", - "filters.weekdayStart": "Inicio entre semana", - "filters.weekdayEnd": "Fin entre semana", "filters.weekendTimes": "Horarios de fin de semana", - "filters.weekendStart": "Inicio de fin de semana", - "filters.weekendEnd": "Fin de fin de semana", - "filters.apply": "Aplicar", - "filters.applyAndDefault": "Aplicar y establecer por defecto", + "filters.divs": "", "filters.showFiltered": "Mostrar filtrados ({{count}})", "filters.hideFiltered": "Ocultar filtrados ({{count}})", "filters.showPendingRequests": "Mostrar peticiones pendientes ({{count}})", diff --git a/locales/es-ES/user.json b/locales/es-ES/user.json index 18f18f0e8..ccc7945ce 100644 --- a/locales/es-ES/user.json +++ b/locales/es-ES/user.json @@ -148,7 +148,6 @@ "sens": "Sensibilidad", "usesPronouns": "Usa", "discordExplanation": "Tu nombre, foto, y cuentas de YouTube, Bluesky y Twitch se obtienen por tu cuenta en Discord. Ver <1>FAQ para más información.", - "results.title": "Todos los resultados", "results.placing": "Lugar", "results.team": "Equipo", "results.tournament": "Torneo", @@ -159,9 +158,26 @@ "results.highlights": "Resaltos", "results.highlights.choose": "Elegir resaltos", "results.highlights.explanation": "Elige los resultados que quieres resaltar", - "results.button.showHighlights": "Mostrar resaltados", - "results.button.showAll": "Mostrar todos", - "results.filter.placeholder": "Filtrar por torneo", + "results.filter.only": "", + "results.filter.highlightsOnly": "", + "results.filter.tournament": "", + "results.filter.team": "", + "results.filter.mate": "", + "results.filter.tier": "", + "results.filter.tier.min": "", + "results.filter.tier.max": "", + "results.filter.placement": "", + "results.filter.placement.first": "", + "results.filter.placement.top": "", + "results.filter.years": "", + "results.filter.years.from": "", + "results.filter.years.to": "", + "results.filter.source": "", + "results.filter.source.ALL": "", + "results.filter.source.SENDOU": "", + "results.filter.source.EXTERNAL": "", + "results.filter.size": "", + "results.filter.size.min": "", "forms.errors.maxWeapons": "Máxima cantidad de armas", "search.info": "Busca usuarios por su nombre de Discord o de Splatoon 3", "search.noResults": "No se encontraron usuarios que coincidan con '{{query}}'", diff --git a/locales/es-US/builds.json b/locales/es-US/builds.json index e89d6b6cf..a0c6bb373 100644 --- a/locales/es-US/builds.json +++ b/locales/es-US/builds.json @@ -21,17 +21,14 @@ "stats.all": "Todas", "stats.public": "Público", "stats.private": "Privado", - "addFilter": "Añadir filtro", "linkButton.abilityStats": "Estadísticas de potenciadores", "linkButton.popularBuilds": "Builds populares", "noPopularBuilds": "Parece que no hay builds populares para esta arma al momento.", "emptyAbilitySlot": "Espacio de potenciador vacío", - "filters.type.ability": "Por potenciador", - "filters.type.mode": "Por estilo", - "filters.type.date": "Por fecha", - "filters.ability.title": "Filtro por potenciador", - "filters.mode.title": "Filtro por estilo", - "filters.date.title": "Filtro por fecha", + "filters.abilities": "", + "filters.mode": "", + "filters.date": "", + "filters.addAbility": "", "filters.has": "Tiene", "filters.does.not.have": "No tiene", "filters.atLeast": "Al menos", diff --git a/locales/es-US/calendar.json b/locales/es-US/calendar.json index 826032e49..073db546a 100644 --- a/locales/es-US/calendar.json +++ b/locales/es-US/calendar.json @@ -48,11 +48,8 @@ "tag.desc.SR": "Evento de Salmon Run", "tag.desc.CARDS": "Evento de Combate carterritorial", "icalFeed": "", - "filter.button": "", - "filter.heading": "", "filter.modes": "", "filter.exactModes": "", - "filter.exactModesBottom": "", "filter.games": "", "filter.vs": "", "filter.vs.4v4": "", @@ -69,11 +66,18 @@ "filter.isSendou": "", "filter.isRanked": "", "filter.minTeamCount": "", + "filter.minTier": "", + "filter.maxTier": "", "filter.orgsIncluded": "", "filter.orgsExcluded": "", "filter.authorIdsExcluded": "", - "filter.apply": "", - "filter.applyAndDefault": "", + "filterBar.eventType": "", + "filterBar.tier": "", + "filterBar.tags": "", + "filterBar.organizers": "", + "filterBar.timeAndSize": "", + "filterBar.sendou": "", + "filterBar.ranked": "", "forms.draft": "", "forms.draftInfo": "", "forms.draftBracketStartBlocked": "", diff --git a/locales/es-US/common.json b/locales/es-US/common.json index b32a8685e..d7e3ad1ac 100644 --- a/locales/es-US/common.json +++ b/locales/es-US/common.json @@ -149,6 +149,8 @@ "actions.outlined": "", "actions.noOutline": "", "actions.join": "Unirse", + "filterBar.addFilter": "", + "filterBar.saveAsDefault": "", "imageExport.export": "", "imageExport.download": "", "imageExport.theme.light": "", diff --git a/locales/es-US/forms.json b/locales/es-US/forms.json index f71b8c436..7ba613fd4 100644 --- a/locales/es-US/forms.json +++ b/locales/es-US/forms.json @@ -140,7 +140,6 @@ "errors.canNotSetIfLookingNow": "", "errors.maxAssociationsReached": "", "labels.weekdayTimes": "", - "labels.weekendTimes": "", "labels.start": "", "labels.end": "", "labels.member": "", @@ -187,24 +186,8 @@ "vodTypes.SCRIM": "Práctica", "vodTypes.MATCHMAKING": "Combate caótico/X/territorial", "vodTypes.SENDOUQ": "SendouQ", - "labels.modesExact": "", - "bottomTexts.modesExact": "", - "labels.games": "", - "labels.vs": "", "labels.startTime": "", - "labels.tagsIncluded": "", - "labels.tagsExcluded": "", - "labels.onlySendouEvents": "", - "labels.onlyRankedEvents": "", - "labels.minTeamCount": "", - "labels.orgsIncluded": "", - "labels.orgsExcluded": "", - "labels.authorIdsExcluded": "", "bottomTexts.authorIdsExcluded": "", - "options.startTime.any": "", - "options.startTime.eu": "", - "options.startTime.na": "", - "options.startTime.au": "", "options.game.S1": "", "options.game.S2": "", "options.game.S3": "", diff --git a/locales/es-US/lfg.json b/locales/es-US/lfg.json index 8406b1836..69cba2e42 100644 --- a/locales/es-US/lfg.json +++ b/locales/es-US/lfg.json @@ -8,7 +8,6 @@ "post.lastActive": "hace", "noPosts": "No posts matching the filter", "expiring": "La publicación caduca. ¿Sigues buscando?", - "addFilter": "", "filters.Weapon": "Grupo de armas", "filters.Type": "Tipo de publicación", "filters.Timezone": "Diferencia horaria", @@ -16,7 +15,6 @@ "filters.PlusTier": "Nivel Plus", "filters.MaxTier": "Nivel máximo", "filters.MinTier": "Nivel mínimo", - "filters.suffix": "filtro", "filters.orAbove": "o más", "new.noMorePosts": "No puede crear más publicaciones", "new.type.header": "Tipo", diff --git a/locales/es-US/scrims.json b/locales/es-US/scrims.json index 9c1ee23f1..1e6340b6a 100644 --- a/locales/es-US/scrims.json +++ b/locales/es-US/scrims.json @@ -12,16 +12,9 @@ "requestModal.at.label": "", "requestModal.at.explanation": "", "pickupBy": "", - "filters.button": "", - "filters.heading": "", "filters.weekdayTimes": "", - "filters.weekdayStart": "", - "filters.weekdayEnd": "", "filters.weekendTimes": "", - "filters.weekendStart": "", - "filters.weekendEnd": "", - "filters.apply": "", - "filters.applyAndDefault": "", + "filters.divs": "", "filters.showFiltered": "", "filters.hideFiltered": "", "filters.showPendingRequests": "", diff --git a/locales/es-US/user.json b/locales/es-US/user.json index 664981f95..94029ba71 100644 --- a/locales/es-US/user.json +++ b/locales/es-US/user.json @@ -148,7 +148,6 @@ "sens": "Sens", "usesPronouns": "", "discordExplanation": "Tu nombre, foto, y cuentas de YouTube, Bluesky y Twitch se obtienen por tu cuenta en Discord. Ver <1>FAQ para más información.", - "results.title": "Todos los resultados", "results.placing": "Lugar", "results.team": "Equipo", "results.tournament": "Torneo", @@ -159,9 +158,26 @@ "results.highlights": "Resaltos", "results.highlights.choose": "Elegir resaltos", "results.highlights.explanation": "Elige los resultados que quieres resaltar", - "results.button.showHighlights": "Mostrar resaltos", - "results.button.showAll": "Mostrar todos", - "results.filter.placeholder": "", + "results.filter.only": "", + "results.filter.highlightsOnly": "", + "results.filter.tournament": "", + "results.filter.team": "", + "results.filter.mate": "", + "results.filter.tier": "", + "results.filter.tier.min": "", + "results.filter.tier.max": "", + "results.filter.placement": "", + "results.filter.placement.first": "", + "results.filter.placement.top": "", + "results.filter.years": "", + "results.filter.years.from": "", + "results.filter.years.to": "", + "results.filter.source": "", + "results.filter.source.ALL": "", + "results.filter.source.SENDOU": "", + "results.filter.source.EXTERNAL": "", + "results.filter.size": "", + "results.filter.size.min": "", "forms.errors.maxWeapons": "Máxima cantidad de armas", "search.info": "", "search.noResults": "No se encontraron usuarios que coincidan con '{{query}}'", diff --git a/locales/fr-CA/builds.json b/locales/fr-CA/builds.json index 77da3ed25..5bd81a83b 100644 --- a/locales/fr-CA/builds.json +++ b/locales/fr-CA/builds.json @@ -21,17 +21,14 @@ "stats.all": "Tous", "stats.public": "", "stats.private": "", - "addFilter": "Ajouter un filtre", "linkButton.abilityStats": "Statistiques", "linkButton.popularBuilds": "Sets populaires", "noPopularBuilds": "Il semble qu'il n'y ait pas de sets populaires pour cette arme en ce moment.", "emptyAbilitySlot": "Emplacement de bonus vide", - "filters.type.ability": "", - "filters.type.mode": "", - "filters.type.date": "", - "filters.ability.title": "", - "filters.mode.title": "", - "filters.date.title": "", + "filters.abilities": "", + "filters.mode": "", + "filters.date": "", + "filters.addAbility": "", "filters.has": "Avec", "filters.does.not.have": "Sans", "filters.atLeast": "Au moins", diff --git a/locales/fr-CA/calendar.json b/locales/fr-CA/calendar.json index 9eaea9e94..8fbce9bd7 100644 --- a/locales/fr-CA/calendar.json +++ b/locales/fr-CA/calendar.json @@ -48,11 +48,8 @@ "tag.desc.SR": "Événement Salmon Run.", "tag.desc.CARDS": "Événement Cartes & Territoire.", "icalFeed": "", - "filter.button": "", - "filter.heading": "", "filter.modes": "", "filter.exactModes": "", - "filter.exactModesBottom": "", "filter.games": "", "filter.vs": "", "filter.vs.4v4": "", @@ -69,11 +66,18 @@ "filter.isSendou": "", "filter.isRanked": "", "filter.minTeamCount": "", + "filter.minTier": "", + "filter.maxTier": "", "filter.orgsIncluded": "", "filter.orgsExcluded": "", "filter.authorIdsExcluded": "", - "filter.apply": "", - "filter.applyAndDefault": "", + "filterBar.eventType": "", + "filterBar.tier": "", + "filterBar.tags": "", + "filterBar.organizers": "", + "filterBar.timeAndSize": "", + "filterBar.sendou": "", + "filterBar.ranked": "", "forms.draft": "", "forms.draftInfo": "", "forms.draftBracketStartBlocked": "", diff --git a/locales/fr-CA/common.json b/locales/fr-CA/common.json index 45afff4cb..1643a94dd 100644 --- a/locales/fr-CA/common.json +++ b/locales/fr-CA/common.json @@ -149,6 +149,8 @@ "actions.outlined": "", "actions.noOutline": "", "actions.join": "Joindre", + "filterBar.addFilter": "", + "filterBar.saveAsDefault": "", "imageExport.export": "", "imageExport.download": "", "imageExport.theme.light": "", diff --git a/locales/fr-CA/forms.json b/locales/fr-CA/forms.json index 014baff1d..08e46827c 100644 --- a/locales/fr-CA/forms.json +++ b/locales/fr-CA/forms.json @@ -140,7 +140,6 @@ "errors.canNotSetIfLookingNow": "", "errors.maxAssociationsReached": "", "labels.weekdayTimes": "", - "labels.weekendTimes": "", "labels.start": "", "labels.end": "", "labels.member": "", @@ -187,24 +186,8 @@ "vodTypes.SCRIM": "Scrim", "vodTypes.MATCHMAKING": "Anarchie/Match X/Guerre de Territoire", "vodTypes.SENDOUQ": "", - "labels.modesExact": "", - "bottomTexts.modesExact": "", - "labels.games": "", - "labels.vs": "", "labels.startTime": "", - "labels.tagsIncluded": "", - "labels.tagsExcluded": "", - "labels.onlySendouEvents": "", - "labels.onlyRankedEvents": "", - "labels.minTeamCount": "", - "labels.orgsIncluded": "", - "labels.orgsExcluded": "", - "labels.authorIdsExcluded": "", "bottomTexts.authorIdsExcluded": "", - "options.startTime.any": "", - "options.startTime.eu": "", - "options.startTime.na": "", - "options.startTime.au": "", "options.game.S1": "", "options.game.S2": "", "options.game.S3": "", diff --git a/locales/fr-CA/lfg.json b/locales/fr-CA/lfg.json index 389605cdf..0dbdb1439 100644 --- a/locales/fr-CA/lfg.json +++ b/locales/fr-CA/lfg.json @@ -8,7 +8,6 @@ "post.lastActive": "", "noPosts": "", "expiring": "", - "addFilter": "", "filters.Weapon": "", "filters.Type": "", "filters.Timezone": "", @@ -16,7 +15,6 @@ "filters.PlusTier": "", "filters.MaxTier": "", "filters.MinTier": "", - "filters.suffix": "", "filters.orAbove": "", "new.noMorePosts": "", "new.type.header": "", diff --git a/locales/fr-CA/scrims.json b/locales/fr-CA/scrims.json index 9c1ee23f1..1e6340b6a 100644 --- a/locales/fr-CA/scrims.json +++ b/locales/fr-CA/scrims.json @@ -12,16 +12,9 @@ "requestModal.at.label": "", "requestModal.at.explanation": "", "pickupBy": "", - "filters.button": "", - "filters.heading": "", "filters.weekdayTimes": "", - "filters.weekdayStart": "", - "filters.weekdayEnd": "", "filters.weekendTimes": "", - "filters.weekendStart": "", - "filters.weekendEnd": "", - "filters.apply": "", - "filters.applyAndDefault": "", + "filters.divs": "", "filters.showFiltered": "", "filters.hideFiltered": "", "filters.showPendingRequests": "", diff --git a/locales/fr-CA/user.json b/locales/fr-CA/user.json index da2712757..07f9ef0c0 100644 --- a/locales/fr-CA/user.json +++ b/locales/fr-CA/user.json @@ -148,7 +148,6 @@ "sens": "Sens", "usesPronouns": "", "discordExplanation": "Votre pseudo, votre photo de profil et vos comptes Youtube, Bluesky et Twitch viennent de votre compte Discord. Voir la <1>FAQ pour plus d'informations.", - "results.title": "", "results.placing": "Placement", "results.team": "Équipe", "results.tournament": "Tournoi", @@ -159,9 +158,26 @@ "results.highlights": "Résultats notables", "results.highlights.choose": "Choisir vos résultats notables", "results.highlights.explanation": "Sélectionnez les résultats que vous voulez mettre en avant", - "results.button.showHighlights": "", - "results.button.showAll": "", - "results.filter.placeholder": "", + "results.filter.only": "", + "results.filter.highlightsOnly": "", + "results.filter.tournament": "", + "results.filter.team": "", + "results.filter.mate": "", + "results.filter.tier": "", + "results.filter.tier.min": "", + "results.filter.tier.max": "", + "results.filter.placement": "", + "results.filter.placement.first": "", + "results.filter.placement.top": "", + "results.filter.years": "", + "results.filter.years.from": "", + "results.filter.years.to": "", + "results.filter.source": "", + "results.filter.source.ALL": "", + "results.filter.source.SENDOU": "", + "results.filter.source.EXTERNAL": "", + "results.filter.size": "", + "results.filter.size.min": "", "forms.errors.maxWeapons": "Nombre d'armes maximum atteint", "search.info": "", "search.noResults": "Aucun utilisateur correspondant à '{{query}}' n'a été trouvé", diff --git a/locales/fr-EU/builds.json b/locales/fr-EU/builds.json index 8ed81e18f..29f896f4b 100644 --- a/locales/fr-EU/builds.json +++ b/locales/fr-EU/builds.json @@ -21,17 +21,14 @@ "stats.all": "Tous", "stats.public": "Publique", "stats.private": "Privé", - "addFilter": "Ajouter un filtre", "linkButton.abilityStats": "Statistiques", "linkButton.popularBuilds": "Sets populaires", "noPopularBuilds": "Il semble qu'il n'y ait pas de sets populaires pour cette arme en ce moment.", "emptyAbilitySlot": "Emplacement de bonus vide", - "filters.type.ability": "Par abilité", - "filters.type.mode": "Par mode", - "filters.type.date": "Par date", - "filters.ability.title": "Filtre par abilité", - "filters.mode.title": "Filtre par mode", - "filters.date.title": "Filtre par date", + "filters.abilities": "", + "filters.mode": "", + "filters.date": "", + "filters.addAbility": "", "filters.has": "Avec", "filters.does.not.have": "Sans", "filters.atLeast": "Au moins", diff --git a/locales/fr-EU/calendar.json b/locales/fr-EU/calendar.json index 9eaea9e94..8fbce9bd7 100644 --- a/locales/fr-EU/calendar.json +++ b/locales/fr-EU/calendar.json @@ -48,11 +48,8 @@ "tag.desc.SR": "Événement Salmon Run.", "tag.desc.CARDS": "Événement Cartes & Territoire.", "icalFeed": "", - "filter.button": "", - "filter.heading": "", "filter.modes": "", "filter.exactModes": "", - "filter.exactModesBottom": "", "filter.games": "", "filter.vs": "", "filter.vs.4v4": "", @@ -69,11 +66,18 @@ "filter.isSendou": "", "filter.isRanked": "", "filter.minTeamCount": "", + "filter.minTier": "", + "filter.maxTier": "", "filter.orgsIncluded": "", "filter.orgsExcluded": "", "filter.authorIdsExcluded": "", - "filter.apply": "", - "filter.applyAndDefault": "", + "filterBar.eventType": "", + "filterBar.tier": "", + "filterBar.tags": "", + "filterBar.organizers": "", + "filterBar.timeAndSize": "", + "filterBar.sendou": "", + "filterBar.ranked": "", "forms.draft": "", "forms.draftInfo": "", "forms.draftBracketStartBlocked": "", diff --git a/locales/fr-EU/common.json b/locales/fr-EU/common.json index ffaf2ed94..2a974fb35 100644 --- a/locales/fr-EU/common.json +++ b/locales/fr-EU/common.json @@ -149,6 +149,8 @@ "actions.outlined": "Outlined", "actions.noOutline": "No outline", "actions.join": "Joindre", + "filterBar.addFilter": "", + "filterBar.saveAsDefault": "", "imageExport.export": "", "imageExport.download": "", "imageExport.theme.light": "", diff --git a/locales/fr-EU/forms.json b/locales/fr-EU/forms.json index 97e9128bc..c30a31c0d 100644 --- a/locales/fr-EU/forms.json +++ b/locales/fr-EU/forms.json @@ -140,7 +140,6 @@ "errors.canNotSetIfLookingNow": "", "errors.maxAssociationsReached": "", "labels.weekdayTimes": "", - "labels.weekendTimes": "", "labels.start": "", "labels.end": "", "labels.member": "", @@ -187,24 +186,8 @@ "vodTypes.SCRIM": "Scrim", "vodTypes.MATCHMAKING": "Anarchie/Match X/Guerre de Territoire", "vodTypes.SENDOUQ": "SendouQ", - "labels.modesExact": "", - "bottomTexts.modesExact": "", - "labels.games": "", - "labels.vs": "", "labels.startTime": "", - "labels.tagsIncluded": "", - "labels.tagsExcluded": "", - "labels.onlySendouEvents": "", - "labels.onlyRankedEvents": "", - "labels.minTeamCount": "", - "labels.orgsIncluded": "", - "labels.orgsExcluded": "", - "labels.authorIdsExcluded": "", "bottomTexts.authorIdsExcluded": "", - "options.startTime.any": "", - "options.startTime.eu": "", - "options.startTime.na": "", - "options.startTime.au": "", "options.game.S1": "", "options.game.S2": "", "options.game.S3": "", diff --git a/locales/fr-EU/lfg.json b/locales/fr-EU/lfg.json index 1ce3b4432..3e4e8e883 100644 --- a/locales/fr-EU/lfg.json +++ b/locales/fr-EU/lfg.json @@ -8,7 +8,6 @@ "post.lastActive": "Mis à jour", "noPosts": "Aucun message correspondant au filtre", "expiring": "Le message expire. Tu cherches toujours ?", - "addFilter": "", "filters.Weapon": "Arme utilisé", "filters.Type": "Type de publication", "filters.Timezone": "Différence horaire entre les heures", @@ -16,7 +15,6 @@ "filters.PlusTier": "Niveau du Plus", "filters.MaxTier": "Niveau max", "filters.MinTier": "Niveau min", - "filters.suffix": "filtre", "filters.orAbove": "ou supérieur", "new.noMorePosts": "Vous ne pouvez pas créer plus de posts", "new.type.header": "Type", diff --git a/locales/fr-EU/scrims.json b/locales/fr-EU/scrims.json index 5d38dcc71..289fff284 100644 --- a/locales/fr-EU/scrims.json +++ b/locales/fr-EU/scrims.json @@ -12,16 +12,9 @@ "requestModal.at.label": "", "requestModal.at.explanation": "", "pickupBy": "", - "filters.button": "", - "filters.heading": "", "filters.weekdayTimes": "", - "filters.weekdayStart": "", - "filters.weekdayEnd": "", "filters.weekendTimes": "", - "filters.weekendStart": "", - "filters.weekendEnd": "", - "filters.apply": "", - "filters.applyAndDefault": "", + "filters.divs": "", "filters.showFiltered": "", "filters.hideFiltered": "", "filters.showPendingRequests": "", diff --git a/locales/fr-EU/user.json b/locales/fr-EU/user.json index 80a1346f2..36c72b99e 100644 --- a/locales/fr-EU/user.json +++ b/locales/fr-EU/user.json @@ -148,7 +148,6 @@ "sens": "Sens", "usesPronouns": "", "discordExplanation": "Votre pseudo, votre photo de profil et vos comptes Youtube, Bluesky et Twitch viennent de votre compte Discord. Voir la <1>FAQ pour plus d'informations.", - "results.title": "Tout les résultats", "results.placing": "Placement", "results.team": "Équipe", "results.tournament": "Tournoi", @@ -159,9 +158,26 @@ "results.highlights": "Résultats notables", "results.highlights.choose": "Choisir vos résultats notables", "results.highlights.explanation": "Sélectionnez les résultats que vous voulez mettre en avant", - "results.button.showHighlights": "Montrer les highlights", - "results.button.showAll": "Tout montrer", - "results.filter.placeholder": "", + "results.filter.only": "", + "results.filter.highlightsOnly": "", + "results.filter.tournament": "", + "results.filter.team": "", + "results.filter.mate": "", + "results.filter.tier": "", + "results.filter.tier.min": "", + "results.filter.tier.max": "", + "results.filter.placement": "", + "results.filter.placement.first": "", + "results.filter.placement.top": "", + "results.filter.years": "", + "results.filter.years.from": "", + "results.filter.years.to": "", + "results.filter.source": "", + "results.filter.source.ALL": "", + "results.filter.source.SENDOU": "", + "results.filter.source.EXTERNAL": "", + "results.filter.size": "", + "results.filter.size.min": "", "forms.errors.maxWeapons": "Nombre d'armes maximum atteint", "search.info": "Recherchez avec le pseudo Discord ou Splatoon 3 du compte", "search.noResults": "Aucun utilisateur correspondant à '{{query}}' n'a été trouvé", diff --git a/locales/he/builds.json b/locales/he/builds.json index a46fc2193..1ae4ef17a 100644 --- a/locales/he/builds.json +++ b/locales/he/builds.json @@ -21,17 +21,14 @@ "stats.all": "הכל", "stats.public": "ציבורי", "stats.private": "פרטי", - "addFilter": "הוספת מסנן", "linkButton.abilityStats": "נתונים סטטיסטיים על היכולות", "linkButton.popularBuilds": "ערכות פופולריות", "noPopularBuilds": "נראה שאין ערכות פופולריות לנשק זה כרגע.", "emptyAbilitySlot": "תא עם יכולת ריקה", - "filters.type.ability": "לפי יכולת", - "filters.type.mode": "לפי מוד", - "filters.type.date": "לפי תאריך", - "filters.ability.title": "מסנן לפי יכולת", - "filters.mode.title": "מסנן לפי מוד", - "filters.date.title": "מסנן לפי תאריך", + "filters.abilities": "", + "filters.mode": "", + "filters.date": "", + "filters.addAbility": "", "filters.has": "יש", "filters.does.not.have": "אין", "filters.atLeast": "לפחות", diff --git a/locales/he/calendar.json b/locales/he/calendar.json index bb9555995..da78a0b78 100644 --- a/locales/he/calendar.json +++ b/locales/he/calendar.json @@ -48,11 +48,8 @@ "tag.desc.SR": "אירוע Salmon Run.", "tag.desc.CARDS": "אירוע Tableturf Battle.", "icalFeed": "iCal", - "filter.button": "מסנן", - "filter.heading": "מסנן אירועי לוח שנה", "filter.modes": "מודים", "filter.exactModes": "מודים ספציפיים", - "filter.exactModesBottom": "הראה רק אירועים שתואמים את כל המודים שנבחרו", "filter.games": "משחקים", "filter.vs": "נגד", "filter.vs.4v4": "4 נגד 4", @@ -69,11 +66,18 @@ "filter.isSendou": "רק אירועים המתארחים ב-sendou.ink", "filter.isRanked": "רק אירועים תחרותיים", "filter.minTeamCount": "מספר צוותים מינימלי", + "filter.minTier": "", + "filter.maxTier": "", "filter.orgsIncluded": "ארגונים גלויים", "filter.orgsExcluded": "ארגונים מוסתרים", "filter.authorIdsExcluded": "מחברים לא נכללו", - "filter.apply": "החל", - "filter.applyAndDefault": "החל והפוך לברירת מחדל", + "filterBar.eventType": "", + "filterBar.tier": "", + "filterBar.tags": "", + "filterBar.organizers": "", + "filterBar.timeAndSize": "", + "filterBar.sendou": "", + "filterBar.ranked": "", "forms.draft": "", "forms.draftInfo": "", "forms.draftBracketStartBlocked": "", diff --git a/locales/he/common.json b/locales/he/common.json index e4ed65238..2568baecd 100644 --- a/locales/he/common.json +++ b/locales/he/common.json @@ -149,6 +149,8 @@ "actions.outlined": "", "actions.noOutline": "", "actions.join": "הצטרפות", + "filterBar.addFilter": "", + "filterBar.saveAsDefault": "", "imageExport.export": "", "imageExport.download": "", "imageExport.theme.light": "", diff --git a/locales/he/forms.json b/locales/he/forms.json index 7e02a45af..17cf14288 100644 --- a/locales/he/forms.json +++ b/locales/he/forms.json @@ -140,7 +140,6 @@ "errors.canNotSetIfLookingNow": "", "errors.maxAssociationsReached": "", "labels.weekdayTimes": "", - "labels.weekendTimes": "", "labels.start": "", "labels.end": "", "labels.member": "", @@ -187,24 +186,8 @@ "vodTypes.SCRIM": "משחק ידידות", "vodTypes.MATCHMAKING": "Anarchy/X Battle/Turf War", "vodTypes.SENDOUQ": "", - "labels.modesExact": "", - "bottomTexts.modesExact": "", - "labels.games": "", - "labels.vs": "", "labels.startTime": "", - "labels.tagsIncluded": "", - "labels.tagsExcluded": "", - "labels.onlySendouEvents": "", - "labels.onlyRankedEvents": "", - "labels.minTeamCount": "", - "labels.orgsIncluded": "", - "labels.orgsExcluded": "", - "labels.authorIdsExcluded": "", "bottomTexts.authorIdsExcluded": "", - "options.startTime.any": "", - "options.startTime.eu": "", - "options.startTime.na": "", - "options.startTime.au": "", "options.game.S1": "", "options.game.S2": "", "options.game.S3": "", diff --git a/locales/he/lfg.json b/locales/he/lfg.json index 389605cdf..0dbdb1439 100644 --- a/locales/he/lfg.json +++ b/locales/he/lfg.json @@ -8,7 +8,6 @@ "post.lastActive": "", "noPosts": "", "expiring": "", - "addFilter": "", "filters.Weapon": "", "filters.Type": "", "filters.Timezone": "", @@ -16,7 +15,6 @@ "filters.PlusTier": "", "filters.MaxTier": "", "filters.MinTier": "", - "filters.suffix": "", "filters.orAbove": "", "new.noMorePosts": "", "new.type.header": "", diff --git a/locales/he/scrims.json b/locales/he/scrims.json index 9c1ee23f1..1e6340b6a 100644 --- a/locales/he/scrims.json +++ b/locales/he/scrims.json @@ -12,16 +12,9 @@ "requestModal.at.label": "", "requestModal.at.explanation": "", "pickupBy": "", - "filters.button": "", - "filters.heading": "", "filters.weekdayTimes": "", - "filters.weekdayStart": "", - "filters.weekdayEnd": "", "filters.weekendTimes": "", - "filters.weekendStart": "", - "filters.weekendEnd": "", - "filters.apply": "", - "filters.applyAndDefault": "", + "filters.divs": "", "filters.showFiltered": "", "filters.hideFiltered": "", "filters.showPendingRequests": "", diff --git a/locales/he/user.json b/locales/he/user.json index c1c016fdd..a37c9ec43 100644 --- a/locales/he/user.json +++ b/locales/he/user.json @@ -148,7 +148,6 @@ "sens": "רגישות", "usesPronouns": "", "discordExplanation": "שם משתמש, תמונת פרופיל, חשבונות YouTube, Bluesky ו-Twitch מגיעים מחשבון Discord שלך. ראו <1>שאלות נפוצות למידע נוסף.", - "results.title": "", "results.placing": "מיקום", "results.team": "צוות", "results.tournament": "טורניר", @@ -159,9 +158,26 @@ "results.highlights": "נקודות שיא", "results.highlights.choose": "בחרו נקודות שיא", "results.highlights.explanation": "בחרו את התוצאות שאתם רוצים להדגיש", - "results.button.showHighlights": "", - "results.button.showAll": "", - "results.filter.placeholder": "", + "results.filter.only": "", + "results.filter.highlightsOnly": "", + "results.filter.tournament": "", + "results.filter.team": "", + "results.filter.mate": "", + "results.filter.tier": "", + "results.filter.tier.min": "", + "results.filter.tier.max": "", + "results.filter.placement": "", + "results.filter.placement.first": "", + "results.filter.placement.top": "", + "results.filter.years": "", + "results.filter.years.from": "", + "results.filter.years.to": "", + "results.filter.source": "", + "results.filter.source.ALL": "", + "results.filter.source.SENDOU": "", + "results.filter.source.EXTERNAL": "", + "results.filter.size": "", + "results.filter.size.min": "", "forms.errors.maxWeapons": "הגעה לכמות מקסימלית של מספר נשקים", "search.info": "", "search.noResults": "לא נמצאו משתמשים התואמים '{{query}}'", diff --git a/locales/it/builds.json b/locales/it/builds.json index 557e012fa..5e680d581 100644 --- a/locales/it/builds.json +++ b/locales/it/builds.json @@ -21,17 +21,14 @@ "stats.all": "Tutte", "stats.public": "Pubbliche", "stats.private": "Private", - "addFilter": "Aggiungi filtro", "linkButton.abilityStats": "Statistiche abilità", "linkButton.popularBuilds": "Build popolari", "noPopularBuilds": "Sembra che non ci siano build popolari per quest'arma in questo momento", "emptyAbilitySlot": "Slot abilità vuoto", - "filters.type.ability": "Per abilità", - "filters.type.mode": "Per modalità", - "filters.type.date": "Per data", - "filters.ability.title": "Filtro abilità", - "filters.mode.title": "Filtro modalità", - "filters.date.title": "Filtro data", + "filters.abilities": "", + "filters.mode": "", + "filters.date": "", + "filters.addAbility": "", "filters.has": "Ha", "filters.does.not.have": "Non ha", "filters.atLeast": "Almeno", diff --git a/locales/it/calendar.json b/locales/it/calendar.json index 42bf791f7..fb89f677b 100644 --- a/locales/it/calendar.json +++ b/locales/it/calendar.json @@ -48,11 +48,8 @@ "tag.desc.SR": "Evento Salmon Run.", "tag.desc.CARDS": "Evento Splattanza", "icalFeed": "", - "filter.button": "", - "filter.heading": "", "filter.modes": "", "filter.exactModes": "", - "filter.exactModesBottom": "", "filter.games": "", "filter.vs": "", "filter.vs.4v4": "", @@ -69,11 +66,18 @@ "filter.isSendou": "", "filter.isRanked": "", "filter.minTeamCount": "", + "filter.minTier": "", + "filter.maxTier": "", "filter.orgsIncluded": "", "filter.orgsExcluded": "", "filter.authorIdsExcluded": "", - "filter.apply": "", - "filter.applyAndDefault": "", + "filterBar.eventType": "", + "filterBar.tier": "", + "filterBar.tags": "", + "filterBar.organizers": "", + "filterBar.timeAndSize": "", + "filterBar.sendou": "", + "filterBar.ranked": "", "forms.draft": "", "forms.draftInfo": "", "forms.draftBracketStartBlocked": "", diff --git a/locales/it/common.json b/locales/it/common.json index a517dff84..875b8af3c 100644 --- a/locales/it/common.json +++ b/locales/it/common.json @@ -149,6 +149,8 @@ "actions.outlined": "Contornato", "actions.noOutline": "Nessun contorno", "actions.join": "Entra", + "filterBar.addFilter": "", + "filterBar.saveAsDefault": "", "imageExport.export": "", "imageExport.download": "", "imageExport.theme.light": "", diff --git a/locales/it/forms.json b/locales/it/forms.json index b77eef09d..0a6115297 100644 --- a/locales/it/forms.json +++ b/locales/it/forms.json @@ -140,7 +140,6 @@ "errors.canNotSetIfLookingNow": "", "errors.maxAssociationsReached": "", "labels.weekdayTimes": "", - "labels.weekendTimes": "", "labels.start": "", "labels.end": "", "labels.member": "", @@ -187,24 +186,8 @@ "vodTypes.SCRIM": "Scrim", "vodTypes.MATCHMAKING": "Anarchiche/Partita X/Mischia Mollusca", "vodTypes.SENDOUQ": "SendouQ", - "labels.modesExact": "", - "bottomTexts.modesExact": "", - "labels.games": "", - "labels.vs": "", "labels.startTime": "", - "labels.tagsIncluded": "", - "labels.tagsExcluded": "", - "labels.onlySendouEvents": "", - "labels.onlyRankedEvents": "", - "labels.minTeamCount": "", - "labels.orgsIncluded": "", - "labels.orgsExcluded": "", - "labels.authorIdsExcluded": "", "bottomTexts.authorIdsExcluded": "", - "options.startTime.any": "", - "options.startTime.eu": "", - "options.startTime.na": "", - "options.startTime.au": "", "options.game.S1": "", "options.game.S2": "", "options.game.S3": "", diff --git a/locales/it/lfg.json b/locales/it/lfg.json index 1e4e3bf66..e82907b8e 100644 --- a/locales/it/lfg.json +++ b/locales/it/lfg.json @@ -8,7 +8,6 @@ "post.lastActive": "aggiornato", "noPosts": "Nessun post per questo filtro", "expiring": "Il post sta scadendo. Stai ancora cercando?", - "addFilter": "", "filters.Weapon": "Pool di armi", "filters.Type": "Tipo di post", "filters.Timezone": "Differenza per fuso orario", @@ -16,7 +15,6 @@ "filters.PlusTier": "Tier Plus", "filters.MaxTier": "Tier massimo", "filters.MinTier": "Tier minimo", - "filters.suffix": "filter", "filters.orAbove": "o più", "new.noMorePosts": "Non puoi creare altri posts", "new.type.header": "Tipo", diff --git a/locales/it/scrims.json b/locales/it/scrims.json index 9c1ee23f1..1e6340b6a 100644 --- a/locales/it/scrims.json +++ b/locales/it/scrims.json @@ -12,16 +12,9 @@ "requestModal.at.label": "", "requestModal.at.explanation": "", "pickupBy": "", - "filters.button": "", - "filters.heading": "", "filters.weekdayTimes": "", - "filters.weekdayStart": "", - "filters.weekdayEnd": "", "filters.weekendTimes": "", - "filters.weekendStart": "", - "filters.weekendEnd": "", - "filters.apply": "", - "filters.applyAndDefault": "", + "filters.divs": "", "filters.showFiltered": "", "filters.hideFiltered": "", "filters.showPendingRequests": "", diff --git a/locales/it/user.json b/locales/it/user.json index 8bd9658a2..81973904c 100644 --- a/locales/it/user.json +++ b/locales/it/user.json @@ -148,7 +148,6 @@ "sens": "Sens.", "usesPronouns": "", "discordExplanation": "Username, foto profilo, account YouTube, Bluesky e Twitch vengono dal tuo account Discord. Visita <1>FAQ per ulteriori informazioni.", - "results.title": "Tutti i risultati", "results.placing": "Risultato", "results.team": "Team", "results.tournament": "Torneo", @@ -159,9 +158,26 @@ "results.highlights": "Highlight", "results.highlights.choose": "Scegli i tuoi highlight", "results.highlights.explanation": "Scegli il risultato che vuoi mettere come highlight", - "results.button.showHighlights": "Mostra highlight", - "results.button.showAll": "Mostra tutti", - "results.filter.placeholder": "", + "results.filter.only": "", + "results.filter.highlightsOnly": "", + "results.filter.tournament": "", + "results.filter.team": "", + "results.filter.mate": "", + "results.filter.tier": "", + "results.filter.tier.min": "", + "results.filter.tier.max": "", + "results.filter.placement": "", + "results.filter.placement.first": "", + "results.filter.placement.top": "", + "results.filter.years": "", + "results.filter.years.from": "", + "results.filter.years.to": "", + "results.filter.source": "", + "results.filter.source.ALL": "", + "results.filter.source.SENDOU": "", + "results.filter.source.EXTERNAL": "", + "results.filter.size": "", + "results.filter.size.min": "", "forms.errors.maxWeapons": "Massimo numero di armi raggiunto", "search.info": "Cerca utenti tramite nome Discord o Splatoon 3", "search.noResults": "Nessun utente trovato per '{{query}}'", diff --git a/locales/ja/builds.json b/locales/ja/builds.json index 760faaad7..09fc65503 100644 --- a/locales/ja/builds.json +++ b/locales/ja/builds.json @@ -21,17 +21,14 @@ "stats.all": "すべて", "stats.public": "公開", "stats.private": "非公開", - "addFilter": "絞り込みを追加", "linkButton.abilityStats": "ギア統計", "linkButton.popularBuilds": "人気のあるギア構成", "noPopularBuilds": "現在、このブキに対する人気のギア構成はないようです。", "emptyAbilitySlot": "空欄", - "filters.type.ability": "ギアパワーで", - "filters.type.mode": "ルールで", - "filters.type.date": "日付で", - "filters.ability.title": "ギアパワーフィルター", - "filters.mode.title": "ルールフィルター", - "filters.date.title": "日付フィルター", + "filters.abilities": "", + "filters.mode": "", + "filters.date": "", + "filters.addAbility": "", "filters.has": "含む", "filters.does.not.have": "含まない", "filters.atLeast": "最小", diff --git a/locales/ja/calendar.json b/locales/ja/calendar.json index 8a4576b20..2f70e6b11 100644 --- a/locales/ja/calendar.json +++ b/locales/ja/calendar.json @@ -44,11 +44,8 @@ "tag.desc.SR": "サーモンランイベント", "tag.desc.CARDS": "ナワバトラーイベント", "icalFeed": ".ical", - "filter.button": "フィルター", - "filter.heading": "表示するイベントをフィルター", "filter.modes": "ルール", "filter.exactModes": "選択されたルールと一致", - "filter.exactModesBottom": "選択されたルールと一致しているイベントのみ表示します", "filter.games": "ゲーム", "filter.vs": "人数", "filter.vs.4v4": "4対4", @@ -65,11 +62,18 @@ "filter.isSendou": "sendou.inkで開催されているイベントのみ", "filter.isRanked": "ランキングイベントのみ", "filter.minTeamCount": "最低参加チーム数", + "filter.minTier": "", + "filter.maxTier": "", "filter.orgsIncluded": "表示するイベント運営組織", "filter.orgsExcluded": "表示しないイベント運営組織", "filter.authorIdsExcluded": "表示しないイベント運営者", - "filter.apply": "フィルターをかける", - "filter.applyAndDefault": "フィルターをかけて基準にする", + "filterBar.eventType": "", + "filterBar.tier": "", + "filterBar.tags": "", + "filterBar.organizers": "", + "filterBar.timeAndSize": "", + "filterBar.sendou": "", + "filterBar.ranked": "", "forms.draft": "未完成", "forms.draftInfo": "未完成のイベントは表示されず、運営側以外は見えません。大会を開始するには、このオプションをオフにし、公開しなければなりません。", "forms.draftBracketStartBlocked": "このイベントは未完成です。大会を開始する前に、イベントの設定から未完成をオフにしてください。", diff --git a/locales/ja/common.json b/locales/ja/common.json index 2bc3333fb..1790b88d6 100644 --- a/locales/ja/common.json +++ b/locales/ja/common.json @@ -149,6 +149,8 @@ "actions.outlined": "アウトラインあり", "actions.noOutline": "アウトラインなし", "actions.join": "参加する", + "filterBar.addFilter": "", + "filterBar.saveAsDefault": "", "imageExport.export": "", "imageExport.download": "", "imageExport.theme.light": "", diff --git a/locales/ja/forms.json b/locales/ja/forms.json index 828b9b158..cdb92de0a 100644 --- a/locales/ja/forms.json +++ b/locales/ja/forms.json @@ -140,7 +140,6 @@ "errors.canNotSetIfLookingNow": "", "errors.maxAssociationsReached": "", "labels.weekdayTimes": "", - "labels.weekendTimes": "", "labels.start": "", "labels.end": "", "labels.member": "", @@ -187,24 +186,8 @@ "vodTypes.SCRIM": "対抗戦", "vodTypes.MATCHMAKING": "バンカラマッチ/X バトル/ナワバリバトル", "vodTypes.SENDOUQ": "SendouQ", - "labels.modesExact": "", - "bottomTexts.modesExact": "", - "labels.games": "", - "labels.vs": "", "labels.startTime": "", - "labels.tagsIncluded": "", - "labels.tagsExcluded": "", - "labels.onlySendouEvents": "", - "labels.onlyRankedEvents": "", - "labels.minTeamCount": "", - "labels.orgsIncluded": "", - "labels.orgsExcluded": "", - "labels.authorIdsExcluded": "", "bottomTexts.authorIdsExcluded": "", - "options.startTime.any": "", - "options.startTime.eu": "", - "options.startTime.na": "", - "options.startTime.au": "", "options.game.S1": "", "options.game.S2": "", "options.game.S3": "", diff --git a/locales/ja/lfg.json b/locales/ja/lfg.json index 75c7c758d..9084a81b3 100644 --- a/locales/ja/lfg.json +++ b/locales/ja/lfg.json @@ -8,7 +8,6 @@ "post.lastActive": "最後にログインした時", "noPosts": "フィルターに該当するポーストはありません", "expiring": "ポーストの期限が切れます。まだ探していますか?", - "addFilter": "", "filters.Weapon": "武器プール", "filters.Type": "ポーストの種類", "filters.Timezone": "タイムゾーン(何時間違うか)", @@ -16,7 +15,6 @@ "filters.PlusTier": "+ティア", "filters.MaxTier": "マックスティア", "filters.MinTier": "最小ティア", - "filters.suffix": "フィルター", "filters.orAbove": "より上", "new.noMorePosts": "これ以上ポーストは作れません", "new.type.header": "種類", diff --git a/locales/ja/scrims.json b/locales/ja/scrims.json index 9c1ee23f1..1e6340b6a 100644 --- a/locales/ja/scrims.json +++ b/locales/ja/scrims.json @@ -12,16 +12,9 @@ "requestModal.at.label": "", "requestModal.at.explanation": "", "pickupBy": "", - "filters.button": "", - "filters.heading": "", "filters.weekdayTimes": "", - "filters.weekdayStart": "", - "filters.weekdayEnd": "", "filters.weekendTimes": "", - "filters.weekendStart": "", - "filters.weekendEnd": "", - "filters.apply": "", - "filters.applyAndDefault": "", + "filters.divs": "", "filters.showFiltered": "", "filters.hideFiltered": "", "filters.showPendingRequests": "", diff --git a/locales/ja/user.json b/locales/ja/user.json index 7fa933268..1eca6c4ef 100644 --- a/locales/ja/user.json +++ b/locales/ja/user.json @@ -148,7 +148,6 @@ "sens": "感度", "usesPronouns": "", "discordExplanation": "ユーザー名、プロファイル画像、YouTube、Bluesky と Twitch アカウントは Discord のアカウントに設定されているものが使用されます。詳しくは <1>FAQ をご覧ください。", - "results.title": "全ての結果", "results.placing": "順位", "results.team": "チーム", "results.tournament": "トーナメント", @@ -159,9 +158,26 @@ "results.highlights": "主な戦績", "results.highlights.choose": "戦績を選ぶ", "results.highlights.explanation": "戦績として選択したい結果を選ぶ", - "results.button.showHighlights": "ハイライトを表示", - "results.button.showAll": "全て表示", - "results.filter.placeholder": "", + "results.filter.only": "", + "results.filter.highlightsOnly": "", + "results.filter.tournament": "", + "results.filter.team": "", + "results.filter.mate": "", + "results.filter.tier": "", + "results.filter.tier.min": "", + "results.filter.tier.max": "", + "results.filter.placement": "", + "results.filter.placement.first": "", + "results.filter.placement.top": "", + "results.filter.years": "", + "results.filter.years.from": "", + "results.filter.years.to": "", + "results.filter.source": "", + "results.filter.source.ALL": "", + "results.filter.source.SENDOU": "", + "results.filter.source.EXTERNAL": "", + "results.filter.size": "", + "results.filter.size.min": "", "forms.errors.maxWeapons": "最大ブキ数を超えました", "search.info": "", "search.noResults": "該当ユーザーが見つかりません '{{query}}'", diff --git a/locales/ko/builds.json b/locales/ko/builds.json index b8ccbe91d..941a35a2b 100644 --- a/locales/ko/builds.json +++ b/locales/ko/builds.json @@ -21,17 +21,14 @@ "stats.all": "전체", "stats.public": "", "stats.private": "", - "addFilter": "필터 추가", "linkButton.abilityStats": "기어 파워 통계", "linkButton.popularBuilds": "인기 빌드", "noPopularBuilds": "이 무기에는 인기 빌드가 아직 없습니다.", "emptyAbilitySlot": "빈 기어 슬롯", - "filters.type.ability": "", - "filters.type.mode": "", - "filters.type.date": "", - "filters.ability.title": "", - "filters.mode.title": "", - "filters.date.title": "", + "filters.abilities": "", + "filters.mode": "", + "filters.date": "", + "filters.addAbility": "", "filters.has": "포함", "filters.does.not.have": "미포함", "filters.atLeast": "최소", diff --git a/locales/ko/calendar.json b/locales/ko/calendar.json index fe8ba6771..aad8656c7 100644 --- a/locales/ko/calendar.json +++ b/locales/ko/calendar.json @@ -42,11 +42,8 @@ "tag.desc.SR": "", "tag.desc.CARDS": "", "icalFeed": "", - "filter.button": "", - "filter.heading": "", "filter.modes": "", "filter.exactModes": "", - "filter.exactModesBottom": "", "filter.games": "", "filter.vs": "", "filter.vs.4v4": "", @@ -63,11 +60,18 @@ "filter.isSendou": "", "filter.isRanked": "", "filter.minTeamCount": "", + "filter.minTier": "", + "filter.maxTier": "", "filter.orgsIncluded": "", "filter.orgsExcluded": "", "filter.authorIdsExcluded": "", - "filter.apply": "", - "filter.applyAndDefault": "", + "filterBar.eventType": "", + "filterBar.tier": "", + "filterBar.tags": "", + "filterBar.organizers": "", + "filterBar.timeAndSize": "", + "filterBar.sendou": "", + "filterBar.ranked": "", "forms.draft": "", "forms.draftInfo": "", "forms.draftBracketStartBlocked": "", diff --git a/locales/ko/common.json b/locales/ko/common.json index c30c56595..5c4c677c8 100644 --- a/locales/ko/common.json +++ b/locales/ko/common.json @@ -149,6 +149,8 @@ "actions.outlined": "", "actions.noOutline": "", "actions.join": "참여하기", + "filterBar.addFilter": "", + "filterBar.saveAsDefault": "", "imageExport.export": "", "imageExport.download": "", "imageExport.theme.light": "", diff --git a/locales/ko/forms.json b/locales/ko/forms.json index a53c67c77..a5a87aaca 100644 --- a/locales/ko/forms.json +++ b/locales/ko/forms.json @@ -140,7 +140,6 @@ "errors.canNotSetIfLookingNow": "", "errors.maxAssociationsReached": "", "labels.weekdayTimes": "", - "labels.weekendTimes": "", "labels.start": "", "labels.end": "", "labels.member": "", @@ -187,24 +186,8 @@ "vodTypes.SCRIM": "", "vodTypes.MATCHMAKING": "", "vodTypes.SENDOUQ": "", - "labels.modesExact": "", - "bottomTexts.modesExact": "", - "labels.games": "", - "labels.vs": "", "labels.startTime": "", - "labels.tagsIncluded": "", - "labels.tagsExcluded": "", - "labels.onlySendouEvents": "", - "labels.onlyRankedEvents": "", - "labels.minTeamCount": "", - "labels.orgsIncluded": "", - "labels.orgsExcluded": "", - "labels.authorIdsExcluded": "", "bottomTexts.authorIdsExcluded": "", - "options.startTime.any": "", - "options.startTime.eu": "", - "options.startTime.na": "", - "options.startTime.au": "", "options.game.S1": "", "options.game.S2": "", "options.game.S3": "", diff --git a/locales/ko/lfg.json b/locales/ko/lfg.json index 389605cdf..0dbdb1439 100644 --- a/locales/ko/lfg.json +++ b/locales/ko/lfg.json @@ -8,7 +8,6 @@ "post.lastActive": "", "noPosts": "", "expiring": "", - "addFilter": "", "filters.Weapon": "", "filters.Type": "", "filters.Timezone": "", @@ -16,7 +15,6 @@ "filters.PlusTier": "", "filters.MaxTier": "", "filters.MinTier": "", - "filters.suffix": "", "filters.orAbove": "", "new.noMorePosts": "", "new.type.header": "", diff --git a/locales/ko/scrims.json b/locales/ko/scrims.json index 9c1ee23f1..1e6340b6a 100644 --- a/locales/ko/scrims.json +++ b/locales/ko/scrims.json @@ -12,16 +12,9 @@ "requestModal.at.label": "", "requestModal.at.explanation": "", "pickupBy": "", - "filters.button": "", - "filters.heading": "", "filters.weekdayTimes": "", - "filters.weekdayStart": "", - "filters.weekdayEnd": "", "filters.weekendTimes": "", - "filters.weekendStart": "", - "filters.weekendEnd": "", - "filters.apply": "", - "filters.applyAndDefault": "", + "filters.divs": "", "filters.showFiltered": "", "filters.hideFiltered": "", "filters.showPendingRequests": "", diff --git a/locales/ko/user.json b/locales/ko/user.json index b862f89c8..350b6310f 100644 --- a/locales/ko/user.json +++ b/locales/ko/user.json @@ -148,7 +148,6 @@ "sens": "", "usesPronouns": "", "discordExplanation": "", - "results.title": "", "results.placing": "순위", "results.team": "팀", "results.tournament": "대회", @@ -159,9 +158,26 @@ "results.highlights": "", "results.highlights.choose": "", "results.highlights.explanation": "", - "results.button.showHighlights": "", - "results.button.showAll": "", - "results.filter.placeholder": "", + "results.filter.only": "", + "results.filter.highlightsOnly": "", + "results.filter.tournament": "", + "results.filter.team": "", + "results.filter.mate": "", + "results.filter.tier": "", + "results.filter.tier.min": "", + "results.filter.tier.max": "", + "results.filter.placement": "", + "results.filter.placement.first": "", + "results.filter.placement.top": "", + "results.filter.years": "", + "results.filter.years.from": "", + "results.filter.years.to": "", + "results.filter.source": "", + "results.filter.source.ALL": "", + "results.filter.source.SENDOU": "", + "results.filter.source.EXTERNAL": "", + "results.filter.size": "", + "results.filter.size.min": "", "forms.errors.maxWeapons": "", "search.info": "", "search.noResults": "", diff --git a/locales/nl/builds.json b/locales/nl/builds.json index 3fd9cdcb3..bd2ec603f 100644 --- a/locales/nl/builds.json +++ b/locales/nl/builds.json @@ -21,17 +21,14 @@ "stats.all": "", "stats.public": "", "stats.private": "", - "addFilter": "", "linkButton.abilityStats": "", "linkButton.popularBuilds": "", "noPopularBuilds": "", "emptyAbilitySlot": "", - "filters.type.ability": "", - "filters.type.mode": "", - "filters.type.date": "", - "filters.ability.title": "", - "filters.mode.title": "", - "filters.date.title": "", + "filters.abilities": "", + "filters.mode": "", + "filters.date": "", + "filters.addAbility": "", "filters.has": "", "filters.does.not.have": "", "filters.atLeast": "", diff --git a/locales/nl/calendar.json b/locales/nl/calendar.json index f36b8782e..15dca99f4 100644 --- a/locales/nl/calendar.json +++ b/locales/nl/calendar.json @@ -46,11 +46,8 @@ "tag.desc.SR": "", "tag.desc.CARDS": "", "icalFeed": "", - "filter.button": "", - "filter.heading": "", "filter.modes": "", "filter.exactModes": "", - "filter.exactModesBottom": "", "filter.games": "", "filter.vs": "", "filter.vs.4v4": "", @@ -67,11 +64,18 @@ "filter.isSendou": "", "filter.isRanked": "", "filter.minTeamCount": "", + "filter.minTier": "", + "filter.maxTier": "", "filter.orgsIncluded": "", "filter.orgsExcluded": "", "filter.authorIdsExcluded": "", - "filter.apply": "", - "filter.applyAndDefault": "", + "filterBar.eventType": "", + "filterBar.tier": "", + "filterBar.tags": "", + "filterBar.organizers": "", + "filterBar.timeAndSize": "", + "filterBar.sendou": "", + "filterBar.ranked": "", "forms.draft": "", "forms.draftInfo": "", "forms.draftBracketStartBlocked": "", diff --git a/locales/nl/common.json b/locales/nl/common.json index ba058e330..18a312c5c 100644 --- a/locales/nl/common.json +++ b/locales/nl/common.json @@ -149,6 +149,8 @@ "actions.outlined": "", "actions.noOutline": "", "actions.join": "", + "filterBar.addFilter": "", + "filterBar.saveAsDefault": "", "imageExport.export": "", "imageExport.download": "", "imageExport.theme.light": "", diff --git a/locales/nl/forms.json b/locales/nl/forms.json index 02af876a1..8ac90b831 100644 --- a/locales/nl/forms.json +++ b/locales/nl/forms.json @@ -140,7 +140,6 @@ "errors.canNotSetIfLookingNow": "", "errors.maxAssociationsReached": "", "labels.weekdayTimes": "", - "labels.weekendTimes": "", "labels.start": "", "labels.end": "", "labels.member": "", @@ -187,24 +186,8 @@ "vodTypes.SCRIM": "", "vodTypes.MATCHMAKING": "", "vodTypes.SENDOUQ": "", - "labels.modesExact": "", - "bottomTexts.modesExact": "", - "labels.games": "", - "labels.vs": "", "labels.startTime": "", - "labels.tagsIncluded": "", - "labels.tagsExcluded": "", - "labels.onlySendouEvents": "", - "labels.onlyRankedEvents": "", - "labels.minTeamCount": "", - "labels.orgsIncluded": "", - "labels.orgsExcluded": "", - "labels.authorIdsExcluded": "", "bottomTexts.authorIdsExcluded": "", - "options.startTime.any": "", - "options.startTime.eu": "", - "options.startTime.na": "", - "options.startTime.au": "", "options.game.S1": "", "options.game.S2": "", "options.game.S3": "", diff --git a/locales/nl/lfg.json b/locales/nl/lfg.json index 389605cdf..0dbdb1439 100644 --- a/locales/nl/lfg.json +++ b/locales/nl/lfg.json @@ -8,7 +8,6 @@ "post.lastActive": "", "noPosts": "", "expiring": "", - "addFilter": "", "filters.Weapon": "", "filters.Type": "", "filters.Timezone": "", @@ -16,7 +15,6 @@ "filters.PlusTier": "", "filters.MaxTier": "", "filters.MinTier": "", - "filters.suffix": "", "filters.orAbove": "", "new.noMorePosts": "", "new.type.header": "", diff --git a/locales/nl/scrims.json b/locales/nl/scrims.json index 9c1ee23f1..1e6340b6a 100644 --- a/locales/nl/scrims.json +++ b/locales/nl/scrims.json @@ -12,16 +12,9 @@ "requestModal.at.label": "", "requestModal.at.explanation": "", "pickupBy": "", - "filters.button": "", - "filters.heading": "", "filters.weekdayTimes": "", - "filters.weekdayStart": "", - "filters.weekdayEnd": "", "filters.weekendTimes": "", - "filters.weekendStart": "", - "filters.weekendEnd": "", - "filters.apply": "", - "filters.applyAndDefault": "", + "filters.divs": "", "filters.showFiltered": "", "filters.hideFiltered": "", "filters.showPendingRequests": "", diff --git a/locales/nl/user.json b/locales/nl/user.json index 974ecd494..6de20999e 100644 --- a/locales/nl/user.json +++ b/locales/nl/user.json @@ -148,7 +148,6 @@ "sens": "Gevoeligheid", "usesPronouns": "", "discordExplanation": "", - "results.title": "", "results.placing": "Plaatsing", "results.team": "Team", "results.tournament": "Toernooi", @@ -159,9 +158,26 @@ "results.highlights": "", "results.highlights.choose": "", "results.highlights.explanation": "", - "results.button.showHighlights": "", - "results.button.showAll": "", - "results.filter.placeholder": "", + "results.filter.only": "", + "results.filter.highlightsOnly": "", + "results.filter.tournament": "", + "results.filter.team": "", + "results.filter.mate": "", + "results.filter.tier": "", + "results.filter.tier.min": "", + "results.filter.tier.max": "", + "results.filter.placement": "", + "results.filter.placement.first": "", + "results.filter.placement.top": "", + "results.filter.years": "", + "results.filter.years.from": "", + "results.filter.years.to": "", + "results.filter.source": "", + "results.filter.source.ALL": "", + "results.filter.source.SENDOU": "", + "results.filter.source.EXTERNAL": "", + "results.filter.size": "", + "results.filter.size.min": "", "forms.errors.maxWeapons": "", "search.info": "", "search.noResults": "", diff --git a/locales/pl/builds.json b/locales/pl/builds.json index dd1c65a70..63a87a619 100644 --- a/locales/pl/builds.json +++ b/locales/pl/builds.json @@ -21,17 +21,14 @@ "stats.all": "", "stats.public": "", "stats.private": "", - "addFilter": "", "linkButton.abilityStats": "", "linkButton.popularBuilds": "", "noPopularBuilds": "", "emptyAbilitySlot": "", - "filters.type.ability": "", - "filters.type.mode": "", - "filters.type.date": "", - "filters.ability.title": "", - "filters.mode.title": "", - "filters.date.title": "", + "filters.abilities": "", + "filters.mode": "", + "filters.date": "", + "filters.addAbility": "", "filters.has": "", "filters.does.not.have": "", "filters.atLeast": "", diff --git a/locales/pl/calendar.json b/locales/pl/calendar.json index 6f5f527ac..592196284 100644 --- a/locales/pl/calendar.json +++ b/locales/pl/calendar.json @@ -50,11 +50,8 @@ "tag.desc.SR": "", "tag.desc.CARDS": "", "icalFeed": "", - "filter.button": "", - "filter.heading": "", "filter.modes": "", "filter.exactModes": "", - "filter.exactModesBottom": "", "filter.games": "", "filter.vs": "", "filter.vs.4v4": "", @@ -71,11 +68,18 @@ "filter.isSendou": "", "filter.isRanked": "", "filter.minTeamCount": "", + "filter.minTier": "", + "filter.maxTier": "", "filter.orgsIncluded": "", "filter.orgsExcluded": "", "filter.authorIdsExcluded": "", - "filter.apply": "", - "filter.applyAndDefault": "", + "filterBar.eventType": "", + "filterBar.tier": "", + "filterBar.tags": "", + "filterBar.organizers": "", + "filterBar.timeAndSize": "", + "filterBar.sendou": "", + "filterBar.ranked": "", "forms.draft": "", "forms.draftInfo": "", "forms.draftBracketStartBlocked": "", diff --git a/locales/pl/common.json b/locales/pl/common.json index 8e08b880a..8908a59a4 100644 --- a/locales/pl/common.json +++ b/locales/pl/common.json @@ -149,6 +149,8 @@ "actions.outlined": "", "actions.noOutline": "", "actions.join": "Dołącz", + "filterBar.addFilter": "", + "filterBar.saveAsDefault": "", "imageExport.export": "", "imageExport.download": "", "imageExport.theme.light": "", diff --git a/locales/pl/forms.json b/locales/pl/forms.json index f7358e0e8..ecb7f5d79 100644 --- a/locales/pl/forms.json +++ b/locales/pl/forms.json @@ -140,7 +140,6 @@ "errors.canNotSetIfLookingNow": "", "errors.maxAssociationsReached": "", "labels.weekdayTimes": "", - "labels.weekendTimes": "", "labels.start": "", "labels.end": "", "labels.member": "", @@ -187,24 +186,8 @@ "vodTypes.SCRIM": "", "vodTypes.MATCHMAKING": "", "vodTypes.SENDOUQ": "", - "labels.modesExact": "", - "bottomTexts.modesExact": "", - "labels.games": "", - "labels.vs": "", "labels.startTime": "", - "labels.tagsIncluded": "", - "labels.tagsExcluded": "", - "labels.onlySendouEvents": "", - "labels.onlyRankedEvents": "", - "labels.minTeamCount": "", - "labels.orgsIncluded": "", - "labels.orgsExcluded": "", - "labels.authorIdsExcluded": "", "bottomTexts.authorIdsExcluded": "", - "options.startTime.any": "", - "options.startTime.eu": "", - "options.startTime.na": "", - "options.startTime.au": "", "options.game.S1": "", "options.game.S2": "", "options.game.S3": "", diff --git a/locales/pl/lfg.json b/locales/pl/lfg.json index 389605cdf..0dbdb1439 100644 --- a/locales/pl/lfg.json +++ b/locales/pl/lfg.json @@ -8,7 +8,6 @@ "post.lastActive": "", "noPosts": "", "expiring": "", - "addFilter": "", "filters.Weapon": "", "filters.Type": "", "filters.Timezone": "", @@ -16,7 +15,6 @@ "filters.PlusTier": "", "filters.MaxTier": "", "filters.MinTier": "", - "filters.suffix": "", "filters.orAbove": "", "new.noMorePosts": "", "new.type.header": "", diff --git a/locales/pl/scrims.json b/locales/pl/scrims.json index 9c1ee23f1..1e6340b6a 100644 --- a/locales/pl/scrims.json +++ b/locales/pl/scrims.json @@ -12,16 +12,9 @@ "requestModal.at.label": "", "requestModal.at.explanation": "", "pickupBy": "", - "filters.button": "", - "filters.heading": "", "filters.weekdayTimes": "", - "filters.weekdayStart": "", - "filters.weekdayEnd": "", "filters.weekendTimes": "", - "filters.weekendStart": "", - "filters.weekendEnd": "", - "filters.apply": "", - "filters.applyAndDefault": "", + "filters.divs": "", "filters.showFiltered": "", "filters.hideFiltered": "", "filters.showPendingRequests": "", diff --git a/locales/pl/user.json b/locales/pl/user.json index 8fa45d203..4c08d66ad 100644 --- a/locales/pl/user.json +++ b/locales/pl/user.json @@ -148,7 +148,6 @@ "sens": "Sens", "usesPronouns": "", "discordExplanation": "Nazwa, profilowe oraz połączone konta brane są z konta Discord. Zobacz <1>FAQ by dowiedzieć się więcej.", - "results.title": "", "results.placing": "Placing", "results.team": "Drużyna", "results.tournament": "Turniej", @@ -159,9 +158,26 @@ "results.highlights": "Wyróżnienia", "results.highlights.choose": "Wybierz wyróżnienia", "results.highlights.explanation": "Wybierz wyniki, które chcesz wyróżnić", - "results.button.showHighlights": "", - "results.button.showAll": "", - "results.filter.placeholder": "", + "results.filter.only": "", + "results.filter.highlightsOnly": "", + "results.filter.tournament": "", + "results.filter.team": "", + "results.filter.mate": "", + "results.filter.tier": "", + "results.filter.tier.min": "", + "results.filter.tier.max": "", + "results.filter.placement": "", + "results.filter.placement.first": "", + "results.filter.placement.top": "", + "results.filter.years": "", + "results.filter.years.from": "", + "results.filter.years.to": "", + "results.filter.source": "", + "results.filter.source.ALL": "", + "results.filter.source.SENDOU": "", + "results.filter.source.EXTERNAL": "", + "results.filter.size": "", + "results.filter.size.min": "", "forms.errors.maxWeapons": "Maksymalna ilość broni osiągnięta", "search.info": "", "search.noResults": "Nie znaleziono użytkownika o nazwie '{{query}}'", diff --git a/locales/pt-BR/builds.json b/locales/pt-BR/builds.json index 5b3c9a3c0..03362efad 100644 --- a/locales/pt-BR/builds.json +++ b/locales/pt-BR/builds.json @@ -21,17 +21,14 @@ "stats.all": "Todas", "stats.public": "", "stats.private": "", - "addFilter": "Adicionar filtro", "linkButton.abilityStats": "Estatísticas de habilidade", "linkButton.popularBuilds": "Builds populares", "noPopularBuilds": "Parece que não existem builds populares para essa arma nesse momento.", "emptyAbilitySlot": "Espaço (Slot) de habilidade vazio", - "filters.type.ability": "Por habilidade", - "filters.type.mode": "Por modo", - "filters.type.date": "Por data", - "filters.ability.title": "Filtro de habilidade", - "filters.mode.title": "Filtro de modo", - "filters.date.title": "Filtro de data", + "filters.abilities": "", + "filters.mode": "", + "filters.date": "", + "filters.addAbility": "", "filters.has": "Tem", "filters.does.not.have": "Não tem", "filters.atLeast": "Pelo menos", diff --git a/locales/pt-BR/calendar.json b/locales/pt-BR/calendar.json index 9cb764f2c..7002feace 100644 --- a/locales/pt-BR/calendar.json +++ b/locales/pt-BR/calendar.json @@ -48,11 +48,8 @@ "tag.desc.SR": "Evento de Salmon Run.", "tag.desc.CARDS": "Evento de Tableturf Battle.", "icalFeed": "", - "filter.button": "", - "filter.heading": "", "filter.modes": "", "filter.exactModes": "", - "filter.exactModesBottom": "", "filter.games": "", "filter.vs": "", "filter.vs.4v4": "", @@ -69,11 +66,18 @@ "filter.isSendou": "", "filter.isRanked": "", "filter.minTeamCount": "", + "filter.minTier": "", + "filter.maxTier": "", "filter.orgsIncluded": "", "filter.orgsExcluded": "", "filter.authorIdsExcluded": "", - "filter.apply": "", - "filter.applyAndDefault": "", + "filterBar.eventType": "", + "filterBar.tier": "", + "filterBar.tags": "", + "filterBar.organizers": "", + "filterBar.timeAndSize": "", + "filterBar.sendou": "", + "filterBar.ranked": "", "forms.draft": "", "forms.draftInfo": "", "forms.draftBracketStartBlocked": "", diff --git a/locales/pt-BR/common.json b/locales/pt-BR/common.json index a1404b3fa..f729a7418 100644 --- a/locales/pt-BR/common.json +++ b/locales/pt-BR/common.json @@ -149,6 +149,8 @@ "actions.outlined": "", "actions.noOutline": "", "actions.join": "Entrar", + "filterBar.addFilter": "", + "filterBar.saveAsDefault": "", "imageExport.export": "", "imageExport.download": "", "imageExport.theme.light": "", diff --git a/locales/pt-BR/forms.json b/locales/pt-BR/forms.json index c80072ada..432f0bd1f 100644 --- a/locales/pt-BR/forms.json +++ b/locales/pt-BR/forms.json @@ -140,7 +140,6 @@ "errors.canNotSetIfLookingNow": "", "errors.maxAssociationsReached": "", "labels.weekdayTimes": "", - "labels.weekendTimes": "", "labels.start": "", "labels.end": "", "labels.member": "", @@ -187,24 +186,8 @@ "vodTypes.SCRIM": "Scrim", "vodTypes.MATCHMAKING": "Anarchy/X Battle/Turf War", "vodTypes.SENDOUQ": "SendouQ", - "labels.modesExact": "", - "bottomTexts.modesExact": "", - "labels.games": "", - "labels.vs": "", "labels.startTime": "", - "labels.tagsIncluded": "", - "labels.tagsExcluded": "", - "labels.onlySendouEvents": "", - "labels.onlyRankedEvents": "", - "labels.minTeamCount": "", - "labels.orgsIncluded": "", - "labels.orgsExcluded": "", - "labels.authorIdsExcluded": "", "bottomTexts.authorIdsExcluded": "", - "options.startTime.any": "", - "options.startTime.eu": "", - "options.startTime.na": "", - "options.startTime.au": "", "options.game.S1": "", "options.game.S2": "", "options.game.S3": "", diff --git a/locales/pt-BR/lfg.json b/locales/pt-BR/lfg.json index c9d84c193..ad1a11a21 100644 --- a/locales/pt-BR/lfg.json +++ b/locales/pt-BR/lfg.json @@ -8,7 +8,6 @@ "post.lastActive": "última atividade", "noPosts": "Não há postagens combinando com o filtro.", "expiring": "O prazo de validade da sua postagem está acabando. Você ainda está procurando?", - "addFilter": "", "filters.Weapon": "Pool de armas", "filters.Type": "Tipo de postagem", "filters.Timezone": "Diferença de horas pelo fuso horário", @@ -16,7 +15,6 @@ "filters.PlusTier": "Tier do Plus", "filters.MaxTier": "Tier Máxima", "filters.MinTier": "Tier Mínima", - "filters.suffix": "filtro", "filters.orAbove": "ou acima", "new.noMorePosts": "Você não pode criar mais postagens", "new.type.header": "Tipo", diff --git a/locales/pt-BR/scrims.json b/locales/pt-BR/scrims.json index 9c1ee23f1..1e6340b6a 100644 --- a/locales/pt-BR/scrims.json +++ b/locales/pt-BR/scrims.json @@ -12,16 +12,9 @@ "requestModal.at.label": "", "requestModal.at.explanation": "", "pickupBy": "", - "filters.button": "", - "filters.heading": "", "filters.weekdayTimes": "", - "filters.weekdayStart": "", - "filters.weekdayEnd": "", "filters.weekendTimes": "", - "filters.weekendStart": "", - "filters.weekendEnd": "", - "filters.apply": "", - "filters.applyAndDefault": "", + "filters.divs": "", "filters.showFiltered": "", "filters.hideFiltered": "", "filters.showPendingRequests": "", diff --git a/locales/pt-BR/user.json b/locales/pt-BR/user.json index d09c981c4..1eb04b5ed 100644 --- a/locales/pt-BR/user.json +++ b/locales/pt-BR/user.json @@ -148,7 +148,6 @@ "sens": "Sens", "usesPronouns": "", "discordExplanation": "Nome de usuário, foto de perfil, conta do YouTube, Bluesky e Twitch vêm da sua conta do Discord. Veja o <1>Perguntas Frequentes para mais informações.", - "results.title": "", "results.placing": "Classificação", "results.team": "Time", "results.tournament": "Torneio", @@ -159,9 +158,26 @@ "results.highlights": "Destaques", "results.highlights.choose": "Escolher Destaques", "results.highlights.explanation": "Escolha os resultados que você quer destacar", - "results.button.showHighlights": "", - "results.button.showAll": "", - "results.filter.placeholder": "", + "results.filter.only": "", + "results.filter.highlightsOnly": "", + "results.filter.tournament": "", + "results.filter.team": "", + "results.filter.mate": "", + "results.filter.tier": "", + "results.filter.tier.min": "", + "results.filter.tier.max": "", + "results.filter.placement": "", + "results.filter.placement.first": "", + "results.filter.placement.top": "", + "results.filter.years": "", + "results.filter.years.from": "", + "results.filter.years.to": "", + "results.filter.source": "", + "results.filter.source.ALL": "", + "results.filter.source.SENDOU": "", + "results.filter.source.EXTERNAL": "", + "results.filter.size": "", + "results.filter.size.min": "", "forms.errors.maxWeapons": "Número máximo de armas no perfil atingido.", "search.info": "", "search.noResults": "Nenhum usuário encontrado com o termo '{{query}}'", diff --git a/locales/ru/builds.json b/locales/ru/builds.json index aac59a3dc..7e495c24f 100644 --- a/locales/ru/builds.json +++ b/locales/ru/builds.json @@ -21,17 +21,14 @@ "stats.all": "Все", "stats.public": "Публичные", "stats.private": "Приватные", - "addFilter": "Фильтры", "linkButton.abilityStats": "Статистика свойств", "linkButton.popularBuilds": "Популярные сборки", "noPopularBuilds": "На данный момент для этого оружия нет популярных сборок.", "emptyAbilitySlot": "Пустой слот", - "filters.type.ability": "По свойствам", - "filters.type.mode": "По режимам", - "filters.type.date": "По дате создания", - "filters.ability.title": "Фильтр свойств", - "filters.mode.title": "Фильтр режимов", - "filters.date.title": "Фильтр даты", + "filters.abilities": "", + "filters.mode": "", + "filters.date": "", + "filters.addAbility": "", "filters.has": "Имеет", "filters.does.not.have": "Не имеет", "filters.atLeast": "Минимум", diff --git a/locales/ru/calendar.json b/locales/ru/calendar.json index 8a28c7cb8..0f5995c84 100644 --- a/locales/ru/calendar.json +++ b/locales/ru/calendar.json @@ -50,11 +50,8 @@ "tag.desc.SR": "Событие по Salmon Run.", "tag.desc.CARDS": "Событие по \"Карты и район\".", "icalFeed": "", - "filter.button": "", - "filter.heading": "", "filter.modes": "", "filter.exactModes": "", - "filter.exactModesBottom": "", "filter.games": "", "filter.vs": "", "filter.vs.4v4": "", @@ -71,11 +68,18 @@ "filter.isSendou": "", "filter.isRanked": "", "filter.minTeamCount": "", + "filter.minTier": "", + "filter.maxTier": "", "filter.orgsIncluded": "", "filter.orgsExcluded": "", "filter.authorIdsExcluded": "", - "filter.apply": "", - "filter.applyAndDefault": "", + "filterBar.eventType": "", + "filterBar.tier": "", + "filterBar.tags": "", + "filterBar.organizers": "", + "filterBar.timeAndSize": "", + "filterBar.sendou": "", + "filterBar.ranked": "", "forms.draft": "", "forms.draftInfo": "", "forms.draftBracketStartBlocked": "", diff --git a/locales/ru/common.json b/locales/ru/common.json index 0ddd3162e..5184f7c20 100644 --- a/locales/ru/common.json +++ b/locales/ru/common.json @@ -149,6 +149,8 @@ "actions.outlined": "Обводка", "actions.noOutline": "Без обводки", "actions.join": "Присоединиться", + "filterBar.addFilter": "", + "filterBar.saveAsDefault": "", "imageExport.export": "", "imageExport.download": "", "imageExport.theme.light": "", diff --git a/locales/ru/forms.json b/locales/ru/forms.json index aff20c7fc..39eb63860 100644 --- a/locales/ru/forms.json +++ b/locales/ru/forms.json @@ -140,7 +140,6 @@ "errors.canNotSetIfLookingNow": "", "errors.maxAssociationsReached": "", "labels.weekdayTimes": "", - "labels.weekendTimes": "", "labels.start": "", "labels.end": "", "labels.member": "", @@ -187,24 +186,8 @@ "vodTypes.SCRIM": "Скрим", "vodTypes.MATCHMAKING": "Стихийный бой/Бой Х/Бой за район", "vodTypes.SENDOUQ": "SendouQ", - "labels.modesExact": "", - "bottomTexts.modesExact": "", - "labels.games": "", - "labels.vs": "", "labels.startTime": "", - "labels.tagsIncluded": "", - "labels.tagsExcluded": "", - "labels.onlySendouEvents": "", - "labels.onlyRankedEvents": "", - "labels.minTeamCount": "", - "labels.orgsIncluded": "", - "labels.orgsExcluded": "", - "labels.authorIdsExcluded": "", "bottomTexts.authorIdsExcluded": "", - "options.startTime.any": "", - "options.startTime.eu": "", - "options.startTime.na": "", - "options.startTime.au": "", "options.game.S1": "", "options.game.S2": "", "options.game.S3": "", diff --git a/locales/ru/lfg.json b/locales/ru/lfg.json index 5dcf62149..985882b6d 100644 --- a/locales/ru/lfg.json +++ b/locales/ru/lfg.json @@ -8,7 +8,6 @@ "post.lastActive": "обновлено", "noPosts": "Посты, соответствующие фильтрам, не найдены", "expiring": "Срок действия поста истекает. Ещё ищете?", - "addFilter": "", "filters.Weapon": "Пул оружия", "filters.Type": "Тип поста", "filters.Timezone": "Разница в часовых поясах", @@ -16,7 +15,6 @@ "filters.PlusTier": "Уровень Plus", "filters.MaxTier": "Максимальный уровень", "filters.MinTier": "Минимальный уровень", - "filters.suffix": "фильтр", "filters.orAbove": "или выше", "new.noMorePosts": "Вы не можете создать больше постов", "new.type.header": "Тип", diff --git a/locales/ru/scrims.json b/locales/ru/scrims.json index 9c1ee23f1..1e6340b6a 100644 --- a/locales/ru/scrims.json +++ b/locales/ru/scrims.json @@ -12,16 +12,9 @@ "requestModal.at.label": "", "requestModal.at.explanation": "", "pickupBy": "", - "filters.button": "", - "filters.heading": "", "filters.weekdayTimes": "", - "filters.weekdayStart": "", - "filters.weekdayEnd": "", "filters.weekendTimes": "", - "filters.weekendStart": "", - "filters.weekendEnd": "", - "filters.apply": "", - "filters.applyAndDefault": "", + "filters.divs": "", "filters.showFiltered": "", "filters.hideFiltered": "", "filters.showPendingRequests": "", diff --git a/locales/ru/user.json b/locales/ru/user.json index cb5d0140d..6105c6527 100644 --- a/locales/ru/user.json +++ b/locales/ru/user.json @@ -148,7 +148,6 @@ "sens": "Чувствительность", "usesPronouns": "", "discordExplanation": "Имя пользователя, аватар, ссылка на аккаунты YouTube, Bluesky и Twitch берутся из вашего аккаунта в Discord. Посмотрите <1>FAQ для дополнительной информации.", - "results.title": "Все результаты", "results.placing": "Место", "results.team": "Команда", "results.tournament": "Турнир", @@ -159,9 +158,26 @@ "results.highlights": "Избранное", "results.highlights.choose": "Выберите избранное", "results.highlights.explanation": "Выберите ваш избранный результат", - "results.button.showHighlights": "Показать избранные", - "results.button.showAll": "Показать все", - "results.filter.placeholder": "", + "results.filter.only": "", + "results.filter.highlightsOnly": "", + "results.filter.tournament": "", + "results.filter.team": "", + "results.filter.mate": "", + "results.filter.tier": "", + "results.filter.tier.min": "", + "results.filter.tier.max": "", + "results.filter.placement": "", + "results.filter.placement.first": "", + "results.filter.placement.top": "", + "results.filter.years": "", + "results.filter.years.from": "", + "results.filter.years.to": "", + "results.filter.source": "", + "results.filter.source.ALL": "", + "results.filter.source.SENDOU": "", + "results.filter.source.EXTERNAL": "", + "results.filter.size": "", + "results.filter.size.min": "", "forms.errors.maxWeapons": "Достигнут максимум", "search.info": "Поиск пользователей по имени Discord или Splatoon 3", "search.noResults": "По запросу '{{query}}' пользователь не найден", diff --git a/locales/zh/builds.json b/locales/zh/builds.json index e1d9a079d..6ee8c1200 100644 --- a/locales/zh/builds.json +++ b/locales/zh/builds.json @@ -21,17 +21,14 @@ "stats.all": "全部", "stats.public": "公开", "stats.private": "私人", - "addFilter": "增加筛选项目", "linkButton.abilityStats": "装备能力数据", "linkButton.popularBuilds": "热门配装", "noPopularBuilds": "目前尚无该武器的热门配装。", "emptyAbilitySlot": "空能力槽", - "filters.type.ability": "装备能力", - "filters.type.mode": "模式", - "filters.type.date": "日期", - "filters.ability.title": "装备能力筛选器", - "filters.mode.title": "模式筛选器", - "filters.date.title": "日期筛选器", + "filters.abilities": "", + "filters.mode": "", + "filters.date": "", + "filters.addAbility": "", "filters.has": "包含", "filters.does.not.have": "不包含", "filters.atLeast": "至少", diff --git a/locales/zh/calendar.json b/locales/zh/calendar.json index 5ab7946b3..635c20b35 100644 --- a/locales/zh/calendar.json +++ b/locales/zh/calendar.json @@ -44,11 +44,8 @@ "tag.desc.SR": "本次赛事为鲑鱼跑赛事。", "tag.desc.CARDS": "本次赛事为占地斗士赛事。", "icalFeed": "iCal", - "filter.button": "筛选", - "filter.heading": "筛选赛事日程", "filter.modes": "模式", "filter.exactModes": "精确筛选", - "filter.exactModesBottom": "仅显示完全符合选定模式的赛事", "filter.games": "游戏", "filter.vs": "对战人数", "filter.vs.4v4": "4v4", @@ -65,11 +62,18 @@ "filter.isSendou": "仅在 sendou.ink 举办的赛事", "filter.isRanked": "仅限排位赛事", "filter.minTeamCount": "参赛队伍数下限", + "filter.minTier": "", + "filter.maxTier": "", "filter.orgsIncluded": "显示的组织", "filter.orgsExcluded": "隐藏的组织", "filter.authorIdsExcluded": "排除的创建者", - "filter.apply": "应用", - "filter.applyAndDefault": "应用并设为默认", + "filterBar.eventType": "", + "filterBar.tier": "", + "filterBar.tags": "", + "filterBar.organizers": "", + "filterBar.timeAndSize": "", + "filterBar.sendou": "", + "filterBar.ranked": "", "forms.draft": "草稿状态", "forms.draftInfo": "处于草稿状态的赛事会被隐藏,仅对举办者可见。在启动任何对战表之前,必须先关闭草稿状态(关闭此开关)以公开赛事。", "forms.draftBracketStartBlocked": "赛事目前处于草稿状态。请先编辑赛事并关闭草稿状态,然后再启动对战表。", diff --git a/locales/zh/common.json b/locales/zh/common.json index 3fde9a571..361b0ca63 100644 --- a/locales/zh/common.json +++ b/locales/zh/common.json @@ -149,6 +149,8 @@ "actions.outlined": "描边", "actions.noOutline": "无描边", "actions.join": "加入", + "filterBar.addFilter": "", + "filterBar.saveAsDefault": "", "imageExport.export": "", "imageExport.download": "", "imageExport.theme.light": "", diff --git a/locales/zh/forms.json b/locales/zh/forms.json index fc302b08f..987e1324d 100644 --- a/locales/zh/forms.json +++ b/locales/zh/forms.json @@ -140,7 +140,6 @@ "errors.canNotSetIfLookingNow": "如果当前正在寻找对抗战,则无法进行此设置", "errors.maxAssociationsReached": "您已达到群组数量的上限", "labels.weekdayTimes": "工作日", - "labels.weekendTimes": "周末", "labels.start": "开始", "labels.end": "结束", "labels.member": "成员", @@ -187,24 +186,8 @@ "vodTypes.SCRIM": "对抗战", "vodTypes.MATCHMAKING": "蛮颓比赛 / X比赛 / 占地对战", "vodTypes.SENDOUQ": "SendouQ", - "labels.modesExact": "精确筛选", - "bottomTexts.modesExact": "仅显示完全符合选定模式的赛事", - "labels.games": "游戏", - "labels.vs": "对战人数", "labels.startTime": "开始时间", - "labels.tagsIncluded": "包含的标签", - "labels.tagsExcluded": "排除的标签", - "labels.onlySendouEvents": "仅在 sendou.ink 举办的赛事", - "labels.onlyRankedEvents": "仅限排位赛事", - "labels.minTeamCount": "参赛队伍数下限", - "labels.orgsIncluded": "显示的组织", - "labels.orgsExcluded": "隐藏的组织", - "labels.authorIdsExcluded": "排除的创建者", "bottomTexts.authorIdsExcluded": "您可以在用户的个人资料页找到他们的 ID", - "options.startTime.any": "任意", - "options.startTime.eu": "适合欧洲时间", - "options.startTime.na": "适合美洲时间", - "options.startTime.au": "适合澳洲 / 新加坡时间", "options.game.S1": "斯普拉遁 1", "options.game.S2": "斯普拉遁 2", "options.game.S3": "斯普拉遁 3", diff --git a/locales/zh/lfg.json b/locales/zh/lfg.json index 8aa734118..dacdbde96 100644 --- a/locales/zh/lfg.json +++ b/locales/zh/lfg.json @@ -8,7 +8,6 @@ "post.lastActive": "最后活跃", "noPosts": "没有招募信息符合条件", "expiring": "招募信息即将过期。还在招募吗?", - "addFilter": "添加筛选条件", "filters.Weapon": "武器池", "filters.Type": "招募类型", "filters.Timezone": "时差", @@ -16,7 +15,6 @@ "filters.PlusTier": "Plus Server 级别", "filters.MaxTier": "SendouQ 段位上限", "filters.MinTier": "SendouQ 段位下限", - "filters.suffix": "筛选条件", "filters.orAbove": "或以上", "new.noMorePosts": "您不能再发表更多招募帖了。", "new.type.header": "类型", diff --git a/locales/zh/scrims.json b/locales/zh/scrims.json index 57164229c..c0c8320ed 100644 --- a/locales/zh/scrims.json +++ b/locales/zh/scrims.json @@ -12,16 +12,9 @@ "requestModal.at.label": "开始时间", "requestModal.at.explanation": "请在招募帖的时间范围内选择一个时间", "pickupBy": "临时队员:", - "filters.button": "筛选", - "filters.heading": "对抗战筛选", "filters.weekdayTimes": "工作日时间", - "filters.weekdayStart": "工作日开始时间", - "filters.weekdayEnd": "工作日结束时间", "filters.weekendTimes": "周末时间", - "filters.weekendStart": "周末开始时间", - "filters.weekendEnd": "周末结束时间", - "filters.apply": "应用", - "filters.applyAndDefault": "应用并设为默认", + "filters.divs": "", "filters.showFiltered": "显示已过滤内容 ({{count}})", "filters.hideFiltered": "隐藏已过滤内容 ({{count}})", "filters.showPendingRequests": "显示待处理请求 ({{count}})", diff --git a/locales/zh/user.json b/locales/zh/user.json index ec841dc0b..7070ccc9e 100644 --- a/locales/zh/user.json +++ b/locales/zh/user.json @@ -148,7 +148,6 @@ "sens": "灵敏度", "usesPronouns": "人称代词", "discordExplanation": "您的用户名、头像、YouTube、Bluesky 和 Twitch 账号信息均同步自您的 Discord 账号。详情请参阅 <1>常见问题与解答。", - "results.title": "所有结果", "results.placing": "排名", "results.team": "队伍", "results.tournament": "赛事", @@ -159,9 +158,26 @@ "results.highlights": "高光结果", "results.highlights.choose": "选择高光结果", "results.highlights.explanation": "选择您想要作为高光展示的结果", - "results.button.showHighlights": "显示高光结果", - "results.button.showAll": "显示全部", - "results.filter.placeholder": "按赛事筛选", + "results.filter.only": "", + "results.filter.highlightsOnly": "", + "results.filter.tournament": "", + "results.filter.team": "", + "results.filter.mate": "", + "results.filter.tier": "", + "results.filter.tier.min": "", + "results.filter.tier.max": "", + "results.filter.placement": "", + "results.filter.placement.first": "", + "results.filter.placement.top": "", + "results.filter.years": "", + "results.filter.years.from": "", + "results.filter.years.to": "", + "results.filter.source": "", + "results.filter.source.ALL": "", + "results.filter.source.SENDOU": "", + "results.filter.source.EXTERNAL": "", + "results.filter.size": "", + "results.filter.size.min": "", "forms.errors.maxWeapons": "已达到武器数量上限", "search.info": "通过 Discord 或《斯普拉遁 3》玩家名搜索用户", "search.noResults": "未能找到匹配 “{{query}}” 的用户",