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} - - - {children} - - - - ); -} - export function SideNavFooter({ children }: { children: React.ReactNode }) { return
{children}
; } diff --git a/app/features/lfg/components/LFGAddFilterButton.tsx b/app/features/lfg/components/LFGAddFilterButton.tsx new file mode 100644 index 000000000..84830f896 --- /dev/null +++ b/app/features/lfg/components/LFGAddFilterButton.tsx @@ -0,0 +1,50 @@ +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 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) => (