mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-05-06 05:07:36 -05:00
Restore /lfg filters code from main
This commit is contained in:
parent
5e4a66477e
commit
aae39b7cc3
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<DialogTrigger>
|
||||
{trigger}
|
||||
<ModalOverlay className={styles.sideNavPanelOverlay} isDismissable>
|
||||
<Modal className={clsx(styles.sideNavPanel, "scrollbar")}>
|
||||
<Dialog className={styles.sideNavPanelDialog}>{children}</Dialog>
|
||||
</Modal>
|
||||
</ModalOverlay>
|
||||
</DialogTrigger>
|
||||
);
|
||||
}
|
||||
|
||||
export function SideNavFooter({ children }: { children: React.ReactNode }) {
|
||||
return <div className={styles.sideNavFooter}>{children}</div>;
|
||||
}
|
||||
|
|
|
|||
50
app/features/lfg/components/LFGAddFilterButton.tsx
Normal file
50
app/features/lfg/components/LFGAddFilterButton.tsx
Normal file
|
|
@ -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<LFGFilter["_tag"], LFGFilter> = {
|
||||
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 (
|
||||
<SendouMenu
|
||||
trigger={
|
||||
<SendouButton
|
||||
variant="outlined"
|
||||
size="small"
|
||||
icon={<Filter />}
|
||||
data-testid="add-filter-button"
|
||||
>
|
||||
{t("lfg:addFilter")}
|
||||
</SendouButton>
|
||||
}
|
||||
>
|
||||
{Object.entries(defaultFilters).map(([tag, defaultFilter]) => (
|
||||
<SendouMenuItem
|
||||
key={tag}
|
||||
isDisabled={filters.some((filter) => filter._tag === tag)}
|
||||
onAction={() => addFilter(defaultFilter)}
|
||||
>
|
||||
{t(`lfg:filters.${tag as LFGFilter["_tag"]}`)}
|
||||
</SendouMenuItem>
|
||||
))}
|
||||
</SendouMenu>
|
||||
);
|
||||
}
|
||||
5
app/features/lfg/components/LFGFilters.module.css
Normal file
5
app/features/lfg/components/LFGFilters.module.css
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
.filter {
|
||||
padding: var(--s-1-5) var(--s-2);
|
||||
background-color: var(--bg-lighter);
|
||||
border-radius: var(--rounded);
|
||||
}
|
||||
299
app/features/lfg/components/LFGFilters.tsx
Normal file
299
app/features/lfg/components/LFGFilters.tsx
Normal file
|
|
@ -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 (
|
||||
<div className="stack md">
|
||||
{filters.map((filter) => (
|
||||
<Filter
|
||||
key={filter._tag}
|
||||
filter={filter}
|
||||
changeFilter={changeFilter}
|
||||
removeFilter={() => removeFilterByTag(filter._tag)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Filter({
|
||||
filter,
|
||||
changeFilter,
|
||||
removeFilter,
|
||||
}: {
|
||||
filter: LFGFilter;
|
||||
changeFilter: (newFilter: LFGFilter) => void;
|
||||
removeFilter: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation(["lfg"]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="stack horizontal justify-between">
|
||||
<Label htmlFor={`${filter._tag.toLowerCase()}-filter`}>
|
||||
{t(`lfg:filters.${filter._tag}`)} {t("lfg:filters.suffix")}
|
||||
</Label>
|
||||
<SendouButton
|
||||
icon={<X />}
|
||||
size="small"
|
||||
variant="minimal-destructive"
|
||||
onPress={removeFilter}
|
||||
aria-label="Delete filter"
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.filter}>
|
||||
{filter._tag === "Weapon" && (
|
||||
<WeaponFilterFields
|
||||
value={filter.weaponSplIds}
|
||||
changeFilter={changeFilter}
|
||||
/>
|
||||
)}
|
||||
{filter._tag === "Type" && (
|
||||
<TypeFilterFields value={filter.type} changeFilter={changeFilter} />
|
||||
)}
|
||||
{filter._tag === "Timezone" && (
|
||||
<TimezoneFilterFields
|
||||
value={filter.maxHourDifference}
|
||||
changeFilter={changeFilter}
|
||||
/>
|
||||
)}
|
||||
{filter._tag === "Language" && (
|
||||
<LanguageFilterFields
|
||||
value={filter.language}
|
||||
changeFilter={changeFilter}
|
||||
/>
|
||||
)}
|
||||
{filter._tag === "PlusTier" && (
|
||||
<PlusTierFilterFields
|
||||
value={filter.tier}
|
||||
changeFilter={changeFilter}
|
||||
/>
|
||||
)}
|
||||
{filter._tag === "MaxTier" && (
|
||||
<TierFilterFields
|
||||
_tag="MaxTier"
|
||||
value={filter.tier}
|
||||
changeFilter={changeFilter}
|
||||
/>
|
||||
)}
|
||||
{filter._tag === "MinTier" && (
|
||||
<TierFilterFields
|
||||
_tag="MinTier"
|
||||
value={filter.tier}
|
||||
changeFilter={changeFilter}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function WeaponFilterFields({
|
||||
value,
|
||||
changeFilter,
|
||||
}: {
|
||||
value: MainWeaponId[];
|
||||
changeFilter: (newFilter: LFGFilter) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="stack horizontal sm flex-wrap">
|
||||
<WeaponSelect
|
||||
disabledWeaponIds={value}
|
||||
onChange={(weaponId) =>
|
||||
changeFilter({
|
||||
_tag: "Weapon",
|
||||
weaponSplIds:
|
||||
value.length >= 10
|
||||
? [...value.slice(1, 10), weaponId]
|
||||
: [...value, weaponId],
|
||||
})
|
||||
}
|
||||
key={value.join("-")}
|
||||
/>
|
||||
{value.map((weapon) => (
|
||||
<SendouButton
|
||||
key={weapon}
|
||||
variant="minimal"
|
||||
onPress={() =>
|
||||
changeFilter({
|
||||
_tag: "Weapon",
|
||||
weaponSplIds: value.filter((weaponId) => weaponId !== weapon),
|
||||
})
|
||||
}
|
||||
>
|
||||
<WeaponImage weaponSplId={weapon} size={32} variant="badge" />
|
||||
</SendouButton>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TypeFilterFields({
|
||||
value,
|
||||
changeFilter,
|
||||
}: {
|
||||
value: Tables["LFGPost"]["type"];
|
||||
changeFilter: (newFilter: LFGFilter) => void;
|
||||
}) {
|
||||
const { t } = useTranslation(["lfg"]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<select
|
||||
id="type-filter"
|
||||
className="w-max"
|
||||
value={value}
|
||||
onChange={(e) =>
|
||||
changeFilter({
|
||||
_tag: "Type",
|
||||
type: e.target.value as Tables["LFGPost"]["type"],
|
||||
})
|
||||
}
|
||||
>
|
||||
{LFG.types.map((type) => (
|
||||
<option key={type} value={type}>
|
||||
{t(`lfg:types.${type}`)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TimezoneFilterFields({
|
||||
value,
|
||||
changeFilter,
|
||||
}: {
|
||||
value: number;
|
||||
changeFilter: (newFilter: LFGFilter) => void;
|
||||
}) {
|
||||
return (
|
||||
<div>
|
||||
<input
|
||||
id="timezone-filter"
|
||||
type="number"
|
||||
value={value}
|
||||
min={0}
|
||||
max={12}
|
||||
onChange={(e) => {
|
||||
changeFilter({
|
||||
_tag: "Timezone",
|
||||
maxHourDifference: Number(e.target.value),
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function LanguageFilterFields({
|
||||
value,
|
||||
changeFilter,
|
||||
}: {
|
||||
value: string;
|
||||
changeFilter: (newFilter: LFGFilter) => void;
|
||||
}) {
|
||||
return (
|
||||
<div>
|
||||
<select
|
||||
id="language-filter"
|
||||
className="w-max"
|
||||
value={value}
|
||||
onChange={(e) =>
|
||||
changeFilter({
|
||||
_tag: "Language",
|
||||
language: e.target.value as Tables["LFGPost"]["type"],
|
||||
})
|
||||
}
|
||||
>
|
||||
{languagesUnified.map((language) => (
|
||||
<option key={language.code} value={language.code}>
|
||||
{language.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PlusTierFilterFields({
|
||||
value,
|
||||
changeFilter,
|
||||
}: {
|
||||
value: number;
|
||||
changeFilter: (newFilter: LFGFilter) => void;
|
||||
}) {
|
||||
const { t } = useTranslation(["lfg"]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<select
|
||||
id="plustier-filter"
|
||||
value={value}
|
||||
onChange={(e) =>
|
||||
changeFilter({ _tag: "PlusTier", tier: Number(e.target.value) })
|
||||
}
|
||||
className="w-max"
|
||||
>
|
||||
<option value="1">+1</option>
|
||||
<option value="2">+2 {t("lfg:filters.orAbove")}</option>
|
||||
<option value="3">+3 {t("lfg:filters.orAbove")}</option>
|
||||
</select>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TierFilterFields({
|
||||
_tag,
|
||||
value,
|
||||
changeFilter,
|
||||
}: {
|
||||
_tag: "MaxTier" | "MinTier";
|
||||
value: TierName;
|
||||
changeFilter: (newFilter: LFGFilter) => void;
|
||||
}) {
|
||||
return (
|
||||
<div>
|
||||
<select
|
||||
id={`${_tag.toLowerCase()}-filter`}
|
||||
value={value}
|
||||
onChange={(e) =>
|
||||
changeFilter({ _tag, tier: e.target.value as TierName })
|
||||
}
|
||||
className="w-max"
|
||||
>
|
||||
{TIERS.map((tier) => (
|
||||
<option key={tier.name} value={tier.name}>
|
||||
{R.capitalize(tier.name.toLowerCase())}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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 (
|
||||
<SideNav>
|
||||
<SideNavHeader icon={<Funnel />} showClose={showClose}>
|
||||
{t("lfg:filters.header")}
|
||||
</SideNavHeader>
|
||||
|
||||
<div className={styles.filterSection}>
|
||||
<Label htmlFor="type-filter" spaced={false}>
|
||||
{t("lfg:filters.Type")}
|
||||
</Label>
|
||||
<select
|
||||
id="type-filter"
|
||||
value={filters.type ?? ""}
|
||||
onChange={(e) =>
|
||||
setFilters({
|
||||
...filters,
|
||||
type: e.target.value === "" ? null : (e.target.value as LFGType),
|
||||
})
|
||||
}
|
||||
>
|
||||
<option value="">{t("common:select.any")}</option>
|
||||
{LFG.types.map((type) => (
|
||||
<option key={type} value={type}>
|
||||
{t(`lfg:types.${type}`)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className={styles.filterSection}>
|
||||
<Label spaced={false}>{t("lfg:filters.Weapon")}</Label>
|
||||
<WeaponSelect
|
||||
disabledWeaponIds={filters.weapon}
|
||||
onChange={(weaponId) =>
|
||||
setFilters({
|
||||
...filters,
|
||||
weapon:
|
||||
filters.weapon.length >= 10
|
||||
? [...filters.weapon.slice(1, 10), weaponId]
|
||||
: [...filters.weapon, weaponId],
|
||||
})
|
||||
}
|
||||
key={filters.weapon.join("-")}
|
||||
/>
|
||||
{filters.weapon.length > 0 ? (
|
||||
<div className={styles.weaponBadges}>
|
||||
{filters.weapon.map((weapon) => (
|
||||
<SendouButton
|
||||
key={weapon}
|
||||
variant="minimal"
|
||||
onPress={() =>
|
||||
setFilters({
|
||||
...filters,
|
||||
weapon: filters.weapon.filter(
|
||||
(weaponId) => weaponId !== weapon,
|
||||
),
|
||||
})
|
||||
}
|
||||
>
|
||||
<WeaponImage weaponSplId={weapon} size={32} variant="badge" />
|
||||
</SendouButton>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className={styles.filterSection}>
|
||||
<Label htmlFor="timezone-filter" spaced={false}>
|
||||
{t("lfg:filters.Timezone")}
|
||||
</Label>
|
||||
<select
|
||||
id="timezone-filter"
|
||||
value={filters.timezone ?? ""}
|
||||
onChange={(e) =>
|
||||
setFilters({
|
||||
...filters,
|
||||
timezone: e.target.value === "" ? null : Number(e.target.value),
|
||||
})
|
||||
}
|
||||
>
|
||||
<option value="">{t("common:select.any")}</option>
|
||||
{Array.from({ length: 13 }, (_, i) => i).map((hours) => (
|
||||
<option key={hours} value={hours}>
|
||||
±{hours}h
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className={styles.filterSection}>
|
||||
<Label htmlFor="language-filter" spaced={false}>
|
||||
{t("lfg:filters.Language")}
|
||||
</Label>
|
||||
<select
|
||||
id="language-filter"
|
||||
value={filters.language ?? ""}
|
||||
onChange={(e) =>
|
||||
setFilters({
|
||||
...filters,
|
||||
language: e.target.value === "" ? null : e.target.value,
|
||||
})
|
||||
}
|
||||
>
|
||||
<option value="">{t("common:select.any")}</option>
|
||||
{languagesUnified.map((language) => (
|
||||
<option key={language.code} value={language.code}>
|
||||
{language.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className={styles.filterSection}>
|
||||
<Label htmlFor="plustier-filter" spaced={false}>
|
||||
{t("lfg:filters.PlusTier")}
|
||||
</Label>
|
||||
<select
|
||||
id="plustier-filter"
|
||||
value={filters.plusTier ?? ""}
|
||||
onChange={(e) =>
|
||||
setFilters({
|
||||
...filters,
|
||||
plusTier: e.target.value === "" ? null : Number(e.target.value),
|
||||
})
|
||||
}
|
||||
>
|
||||
<option value="">{t("common:select.any")}</option>
|
||||
<option value="1">+1</option>
|
||||
<option value="2">+2 {t("lfg:filters.orAbove")}</option>
|
||||
<option value="3">+3 {t("lfg:filters.orAbove")}</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className={styles.filterSection}>
|
||||
<Label htmlFor="mintier-filter" spaced={false}>
|
||||
{t("lfg:filters.MinTier")}
|
||||
</Label>
|
||||
<select
|
||||
id="mintier-filter"
|
||||
value={filters.minTier ?? ""}
|
||||
onChange={(e) =>
|
||||
setFilters({
|
||||
...filters,
|
||||
minTier:
|
||||
e.target.value === "" ? null : (e.target.value as TierName),
|
||||
})
|
||||
}
|
||||
>
|
||||
<option value="">{t("common:select.any")}</option>
|
||||
{TIERS.map((tier) => (
|
||||
<option key={tier.name} value={tier.name}>
|
||||
{R.capitalize(tier.name.toLowerCase())}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className={styles.filterSection}>
|
||||
<Label htmlFor="maxtier-filter" spaced={false}>
|
||||
{t("lfg:filters.MaxTier")}
|
||||
</Label>
|
||||
<select
|
||||
id="maxtier-filter"
|
||||
value={filters.maxTier ?? ""}
|
||||
onChange={(e) =>
|
||||
setFilters({
|
||||
...filters,
|
||||
maxTier:
|
||||
e.target.value === "" ? null : (e.target.value as TierName),
|
||||
})
|
||||
}
|
||||
>
|
||||
<option value="">{t("common:select.any")}</option>
|
||||
{TIERS.map((tier) => (
|
||||
<option key={tier.name} value={tier.name}>
|
||||
{R.capitalize(tier.name.toLowerCase())}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</SideNav>
|
||||
);
|
||||
}
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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 = (
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,13 +1,3 @@
|
|||
.mobileFilterButton {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@media (max-width: 800px) {
|
||||
.mobileFilterButton {
|
||||
display: flex;
|
||||
}
|
||||
}
|
||||
|
||||
.post {
|
||||
scroll-margin-top: 6rem;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<typeof unserializeTiers>;
|
|||
const unserializeTiers = (data: SerializeFrom<typeof loader>) =>
|
||||
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<typeof loader>();
|
||||
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<LFGFiltersState>(filterFromSearch);
|
||||
const setFilters = (x: LFGFiltersState) => {
|
||||
setFilterFromSearch(x);
|
||||
const [filters, _setFilters] = React.useState<LFGFilter[]>(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 (
|
||||
<Main
|
||||
className="stack xl"
|
||||
sideNav={<LFGFiltersSideNav filters={filters} setFilters={setFilters} />}
|
||||
>
|
||||
<Main className="stack xl">
|
||||
<div className="stack sm horizontal justify-end">
|
||||
<SideNavPanel
|
||||
trigger={
|
||||
<SendouButton
|
||||
variant="outlined"
|
||||
size="small"
|
||||
icon={<Funnel />}
|
||||
className={styles.mobileFilterButton}
|
||||
>
|
||||
{t("lfg:filters.button")}
|
||||
{activeFilterCount > 0 ? ` (${activeFilterCount})` : null}
|
||||
</SendouButton>
|
||||
}
|
||||
>
|
||||
<LFGFiltersSideNav
|
||||
filters={filters}
|
||||
setFilters={setFilters}
|
||||
showClose
|
||||
/>
|
||||
</SideNavPanel>
|
||||
<LFGAddFilterButton
|
||||
addFilter={(newFilter) => setFilters([...filters, newFilter])}
|
||||
filters={filters}
|
||||
/>
|
||||
<AddNewButton navIcon="lfg" to={lfgNewPostPage()} />
|
||||
</div>
|
||||
<LFGFilters
|
||||
filters={filters}
|
||||
changeFilter={(newFilter) =>
|
||||
setFilters(
|
||||
filters.map((filter) =>
|
||||
filter._tag === newFilter._tag ? newFilter : filter,
|
||||
),
|
||||
)
|
||||
}
|
||||
removeFilterByTag={(tag) =>
|
||||
setFilters(filters.filter((filter) => filter._tag !== tag))
|
||||
}
|
||||
/>
|
||||
{filteredPosts.map((post) => (
|
||||
<div
|
||||
key={post.id}
|
||||
|
|
|
|||
|
|
@ -342,6 +342,9 @@
|
|||
"settings.customTheme.selectors": "",
|
||||
"settings.customTheme.spacings": "",
|
||||
"settings.customTheme.borderWidth": "",
|
||||
"settings.customTheme.shareCode": "",
|
||||
"settings.customTheme.copy": "",
|
||||
"settings.customTheme.paste": "",
|
||||
"settings.customTheme.patreonText": "",
|
||||
"settings.customTheme.joinPatreon": "",
|
||||
"clockFormat.auto": "",
|
||||
|
|
|
|||
|
|
@ -8,8 +8,7 @@
|
|||
"post.lastActive": "sidst aktiv",
|
||||
"noPosts": "Ingen opslag passer dette filter",
|
||||
"expiring": "Opslag er ved at udløbe, stadigvæk interresteret?",
|
||||
"filters.header": "",
|
||||
"filters.button": "",
|
||||
"addFilter": "",
|
||||
"filters.Weapon": "Våbenpulje",
|
||||
"filters.Type": "Opslagstype",
|
||||
"filters.Timezone": "Tidszoneforskel",
|
||||
|
|
|
|||
|
|
@ -342,6 +342,9 @@
|
|||
"settings.customTheme.selectors": "",
|
||||
"settings.customTheme.spacings": "",
|
||||
"settings.customTheme.borderWidth": "",
|
||||
"settings.customTheme.shareCode": "",
|
||||
"settings.customTheme.copy": "",
|
||||
"settings.customTheme.paste": "",
|
||||
"settings.customTheme.patreonText": "",
|
||||
"settings.customTheme.joinPatreon": "",
|
||||
"clockFormat.auto": "",
|
||||
|
|
|
|||
|
|
@ -8,8 +8,7 @@
|
|||
"post.lastActive": "",
|
||||
"noPosts": "",
|
||||
"expiring": "",
|
||||
"filters.header": "",
|
||||
"filters.button": "",
|
||||
"addFilter": "",
|
||||
"filters.Weapon": "",
|
||||
"filters.Type": "",
|
||||
"filters.Timezone": "",
|
||||
|
|
|
|||
|
|
@ -8,8 +8,7 @@
|
|||
"post.lastActive": "updated",
|
||||
"noPosts": "No posts matching the filter",
|
||||
"expiring": "Post is expiring. Still looking?",
|
||||
"filters.header": "Filter",
|
||||
"filters.button": "Filters",
|
||||
"addFilter": "Add filter",
|
||||
"filters.Weapon": "Weapon pool",
|
||||
"filters.Type": "Post type",
|
||||
"filters.Timezone": "Timezone hour difference",
|
||||
|
|
|
|||
|
|
@ -344,6 +344,9 @@
|
|||
"settings.customTheme.selectors": "",
|
||||
"settings.customTheme.spacings": "",
|
||||
"settings.customTheme.borderWidth": "",
|
||||
"settings.customTheme.shareCode": "",
|
||||
"settings.customTheme.copy": "",
|
||||
"settings.customTheme.paste": "",
|
||||
"settings.customTheme.patreonText": "",
|
||||
"settings.customTheme.joinPatreon": "",
|
||||
"clockFormat.auto": "",
|
||||
|
|
|
|||
|
|
@ -8,8 +8,7 @@
|
|||
"post.lastActive": "hace",
|
||||
"noPosts": "No posts matching the filter",
|
||||
"expiring": "La publicación caduca. ¿Sigues buscando?",
|
||||
"filters.header": "",
|
||||
"filters.button": "",
|
||||
"addFilter": "",
|
||||
"filters.Weapon": "Grupo de armas",
|
||||
"filters.Type": "Tipo de publicación",
|
||||
"filters.Timezone": "Diferencia horaria",
|
||||
|
|
|
|||
|
|
@ -344,6 +344,9 @@
|
|||
"settings.customTheme.selectors": "",
|
||||
"settings.customTheme.spacings": "",
|
||||
"settings.customTheme.borderWidth": "",
|
||||
"settings.customTheme.shareCode": "",
|
||||
"settings.customTheme.copy": "",
|
||||
"settings.customTheme.paste": "",
|
||||
"settings.customTheme.patreonText": "",
|
||||
"settings.customTheme.joinPatreon": "",
|
||||
"clockFormat.auto": "",
|
||||
|
|
|
|||
|
|
@ -8,8 +8,7 @@
|
|||
"post.lastActive": "hace",
|
||||
"noPosts": "No posts matching the filter",
|
||||
"expiring": "La publicación caduca. ¿Sigues buscando?",
|
||||
"filters.header": "",
|
||||
"filters.button": "",
|
||||
"addFilter": "",
|
||||
"filters.Weapon": "Grupo de armas",
|
||||
"filters.Type": "Tipo de publicación",
|
||||
"filters.Timezone": "Diferencia horaria",
|
||||
|
|
|
|||
|
|
@ -344,6 +344,9 @@
|
|||
"settings.customTheme.selectors": "",
|
||||
"settings.customTheme.spacings": "",
|
||||
"settings.customTheme.borderWidth": "",
|
||||
"settings.customTheme.shareCode": "",
|
||||
"settings.customTheme.copy": "",
|
||||
"settings.customTheme.paste": "",
|
||||
"settings.customTheme.patreonText": "",
|
||||
"settings.customTheme.joinPatreon": "",
|
||||
"clockFormat.auto": "",
|
||||
|
|
|
|||
|
|
@ -8,8 +8,7 @@
|
|||
"post.lastActive": "",
|
||||
"noPosts": "",
|
||||
"expiring": "",
|
||||
"filters.header": "",
|
||||
"filters.button": "",
|
||||
"addFilter": "",
|
||||
"filters.Weapon": "",
|
||||
"filters.Type": "",
|
||||
"filters.Timezone": "",
|
||||
|
|
|
|||
|
|
@ -344,6 +344,9 @@
|
|||
"settings.customTheme.selectors": "",
|
||||
"settings.customTheme.spacings": "",
|
||||
"settings.customTheme.borderWidth": "",
|
||||
"settings.customTheme.shareCode": "",
|
||||
"settings.customTheme.copy": "",
|
||||
"settings.customTheme.paste": "",
|
||||
"settings.customTheme.patreonText": "",
|
||||
"settings.customTheme.joinPatreon": "",
|
||||
"clockFormat.auto": "",
|
||||
|
|
|
|||
|
|
@ -8,8 +8,7 @@
|
|||
"post.lastActive": "Mis à jour",
|
||||
"noPosts": "Aucun message correspondant au filtre",
|
||||
"expiring": "Le message expire. Tu cherches toujours ?",
|
||||
"filters.header": "",
|
||||
"filters.button": "",
|
||||
"addFilter": "",
|
||||
"filters.Weapon": "Arme utilisé",
|
||||
"filters.Type": "Type de publication",
|
||||
"filters.Timezone": "Différence horaire entre les heures",
|
||||
|
|
|
|||
|
|
@ -343,6 +343,9 @@
|
|||
"settings.customTheme.selectors": "",
|
||||
"settings.customTheme.spacings": "",
|
||||
"settings.customTheme.borderWidth": "",
|
||||
"settings.customTheme.shareCode": "",
|
||||
"settings.customTheme.copy": "",
|
||||
"settings.customTheme.paste": "",
|
||||
"settings.customTheme.patreonText": "",
|
||||
"settings.customTheme.joinPatreon": "",
|
||||
"clockFormat.auto": "",
|
||||
|
|
|
|||
|
|
@ -8,8 +8,7 @@
|
|||
"post.lastActive": "",
|
||||
"noPosts": "",
|
||||
"expiring": "",
|
||||
"filters.header": "",
|
||||
"filters.button": "",
|
||||
"addFilter": "",
|
||||
"filters.Weapon": "",
|
||||
"filters.Type": "",
|
||||
"filters.Timezone": "",
|
||||
|
|
|
|||
|
|
@ -344,6 +344,9 @@
|
|||
"settings.customTheme.selectors": "",
|
||||
"settings.customTheme.spacings": "",
|
||||
"settings.customTheme.borderWidth": "",
|
||||
"settings.customTheme.shareCode": "",
|
||||
"settings.customTheme.copy": "",
|
||||
"settings.customTheme.paste": "",
|
||||
"settings.customTheme.patreonText": "",
|
||||
"settings.customTheme.joinPatreon": "",
|
||||
"clockFormat.auto": "",
|
||||
|
|
|
|||
|
|
@ -8,8 +8,7 @@
|
|||
"post.lastActive": "aggiornato",
|
||||
"noPosts": "Nessun post per questo filtro",
|
||||
"expiring": "Il post sta scadendo. Stai ancora cercando?",
|
||||
"filters.header": "",
|
||||
"filters.button": "",
|
||||
"addFilter": "",
|
||||
"filters.Weapon": "Pool di armi",
|
||||
"filters.Type": "Tipo di post",
|
||||
"filters.Timezone": "Differenza per fuso orario",
|
||||
|
|
|
|||
|
|
@ -338,6 +338,9 @@
|
|||
"settings.customTheme.selectors": "",
|
||||
"settings.customTheme.spacings": "",
|
||||
"settings.customTheme.borderWidth": "",
|
||||
"settings.customTheme.shareCode": "",
|
||||
"settings.customTheme.copy": "",
|
||||
"settings.customTheme.paste": "",
|
||||
"settings.customTheme.patreonText": "",
|
||||
"settings.customTheme.joinPatreon": "",
|
||||
"clockFormat.auto": "",
|
||||
|
|
|
|||
|
|
@ -8,8 +8,7 @@
|
|||
"post.lastActive": "最後にログインした時",
|
||||
"noPosts": "フィルターに該当するポーストはありません",
|
||||
"expiring": "ポーストの期限が切れます。まだ探していますか?",
|
||||
"filters.header": "",
|
||||
"filters.button": "",
|
||||
"addFilter": "",
|
||||
"filters.Weapon": "武器プール",
|
||||
"filters.Type": "ポーストの種類",
|
||||
"filters.Timezone": "タイムゾーン(何時間違うか)",
|
||||
|
|
|
|||
|
|
@ -338,6 +338,9 @@
|
|||
"settings.customTheme.selectors": "",
|
||||
"settings.customTheme.spacings": "",
|
||||
"settings.customTheme.borderWidth": "",
|
||||
"settings.customTheme.shareCode": "",
|
||||
"settings.customTheme.copy": "",
|
||||
"settings.customTheme.paste": "",
|
||||
"settings.customTheme.patreonText": "",
|
||||
"settings.customTheme.joinPatreon": "",
|
||||
"clockFormat.auto": "",
|
||||
|
|
|
|||
|
|
@ -8,8 +8,7 @@
|
|||
"post.lastActive": "",
|
||||
"noPosts": "",
|
||||
"expiring": "",
|
||||
"filters.header": "",
|
||||
"filters.button": "",
|
||||
"addFilter": "",
|
||||
"filters.Weapon": "",
|
||||
"filters.Type": "",
|
||||
"filters.Timezone": "",
|
||||
|
|
|
|||
|
|
@ -342,6 +342,9 @@
|
|||
"settings.customTheme.selectors": "",
|
||||
"settings.customTheme.spacings": "",
|
||||
"settings.customTheme.borderWidth": "",
|
||||
"settings.customTheme.shareCode": "",
|
||||
"settings.customTheme.copy": "",
|
||||
"settings.customTheme.paste": "",
|
||||
"settings.customTheme.patreonText": "",
|
||||
"settings.customTheme.joinPatreon": "",
|
||||
"clockFormat.auto": "",
|
||||
|
|
|
|||
|
|
@ -8,8 +8,7 @@
|
|||
"post.lastActive": "",
|
||||
"noPosts": "",
|
||||
"expiring": "",
|
||||
"filters.header": "",
|
||||
"filters.button": "",
|
||||
"addFilter": "",
|
||||
"filters.Weapon": "",
|
||||
"filters.Type": "",
|
||||
"filters.Timezone": "",
|
||||
|
|
|
|||
|
|
@ -345,6 +345,9 @@
|
|||
"settings.customTheme.selectors": "",
|
||||
"settings.customTheme.spacings": "",
|
||||
"settings.customTheme.borderWidth": "",
|
||||
"settings.customTheme.shareCode": "",
|
||||
"settings.customTheme.copy": "",
|
||||
"settings.customTheme.paste": "",
|
||||
"settings.customTheme.patreonText": "",
|
||||
"settings.customTheme.joinPatreon": "",
|
||||
"clockFormat.auto": "",
|
||||
|
|
|
|||
|
|
@ -8,8 +8,7 @@
|
|||
"post.lastActive": "",
|
||||
"noPosts": "",
|
||||
"expiring": "",
|
||||
"filters.header": "",
|
||||
"filters.button": "",
|
||||
"addFilter": "",
|
||||
"filters.Weapon": "",
|
||||
"filters.Type": "",
|
||||
"filters.Timezone": "",
|
||||
|
|
|
|||
|
|
@ -344,6 +344,9 @@
|
|||
"settings.customTheme.selectors": "",
|
||||
"settings.customTheme.spacings": "",
|
||||
"settings.customTheme.borderWidth": "",
|
||||
"settings.customTheme.shareCode": "",
|
||||
"settings.customTheme.copy": "",
|
||||
"settings.customTheme.paste": "",
|
||||
"settings.customTheme.patreonText": "",
|
||||
"settings.customTheme.joinPatreon": "",
|
||||
"clockFormat.auto": "",
|
||||
|
|
|
|||
|
|
@ -8,8 +8,7 @@
|
|||
"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?",
|
||||
"filters.header": "",
|
||||
"filters.button": "",
|
||||
"addFilter": "",
|
||||
"filters.Weapon": "Pool de armas",
|
||||
"filters.Type": "Tipo de postagem",
|
||||
"filters.Timezone": "Diferença de horas pelo fuso horário",
|
||||
|
|
|
|||
|
|
@ -345,6 +345,9 @@
|
|||
"settings.customTheme.selectors": "",
|
||||
"settings.customTheme.spacings": "",
|
||||
"settings.customTheme.borderWidth": "",
|
||||
"settings.customTheme.shareCode": "",
|
||||
"settings.customTheme.copy": "",
|
||||
"settings.customTheme.paste": "",
|
||||
"settings.customTheme.patreonText": "",
|
||||
"settings.customTheme.joinPatreon": "",
|
||||
"clockFormat.auto": "",
|
||||
|
|
|
|||
|
|
@ -8,8 +8,7 @@
|
|||
"post.lastActive": "обновлено",
|
||||
"noPosts": "Посты, соответствующие фильтрам, не найдены",
|
||||
"expiring": "Срок действия поста истекает. Ещё ищете?",
|
||||
"filters.header": "",
|
||||
"filters.button": "",
|
||||
"addFilter": "",
|
||||
"filters.Weapon": "Пул оружия",
|
||||
"filters.Type": "Тип поста",
|
||||
"filters.Timezone": "Разница в часовых поясах",
|
||||
|
|
|
|||
|
|
@ -338,6 +338,9 @@
|
|||
"settings.customTheme.selectors": "",
|
||||
"settings.customTheme.spacings": "",
|
||||
"settings.customTheme.borderWidth": "",
|
||||
"settings.customTheme.shareCode": "",
|
||||
"settings.customTheme.copy": "",
|
||||
"settings.customTheme.paste": "",
|
||||
"settings.customTheme.patreonText": "",
|
||||
"settings.customTheme.joinPatreon": "",
|
||||
"clockFormat.auto": "",
|
||||
|
|
|
|||
|
|
@ -8,8 +8,7 @@
|
|||
"post.lastActive": "最后活跃",
|
||||
"noPosts": "没有招募信息符合条件",
|
||||
"expiring": "招募信息即将过期。还在招募吗?",
|
||||
"filters.header": "",
|
||||
"filters.button": "",
|
||||
"addFilter": "",
|
||||
"filters.Weapon": "武器池",
|
||||
"filters.Type": "招募类型",
|
||||
"filters.Timezone": "时差",
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user