* Initial

* Can post new

* Load team

* seed + loader posts

* LFGPost render initial

* More UI work

* Tiers

* sticky left

* Mobile

* new.tsx work

* TeamLFGPost component initial

* Full team member list

* Add TODO

* Delete post action

* Edit post etc.

* Delete team posts when team disbands

* Prevent adding same post type twice in UI

* Post expiry logic

* Fix layout shift

* Filters initial

* Progress

* Weapon filtered implemented

* Weapon alt kits in filtering

* + visibility

* i18n

* E2E test

* Team = null
This commit is contained in:
Kalle 2024-05-19 13:43:59 +03:00 committed by GitHub
parent 335fe487d2
commit 4beb2bdfdd
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
35 changed files with 2573 additions and 3 deletions

View File

@ -10,6 +10,7 @@ import {
stageImageUrl,
tierImageUrl,
} from "~/utils/urls";
import clsx from "clsx";
interface ImageProps {
path: string;
@ -143,13 +144,13 @@ type TierImageProps = {
tier: { name: TierName; isPlus: boolean };
} & Omit<ImageProps, "path" | "alt" | "title" | "size" | "height">;
export function TierImage({ tier, width = 200 }: TierImageProps) {
export function TierImage({ tier, className, width = 200 }: TierImageProps) {
const title = `${tier.name}${tier.isPlus ? "+" : ""}`;
const height = width * 0.8675;
return (
<div className="tier__container" style={{ width }}>
<div className={clsx("tier__container", className)} style={{ width }}>
<Image
path={tierImageUrl(tier.name)}
width={width}

View File

@ -44,6 +44,15 @@
"invert(26%) sepia(43%) saturate(7146%) hue-rotate(314deg) brightness(100%) contrast(98%)"
]
},
{
"name": "lfg",
"url": "lfg",
"prefetch": false,
"filters": [
"invert(50%) sepia(98%) saturate(1745%) hue-rotate(175deg) brightness(96%) contrast(94%)",
"invert(27%) sepia(6%) saturate(919%) hue-rotate(211deg) brightness(97%) contrast(83%)"
]
},
{
"name": "plans",
"url": "plans",

View File

@ -38,6 +38,7 @@ import * as PlusVotingRepository from "~/features/plus-voting/PlusVotingReposito
import * as QRepository from "~/features/sendouq/QRepository.server";
import * as QMatchRepository from "~/features/sendouq-match/QMatchRepository.server";
import * as QSettingsRepository from "~/features/sendouq-settings/QSettingsRepository.server";
import * as LFGRepository from "~/features/lfg/LFGRepository.server";
import { calculateMatchSkills } from "~/features/sendouq/core/skills.server";
import {
summarizeMaps,
@ -71,6 +72,7 @@ import { BANNED_MAPS } from "~/features/sendouq-settings/banned-maps";
import { AMOUNT_OF_MAPS_IN_POOL_PER_MODE } from "~/features/sendouq-settings/q-settings-constants";
import { tags } from "~/features/calendar/calendar-constants";
import { SENDOUQ_DEFAULT_MAPS } from "~/modules/tournament-map-list-generator/constants";
import { TIMEZONES } from "~/features/lfg/lfg-constants";
const calendarEventWithToToolsRegOpen = () =>
calendarEventWithToTools("PICNIC", true);
@ -155,6 +157,7 @@ const basicSeeds = (variation?: SeedVariation | null) => [
playedMatches,
groups,
friendCodes,
lfgPosts,
];
export async function seed(variation?: SeedVariation | null) {
@ -175,6 +178,7 @@ export async function seed(variation?: SeedVariation | null) {
function wipeDB() {
const tablesToDelete = [
"LFGPost",
"Skill",
"ReportedWeapon",
"GroupMatchMap",
@ -2152,3 +2156,26 @@ async function friendCodes() {
});
}
}
async function lfgPosts() {
const allUsers = userIdsInRandomOrder(true).slice(0, 100);
allUsers.unshift(NZAP_TEST_ID);
for (const user of allUsers) {
await LFGRepository.insertPost({
authorId: user,
text: faker.lorem.paragraphs({ min: 1, max: 6 }),
timezone: faker.helpers.arrayElement(TIMEZONES),
type: faker.helpers.arrayElement(["PLAYER_FOR_TEAM", "COACH_FOR_TEAM"]),
});
}
await LFGRepository.insertPost({
authorId: ADMIN_ID,
text: faker.lorem.paragraphs({ min: 1, max: 6 }),
timezone: "Europe/Helsinki",
type: "TEAM_FOR_PLAYER",
teamId: 1,
});
}

View File

@ -255,6 +255,23 @@ export interface LogInLink {
userId: number;
}
export interface LFGPost {
id: GeneratedAlways<number>;
type:
| "PLAYER_FOR_TEAM"
| "TEAM_FOR_PLAYER"
| "TEAM_FOR_COACH"
| "COACH_FOR_TEAM";
text: string;
/** e.g. Europe/Helsinki */
timezone: string;
authorId: number;
teamId: number | null;
plusTierVisibility: number | null;
updatedAt: Generated<number>;
createdAt: GeneratedAlways<number>;
}
export interface MapPoolMap {
calendarEventId: number | null;
mode: ModeShort;
@ -753,6 +770,7 @@ export interface DB {
GroupMember: GroupMember;
PrivateUserNote: PrivateUserNote;
LogInLink: LogInLink;
LFGPost: LFGPost;
MapPoolMap: MapPoolMap;
MapResult: MapResult;
migrations: Migrations;

View File

@ -0,0 +1,148 @@
import { sub } from "date-fns";
import { sql, type NotNull } from "kysely";
import { jsonArrayFrom, jsonObjectFrom } from "kysely/helpers/sqlite";
import { db } from "~/db/sql";
import type { TablesInsertable } from "~/db/tables";
import { databaseTimestampNow, dateToDatabaseTimestamp } from "~/utils/dates";
import { COMMON_USER_FIELDS } from "~/utils/kysely.server";
import { LFG } from "./lfg-constants";
export async function posts(user?: { id: number; plusTier: number | null }) {
// "-1" won't match any user
const userId = user?.id ?? -1;
const rows = await db
.selectFrom("LFGPost")
.select(({ eb }) => [
"LFGPost.id",
"LFGPost.timezone",
"LFGPost.type",
"LFGPost.text",
"LFGPost.createdAt",
"LFGPost.updatedAt",
"LFGPost.plusTierVisibility",
jsonObjectFrom(
eb
.selectFrom("User")
.leftJoin("PlusTier", "PlusTier.userId", "User.id")
.select(({ eb: innerEb }) => [
...COMMON_USER_FIELDS,
"User.languages",
"User.country",
"PlusTier.tier as plusTier",
jsonArrayFrom(
innerEb
.selectFrom("UserWeapon")
.whereRef("UserWeapon.userId", "=", "User.id")
.orderBy("UserWeapon.order asc")
.select(["UserWeapon.weaponSplId", "UserWeapon.isFavorite"]),
).as("weaponPool"),
])
.whereRef("User.id", "=", "LFGPost.authorId"),
).as("author"),
jsonObjectFrom(
eb
.selectFrom("Team")
.leftJoin(
"UserSubmittedImage",
"UserSubmittedImage.id",
"Team.avatarImgId",
)
.select(({ eb: innerEb }) => [
"Team.id",
"Team.name",
"UserSubmittedImage.url as avatarUrl",
jsonArrayFrom(
innerEb
.selectFrom(["TeamMember"])
.innerJoin("User", "User.id", "TeamMember.userId")
.leftJoin("PlusTier", "PlusTier.userId", "User.id")
.select(({ eb: innestEb }) => [
...COMMON_USER_FIELDS,
"User.languages",
"User.country",
"PlusTier.tier as plusTier",
jsonArrayFrom(
innestEb
.selectFrom("UserWeapon")
.whereRef("UserWeapon.userId", "=", "User.id")
.orderBy("UserWeapon.order asc")
.select([
"UserWeapon.weaponSplId",
"UserWeapon.isFavorite",
]),
).as("weaponPool"),
])
.whereRef("TeamMember.teamId", "=", "Team.id"),
).as("members"),
])
.whereRef("Team.id", "=", "LFGPost.teamId"),
).as("team"),
])
.orderBy(sql`LFGPost.authorId = ${sql`${userId}`} desc`)
.orderBy("LFGPost.updatedAt desc")
.where((eb) =>
eb.or([
eb(
"LFGPost.updatedAt",
">",
dateToDatabaseTimestamp(postExpiryCutoff()),
),
eb("LFGPost.authorId", "=", userId),
]),
)
.$narrowType<{ author: NotNull }>()
.execute();
return rows.filter((row) => {
if (!row.plusTierVisibility) return true;
if (!user?.plusTier) return false;
return row.plusTierVisibility >= user.plusTier;
});
}
const postExpiryCutoff = () =>
sub(new Date(), { days: LFG.POST_FRESHNESS_DAYS });
export function insertPost(
args: Omit<TablesInsertable["LFGPost"], "updatedAt">,
) {
return db.insertInto("LFGPost").values(args).execute();
}
export function updatePost(
postId: number,
args: Omit<TablesInsertable["LFGPost"], "updatedAt" | "authorId">,
) {
return db
.updateTable("LFGPost")
.set({
teamId: args.teamId,
text: args.text,
timezone: args.timezone,
type: args.type,
plusTierVisibility: args.plusTierVisibility,
updatedAt: dateToDatabaseTimestamp(new Date()),
})
.where("id", "=", postId)
.execute();
}
export function bumpPost(postId: number) {
return db
.updateTable("LFGPost")
.set({
updatedAt: databaseTimestampNow(),
})
.where("id", "=", postId)
.execute();
}
export function deletePost(id: number) {
return db.deleteFrom("LFGPost").where("id", "=", id).execute();
}
export function deletePostsByTeamId(teamId: number) {
return db.deleteFrom("LFGPost").where("teamId", "=", teamId).execute();
}

View File

@ -0,0 +1,78 @@
import { z } from "zod";
import { TEAM_POST_TYPES, LFG, TIMEZONES } from "../lfg-constants";
import type { ActionFunctionArgs } from "@remix-run/node";
import { redirect } from "@remix-run/node";
import { requireUser } from "~/features/auth/core/user.server";
import { parseRequestFormData, validate } from "~/utils/remix";
import { LFG_PAGE } from "~/utils/urls";
import * as LFGRepository from "../LFGRepository.server";
import * as UserRepository from "~/features/user-page/UserRepository.server";
import { falsyToNull, id } from "~/utils/zod";
export const action = async ({ request }: ActionFunctionArgs) => {
const user = await requireUser(request);
const data = await parseRequestFormData({
request,
schema,
});
const identifier = String(user.id);
const { team } = (await UserRepository.findByIdentifier(identifier)) ?? {};
const shouldIncludeTeam = TEAM_POST_TYPES.includes(data.type);
validate(
!shouldIncludeTeam || team,
"Team needs to be set for this type of post",
);
if (data.postId) {
await validateCanUpdatePost({
postId: data.postId,
user,
});
await LFGRepository.updatePost(data.postId, {
text: data.postText,
timezone: data.timezone,
type: data.type,
teamId: shouldIncludeTeam ? team?.id : null,
plusTierVisibility: data.plusTierVisibility,
});
} else {
await LFGRepository.insertPost({
text: data.postText,
timezone: data.timezone,
type: data.type,
teamId: shouldIncludeTeam ? team?.id : null,
authorId: user.id,
plusTierVisibility: data.plusTierVisibility,
});
}
return redirect(LFG_PAGE);
};
const schema = z.object({
postId: id.optional(),
type: z.enum(LFG.types),
postText: z.string().min(LFG.MIN_TEXT_LENGTH).max(LFG.MAX_TEXT_LENGTH),
timezone: z.string().refine((val) => TIMEZONES.includes(val)),
plusTierVisibility: z.preprocess(
falsyToNull,
z.coerce.number().int().min(1).max(3).nullish(),
),
});
const validateCanUpdatePost = async ({
postId,
user,
}: {
postId: number;
user: { id: number; plusTier: number | null };
}) => {
const posts = await LFGRepository.posts(user);
const post = posts.find((post) => post.id === postId);
validate(post, "Post to update not found");
validate(post.author.id === user.id, "You can only update your own posts");
};

View File

@ -0,0 +1,44 @@
import type { ActionFunctionArgs } from "@remix-run/node";
import { z } from "zod";
import { requireUser } from "~/features/auth/core/user.server";
import { parseRequestFormData, validate } from "~/utils/remix";
import { _action, id } from "~/utils/zod";
import * as LFGRepository from "../LFGRepository.server";
import { isAdmin } from "~/permissions";
export const action = async ({ request }: ActionFunctionArgs) => {
const user = await requireUser(request);
const data = await parseRequestFormData({
request,
schema,
});
const posts = await LFGRepository.posts(user);
const post = posts.find((post) => post.id === data.id);
validate(post, "Post not found");
validate(isAdmin(user) || post.author.id === user.id, "Not your own post");
switch (data._action) {
case "DELETE_POST": {
await LFGRepository.deletePost(data.id);
break;
}
case "BUMP_POST": {
await LFGRepository.bumpPost(data.id);
break;
}
}
return null;
};
const schema = z.union([
z.object({
_action: _action("DELETE_POST"),
id,
}),
z.object({
_action: _action("BUMP_POST"),
id,
}),
]);

View File

@ -0,0 +1,55 @@
import { Button } from "~/components/Button";
import { Menu } from "~/components/Menu";
import * as React from "react";
import { FilterIcon } from "~/components/icons/Filter";
import type { LFGFilter } from "../lfg-types";
import { useTranslation } from "react-i18next";
const FilterMenuButton = React.forwardRef(function (props, ref) {
const { t } = useTranslation(["lfg"]);
return (
<Button
variant="outlined"
size="tiny"
icon={<FilterIcon />}
testId="add-filter-button"
{...props}
_ref={ref}
>
{t("lfg:addFilter")}
</Button>
);
});
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 (
<Menu
items={Object.entries(defaultFilters).map(([tag, defaultFilter]) => ({
id: tag,
text: t(`lfg:filters.${tag}`),
disabled: filters.some((filter) => filter._tag === tag),
onClick: () => addFilter(defaultFilter),
}))}
button={FilterMenuButton}
/>
);
}

View File

@ -0,0 +1,294 @@
import { Label } from "~/components/Label";
import type { LFGFilter } from "../lfg-types";
import { Button } from "~/components/Button";
import { CrossIcon } from "~/components/icons/Cross";
import type { Tables } from "~/db/tables";
import { LFG } from "../lfg-constants";
import { useTranslation } from "react-i18next";
import { languagesUnified } from "~/modules/i18n/config";
import type { TierName } from "~/features/mmr/mmr-constants";
import { TIERS } from "~/features/mmr/mmr-constants";
import { capitalize } from "~/utils/strings";
import type { MainWeaponId } from "~/modules/in-game-lists";
import { WeaponCombobox } from "~/components/Combobox";
import { WeaponImage } from "~/components/Image";
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>
{t(`lfg:filters.${filter._tag}`)} {t("lfg:filters.suffix")}
</Label>
<Button
icon={<CrossIcon />}
size="tiny"
variant="minimal-destructive"
onClick={removeFilter}
aria-label="Delete filter"
/>
</div>
<div className="lfg__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">
<WeaponCombobox
inputName="weapon"
key={value.length}
weaponIdsToOmit={new Set(value)}
onChange={(wpn) =>
wpn &&
changeFilter({
_tag: "Weapon",
weaponSplIds:
value.length >= 10
? [...value.slice(1, 10), Number(wpn.value) as MainWeaponId]
: [...value, Number(wpn.value) as MainWeaponId],
})
}
/>
{value.map((weapon) => (
<Button
key={weapon}
variant="minimal"
onClick={() =>
changeFilter({
_tag: "Weapon",
weaponSplIds: value.filter((weaponId) => weaponId !== weapon),
})
}
>
<WeaponImage weaponSplId={weapon} size={32} variant="badge" />
</Button>
))}
</div>
);
}
function TypeFilterFields({
value,
changeFilter,
}: {
value: Tables["LFGPost"]["type"];
changeFilter: (newFilter: LFGFilter) => void;
}) {
const { t } = useTranslation(["lfg"]);
return (
<div>
<select
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
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
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
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
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}>
{capitalize(tier.name.toLowerCase())}
</option>
))}
</select>
</div>
);
}

View File

@ -0,0 +1,514 @@
import { Avatar } from "~/components/Avatar";
import type { LFGLoaderData, TiersMap } from "../routes/lfg";
import { Image, TierImage, WeaponImage } from "~/components/Image";
import { Flag } from "~/components/Flag";
import { Button } from "~/components/Button";
import React from "react";
import clsx from "clsx";
import { hourDifferenceBetweenTimezones } from "../core/timezone";
import { databaseTimestampToDate } from "~/utils/dates";
import { useTranslation } from "react-i18next";
import {
lfgNewPostPage,
navIconUrl,
userPage,
userSubmittedImage,
} from "~/utils/urls";
import { currentOrPreviousSeason } from "~/features/mmr/season";
import type { TieredSkill } from "~/features/mmr/tiered.server";
import { useIsMounted } from "~/hooks/useIsMounted";
import { formatDistanceToNow } from "date-fns";
import { Divider } from "~/components/Divider";
import { Link, useFetcher } from "@remix-run/react";
import { FormWithConfirm } from "~/components/FormWithConfirm";
import { TrashIcon } from "~/components/icons/Trash";
import { useUser } from "~/features/auth/core/user";
import { isAdmin } from "~/permissions";
import { EditIcon } from "~/components/icons/Edit";
type Post = LFGLoaderData["posts"][number];
export function LFGPost({
post,
tiersMap,
}: {
post: Post;
tiersMap: TiersMap;
}) {
if (post.team) {
return (
<TeamLFGPost post={{ ...post, team: post.team }} tiersMap={tiersMap} />
);
}
return <UserLFGPost post={post} tiersMap={tiersMap} />;
}
const USER_POST_EXPANDABLE_CRITERIA = 500;
function UserLFGPost({ post, tiersMap }: { post: Post; tiersMap: TiersMap }) {
const user = useUser();
const [isExpanded, setIsExpanded] = React.useState(false);
return (
<div className="lfg-post__wide-layout">
<div className="lfg-post__wide-layout__left-row">
<PostUserHeader
author={post.author}
includeWeapons={post.type !== "COACH_FOR_TEAM"}
/>
<PostTime createdAt={post.createdAt} updatedAt={post.updatedAt} />
<PostPills
languages={post.author.languages}
plusTier={post.author.plusTier}
timezone={post.timezone}
tiers={
post.type !== "COACH_FOR_TEAM"
? tiersMap.get(post.author.id)
: undefined
}
canEdit={post.author.id === user?.id}
postId={post.id}
/>
</div>
<div>
<div className="stack horizontal justify-between">
<PostTextTypeHeader type={post.type} />
{isAdmin(user) || post.author.id === user?.id ? (
<PostDeleteButton id={post.id} type={post.type} />
) : null}
</div>
<PostExpandableText
text={post.text}
isExpanded={isExpanded}
setIsExpanded={setIsExpanded}
expandableCriteria={USER_POST_EXPANDABLE_CRITERIA}
/>
</div>
</div>
);
}
function TeamLFGPost({
post,
tiersMap,
}: {
post: Post & { team: NonNullable<Post["team"]> };
tiersMap: TiersMap;
}) {
const isMounted = useIsMounted();
const user = useUser();
const [isExpanded, setIsExpanded] = React.useState(false);
return (
<div className="lfg-post__wide-layout">
<div className="stack md">
<div className="stack xs">
<div className="stack horizontal items-center justify-between">
<PostTeamLogoHeader team={post.team} />
{isMounted && <PostTimezonePill timezone={post.timezone} />}
</div>
<Divider />
<div className="stack horizontal justify-between">
<PostTime createdAt={post.createdAt} updatedAt={post.updatedAt} />
{post.author.id === user?.id ? (
<PostEditButton id={post.id} />
) : null}
</div>
</div>
{isExpanded ? (
<PostTeamMembersFull
team={post.team}
tiersMap={tiersMap}
postId={post.id}
/>
) : (
<PostTeamMembersPeek team={post.team} tiersMap={tiersMap} />
)}
</div>
<div>
<div className="stack horizontal justify-between">
<PostTextTypeHeader type={post.type} />
{isAdmin(user) || post.author.id === user?.id ? (
<PostDeleteButton id={post.id} type={post.type} />
) : null}
</div>
<PostExpandableText
text={post.text}
isExpanded={isExpanded}
setIsExpanded={setIsExpanded}
/>
</div>
</div>
);
}
function PostTeamLogoHeader({ team }: { team: NonNullable<Post["team"]> }) {
return (
<div className="stack horizontal sm items-center font-bold">
{team.avatarUrl ? (
<Avatar size="xs" url={userSubmittedImage(team.avatarUrl)} />
) : null}
{team.name}
</div>
);
}
function PostTeamMembersPeek({
team,
tiersMap,
}: {
team: NonNullable<Post["team"]>;
tiersMap: TiersMap;
}) {
return (
<div className="stack sm xs-row horizontal flex-wrap">
{team.members.map((member) => (
<PostTeamMember key={member.id} member={member} tiersMap={tiersMap} />
))}
</div>
);
}
function PostTeamMembersFull({
team,
tiersMap,
postId,
}: {
team: NonNullable<Post["team"]>;
tiersMap: TiersMap;
postId: number;
}) {
return (
<div className="stack lg">
{team.members.map((member) => (
<div key={member.id} className="stack sm">
<PostUserHeader author={member} includeWeapons />
<PostPills
languages={member.languages}
plusTier={member.plusTier}
tiers={tiersMap.get(member.id)}
postId={postId}
/>
</div>
))}
</div>
);
}
function PostTeamMember({
member,
tiersMap,
}: {
member: NonNullable<Post["team"]>["members"][number];
tiersMap: TiersMap;
}) {
const tiers = tiersMap.get(member.id);
const tier = tiers?.latest ?? tiers?.previous;
return (
<div className="stack sm items-center flex-same-size">
<div className="stack sm items-center">
<Avatar size="xs" user={member} />
<Link to={userPage(member)} className="lfg__post-team-member-name">
{member.discordName}
</Link>
{tier ? <TierImage tier={tier} width={32} /> : null}
</div>
</div>
);
}
function PostUserHeader({
author,
includeWeapons,
}: {
author: Post["author"];
includeWeapons: boolean;
}) {
return (
<div className="stack sm">
<div className="stack sm horizontal items-center">
<Avatar size="xsm" user={author} />
<div>
<div className="stack horizontal sm items-center text-md font-bold">
<Link to={userPage(author)} className="lfg__post-user-name">
{author.discordName}
</Link>{" "}
{author.country ? <Flag countryCode={author.country} tiny /> : null}
</div>
</div>
</div>
{includeWeapons ? (
<div className="stack horizontal sm">
{author.weaponPool.map(({ weaponSplId, isFavorite }) => (
<WeaponImage
key={weaponSplId}
weaponSplId={weaponSplId}
size={32}
variant={isFavorite ? "badge-5-star" : "badge"}
/>
))}
</div>
) : null}
</div>
);
}
function PostTime({
createdAt,
updatedAt,
}: {
createdAt: number;
updatedAt: number;
}) {
const { t, i18n } = useTranslation(["lfg"]);
const createdAtDate = databaseTimestampToDate(createdAt);
const updatedAtDate = databaseTimestampToDate(updatedAt);
const overDayDifferenceBetween =
createdAtDate.getTime() - updatedAtDate.getTime() > 1000 * 60 * 60 * 24;
return (
<div className="text-lighter text-xs font-bold">
{createdAtDate.toLocaleString(i18n.language, {
month: "long",
day: "numeric",
})}{" "}
{overDayDifferenceBetween ? (
<i>
({t("lfg:post.lastActive")}{" "}
{formatDistanceToNow(updatedAtDate, {
addSuffix: true,
})}
)
</i>
) : null}
</div>
);
}
function PostPills({
timezone,
plusTier,
languages,
tiers,
canEdit,
postId,
}: {
timezone?: string | null;
plusTier?: number | null;
languages?: string | null;
tiers?: NonNullable<ReturnType<TiersMap["get"]>>;
canEdit?: boolean;
postId: number;
}) {
const isMounted = useIsMounted();
return (
<div
className={clsx("stack sm xs-row horizontal flex-wrap", {
invisible: !isMounted,
})}
>
{typeof timezone === "string" && isMounted && (
<PostTimezonePill timezone={timezone} />
)}
{!isMounted && <PostTimezonePillPlaceholder />}
{typeof plusTier === "number" && (
<PostPlusServerPill plusTier={plusTier} />
)}
{tiers && <PostSkillPills tiers={tiers} />}
{typeof languages === "string" && (
<PostLanguagePill languages={languages} />
)}
{canEdit && <PostEditButton id={postId} />}
</div>
);
}
function PostTimezonePillPlaceholder() {
return <div className="lfg-post__pill lfg-post__pill--placeholder" />;
}
const currentSeasonNth = currentOrPreviousSeason(new Date())!.nth;
function PostSkillPills({
tiers,
}: {
tiers: NonNullable<ReturnType<TiersMap["get"]>>;
}) {
const hasBoth = tiers.latest && tiers.previous;
return (
<div className="stack xxxs horizontal">
{tiers.latest ? (
<PostSkillPill
seasonNth={currentSeasonNth}
tier={tiers.latest}
cut={hasBoth ? "END" : undefined}
/>
) : null}
{tiers.previous ? (
<PostSkillPill
seasonNth={currentSeasonNth - 1}
tier={tiers.previous}
cut={hasBoth ? "START" : undefined}
/>
) : null}
</div>
);
}
function PostSkillPill({
seasonNth,
tier,
cut,
}: {
seasonNth: number;
tier: TieredSkill["tier"];
cut?: "START" | "END";
}) {
return (
<div
className={clsx("lfg-post__pill", "lfg-post__tier-pill", {
"lfg-post__tier-pill--start": cut === "START",
"lfg-post__tier-pill--end": cut === "END",
})}
>
S{seasonNth}
<TierImage tier={tier} width={32} className="lfg-post__tier" />
</div>
);
}
function PostPlusServerPill({ plusTier }: { plusTier: number }) {
return (
<div className="lfg-post__pill">
<Image alt="" path={navIconUrl("plus")} size={18} />
{plusTier}
</div>
);
}
function PostTimezonePill({ timezone }: { timezone: string }) {
const userTimezone = Intl.DateTimeFormat().resolvedOptions().timeZone;
const diff = hourDifferenceBetweenTimezones(userTimezone, timezone);
const textColorClass = () => {
const absDiff = Math.abs(diff);
if (absDiff <= 3) {
return "text-success";
} else if (absDiff <= 6) {
return "text-warning";
} else {
return "text-error";
}
};
return (
<div title={timezone} className={clsx("lfg-post__pill", textColorClass())}>
{diff === 0 ? "±" : ""}
{diff > 0 ? "+" : ""}
{diff}h
</div>
);
}
function PostLanguagePill({ languages }: { languages: string }) {
return (
<div className="lfg-post__pill">
{languages.replace(/,/g, " / ").toUpperCase()}
</div>
);
}
function PostTextTypeHeader({ type }: { type: Post["type"] }) {
const { t } = useTranslation(["lfg"]);
return (
<div className="text-xs text-lighter font-bold">
{t(`lfg:types.${type}`)}
</div>
);
}
function PostEditButton({ id }: { id: number }) {
const { t } = useTranslation(["common"]);
return (
<Link className="lfg-post__edit-button" to={lfgNewPostPage(id)}>
<EditIcon />
{t("common:actions.edit")}
</Link>
);
}
function PostDeleteButton({ id, type }: { id: number; type: Post["type"] }) {
const fetcher = useFetcher();
const { t } = useTranslation(["common", "lfg"]);
return (
<FormWithConfirm
dialogHeading={`Delete post (${t(`lfg:types.${type}`).toLowerCase()})?`}
fields={[
["id", id],
["_action", "DELETE_POST"],
]}
fetcher={fetcher}
>
<Button
className="build__small-text"
variant="minimal-destructive"
size="tiny"
type="submit"
icon={<TrashIcon className="build__icon" />}
>
{t("common:actions.delete")}
</Button>
</FormWithConfirm>
);
}
function PostExpandableText({
text,
isExpanded: _isExpanded,
setIsExpanded,
expandableCriteria,
}: {
text: string;
isExpanded: boolean;
setIsExpanded: (isExpanded: boolean) => void;
expandableCriteria?: number;
}) {
const { t } = useTranslation(["common"]);
const isExpandable = !expandableCriteria || text.length > expandableCriteria;
const isExpanded = !isExpandable ? true : _isExpanded;
return (
<div
className={clsx({
"lfg__post-text-container": !isExpanded,
"lfg__post-text-container--expanded": isExpanded,
})}
>
<div className="lfg__post-text">{text}</div>
{isExpandable ? (
<Button
onClick={() => setIsExpanded(!isExpanded)}
className={clsx("lfg__post-text__show-all-button", {
"lfg__post-text__show-all-button--expanded": isExpanded,
})}
variant="outlined"
size="tiny"
>
{isExpanded
? t("common:actions.showLess")
: t("common:actions.showMore")}
</Button>
) : null}
{!isExpanded ? <div className="lfg__post-text-cut" /> : null}
</div>
);
}

View File

@ -0,0 +1,161 @@
import { compareTwoTiers } from "~/features/mmr/mmr-utils";
import type { LFGFilter } from "../lfg-types";
import type { LFGLoaderData, LFGLoaderPost, TiersMap } from "../routes/lfg";
import { hourDifferenceBetweenTimezones } from "./timezone";
import { assertUnreachable } from "~/utils/types";
import type { MainWeaponId } from "~/modules/in-game-lists";
import {
altWeaponIdToId,
mainWeaponIds,
weaponIdToAltId,
} from "~/modules/in-game-lists/weapon-ids";
export function filterPosts(
posts: LFGLoaderData["posts"],
filters: LFGFilter[],
tiersMap: TiersMap,
) {
return posts.filter((post) => {
for (const filter of filters) {
if (!filterMatchesPost(post, filter, tiersMap)) return false;
}
return true;
});
}
function filterMatchesPost(
post: LFGLoaderPost,
filter: LFGFilter,
tiersMap: TiersMap,
) {
if (post.type === "COACH_FOR_TEAM") {
// not visible in the UI
if (
filter._tag === "Weapon" ||
filter._tag === "MaxTier" ||
filter._tag === "MinTier"
) {
return false;
}
}
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 (
Math.abs(hourDifferenceBetweenTimezones(post.timezone, userTimezone)) <=
filter.maxHourDifference
);
}
case "Language":
return checkMatchesSomeUserInPost(post, (user) =>
user.languages?.includes(filter.language),
);
case "PlusTier":
return checkMatchesSomeUserInPost(
post,
(user) => user.plusTier && user.plusTier <= filter.tier,
);
case "MaxTier":
return checkMatchesSomeUserInPost(post, (user) => {
const tiers = tiersMap.get(user.id);
if (!tiers) return false;
if (
tiers.latest &&
compareTwoTiers(tiers.latest.name, filter.tier) >= 0
) {
return true;
}
if (
tiers.previous &&
compareTwoTiers(tiers.previous.name, filter.tier) >= 0
) {
return true;
}
return false;
});
case "MinTier":
return checkMatchesSomeUserInPost(post, (user) => {
const tiers = tiersMap.get(user.id);
if (!tiers) return false;
if (
tiers.latest &&
compareTwoTiers(tiers.latest.name, filter.tier) <= 0
) {
return true;
}
if (
tiers.previous &&
compareTwoTiers(tiers.previous.name, filter.tier) <= 0
) {
return true;
}
return false;
});
default:
assertUnreachable(filter);
}
}
const checkMatchesSomeUserInPost = (
post: LFGLoaderPost,
check: (user: LFGLoaderPost["author"]) => boolean | undefined | null | 0,
) => {
if (check(post.author)) return true;
if (post.team?.members.some(check)) return true;
return false;
};
// TODO: could be written more clearly, fails in some edge cases like if "Hero Shot" was selected it won't find "Octo Shot"
const weaponIdToRelated = (weaponSplId: MainWeaponId) => {
const idsSet = new Set<MainWeaponId>([weaponSplId]);
const reg = altWeaponIdToId.get(weaponSplId);
if (reg) {
idsSet.add(reg);
}
const alt = weaponIdToAltId.get(weaponSplId);
if (alt) {
for (const id of Array.isArray(alt) ? alt : [alt]) {
idsSet.add(id);
}
}
const finalIdsSet = new Set<MainWeaponId>(idsSet);
for (const id of idsSet) {
// alt kits
const maybeId1 = id - 1;
const maybeId2 = id + 1;
for (const maybeId of [maybeId1, maybeId2]) {
if (mainWeaponIds.includes(maybeId as MainWeaponId)) {
finalIdsSet.add(maybeId as MainWeaponId);
}
}
}
return Array.from(finalIdsSet);
};

View File

@ -0,0 +1,31 @@
// timezone example is 'Asia/Tokyo'
export function hourDifferenceBetweenTimezones(
timezone1: string,
timezone2: string,
) {
const offset1 = getTimezoneOffset(timezone1);
const offset2 = getTimezoneOffset(timezone2);
return (offset1 - offset2) / 60;
}
// https://stackoverflow.com/a/29268535
function getTimezoneOffset(timeZone: string) {
const date = new Date();
// Abuse the Intl API to get a local ISO 8601 string for a given time zone.
let iso = date
.toLocaleString("en-CA", { timeZone, hour12: false })
.replace(", ", "T");
// Include the milliseconds from the original timestamp
iso += "." + date.getMilliseconds().toString().padStart(3, "0");
// Lie to the Date object constructor that it's a UTC time.
const lie = new Date(iso + "Z");
// Return the difference in timestamps, as minutes
// Positive values are West of GMT, opposite of ISO 8601
// this matches the output of `Date.getTimeZoneOffset`
return -(lie.getTime() - date.getTime()) / 60 / 1000;
}

View File

@ -0,0 +1,367 @@
import type { Tables } from "~/db/tables";
export const LFG = {
MIN_TEXT_LENGTH: 1,
MAX_TEXT_LENGTH: 2_000,
POST_FRESHNESS_DAYS: 30 as const,
types: [
"PLAYER_FOR_TEAM",
"TEAM_FOR_PLAYER",
"TEAM_FOR_COACH",
"COACH_FOR_TEAM",
] as const,
};
export const TEAM_POST_TYPES: Array<Tables["LFGPost"]["type"]> = [
"TEAM_FOR_COACH",
"TEAM_FOR_PLAYER",
];
export const TIMEZONES = [
"Africa/Abidjan",
"Africa/Accra",
"Africa/Algiers",
"Africa/Bissau",
"Africa/Cairo",
"Africa/Casablanca",
"Africa/Ceuta",
"Africa/El_Aaiun",
"Africa/Johannesburg",
"Africa/Juba",
"Africa/Khartoum",
"Africa/Lagos",
"Africa/Maputo",
"Africa/Monrovia",
"Africa/Nairobi",
"Africa/Ndjamena",
"Africa/Sao_Tome",
"Africa/Tripoli",
"Africa/Tunis",
"Africa/Windhoek",
"America/Adak",
"America/Anchorage",
"America/Araguaina",
"America/Argentina/Buenos_Aires",
"America/Argentina/Catamarca",
"America/Argentina/Cordoba",
"America/Argentina/Jujuy",
"America/Argentina/La_Rioja",
"America/Argentina/Mendoza",
"America/Argentina/Rio_Gallegos",
"America/Argentina/Salta",
"America/Argentina/San_Juan",
"America/Argentina/San_Luis",
"America/Argentina/Tucuman",
"America/Argentina/Ushuaia",
"America/Asuncion",
"America/Atikokan",
"America/Bahia",
"America/Bahia_Banderas",
"America/Barbados",
"America/Belem",
"America/Belize",
"America/Blanc-Sablon",
"America/Boa_Vista",
"America/Bogota",
"America/Boise",
"America/Cambridge_Bay",
"America/Campo_Grande",
"America/Cancun",
"America/Caracas",
"America/Cayenne",
"America/Chicago",
"America/Chihuahua",
"America/Costa_Rica",
"America/Creston",
"America/Cuiaba",
"America/Curacao",
"America/Danmarkshavn",
"America/Dawson",
"America/Dawson_Creek",
"America/Denver",
"America/Detroit",
"America/Edmonton",
"America/Eirunepe",
"America/El_Salvador",
"America/Fort_Nelson",
"America/Fortaleza",
"America/Glace_Bay",
"America/Godthab",
"America/Goose_Bay",
"America/Grand_Turk",
"America/Guatemala",
"America/Guayaquil",
"America/Guyana",
"America/Halifax",
"America/Havana",
"America/Hermosillo",
"America/Indiana/Indianapolis",
"America/Indiana/Knox",
"America/Indiana/Marengo",
"America/Indiana/Petersburg",
"America/Indiana/Tell_City",
"America/Indiana/Vevay",
"America/Indiana/Vincennes",
"America/Indiana/Winamac",
"America/Inuvik",
"America/Iqaluit",
"America/Jamaica",
"America/Juneau",
"America/Kentucky/Louisville",
"America/Kentucky/Monticello",
"America/La_Paz",
"America/Lima",
"America/Los_Angeles",
"America/Maceio",
"America/Managua",
"America/Manaus",
"America/Martinique",
"America/Matamoros",
"America/Mazatlan",
"America/Menominee",
"America/Merida",
"America/Metlakatla",
"America/Mexico_City",
"America/Miquelon",
"America/Moncton",
"America/Monterrey",
"America/Montevideo",
"America/Nassau",
"America/New_York",
"America/Nipigon",
"America/Nome",
"America/Noronha",
"America/North_Dakota/Beulah",
"America/North_Dakota/Center",
"America/North_Dakota/New_Salem",
"America/Ojinaga",
"America/Panama",
"America/Pangnirtung",
"America/Paramaribo",
"America/Phoenix",
"America/Port_of_Spain",
"America/Port-au-Prince",
"America/Porto_Velho",
"America/Puerto_Rico",
"America/Punta_Arenas",
"America/Rainy_River",
"America/Rankin_Inlet",
"America/Recife",
"America/Regina",
"America/Resolute",
"America/Rio_Branco",
"America/Santarem",
"America/Santiago",
"America/Santo_Domingo",
"America/Sao_Paulo",
"America/Scoresbysund",
"America/Sitka",
"America/St_Johns",
"America/Swift_Current",
"America/Tegucigalpa",
"America/Thule",
"America/Thunder_Bay",
"America/Tijuana",
"America/Toronto",
"America/Vancouver",
"America/Whitehorse",
"America/Winnipeg",
"America/Yakutat",
"America/Yellowknife",
"Antarctica/Casey",
"Antarctica/Davis",
"Antarctica/Macquarie",
"Antarctica/Mawson",
"Antarctica/Palmer",
"Antarctica/Rothera",
"Antarctica/Syowa",
"Antarctica/Troll",
"Antarctica/Vostok",
"Asia/Almaty",
"Asia/Amman",
"Asia/Anadyr",
"Asia/Aqtau",
"Asia/Aqtobe",
"Asia/Ashgabat",
"Asia/Atyrau",
"Asia/Baghdad",
"Asia/Baku",
"Asia/Bangkok",
"Asia/Barnaul",
"Asia/Beirut",
"Asia/Bishkek",
"Asia/Brunei",
"Asia/Chita",
"Asia/Choibalsan",
"Asia/Colombo",
"Asia/Damascus",
"Asia/Dhaka",
"Asia/Dili",
"Asia/Dubai",
"Asia/Dushanbe",
"Asia/Famagusta",
"Asia/Gaza",
"Asia/Hebron",
"Asia/Ho_Chi_Minh",
"Asia/Hong_Kong",
"Asia/Hovd",
"Asia/Irkutsk",
"Asia/Jakarta",
"Asia/Jayapura",
"Asia/Jerusalem",
"Asia/Kabul",
"Asia/Kamchatka",
"Asia/Karachi",
"Asia/Kathmandu",
"Asia/Khandyga",
"Asia/Kolkata",
"Asia/Krasnoyarsk",
"Asia/Kuala_Lumpur",
"Asia/Kuching",
"Asia/Macau",
"Asia/Magadan",
"Asia/Makassar",
"Asia/Manila",
"Asia/Nicosia",
"Asia/Novokuznetsk",
"Asia/Novosibirsk",
"Asia/Omsk",
"Asia/Oral",
"Asia/Pontianak",
"Asia/Pyongyang",
"Asia/Qatar",
"Asia/Qyzylorda",
"Asia/Riyadh",
"Asia/Sakhalin",
"Asia/Samarkand",
"Asia/Seoul",
"Asia/Shanghai",
"Asia/Singapore",
"Asia/Srednekolymsk",
"Asia/Taipei",
"Asia/Tashkent",
"Asia/Tbilisi",
"Asia/Tehran",
"Asia/Thimphu",
"Asia/Tokyo",
"Asia/Tomsk",
"Asia/Ulaanbaatar",
"Asia/Urumqi",
"Asia/Ust-Nera",
"Asia/Vladivostok",
"Asia/Yakutsk",
"Asia/Yangon",
"Asia/Yekaterinburg",
"Asia/Yerevan",
"Atlantic/Azores",
"Atlantic/Bermuda",
"Atlantic/Canary",
"Atlantic/Cape_Verde",
"Atlantic/Faroe",
"Atlantic/Madeira",
"Atlantic/Reykjavik",
"Atlantic/South_Georgia",
"Atlantic/Stanley",
"Australia/Adelaide",
"Australia/Brisbane",
"Australia/Broken_Hill",
"Australia/Currie",
"Australia/Darwin",
"Australia/Eucla",
"Australia/Hobart",
"Australia/Lindeman",
"Australia/Lord_Howe",
"Australia/Melbourne",
"Australia/Perth",
"Australia/Sydney",
"Europe/Amsterdam",
"Europe/Andorra",
"Europe/Astrakhan",
"Europe/Athens",
"Europe/Belgrade",
"Europe/Berlin",
"Europe/Brussels",
"Europe/Bucharest",
"Europe/Budapest",
"Europe/Chisinau",
"Europe/Copenhagen",
"Europe/Dublin",
"Europe/Gibraltar",
"Europe/Helsinki",
"Europe/Istanbul",
"Europe/Kaliningrad",
"Europe/Kiev",
"Europe/Kirov",
"Europe/Lisbon",
"Europe/London",
"Europe/Luxembourg",
"Europe/Madrid",
"Europe/Malta",
"Europe/Minsk",
"Europe/Monaco",
"Europe/Moscow",
"Europe/Oslo",
"Europe/Paris",
"Europe/Prague",
"Europe/Riga",
"Europe/Rome",
"Europe/Samara",
"Europe/Saratov",
"Europe/Simferopol",
"Europe/Sofia",
"Europe/Stockholm",
"Europe/Tallinn",
"Europe/Tirane",
"Europe/Ulyanovsk",
"Europe/Uzhgorod",
"Europe/Vienna",
"Europe/Vilnius",
"Europe/Volgograd",
"Europe/Warsaw",
"Europe/Zaporozhye",
"Europe/Zurich",
"Indian/Chagos",
"Indian/Christmas",
"Indian/Cocos",
"Indian/Kerguelen",
"Indian/Mahe",
"Indian/Maldives",
"Indian/Mauritius",
"Indian/Reunion",
"Pacific/Apia",
"Pacific/Auckland",
"Pacific/Bougainville",
"Pacific/Chatham",
"Pacific/Chuuk",
"Pacific/Easter",
"Pacific/Efate",
"Pacific/Enderbury",
"Pacific/Fakaofo",
"Pacific/Fiji",
"Pacific/Funafuti",
"Pacific/Galapagos",
"Pacific/Gambier",
"Pacific/Guadalcanal",
"Pacific/Guam",
"Pacific/Honolulu",
"Pacific/Kiritimati",
"Pacific/Kosrae",
"Pacific/Kwajalein",
"Pacific/Majuro",
"Pacific/Marquesas",
"Pacific/Nauru",
"Pacific/Niue",
"Pacific/Norfolk",
"Pacific/Noumea",
"Pacific/Pago_Pago",
"Pacific/Palau",
"Pacific/Pitcairn",
"Pacific/Pohnpei",
"Pacific/Port_Moresby",
"Pacific/Rarotonga",
"Pacific/Tahiti",
"Pacific/Tarawa",
"Pacific/Tongatapu",
"Pacific/Wake",
"Pacific/Wallis",
];

View File

@ -0,0 +1,47 @@
import type { Tables } from "~/db/tables";
import type { MainWeaponId } from "~/modules/in-game-lists";
import type { TierName } from "../mmr/mmr-constants";
export type LFGFilter =
| WeaponFilter
| TypeFilter
| TimezoneFilter
| LanguageFilter
| PlusTierFilter
| MaxTierFilter
| MinTierFilter;
type WeaponFilter = {
_tag: "Weapon";
weaponSplIds: MainWeaponId[];
};
type TypeFilter = {
_tag: "Type";
type: Tables["LFGPost"]["type"];
};
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;
};

137
app/features/lfg/lfg.css Normal file
View File

@ -0,0 +1,137 @@
.lfg-post__wide-layout {
display: grid;
grid-template-columns: 1fr;
align-items: flex-start;
column-gap: var(--s-6);
row-gap: var(--s-2);
}
.lfg-post__wide-layout__left-row {
display: flex;
gap: var(--s-2);
flex-direction: column;
}
@media screen and (min-width: 640px) {
.lfg-post__wide-layout {
grid-template-columns: 1fr 2fr;
row-gap: 0;
}
.lfg-post__wide-layout__left-row {
position: sticky;
top: 55px;
}
}
.lfg__post-user-name {
max-width: 125px;
text-overflow: ellipsis;
overflow: hidden;
white-space: nowrap;
color: var(--text);
}
.lfg__post-team-member-name {
max-width: 75px;
text-overflow: ellipsis;
overflow: hidden;
white-space: nowrap;
font-size: var(--fonts-xs);
font-weight: var(--semi-bold);
color: var(--text);
}
.lfg__post-text-container {
position: relative;
max-height: 150px;
overflow: hidden;
}
.lfg__post-text-container--expanded {
position: relative;
margin-bottom: var(--s-6);
}
.lfg__post-text {
white-space: pre-wrap;
}
.lfg__post-text-cut {
background-image: linear-gradient(to bottom, transparent, var(--bg));
width: 100%;
position: absolute;
top: 0;
height: 100%;
}
.lfg__post-text__show-all-button {
position: absolute;
bottom: 0;
left: 0;
right: 0;
width: max-content;
margin-left: auto;
margin-right: auto;
z-index: 99;
}
.lfg__post-text__show-all-button--expanded {
bottom: -35px;
}
.lfg-post__pill {
font-size: var(--fonts-xxs);
font-weight: var(--semi-bold);
border-radius: var(--rounded);
background-color: var(--bg-lightest);
padding: var(--s-0-5) var(--s-2);
display: flex;
align-items: center;
gap: var(--s-0-5);
position: relative;
}
.lfg-post__pill--placeholder {
visibility: hidden;
width: 37px;
}
.lfg-post__edit-button {
font-size: var(--fonts-xxs);
font-weight: var(--semi-bold);
border-radius: var(--rounded);
background-color: var(--bg-lightest);
padding: var(--s-0-5) var(--s-2);
color: var(--text);
display: flex;
gap: var(--s-1);
}
.lfg-post__edit-button > svg {
width: 12px;
}
.lfg-post__tier-pill--end {
border-radius: var(--rounded) 0 0 var(--rounded);
}
.lfg-post__tier-pill--start {
border-radius: 0 var(--rounded) var(--rounded) 0;
}
.lfg-post__tier {
position: absolute;
top: -3px;
right: 4px;
}
.lfg-post__tier-pill {
width: 60px;
}
.lfg__filter {
padding: var(--s-1-5) var(--s-2);
background-color: var(--bg-lighter);
border-radius: var(--rounded);
}

View File

@ -0,0 +1,53 @@
import type { LoaderFunctionArgs } from "@remix-run/node";
import { requireUser } from "~/features/auth/core/user.server";
import * as UserRepository from "~/features/user-page/UserRepository.server";
import * as QSettingsRepository from "~/features/sendouq-settings/QSettingsRepository.server";
import { z } from "zod";
import { id } from "~/utils/zod";
import { parseSafeSearchParams } from "~/utils/remix";
import * as LFGRepository from "../LFGRepository.server";
import type { Unpacked } from "~/utils/types";
export const loader = async ({ request }: LoaderFunctionArgs) => {
const user = await requireUser(request);
const userProfileData = await UserRepository.findByIdentifier(
String(user.id),
);
const userQSettingsData = await QSettingsRepository.settingsByUserId(user.id);
const allPosts = await LFGRepository.posts(user);
return {
team: userProfileData?.team,
weaponPool: userProfileData?.weapons,
languages: userQSettingsData.languages,
postToEdit: searchParamsToBuildToEdit(request, user.id, allPosts),
userPostTypes: userPostTypes(allPosts, user.id),
};
};
const searchParamsSchema = z.object({
postId: id,
});
const searchParamsToBuildToEdit = (
request: LoaderFunctionArgs["request"],
userId: number,
allPosts: Unpacked<ReturnType<typeof LFGRepository.posts>>,
) => {
const params = parseSafeSearchParams({ request, schema: searchParamsSchema });
if (!params.success) return;
const post = allPosts.find(
(p) => p.id === params.data.postId && p.author.id === userId,
);
return post;
};
const userPostTypes = (
allPosts: Unpacked<ReturnType<typeof LFGRepository.posts>>,
userId: number,
) =>
allPosts.filter((post) => post.author.id === userId).map((post) => post.type);

View File

@ -0,0 +1,54 @@
import { currentOrPreviousSeason } from "~/features/mmr/season";
import type { TieredSkill } from "~/features/mmr/tiered.server";
import { userSkills } from "~/features/mmr/tiered.server";
import type { Unpacked } from "~/utils/types";
import * as LFGRepository from "../LFGRepository.server";
import type { LoaderFunctionArgs } from "@remix-run/node";
import { getUser } from "~/features/auth/core/user.server";
export const loader = async ({ request }: LoaderFunctionArgs) => {
const user = await getUser(request);
const posts = await LFGRepository.posts(user);
return {
posts,
tiersMap: postsUsersTiersMap(posts),
};
};
function postsUsersTiersMap(
posts: Unpacked<ReturnType<typeof LFGRepository.posts>>,
) {
const latestSeason = currentOrPreviousSeason(new Date())!.nth;
const previousSeason = latestSeason - 1;
const latestSeasonSkills = userSkills(latestSeason).userSkills;
const previousSeasonSkills = userSkills(previousSeason).userSkills;
const uniqueUsers = new Set<number>();
for (const post of posts) {
uniqueUsers.add(post.author.id);
for (const user of post.team?.members ?? []) {
uniqueUsers.add(user.id);
}
}
const userSkillsMap = new Map<
number,
{ latest?: TieredSkill["tier"]; previous?: TieredSkill["tier"] }
>();
for (const userId of uniqueUsers) {
const tiers = {
latest: latestSeasonSkills[userId]?.tier,
previous: previousSeasonSkills[userId]?.tier,
};
if (tiers.latest || tiers.previous) {
userSkillsMap.set(userId, tiers);
}
}
return Array.from(userSkillsMap.entries());
}

View File

@ -0,0 +1,271 @@
import { Link, useFetcher, useLoaderData } from "@remix-run/react";
import { useTranslation } from "react-i18next";
import { Main } from "~/components/Main";
import { SubmitButton } from "~/components/SubmitButton";
import { TEAM_POST_TYPES, LFG, TIMEZONES } from "../lfg-constants";
import * as React from "react";
import { Label } from "~/components/Label";
import type { SendouRouteHandle } from "~/utils/remix";
import {
LFG_PAGE,
SENDOUQ_SETTINGS_PAGE,
navIconUrl,
userEditProfilePage,
} from "~/utils/urls";
import { FormMessage } from "~/components/FormMessage";
import { WeaponImage } from "~/components/Image";
import { useUser } from "~/features/auth/core/user";
import type { Tables } from "~/db/tables";
import { LinkButton } from "~/components/Button";
import { ArrowLeftIcon } from "~/components/icons/ArrowLeft";
import { loader } from "../loaders/lfg.new.server";
import { action } from "../actions/lfg.new.server";
export { loader, action };
export const handle: SendouRouteHandle = {
i18n: ["lfg"],
breadcrumb: () => ({
imgPath: navIconUrl("lfg"),
href: LFG_PAGE,
type: "IMAGE",
}),
};
export default function LFGNewPostPage() {
const user = useUser();
const data = useLoaderData<typeof loader>();
const fetcher = useFetcher();
const { t } = useTranslation(["common", "lfg"]);
const availableTypes = useAvailablePostTypes();
const [type, setType] = React.useState(data.postToEdit?.type ?? LFG.types[0]);
if (availableTypes.length === 0) {
return (
<Main halfWidth className="stack items-center">
<h2 className="text-lg mb-4">{t("lfg:new.noMorePosts")}</h2>
<LinkButton to={LFG_PAGE} icon={<ArrowLeftIcon />}>
{t("common:actions.goBack")}
</LinkButton>
</Main>
);
}
return (
<Main halfWidth>
<h2 className="text-lg mb-4">
{data.postToEdit ? "Editing LFG post" : "New LFG post"}
</h2>
<fetcher.Form className="stack md items-start" method="post">
{data.postToEdit ? (
<input type="hidden" name="postId" value={data.postToEdit.id} />
) : null}
<TypeSelect
type={type}
setType={setType}
availableTypes={availableTypes}
/>
<TimezoneSelect />
<Textarea />
{user?.plusTier && type !== "COACH_FOR_TEAM" ? (
<PlusVisibilitySelect />
) : null}
<Languages />
{type !== "COACH_FOR_TEAM" && <WeaponPool />}
<SubmitButton state={fetcher.state}>
{t("common:actions.submit")}
</SubmitButton>
</fetcher.Form>
</Main>
);
}
const useAvailablePostTypes = () => {
const data = useLoaderData<typeof loader>();
return (
LFG.types
// can't look for a team, if not in one
.filter((type) => data.team || !TEAM_POST_TYPES.includes(type))
// can't post two posts of same type
.filter(
(type) =>
!data.userPostTypes.includes(type) || data.postToEdit?.type === type,
)
);
};
function TypeSelect({
type,
setType,
availableTypes,
}: {
type: Tables["LFGPost"]["type"];
setType: (type: Tables["LFGPost"]["type"]) => void;
availableTypes: Tables["LFGPost"]["type"][];
}) {
const { t } = useTranslation(["lfg"]);
const data = useLoaderData<typeof loader>();
return (
<div>
<Label>{t("lfg:new.type.header")}</Label>
{data.postToEdit ? (
<input type="hidden" name="type" value={type} />
) : null}
<select
name="type"
value={type}
onChange={(e) => setType(e.target.value as Tables["LFGPost"]["type"])}
disabled={Boolean(data.postToEdit)}
>
{availableTypes.map((type) => (
<option key={type} value={type}>
{t(`lfg:types.${type}`)}{" "}
{data.team && TEAM_POST_TYPES.includes(type)
? `(${data.team.name})`
: ""}
</option>
))}
</select>
</div>
);
}
function TimezoneSelect() {
const { t } = useTranslation(["lfg"]);
const data = useLoaderData<typeof loader>();
const [selected, setSelected] = React.useState(
data.postToEdit?.timezone ?? TIMEZONES[0],
);
React.useEffect(() => {
const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone;
if (!TIMEZONES.includes(timezone)) return;
setSelected(timezone);
}, []);
return (
<div>
<Label>{t("lfg:new.timezone.header")}</Label>
<select
name="timezone"
onChange={(e) => setSelected(e.target.value)}
value={selected}
>
{TIMEZONES.map((tz) => (
<option key={tz} value={tz}>
{tz}
</option>
))}
</select>
</div>
);
}
function Textarea() {
const { t } = useTranslation(["lfg"]);
const data = useLoaderData<typeof loader>();
const [value, setValue] = React.useState(data.postToEdit?.text ?? "");
return (
<div>
<Label
htmlFor="postText"
valueLimits={{ current: value.length, max: LFG.MAX_TEXT_LENGTH }}
>
{t("lfg:new.text.header")}
</Label>
<textarea
id="postText"
name="postText"
value={value}
onChange={(e) => setValue(e.target.value)}
maxLength={LFG.MAX_TEXT_LENGTH}
required
/>
</div>
);
}
function PlusVisibilitySelect() {
const { t } = useTranslation(["lfg"]);
const user = useUser();
const data = useLoaderData<typeof loader>();
const [selected, setSelected] = React.useState<number | "">(
data.postToEdit?.plusTierVisibility ?? "",
);
const options = [1, 2, 3].filter(
(tier) => user && user.plusTier && tier >= user?.plusTier,
);
return (
<div>
<Label>{t("lfg:new.visibility.header")}</Label>
<select
name="plusTierVisibility"
onChange={(e) =>
setSelected(e.target.value === "" ? "" : Number(e.target.value))
}
value={selected}
>
{options.map((tier) => (
<option key={tier} value={tier}>
+{tier} {tier > 1 ? t("lfg:filters.orAbove") : ""}
</option>
))}
<option value="">{t("lfg:new.visibility.everyone")}</option>
</select>
</div>
);
}
function Languages() {
const { t } = useTranslation(["lfg"]);
const data = useLoaderData<typeof loader>();
return (
<div>
<Label>{t("lfg:new.languages.header")}</Label>
<div className="stack horizontal sm">
{data.languages?.join(" / ").toUpperCase()}
</div>
<FormMessage type="info">
{t("lfg:new.editOn")}{" "}
<Link to={SENDOUQ_SETTINGS_PAGE} target="_blank" rel="noreferrer">
{t("lfg:new.languages.sqSettingsPage")}
</Link>
</FormMessage>
</div>
);
}
function WeaponPool() {
const { t } = useTranslation(["lfg"]);
const user = useUser();
const data = useLoaderData<typeof loader>();
return (
<div>
<Label>{t("lfg:new.weaponPool.header")}</Label>
<div className="stack horizontal sm">
{data.weaponPool?.map(({ weaponSplId }) => (
<WeaponImage
key={weaponSplId}
weaponSplId={weaponSplId}
size={32}
variant="build"
/>
))}
</div>
<FormMessage type="info">
{t("lfg:new.editOn")}{" "}
<Link to={userEditProfilePage(user!)} target="_blank" rel="noreferrer">
{t("lfg:new.weaponPool.userProfile")}
</Link>
</FormMessage>
</div>
);
}

View File

@ -0,0 +1,134 @@
import { Main } from "~/components/Main";
import { useFetcher, useLoaderData } from "@remix-run/react";
import type { MetaFunction, SerializeFrom } from "@remix-run/node";
import { LFGPost } from "../components/LFGPost";
import { LFG_PAGE, lfgNewPostPage, navIconUrl } from "~/utils/urls";
import type { SendouRouteHandle } from "~/utils/remix";
import React from "react";
import { LinkButton } from "~/components/Button";
import { useUser } from "~/features/auth/core/user";
import { Alert } from "~/components/Alert";
import type { Unpacked } from "~/utils/types";
import { add, sub } from "date-fns";
import { databaseTimestampToDate } from "~/utils/dates";
import { LFG } from "../lfg-constants";
import { SubmitButton } from "~/components/SubmitButton";
import type { LFGFilter } from "../lfg-types";
import { filterPosts } from "../core/filtering";
import { LFGAddFilterButton } from "../components/LFGAddFilterButton";
import { LFGFilters } from "../components/LFGFilters";
import { makeTitle } from "~/utils/strings";
import { useTranslation } from "react-i18next";
import { loader } from "../loaders/lfg.server";
import { action } from "../actions/lfg.server";
export { loader, action };
import "../lfg.css";
export const handle: SendouRouteHandle = {
i18n: ["lfg"],
breadcrumb: () => ({
imgPath: navIconUrl("lfg"),
href: LFG_PAGE,
type: "IMAGE",
}),
};
export const meta: MetaFunction = () => {
return [{ title: makeTitle("Looking for group") }];
};
export type LFGLoaderData = SerializeFrom<typeof loader>;
export type LFGLoaderPost = Unpacked<LFGLoaderData["posts"]>;
export type TiersMap = ReturnType<typeof unserializeTiers>;
const unserializeTiers = (data: SerializeFrom<typeof loader>) =>
new Map(data.tiersMap);
export default function LFGPage() {
const { t } = useTranslation(["common, lfg"]);
const user = useUser();
const data = useLoaderData<typeof loader>();
const [filters, setFilters] = React.useState<LFGFilter[]>([]);
const tiersMap = React.useMemo(() => unserializeTiers(data), [data]);
const filteredPosts = filterPosts(data.posts, filters, tiersMap);
const showExpiryAlert = (post: Unpacked<LFGLoaderData["posts"]>) => {
if (post.author.id !== user?.id) return false;
const expiryDate = add(databaseTimestampToDate(post.updatedAt), {
days: LFG.POST_FRESHNESS_DAYS,
});
const expiryCloseDate = sub(expiryDate, { days: 7 });
if (new Date() < expiryCloseDate) return false;
return true;
};
return (
<Main className="stack xl">
<div className="stack horizontal justify-between">
<LFGAddFilterButton
addFilter={(newFilter) => setFilters([...filters, newFilter])}
filters={filters}
/>
{user && (
<div className="stack sm horizontal items-center justify-end">
<LinkButton
to={lfgNewPostPage()}
size="tiny"
testId="add-new-button"
>
{t("common:actions.addNew")}
</LinkButton>
</div>
)}
</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} className="stack sm">
{showExpiryAlert(post) ? <PostExpiryAlert postId={post.id} /> : null}
<LFGPost post={post} tiersMap={tiersMap} />
</div>
))}
{filteredPosts.length === 0 ? (
<div className="text-lighter text-lg font-semi-bold text-center mt-6">
{t("lfg:noPosts")}
</div>
) : null}
</Main>
);
}
function PostExpiryAlert({ postId }: { postId: number }) {
const { t } = useTranslation(["common", "lfg"]);
const fetcher = useFetcher();
return (
<Alert variation="WARNING">
<fetcher.Form method="post" className="stack md horizontal items-center">
<input type="hidden" name="id" value={postId} />
{t("lfg:expiring")}{" "}
<SubmitButton _action="BUMP_POST" variant="outlined" size="tiny">
{t("common:actions.clickHere")}
</SubmitButton>
</fetcher.Form>
</Alert>
);
}

View File

@ -1,6 +1,8 @@
import { rate as openskillRate, ordinal, rating } from "openskill";
import type { Rating, Team } from "openskill/dist/types";
import invariant from "tiny-invariant";
import type { TierName } from "./mmr-constants";
import { TIERS } from "./mmr-constants";
const TAU = 0.3;
@ -84,3 +86,10 @@ export function identifierToUserIds(identifier: string) {
export function defaultOrdinal() {
return ordinal(rating());
}
export function compareTwoTiers(tier1: TierName, tier2: TierName) {
return (
TIERS.findIndex(({ name }) => name === tier1) -
TIERS.findIndex(({ name }) => name === tier2)
);
}

View File

@ -39,6 +39,7 @@ import { findByIdentifier } from "../queries/findByIdentifier.server";
import { TEAM } from "../team-constants";
import { editTeamSchema, teamParamsSchema } from "../team-schemas.server";
import { canAddCustomizedColors, isTeamOwner } from "../team-utils";
import * as LFGRepository from "~/features/lfg/LFGRepository.server";
import "../team.css";
@ -85,6 +86,7 @@ export const action: ActionFunction = async ({ request, params }) => {
switch (data._action) {
case "DELETE": {
await LFGRepository.deletePostsByTeamId(team.id);
deleteTeam(team.id);
throw redirect(TEAM_SEARCH_PAGE);

View File

@ -225,6 +225,7 @@ export const namespaceJsonsToPreloadObj: Record<
vods: true,
art: true,
q: true,
lfg: true,
};
const namespaceJsonsToPreload = Object.keys(namespaceJsonsToPreloadObj);

View File

@ -891,6 +891,10 @@ dialog::backdrop {
flex-direction: column;
}
.stack.xxxs {
gap: var(--s-0-5);
}
.stack.xxs {
gap: var(--s-1);
}
@ -923,6 +927,10 @@ dialog::backdrop {
gap: var(--s-8);
}
.stack.xs-row {
row-gap: var(--s-1-5);
}
.stack.lg-row {
row-gap: var(--s-8);
}
@ -935,6 +943,10 @@ dialog::backdrop {
flex-direction: row;
}
.flex-same-size {
flex: 1 1 0px;
}
.lock-scroll {
overflow: hidden;
height: unset;

View File

@ -9,6 +9,10 @@ export function dateToDatabaseTimestamp(date: Date) {
return Math.floor(date.getTime() / 1000);
}
export function databaseTimestampNow() {
return dateToDatabaseTimestamp(new Date());
}
export function databaseCreatedAt() {
return dateToDatabaseTimestamp(new Date());
}

View File

@ -108,6 +108,7 @@ export const SENDOUQ_LOOKING_PREVIEW_PAGE = "/q/looking?preview=true";
export const SENDOUQ_STREAMS_PAGE = "/q/streams";
export const TIERS_PAGE = "/tiers";
export const SUSPENDED_PAGE = "/suspended";
export const LFG_PAGE = "/lfg";
export const BLANK_IMAGE_URL = "/static-assets/img/blank.gif";
export const COMMON_PREVIEW_IMAGE =
@ -338,6 +339,9 @@ export const uploadImagePage = (type: ImageUploadType) =>
export const vodVideoPage = (videoId: number) => `${VODS_PAGE}/${videoId}`;
export const lfgNewPostPage = (postId?: number) =>
`${LFG_PAGE}/new${postId ? `?postId=${postId}` : ""}`;
export const badgeUrl = ({
code,
extension,

24
e2e/lfg.spec.ts Normal file
View File

@ -0,0 +1,24 @@
import test, { expect } from "@playwright/test";
import { impersonate, navigate, seed, submit } from "~/utils/playwright";
import { LFG_PAGE } from "~/utils/urls";
test.describe("LFG", () => {
test("adds a new lfg post", async ({ page }) => {
await seed(page);
await impersonate(page);
await navigate({
page,
url: LFG_PAGE,
});
await page.getByTestId("add-new-button").click();
await page.getByLabel("Text").fill("looking for a cool team");
await submit(page);
// got redirected
await expect(page.getByTestId("add-new-button")).toBeVisible();
await expect(page.getByText("looking for a cool team")).toBeVisible();
});
});

View File

@ -14,6 +14,7 @@ import { teamPage, TEAM_SEARCH_PAGE } from "~/utils/urls";
test.describe("Team search page", () => {
test("filters teams", async ({ page }) => {
await seed(page);
await impersonate(page);
await navigate({ page, url: TEAM_SEARCH_PAGE });
const searchInput = page.getByTestId("team-search-input");

View File

@ -568,7 +568,7 @@ test.describe("Tournament bracket", () => {
});
await backToBracket(page);
await expect(page.getByText(" CAST")).toBeVisible();
await expect(page.getByText("🔒 CAST")).toBeVisible();
await page.locator('[data-match-id="3"]').click();
await expect(page.getByText("Match locked to be casted")).toBeVisible();
await page.getByTestId("cast-info-submit-button").click();

View File

@ -22,6 +22,7 @@
"pages.links": "Links",
"pages.art": "Art",
"pages.sendouq": "SendouQ",
"pages.lfg": "LFG",
"header.profile": "Profile",
"header.logout": "Log out",
@ -45,10 +46,13 @@
"actions.submit": "Submit",
"actions.edit": "Edit",
"actions.add": "Add",
"actions.addNew": "Add new",
"actions.remove": "Remove",
"actions.delete": "Delete",
"actions.reset": "Reset",
"actions.loadMore": "Load more",
"actions.showMore": "Show more",
"actions.showLess": "Show less",
"actions.copyToClipboard": "Copy to clipboard",
"actions.create": "Create",
"actions.close": "Close",
@ -61,6 +65,8 @@
"actions.join": "Join",
"actions.nevermind": "Nevermind",
"actions.upload": "Upload",
"actions.clickHere": "Click here",
"actions.goBack": "Go back",
"maps.createMapList": "Create map list",
"maps.halfSz": "50% SZ",

30
locales/en/lfg.json Normal file
View File

@ -0,0 +1,30 @@
{
"types.PLAYER_FOR_TEAM": "Looking for team",
"types.TEAM_FOR_PLAYER": "Looking for players",
"types.TEAM_FOR_COACH": "Looking for coach",
"types.COACH_FOR_TEAM": "Offering coaching",
"post.lastActive": "last active",
"noPosts": "No posts matching the filter",
"expiring": "Post is expiring. Still looking?",
"addFilter": "Add filter",
"filters.Weapon": "Weapon pool",
"filters.Type": "Post type",
"filters.Timezone": "Timezone hour difference",
"filters.Language": "Spoken language",
"filters.PlusTier": "Plus tier",
"filters.MaxTier": "Max tier",
"filters.MinTier": "Min tier",
"filters.suffix": "filter",
"filters.orAbove": "or above",
"new.noMorePosts": "You can't create any more posts",
"new.type.header": "Type",
"new.timezone.header": "Timezone",
"new.text.header": "Text",
"new.visibility.header": "Visibility",
"new.visibility.everyone": "Everyone",
"new.editOn": "Edit on your",
"new.weaponPool.header": "Weapon pool",
"new.weaponPool.userProfile": "user profile",
"new.languages.header": "Languages",
"new.languages.sqSettingsPage": "SendouQ settings page"
}

29
migrations/055-lfg.js Normal file
View File

@ -0,0 +1,29 @@
export function up(db) {
db.transaction(() => {
db.prepare(
/*sql*/ `
create table "LFGPost" (
"id" integer primary key,
"type" text not null,
"text" text not null,
"timezone" text not null,
"authorId" integer not null,
"teamId" integer,
"plusTierVisibility" integer,
"updatedAt" integer default (strftime('%s', 'now')) not null,
"createdAt" integer default (strftime('%s', 'now')) not null,
foreign key ("authorId") references "User"("id") on delete restrict,
foreign key ("teamId") references "AllTeam"("id") on delete cascade,
unique("authorId", "type") on conflict rollback
) strict
`,
).run();
db.prepare(
/*sql*/ `create index lfg_post_author_id on "LFGPost"("authorId")`,
).run();
db.prepare(
/*sql*/ `create index lfg_post_team_id on "LFGPost"("teamId")`,
).run();
})();
}

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

View File

@ -16,6 +16,7 @@ import type team from "../public/locales/en/team.json";
import type vods from "../public/locales/en/vods.json";
import type art from "../public/locales/en/art.json";
import type q from "../public/locales/en/q.json";
import type lfg from "../public/locales/en/lfg.json";
declare module "react-i18next" {
interface CustomTypeOptions {
@ -37,6 +38,7 @@ declare module "react-i18next" {
vods: typeof vods;
art: typeof art;
q: typeof q;
lfg: typeof lfg;
};
}
}

View File

@ -265,6 +265,9 @@ export default defineConfig(() => {
route("/tiers", "features/sendouq/routes/tiers.tsx");
route("/lfg", "features/lfg/routes/lfg.tsx");
route("/lfg/new", "features/lfg/routes/lfg.new.tsx");
route("/admin", "features/admin/routes/admin.tsx");
route("/a", "features/articles/routes/a.tsx");