Restore /lfg filters code from main

This commit is contained in:
Kalle 2026-03-07 07:38:24 +02:00
parent 5e4a66477e
commit aae39b7cc3
44 changed files with 671 additions and 1051 deletions

View File

@ -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;

View File

@ -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>;
}

View 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>
);
}

View File

@ -0,0 +1,5 @@
.filter {
padding: var(--s-1-5) var(--s-2);
background-color: var(--bg-lighter);
border-radius: var(--rounded);
}

View 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>
);
}

View File

@ -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);
}

View File

@ -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>
);
}

View File

@ -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);
});
});

View File

@ -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 = (

View File

@ -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);
});
});

View File

@ -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;
}

View File

@ -1,13 +1,3 @@
.mobileFilterButton {
display: none;
}
@media (max-width: 800px) {
.mobileFilterButton {
display: flex;
}
}
.post {
scroll-margin-top: 6rem;
}

View File

@ -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}

View File

@ -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": "",

View File

@ -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",

View File

@ -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": "",

View File

@ -8,8 +8,7 @@
"post.lastActive": "",
"noPosts": "",
"expiring": "",
"filters.header": "",
"filters.button": "",
"addFilter": "",
"filters.Weapon": "",
"filters.Type": "",
"filters.Timezone": "",

View File

@ -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",

View File

@ -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": "",

View File

@ -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",

View File

@ -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": "",

View File

@ -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",

View File

@ -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": "",

View File

@ -8,8 +8,7 @@
"post.lastActive": "",
"noPosts": "",
"expiring": "",
"filters.header": "",
"filters.button": "",
"addFilter": "",
"filters.Weapon": "",
"filters.Type": "",
"filters.Timezone": "",

View File

@ -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": "",

View File

@ -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",

View File

@ -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": "",

View File

@ -8,8 +8,7 @@
"post.lastActive": "",
"noPosts": "",
"expiring": "",
"filters.header": "",
"filters.button": "",
"addFilter": "",
"filters.Weapon": "",
"filters.Type": "",
"filters.Timezone": "",

View File

@ -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": "",

View File

@ -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",

View File

@ -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": "",

View File

@ -8,8 +8,7 @@
"post.lastActive": "最後にログインした時",
"noPosts": "フィルターに該当するポーストはありません",
"expiring": "ポーストの期限が切れます。まだ探していますか?",
"filters.header": "",
"filters.button": "",
"addFilter": "",
"filters.Weapon": "武器プール",
"filters.Type": "ポーストの種類",
"filters.Timezone": "タイムゾーン(何時間違うか)",

View File

@ -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": "",

View File

@ -8,8 +8,7 @@
"post.lastActive": "",
"noPosts": "",
"expiring": "",
"filters.header": "",
"filters.button": "",
"addFilter": "",
"filters.Weapon": "",
"filters.Type": "",
"filters.Timezone": "",

View File

@ -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": "",

View File

@ -8,8 +8,7 @@
"post.lastActive": "",
"noPosts": "",
"expiring": "",
"filters.header": "",
"filters.button": "",
"addFilter": "",
"filters.Weapon": "",
"filters.Type": "",
"filters.Timezone": "",

View File

@ -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": "",

View File

@ -8,8 +8,7 @@
"post.lastActive": "",
"noPosts": "",
"expiring": "",
"filters.header": "",
"filters.button": "",
"addFilter": "",
"filters.Weapon": "",
"filters.Type": "",
"filters.Timezone": "",

View File

@ -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": "",

View File

@ -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",

View File

@ -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": "",

View File

@ -8,8 +8,7 @@
"post.lastActive": "обновлено",
"noPosts": "Посты, соответствующие фильтрам, не найдены",
"expiring": "Срок действия поста истекает. Ещё ищете?",
"filters.header": "",
"filters.button": "",
"addFilter": "",
"filters.Weapon": "Пул оружия",
"filters.Type": "Тип поста",
"filters.Timezone": "Разница в часовых поясах",

View File

@ -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": "",

View File

@ -8,8 +8,7 @@
"post.lastActive": "最后活跃",
"noPosts": "没有招募信息符合条件",
"expiring": "招募信息即将过期。还在招募吗?",
"filters.header": "",
"filters.button": "",
"addFilter": "",
"filters.Weapon": "武器池",
"filters.Type": "招募类型",
"filters.Timezone": "时差",