diff --git a/app/components/SideNav.module.css b/app/components/SideNav.module.css
index 0d60f80da..50a8e497c 100644
--- a/app/components/SideNav.module.css
+++ b/app/components/SideNav.module.css
@@ -357,54 +357,6 @@
background-color: var(--color-text-second);
}
-.sideNavPanelOverlay {
- position: fixed;
- inset: 0;
- z-index: 10;
- background-color: rgba(0, 0, 0, 0.25);
- backdrop-filter: blur(10px);
-}
-
-.sideNavPanelOverlay[data-entering] {
- animation: fade-in 200ms ease-out;
-}
-
-@keyframes fade-in {
- from {
- opacity: 0;
- }
- to {
- opacity: 1;
- }
-}
-
-.sideNavPanel {
- position: fixed;
- top: 50%;
- left: 0;
- transform: translateY(-50%);
- max-height: 90vh;
- overflow-y: auto;
- border-radius: var(--radius-box);
-}
-
-.sideNavPanel[data-entering] {
- animation: slide-in-left 200ms ease-out;
-}
-
-@keyframes slide-in-left {
- from {
- transform: translateX(-100%) translateY(-50%);
- }
- to {
- transform: translateX(0) translateY(-50%);
- }
-}
-
-.sideNavPanelDialog {
- outline: none;
-}
-
@media screen and (max-width: 599px) {
.sideNav {
display: none;
diff --git a/app/components/SideNav.tsx b/app/components/SideNav.tsx
index 9493f387b..e32280e33 100644
--- a/app/components/SideNav.tsx
+++ b/app/components/SideNav.tsx
@@ -1,13 +1,7 @@
import clsx from "clsx";
import { X } from "lucide-react";
import type * as React from "react";
-import {
- Button,
- Dialog,
- DialogTrigger,
- Modal,
- ModalOverlay,
-} from "react-aria-components";
+import { Button } from "react-aria-components";
import { Link } from "react-router";
import { SendouButton } from "~/components/elements/Button";
import type { Tables } from "~/db/tables";
@@ -185,25 +179,6 @@ export function ListButton({
);
}
-export function SideNavPanel({
- children,
- trigger,
-}: {
- children: React.ReactNode;
- trigger: React.ReactNode;
-}) {
- return (
-
- {trigger}
-
-
-
-
-
-
- );
-}
-
export function SideNavFooter({ children }: { children: React.ReactNode }) {
return
= {
+ 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
new file mode 100644
index 000000000..c3acf3c27
--- /dev/null
+++ b/app/features/lfg/components/LFGFilters.module.css
@@ -0,0 +1,5 @@
+.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
new file mode 100644
index 000000000..4840db3cc
--- /dev/null
+++ b/app/features/lfg/components/LFGFilters.tsx
@@ -0,0 +1,299 @@
+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 } 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/components/LFGFiltersSideNav.module.css b/app/features/lfg/components/LFGFiltersSideNav.module.css
deleted file mode 100644
index 5135a2667..000000000
--- a/app/features/lfg/components/LFGFiltersSideNav.module.css
+++ /dev/null
@@ -1,16 +0,0 @@
-.filterSection {
- padding: var(--s-1) var(--s-2);
- padding-block-end: var(--s-3);
-}
-
-.filterSection select,
-.filterSection input {
- width: 100%;
-}
-
-.weaponBadges {
- display: flex;
- flex-wrap: wrap;
- gap: var(--s-1);
- margin-top: var(--s-1);
-}
diff --git a/app/features/lfg/components/LFGFiltersSideNav.tsx b/app/features/lfg/components/LFGFiltersSideNav.tsx
deleted file mode 100644
index e363a677d..000000000
--- a/app/features/lfg/components/LFGFiltersSideNav.tsx
+++ /dev/null
@@ -1,211 +0,0 @@
-import { Funnel } 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 { SideNav, SideNavHeader } from "~/components/SideNav";
-import { WeaponSelect } from "~/components/WeaponSelect";
-import type { LFGType } from "~/db/tables";
-import type { TierName } from "~/features/mmr/mmr-constants";
-import { TIERS } from "~/features/mmr/mmr-constants";
-import { languagesUnified } from "~/modules/i18n/config";
-import { LFG } from "../lfg-constants";
-import type { LFGFiltersState } from "../lfg-types";
-
-import styles from "./LFGFiltersSideNav.module.css";
-
-export function LFGFiltersSideNav({
- filters,
- setFilters,
- showClose,
-}: {
- filters: LFGFiltersState;
- setFilters: (filters: LFGFiltersState) => void;
- showClose?: boolean;
-}) {
- const { t } = useTranslation(["common", "lfg"]);
-
- return (
-
- } showClose={showClose}>
- {t("lfg:filters.header")}
-
-
-
-
-
-
-
-
-
-
- setFilters({
- ...filters,
- weapon:
- filters.weapon.length >= 10
- ? [...filters.weapon.slice(1, 10), weaponId]
- : [...filters.weapon, weaponId],
- })
- }
- key={filters.weapon.join("-")}
- />
- {filters.weapon.length > 0 ? (
-
- {filters.weapon.map((weapon) => (
-
- setFilters({
- ...filters,
- weapon: filters.weapon.filter(
- (weaponId) => weaponId !== weapon,
- ),
- })
- }
- >
-
-
- ))}
-
- ) : null}
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- );
-}
diff --git a/app/features/lfg/core/filtering.test.ts b/app/features/lfg/core/filtering.test.ts
deleted file mode 100644
index 4b045386a..000000000
--- a/app/features/lfg/core/filtering.test.ts
+++ /dev/null
@@ -1,273 +0,0 @@
-import { describe, expect, test } from "vitest";
-import type { LFGType } from "~/db/tables";
-import type { MainWeaponId } from "~/modules/in-game-lists/types";
-import type { LFGFiltersState } from "../lfg-types";
-import type { LFGLoaderPost, TiersMap } from "../routes/lfg";
-import { filterPosts } from "./filtering";
-
-const createPost = (
- overrides: Partial<{
- type: LFGType;
- timezone: string;
- languages: string | null;
- authorId: number;
- authorWeapons: MainWeaponId[];
- authorPlusTier: number | null;
- teamMembers: Array<{
- id: number;
- weaponPool: MainWeaponId[];
- plusTier: number | null;
- }>;
- }> = {},
-): LFGLoaderPost => {
- return {
- id: 1,
- type: overrides.type ?? "PLAYER_FOR_TEAM",
- timezone: overrides.timezone ?? "America/New_York",
- text: "Looking for team",
- createdAt: 1704067200,
- updatedAt: 1704067200,
- plusTierVisibility: null,
- languages: overrides.languages ?? null,
- author: {
- id: overrides.authorId ?? 1,
- discordId: "123456789",
- username: "Player",
- discordAvatar: null,
- customUrl: "player",
- plusTier: overrides.authorPlusTier ?? null,
- weaponPool: (overrides.authorWeapons ?? []).map((weaponSplId) => ({
- weaponSplId,
- isFavorite: 0,
- })),
- languages: null,
- country: null,
- },
- team: overrides.teamMembers
- ? {
- id: 1,
- name: "Test Team",
- avatarUrl: null,
- members: overrides.teamMembers.map((m, i) => ({
- id: m.id,
- discordId: `member${i}`,
- username: `Member ${i}`,
- discordAvatar: null,
- customUrl: `member${i}`,
- plusTier: m.plusTier,
- weaponPool: m.weaponPool.map((weaponSplId) => ({
- weaponSplId,
- isFavorite: 0,
- })),
- languages: null,
- country: null,
- })),
- }
- : null,
- };
-};
-
-const defaultFilters: LFGFiltersState = {
- weapon: [],
- type: null,
- timezone: null,
- language: null,
- plusTier: null,
- minTier: null,
- maxTier: null,
-};
-
-const emptyTiersMap: TiersMap = new Map();
-
-describe("filterPosts()", () => {
- test("returns all posts when no filters applied", () => {
- const posts = [createPost(), createPost({ authorId: 2 })];
- const result = filterPosts(posts, defaultFilters, emptyTiersMap);
- expect(result).toHaveLength(2);
- });
-
- test("filters by post type", () => {
- const posts = [
- createPost({ type: "PLAYER_FOR_TEAM" }),
- createPost({ type: "TEAM_FOR_PLAYER", authorId: 2 }),
- ];
- const result = filterPosts(
- posts,
- { ...defaultFilters, type: "PLAYER_FOR_TEAM" },
- emptyTiersMap,
- );
- expect(result).toHaveLength(1);
- expect(result[0].type).toBe("PLAYER_FOR_TEAM");
- });
-
- test("filters by weapon", () => {
- const posts = [
- createPost({ authorWeapons: [10 as MainWeaponId] }),
- createPost({
- authorId: 2,
- authorWeapons: [20 as MainWeaponId],
- }),
- ];
- const result = filterPosts(
- posts,
- { ...defaultFilters, weapon: [10 as MainWeaponId] },
- emptyTiersMap,
- );
- expect(result).toHaveLength(1);
- });
-
- test("weapon filter matches team members", () => {
- const posts = [
- createPost({
- authorWeapons: [],
- teamMembers: [
- { id: 10, weaponPool: [10 as MainWeaponId], plusTier: null },
- ],
- }),
- ];
- const result = filterPosts(
- posts,
- { ...defaultFilters, weapon: [10 as MainWeaponId] },
- emptyTiersMap,
- );
- expect(result).toHaveLength(1);
- });
-
- test("weapon filter skips COACH_FOR_TEAM posts", () => {
- const posts = [createPost({ type: "COACH_FOR_TEAM", authorWeapons: [] })];
- const result = filterPosts(
- posts,
- { ...defaultFilters, weapon: [10 as MainWeaponId] },
- emptyTiersMap,
- );
- expect(result).toHaveLength(1);
- });
-
- test("filters by language", () => {
- const posts = [
- createPost({ languages: "en,ja" }),
- createPost({ authorId: 2, languages: "de" }),
- createPost({ authorId: 3, languages: null }),
- ];
- const result = filterPosts(
- posts,
- { ...defaultFilters, language: "en" },
- emptyTiersMap,
- );
- expect(result).toHaveLength(1);
- });
-
- test("filters by plusTier", () => {
- const posts = [
- createPost({ authorPlusTier: 1 }),
- createPost({ authorId: 2, authorPlusTier: 2 }),
- createPost({ authorId: 3, authorPlusTier: 3 }),
- createPost({ authorId: 4, authorPlusTier: null }),
- ];
- const result = filterPosts(
- posts,
- { ...defaultFilters, plusTier: 2 },
- emptyTiersMap,
- );
- expect(result).toHaveLength(2);
- });
-
- test("plusTier filter matches team members", () => {
- const posts = [
- createPost({
- authorPlusTier: null,
- teamMembers: [{ id: 10, weaponPool: [], plusTier: 1 }],
- }),
- ];
- const result = filterPosts(
- posts,
- { ...defaultFilters, plusTier: 2 },
- emptyTiersMap,
- );
- expect(result).toHaveLength(1);
- });
-
- test("filters by minTier", () => {
- const tiersMap: TiersMap = new Map([
- [1, { latest: { name: "GOLD", isPlus: false } }],
- [2, { latest: { name: "IRON", isPlus: false } }],
- ]);
- const posts = [createPost({ authorId: 1 }), createPost({ authorId: 2 })];
- const result = filterPosts(
- posts,
- { ...defaultFilters, minTier: "SILVER" },
- tiersMap,
- );
- expect(result).toHaveLength(1);
- });
-
- test("filters by maxTier", () => {
- const tiersMap: TiersMap = new Map([
- [1, { latest: { name: "LEVIATHAN", isPlus: false } }],
- [2, { latest: { name: "SILVER", isPlus: false } }],
- ]);
- const posts = [createPost({ authorId: 1 }), createPost({ authorId: 2 })];
- const result = filterPosts(
- posts,
- { ...defaultFilters, maxTier: "GOLD" },
- tiersMap,
- );
- expect(result).toHaveLength(1);
- });
-
- test("tier filter uses previous season if latest not available", () => {
- const tiersMap: TiersMap = new Map([
- [1, { previous: { name: "DIAMOND", isPlus: false } }],
- ]);
- const posts = [createPost({ authorId: 1 })];
- const result = filterPosts(
- posts,
- { ...defaultFilters, minTier: "PLATINUM" },
- tiersMap,
- );
- expect(result).toHaveLength(1);
- });
-
- test("tier filter skips COACH_FOR_TEAM posts", () => {
- const posts = [createPost({ type: "COACH_FOR_TEAM", authorId: 1 })];
- const result = filterPosts(
- posts,
- { ...defaultFilters, minTier: "DIAMOND" },
- emptyTiersMap,
- );
- expect(result).toHaveLength(1);
- });
-
- test("combines multiple filters", () => {
- const posts = [
- createPost({
- type: "PLAYER_FOR_TEAM",
- languages: "en",
- authorWeapons: [10 as MainWeaponId],
- }),
- createPost({
- authorId: 2,
- type: "TEAM_FOR_PLAYER",
- languages: "en",
- authorWeapons: [10 as MainWeaponId],
- }),
- createPost({
- authorId: 3,
- type: "PLAYER_FOR_TEAM",
- languages: "de",
- authorWeapons: [10 as MainWeaponId],
- }),
- ];
- const result = filterPosts(
- posts,
- {
- ...defaultFilters,
- type: "PLAYER_FOR_TEAM",
- language: "en",
- weapon: [10 as MainWeaponId],
- },
- emptyTiersMap,
- );
- expect(result).toHaveLength(1);
- });
-});
diff --git a/app/features/lfg/core/filtering.ts b/app/features/lfg/core/filtering.ts
index c7ce0ecaa..9e67e495d 100644
--- a/app/features/lfg/core/filtering.ts
+++ b/app/features/lfg/core/filtering.ts
@@ -4,129 +4,116 @@ import {
mainWeaponIds,
weaponIdToBaseWeaponId,
} from "~/modules/in-game-lists/weapon-ids";
-import type { LFGFiltersState } from "../lfg-types";
+import { assertUnreachable } from "~/utils/types";
+import type { LFGFilter } from "../lfg-types";
import type { LFGLoaderData, LFGLoaderPost, TiersMap } from "../routes/lfg";
import { hourDifferenceBetweenTimezones } from "./timezone";
export function filterPosts(
posts: LFGLoaderData["posts"],
- filters: LFGFiltersState,
+ filters: LFGFilter[],
tiersMap: TiersMap,
) {
return posts.filter((post) => {
- if (!matchesTypeFilter(post, filters.type)) return false;
- if (!matchesWeaponFilter(post, filters.weapon)) return false;
- if (!matchesTimezoneFilter(post, filters.timezone)) return false;
- if (!matchesLanguageFilter(post, filters.language)) return false;
- if (!matchesPlusTierFilter(post, filters.plusTier)) return false;
- if (!matchesMinTierFilter(post, filters.minTier, tiersMap)) return false;
- if (!matchesMaxTierFilter(post, filters.maxTier, tiersMap)) return false;
+ for (const filter of filters) {
+ if (!filterMatchesPost(post, filter, tiersMap)) return false;
+ }
return true;
});
}
-function matchesTypeFilter(post: LFGLoaderPost, type: LFGFiltersState["type"]) {
- if (type === null) return true;
- return post.type === type;
-}
-
-function matchesWeaponFilter(
+function filterMatchesPost(
post: LFGLoaderPost,
- weapon: LFGFiltersState["weapon"],
-) {
- if (weapon.length === 0) return true;
- if (post.type === "COACH_FOR_TEAM") return true;
-
- const weaponIdsWithRelated = weapon.flatMap(weaponIdToRelated);
-
- return checkMatchesSomeUserInPost(post, (user) =>
- user.weaponPool.some(({ weaponSplId }) =>
- weaponIdsWithRelated.includes(weaponSplId),
- ),
- );
-}
-
-function matchesTimezoneFilter(
- post: LFGLoaderPost,
- timezone: LFGFiltersState["timezone"],
-) {
- if (timezone === null) return true;
-
- const userTimezone = Intl.DateTimeFormat().resolvedOptions().timeZone;
-
- return (
- Math.abs(hourDifferenceBetweenTimezones(post.timezone, userTimezone)) <=
- timezone
- );
-}
-
-function matchesLanguageFilter(
- post: LFGLoaderPost,
- language: LFGFiltersState["language"],
-) {
- if (language === null) return true;
- return !!post.languages?.includes(language);
-}
-
-function matchesPlusTierFilter(
- post: LFGLoaderPost,
- plusTier: LFGFiltersState["plusTier"],
-) {
- if (plusTier === null) return true;
-
- return checkMatchesSomeUserInPost(
- post,
- (user) => user.plusTier !== null && user.plusTier <= plusTier,
- );
-}
-
-function matchesMinTierFilter(
- post: LFGLoaderPost,
- minTier: LFGFiltersState["minTier"],
+ filter: LFGFilter,
tiersMap: TiersMap,
) {
- if (minTier === null) return true;
- if (post.type === "COACH_FOR_TEAM") return true;
-
- 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;
+ if (post.type === "COACH_FOR_TEAM") {
+ // not visible in the UI
+ if (
+ filter._tag === "Weapon" ||
+ filter._tag === "MaxTier" ||
+ filter._tag === "MinTier"
+ ) {
+ return false;
}
+ }
- if (tiers.previous && compareTwoTiers(tiers.previous.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),
+ ),
+ );
}
+ case "Type":
+ return post.type === filter.type;
+ case "Timezone": {
+ const userTimezone = Intl.DateTimeFormat().resolvedOptions().timeZone;
- return false;
- });
-}
-
-function matchesMaxTierFilter(
- post: LFGLoaderPost,
- maxTier: LFGFiltersState["maxTier"],
- tiersMap: TiersMap,
-) {
- if (maxTier === null) return true;
- if (post.type === "COACH_FOR_TEAM") return true;
-
- 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 (
+ 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.previous && compareTwoTiers(tiers.previous.name, maxTier) >= 0) {
- return true;
- }
+ if (
+ tiers.latest &&
+ compareTwoTiers(tiers.latest.name, filter.tier) >= 0
+ ) {
+ return true;
+ }
- return false;
- });
+ 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);
+ }
}
const checkMatchesSomeUserInPost = (
diff --git a/app/features/lfg/lfg-types.test.ts b/app/features/lfg/lfg-types.test.ts
deleted file mode 100644
index a178d8c1d..000000000
--- a/app/features/lfg/lfg-types.test.ts
+++ /dev/null
@@ -1,174 +0,0 @@
-import { describe, expect, test } from "vitest";
-import type { MainWeaponId } from "~/modules/in-game-lists/types";
-import {
- DEFAULT_LFG_FILTERS,
- decodeFiltersState,
- encodeFiltersState,
- type LFGFiltersState,
-} from "./lfg-types";
-
-describe("encodeFiltersState()", () => {
- test("returns empty string for default filters", () => {
- expect(encodeFiltersState(DEFAULT_LFG_FILTERS)).toBe("");
- });
-
- test("encodes weapon filter", () => {
- const filters: LFGFiltersState = {
- ...DEFAULT_LFG_FILTERS,
- weapon: [10, 20] as MainWeaponId[],
- };
- expect(encodeFiltersState(filters)).toBe("w.10,20");
- });
-
- test("encodes type filter", () => {
- const filters: LFGFiltersState = {
- ...DEFAULT_LFG_FILTERS,
- type: "TEAM_FOR_PLAYER",
- };
- expect(encodeFiltersState(filters)).toBe("t.2");
- });
-
- test("encodes timezone filter", () => {
- const filters: LFGFiltersState = {
- ...DEFAULT_LFG_FILTERS,
- timezone: 8,
- };
- expect(encodeFiltersState(filters)).toBe("tz.8");
- });
-
- test("encodes language filter", () => {
- const filters: LFGFiltersState = {
- ...DEFAULT_LFG_FILTERS,
- language: "en",
- };
- expect(encodeFiltersState(filters)).toBe("l.en");
- });
-
- test("encodes plusTier filter", () => {
- const filters: LFGFiltersState = {
- ...DEFAULT_LFG_FILTERS,
- plusTier: 2,
- };
- expect(encodeFiltersState(filters)).toBe("pt.2");
- });
-
- test("encodes minTier filter", () => {
- const filters: LFGFiltersState = {
- ...DEFAULT_LFG_FILTERS,
- minTier: "GOLD",
- };
- expect(encodeFiltersState(filters)).toBe("mn.3");
- });
-
- test("encodes maxTier filter", () => {
- const filters: LFGFiltersState = {
- ...DEFAULT_LFG_FILTERS,
- maxTier: "SILVER",
- };
- expect(encodeFiltersState(filters)).toBe("mx.4");
- });
-
- test("encodes multiple filters with dash delimiter", () => {
- const filters: LFGFiltersState = {
- ...DEFAULT_LFG_FILTERS,
- weapon: [10] as MainWeaponId[],
- type: "PLAYER_FOR_TEAM",
- timezone: 4,
- };
- expect(encodeFiltersState(filters)).toBe("w.10-t.0-tz.4");
- });
-});
-
-describe("decodeFiltersState()", () => {
- test("returns default filters for empty string", () => {
- expect(decodeFiltersState("")).toEqual(DEFAULT_LFG_FILTERS);
- });
-
- test("decodes weapon filter", () => {
- const result = decodeFiltersState("w.10,20,30");
- expect(result.weapon).toEqual([10, 20, 30]);
- });
-
- test("decodes type filter", () => {
- const result = decodeFiltersState("t.2");
- expect(result.type).toBe("TEAM_FOR_PLAYER");
- });
-
- test("decodes timezone filter", () => {
- const result = decodeFiltersState("tz.6");
- expect(result.timezone).toBe(6);
- });
-
- test("decodes language filter", () => {
- const result = decodeFiltersState("l.ja");
- expect(result.language).toBe("ja");
- });
-
- test("decodes plusTier filter", () => {
- const result = decodeFiltersState("pt.3");
- expect(result.plusTier).toBe(3);
- });
-
- test("decodes minTier filter", () => {
- const result = decodeFiltersState("mn.1");
- expect(result.minTier).toBe("DIAMOND");
- });
-
- test("decodes maxTier filter", () => {
- const result = decodeFiltersState("mx.5");
- expect(result.maxTier).toBe("BRONZE");
- });
-
- test("decodes multiple filters", () => {
- const result = decodeFiltersState("w.10-t.1-tz.8-l.en");
- expect(result.weapon).toEqual([10]);
- expect(result.type).toBe("PLAYER_FOR_COACH");
- expect(result.timezone).toBe(8);
- expect(result.language).toBe("en");
- });
-
- test("ignores invalid weapon IDs", () => {
- const result = decodeFiltersState("w.abc,10,xyz");
- expect(result.weapon).toEqual([10]);
- });
-
- test("ignores invalid language codes", () => {
- const result = decodeFiltersState("l.invalid");
- expect(result.language).toBeNull();
- });
-
- test("ignores invalid type indices", () => {
- const result = decodeFiltersState("t.99");
- expect(result.type).toBeNull();
- });
-
- test("ignores invalid tier indices", () => {
- const result = decodeFiltersState("mn.99");
- expect(result.minTier).toBeNull();
- });
-
- test("ignores malformed parts", () => {
- const result = decodeFiltersState("w.10-invalid-t.0");
- expect(result.weapon).toEqual([10]);
- expect(result.type).toBe("PLAYER_FOR_TEAM");
- });
-});
-
-describe("encode/decode roundtrip", () => {
- test("roundtrip preserves all filter values", () => {
- const original: LFGFiltersState = {
- weapon: [10, 20] as MainWeaponId[],
- type: "TEAM_FOR_SCRIM",
- timezone: 6,
- language: "ja",
- plusTier: 2,
- minTier: "GOLD",
- maxTier: "PLATINUM",
- };
-
- const encoded = encodeFiltersState(original);
- const decoded = decodeFiltersState(encoded);
-
- expect(decoded).toEqual(original);
- });
-});
diff --git a/app/features/lfg/lfg-types.ts b/app/features/lfg/lfg-types.ts
index c7499a072..0f14a463a 100644
--- a/app/features/lfg/lfg-types.ts
+++ b/app/features/lfg/lfg-types.ts
@@ -1,26 +1,51 @@
import { LFG_TYPES, type LFGType } from "~/db/tables";
import { languagesUnified } 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 LFGFiltersState = {
- weapon: MainWeaponId[];
- type: LFGType | null;
- timezone: number | null;
- language: string | null;
- plusTier: number | null;
- minTier: TierName | null;
- maxTier: TierName | null;
+export type LFGFilter =
+ | WeaponFilter
+ | TypeFilter
+ | TimezoneFilter
+ | LanguageFilter
+ | PlusTierFilter
+ | MaxTierFilter
+ | MinTierFilter;
+
+type WeaponFilter = {
+ _tag: "Weapon";
+ weaponSplIds: MainWeaponId[];
};
-export const DEFAULT_LFG_FILTERS: LFGFiltersState = {
- weapon: [],
- type: null,
- timezone: null,
- language: null,
- plusTier: null,
- minTier: null,
- maxTier: null,
+type TypeFilter = {
+ _tag: "Type";
+ type: LFGType;
+};
+
+type TimezoneFilter = {
+ _tag: "Timezone";
+ maxHourDifference: number;
+};
+
+type LanguageFilter = {
+ _tag: "Language";
+ language: string;
+};
+
+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}`]));
@@ -39,111 +64,92 @@ const numToTier = new Map(
Array.from(tierToNum).map(([tier, num]) => [`${num}`, tier]),
);
-export function encodeFiltersState(filters: LFGFiltersState): string {
- const parts: string[] = [];
-
- if (filters.weapon.length > 0) {
- parts.push(`w.${filters.weapon.join(",")}`);
+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);
}
- if (filters.type !== null) {
- parts.push(`t.${typeToNum.get(filters.type)}`);
- }
- if (filters.timezone !== null) {
- parts.push(`tz.${filters.timezone}`);
- }
- if (filters.language !== null) {
- parts.push(`l.${filters.language}`);
- }
- if (filters.plusTier !== null) {
- parts.push(`pt.${filters.plusTier}`);
- }
- if (filters.minTier !== null) {
- parts.push(`mn.${tierToNum.get(filters.minTier)}`);
- }
- if (filters.maxTier !== null) {
- parts.push(`mx.${tierToNum.get(filters.maxTier)}`);
- }
-
- return parts.join("-");
}
-export function countActiveFilters(filters: LFGFiltersState): number {
- let count = 0;
- if (filters.weapon.length > 0) count++;
- if (filters.type !== null) count++;
- if (filters.timezone !== null) count++;
- if (filters.language !== null) count++;
- if (filters.plusTier !== null) count++;
- if (filters.minTier !== null) count++;
- if (filters.maxTier !== null) count++;
- return count;
-}
+export function smallStrToFilter(s: string): LFGFilter | null {
+ const [tag, val] = s.split(".");
+ if (!tag || !val) return null;
-export function decodeFiltersState(queryString: string): LFGFiltersState {
- const result: LFGFiltersState = { ...DEFAULT_LFG_FILTERS };
-
- if (queryString === "") {
- return result;
- }
-
- for (const part of queryString.split("-")) {
- const [tag, val] = part.split(".");
- if (!tag || !val) continue;
-
- switch (tag) {
- case "w": {
- const weaponIds = val
- .split(",")
- .map((x) => Number.parseInt(x, 10) as MainWeaponId)
- .filter((x) => !Number.isNaN(x));
- if (weaponIds.length > 0) {
- result.weapon = weaponIds;
- }
- break;
- }
- case "t": {
- const filterType = numToType.get(val);
- if (filterType) {
- result.type = filterType;
- }
- break;
- }
- case "tz": {
- const n = Number.parseInt(val, 10);
- if (!Number.isNaN(n)) {
- result.timezone = n;
- }
- break;
- }
- case "l": {
- if (languagesUnified.some((lang) => lang.code === val)) {
- result.language = val;
- }
- break;
- }
- case "pt": {
- const n = Number.parseInt(val, 10);
- if (!Number.isNaN(n)) {
- result.plusTier = n;
- }
- break;
- }
- case "mx": {
- const tier = numToTier.get(val);
- if (tier) {
- result.maxTier = tier;
- }
- break;
- }
- case "mn": {
- const tier = numToTier.get(val);
- if (tier) {
- result.minTier = tier;
- }
- break;
- }
+ switch (tag) {
+ case "w": {
+ const weaponIds = val
+ .split(",")
+ .map((x) => Number.parseInt(x, 10) as MainWeaponId)
+ .filter((x) => x !== null && x !== undefined);
+ if (weaponIds.length === 0) return null;
+ 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": {
+ if (!languagesUnified.some((lang) => lang.code === val)) return null;
+ return {
+ _tag: "Language",
+ language: val,
+ };
+ }
+ 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 result;
+ return null;
}
diff --git a/app/features/lfg/routes/lfg.module.css b/app/features/lfg/routes/lfg.module.css
index 0290ac892..0ef45816d 100644
--- a/app/features/lfg/routes/lfg.module.css
+++ b/app/features/lfg/routes/lfg.module.css
@@ -1,13 +1,3 @@
-.mobileFilterButton {
- display: none;
-}
-
-@media (max-width: 800px) {
- .mobileFilterButton {
- display: flex;
- }
-}
-
.post {
scroll-margin-top: 6rem;
}
diff --git a/app/features/lfg/routes/lfg.tsx b/app/features/lfg/routes/lfg.tsx
index f625ba30d..bf10aef52 100644
--- a/app/features/lfg/routes/lfg.tsx
+++ b/app/features/lfg/routes/lfg.tsx
@@ -1,15 +1,12 @@
import clsx from "clsx";
import { add, sub } from "date-fns";
-import { Funnel } from "lucide-react";
-import * as React from "react";
+import React from "react";
import { useTranslation } from "react-i18next";
import type { MetaFunction } from "react-router";
import { useFetcher, useLoaderData } from "react-router";
import { AddNewButton } from "~/components/AddNewButton";
import { Alert } from "~/components/Alert";
-import { SendouButton } from "~/components/elements/Button";
import { Main } from "~/components/Main";
-import { SideNavPanel } from "~/components/SideNav";
import { SubmitButton } from "~/components/SubmitButton";
import { useUser } from "~/features/auth/core/user";
import { useSearchParamStateEncoder } from "~/hooks/useSearchParamState";
@@ -19,16 +16,15 @@ import type { SendouRouteHandle } from "~/utils/remix.server";
import type { Unpacked } from "~/utils/types";
import { LFG_PAGE, lfgNewPostPage, navIconUrl } from "~/utils/urls";
import { action } from "../actions/lfg.server";
-import { LFGFiltersSideNav } from "../components/LFGFiltersSideNav";
+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";
import {
- countActiveFilters,
- DEFAULT_LFG_FILTERS,
- decodeFiltersState,
- encodeFiltersState,
- type LFGFiltersState,
+ filterToSmallStr,
+ type LFGFilter,
+ smallStrToFilter,
} from "../lfg-types";
import { loader } from "../loaders/lfg.server";
import styles from "./lfg.module.css";
@@ -60,20 +56,33 @@ export type TiersMap = ReturnType;
const unserializeTiers = (data: SerializeFrom) =>
new Map(data.tiersMap);
+function decodeURLQuery(queryString: string): LFGFilter[] {
+ if (queryString === "") {
+ return [];
+ }
+ return queryString
+ .split("-")
+ .map(smallStrToFilter)
+ .filter((x) => x !== null);
+}
+
+function encodeURLQuery(filters: LFGFilter[]): string {
+ return filters.map(filterToSmallStr).join("-");
+}
+
export default function LFGPage() {
const { t } = useTranslation(["common", "lfg"]);
const user = useUser();
const data = useLoaderData();
- const [filterFromSearch, setFilterFromSearch] = useSearchParamStateEncoder({
- defaultValue: DEFAULT_LFG_FILTERS,
+ const [filterFromSearch, setTilterFromSearch] = useSearchParamStateEncoder({
+ defaultValue: [],
name: "q",
- revive: decodeFiltersState,
- encode: encodeFiltersState,
+ revive: decodeURLQuery,
+ encode: encodeURLQuery,
});
- const [filters, _setFilters] =
- React.useState(filterFromSearch);
- const setFilters = (x: LFGFiltersState) => {
- setFilterFromSearch(x);
+ const [filters, _setFilters] = React.useState(filterFromSearch);
+ const setFilters = (x: LFGFilter[]) => {
+ setTilterFromSearch(x);
_setFilters(x);
};
@@ -94,36 +103,28 @@ export default function LFGPage() {
return true;
};
- const activeFilterCount = countActiveFilters(filters);
-
- // xxx: undo changes that introduce the panel here
return (
- }
- >
+
-
}
- className={styles.mobileFilterButton}
- >
- {t("lfg:filters.button")}
- {activeFilterCount > 0 ? ` (${activeFilterCount})` : null}
-
- }
- >
-
-
+
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) => (