mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-10-01 23:58:36 -05:00
Move app to apps/web-react in pnpm workspace layout
This commit is contained in:
32
apps/web-react/app/features/lfg/LFGRepository.server.test.ts
Normal file
32
apps/web-react/app/features/lfg/LFGRepository.server.test.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import * as LFGPostFactory from "~/db/seed/factories/LFGPostFactory";
|
||||
import * as UserFactory from "~/db/seed/factories/UserFactory";
|
||||
import * as LFGRepository from "./LFGRepository.server";
|
||||
|
||||
describe("LFGRepository.findAllPosts plus-tier visibility", () => {
|
||||
test("still returns the author's own post after they lose their plus tier", async () => {
|
||||
const author = await UserFactory.create(null, { plusTier: 2 });
|
||||
|
||||
const { id: postId } = await LFGPostFactory.create({
|
||||
type: "PLAYER_FOR_TEAM",
|
||||
authorId: author.id,
|
||||
plusTierVisibility: 2,
|
||||
});
|
||||
|
||||
// while +2, the author can of course see (and thus manage) their own post
|
||||
const whileMember = await LFGRepository.findAllPosts({
|
||||
id: author.id,
|
||||
plusTier: 2,
|
||||
});
|
||||
expect(whileMember.map((post) => post.id)).toContain(postId);
|
||||
|
||||
// next monthly voting drops the author from the plus server: plusTier -> null.
|
||||
// Their own post must remain visible to them, otherwise DELETE_POST / BUMP_POST
|
||||
// (which resolve the post through findAllPosts) 404 and it can never be taken down.
|
||||
const afterDrop = await LFGRepository.findAllPosts({
|
||||
id: author.id,
|
||||
plusTier: null,
|
||||
});
|
||||
expect(afterDrop.map((post) => post.id)).toContain(postId);
|
||||
});
|
||||
});
|
||||
171
apps/web-react/app/features/lfg/LFGRepository.server.ts
Normal file
171
apps/web-react/app/features/lfg/LFGRepository.server.ts
Normal file
@@ -0,0 +1,171 @@
|
||||
import { sub } from "date-fns";
|
||||
import { type NotNull, sql, type Transaction } from "kysely";
|
||||
import { db } from "~/db/sql";
|
||||
import type { DB, TablesInsertable } from "~/db/tables";
|
||||
import { databaseTimestampNow, dateToDatabaseTimestamp } from "~/utils/dates";
|
||||
import {
|
||||
commonUserSelect,
|
||||
concatUserSubmittedImagePrefix,
|
||||
jsonArrayFrom,
|
||||
jsonObjectFrom,
|
||||
userProfileWeapons,
|
||||
} from "~/utils/kysely.server";
|
||||
import { LFG } from "./lfg-constants";
|
||||
|
||||
export async function findAllPosts(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",
|
||||
"LFGPost.languages",
|
||||
jsonObjectFrom(
|
||||
eb
|
||||
.selectFrom("User")
|
||||
.leftJoin("PlusTier", "PlusTier.userId", "User.id")
|
||||
.select(({ eb: innerEb }) => [
|
||||
...commonUserSelect(innerEb),
|
||||
"User.languages",
|
||||
"User.country",
|
||||
"PlusTier.tier as plusTier",
|
||||
userProfileWeapons(innerEb).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",
|
||||
concatUserSubmittedImagePrefix(
|
||||
innerEb.ref("UserSubmittedImage.url"),
|
||||
).as("avatarUrl"),
|
||||
jsonArrayFrom(
|
||||
innerEb
|
||||
.selectFrom("TeamMemberWithSecondary")
|
||||
.innerJoin("User", "User.id", "TeamMemberWithSecondary.userId")
|
||||
.leftJoin("PlusTier", "PlusTier.userId", "User.id")
|
||||
.select(({ eb: innestEb }) => [
|
||||
...commonUserSelect(innestEb),
|
||||
"User.languages",
|
||||
"User.country",
|
||||
"PlusTier.tier as plusTier",
|
||||
userProfileWeapons(innestEb).as("weaponPool"),
|
||||
])
|
||||
.whereRef("TeamMemberWithSecondary.teamId", "=", "Team.id"),
|
||||
).as("members"),
|
||||
])
|
||||
.whereRef("Team.id", "=", "LFGPost.teamId"),
|
||||
).as("team"),
|
||||
])
|
||||
.orderBy(sql`LFGPost.authorId = ${sql`${userId}`} desc`)
|
||||
.orderBy("LFGPost.updatedAt", "desc")
|
||||
.orderBy("LFGPost.type", "asc")
|
||||
.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 (row.author.id === userId) return true;
|
||||
if (!user?.plusTier) return false;
|
||||
|
||||
return row.plusTierVisibility >= user.plusTier;
|
||||
})
|
||||
.map((row) => ({
|
||||
...row,
|
||||
permissions: {
|
||||
EDIT: [row.author.id],
|
||||
DELETE: [row.author.id],
|
||||
},
|
||||
}));
|
||||
}
|
||||
|
||||
const postExpiryCutoff = () =>
|
||||
sub(new Date(), { days: LFG.POST_FRESHNESS_DAYS });
|
||||
|
||||
export function insertPost(
|
||||
args: Omit<TablesInsertable["LFGPost"], "updatedAt">,
|
||||
) {
|
||||
return db
|
||||
.insertInto("LFGPost")
|
||||
.values(args)
|
||||
.returning("id")
|
||||
.executeTakeFirstOrThrow();
|
||||
}
|
||||
|
||||
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,
|
||||
languages: args.languages,
|
||||
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, trx?: Transaction<DB>) {
|
||||
return (trx ?? db)
|
||||
.deleteFrom("LFGPost")
|
||||
.where("teamId", "=", teamId)
|
||||
.execute();
|
||||
}
|
||||
|
||||
export async function findByAuthorUserId(authorId: number) {
|
||||
return db
|
||||
.selectFrom("LFGPost")
|
||||
.select(["id", "type"])
|
||||
.where("authorId", "=", authorId)
|
||||
.orderBy("updatedAt", "desc")
|
||||
.execute();
|
||||
}
|
||||
85
apps/web-react/app/features/lfg/actions/lfg.new.server.ts
Normal file
85
apps/web-react/app/features/lfg/actions/lfg.new.server.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
import type { ActionFunctionArgs } from "react-router";
|
||||
import { redirect } from "react-router";
|
||||
import type { Tables } from "~/db/tables";
|
||||
import { requireUser } from "~/features/auth/core/user.server";
|
||||
import * as UserRepository from "~/features/user-page/UserRepository.server";
|
||||
import { parseFormData } from "~/form/parse.server";
|
||||
import { requirePermission } from "~/modules/permissions/guards.server";
|
||||
import { errorToastIfFalsy } from "~/utils/remix.server";
|
||||
import { LFG_PAGE } from "~/utils/urls";
|
||||
import * as LFGRepository from "../LFGRepository.server";
|
||||
import { TEAM_POST_TYPES } from "../lfg-constants";
|
||||
import { lfgNewSchema } from "../lfg-schemas";
|
||||
|
||||
export const action = async ({ request }: ActionFunctionArgs) => {
|
||||
const user = requireUser();
|
||||
const result = await parseFormData({
|
||||
request,
|
||||
schema: lfgNewSchema,
|
||||
});
|
||||
|
||||
if (!result.success) {
|
||||
return { fieldErrors: result.fieldErrors };
|
||||
}
|
||||
|
||||
const data = result.data;
|
||||
const type = data.type as Tables["LFGPost"]["type"];
|
||||
|
||||
const identifier = String(user.id);
|
||||
const { team } =
|
||||
(await UserRepository.findProfileByIdentifier(identifier)) ?? {};
|
||||
|
||||
const shouldIncludeTeam = TEAM_POST_TYPES.includes(type);
|
||||
|
||||
errorToastIfFalsy(
|
||||
!shouldIncludeTeam || team,
|
||||
"Team needs to be set for this type of post",
|
||||
);
|
||||
|
||||
const plusTierVisibility = data.plusTierVisibility
|
||||
? Number(data.plusTierVisibility)
|
||||
: null;
|
||||
|
||||
if (data.postId) {
|
||||
await validateCanUpdatePost({
|
||||
postId: data.postId,
|
||||
user,
|
||||
});
|
||||
|
||||
await LFGRepository.updatePost(data.postId, {
|
||||
text: data.postText,
|
||||
timezone: data.timezone,
|
||||
type,
|
||||
teamId: shouldIncludeTeam ? team?.id : null,
|
||||
plusTierVisibility,
|
||||
languages:
|
||||
data.languages.length > 0 ? JSON.stringify(data.languages) : null,
|
||||
});
|
||||
} else {
|
||||
await LFGRepository.insertPost({
|
||||
text: data.postText,
|
||||
timezone: data.timezone,
|
||||
type,
|
||||
teamId: shouldIncludeTeam ? team?.id : null,
|
||||
authorId: user.id,
|
||||
plusTierVisibility,
|
||||
languages:
|
||||
data.languages.length > 0 ? JSON.stringify(data.languages) : null,
|
||||
});
|
||||
}
|
||||
|
||||
return redirect(LFG_PAGE);
|
||||
};
|
||||
|
||||
const validateCanUpdatePost = async ({
|
||||
postId,
|
||||
user,
|
||||
}: {
|
||||
postId: number;
|
||||
user: { id: number; plusTier: number | null };
|
||||
}) => {
|
||||
const posts = await LFGRepository.findAllPosts(user);
|
||||
const post = posts.find((post) => post.id === postId);
|
||||
errorToastIfFalsy(post, "Post to update not found");
|
||||
requirePermission(post, "EDIT");
|
||||
};
|
||||
33
apps/web-react/app/features/lfg/actions/lfg.server.ts
Normal file
33
apps/web-react/app/features/lfg/actions/lfg.server.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import type { ActionFunctionArgs } from "react-router";
|
||||
import { requireUser } from "~/features/auth/core/user.server";
|
||||
import { requirePermission } from "~/modules/permissions/guards.server";
|
||||
import { errorToastIfFalsy, parseRequestPayload } from "~/utils/remix.server";
|
||||
import * as LFGRepository from "../LFGRepository.server";
|
||||
import { lfgActionSchema } from "../lfg-schemas";
|
||||
|
||||
export const action = async ({ request }: ActionFunctionArgs) => {
|
||||
const user = requireUser();
|
||||
const data = await parseRequestPayload({
|
||||
request,
|
||||
schema: lfgActionSchema,
|
||||
});
|
||||
|
||||
const posts = await LFGRepository.findAllPosts(user);
|
||||
const post = posts.find((post) => post.id === data.id);
|
||||
errorToastIfFalsy(post, "Post not found");
|
||||
|
||||
switch (data._action) {
|
||||
case "DELETE_POST": {
|
||||
requirePermission(post, "DELETE");
|
||||
await LFGRepository.deletePost(data.id);
|
||||
break;
|
||||
}
|
||||
case "BUMP_POST": {
|
||||
requirePermission(post, "EDIT");
|
||||
await LFGRepository.bumpPost(data.id);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
119
apps/web-react/app/features/lfg/components/LFGPost.module.css
Normal file
119
apps/web-react/app/features/lfg/components/LFGPost.module.css
Normal file
@@ -0,0 +1,119 @@
|
||||
.wideLayout {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
align-items: flex-start;
|
||||
column-gap: var(--s-6);
|
||||
row-gap: var(--s-2);
|
||||
|
||||
& .leftRow {
|
||||
display: flex;
|
||||
gap: var(--s-2);
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
|
||||
@container (width >= 640px) {
|
||||
.wideLayout {
|
||||
grid-template-columns: 1fr 2fr;
|
||||
row-gap: 0;
|
||||
}
|
||||
|
||||
.wideLayout .leftRow {
|
||||
position: sticky;
|
||||
top: 55px;
|
||||
}
|
||||
}
|
||||
|
||||
.userName {
|
||||
max-width: 125px;
|
||||
text-overflow: ellipsis;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.teamMemberName {
|
||||
max-width: 75px;
|
||||
text-overflow: ellipsis;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
font-size: var(--font-xs);
|
||||
font-weight: var(--weight-semi);
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.textContainer {
|
||||
position: relative;
|
||||
max-height: 150px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.textContainerExpanded {
|
||||
position: relative;
|
||||
margin-bottom: var(--s-6);
|
||||
}
|
||||
|
||||
.text {
|
||||
word-wrap: break-word;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.textCut {
|
||||
background-image: linear-gradient(to bottom, transparent, var(--color-bg));
|
||||
width: 100%;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.showAllButton {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
width: max-content;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.showAllButtonExpanded {
|
||||
bottom: -35px;
|
||||
}
|
||||
|
||||
.pill {
|
||||
font-size: var(--font-2xs);
|
||||
font-weight: var(--weight-semi);
|
||||
border-radius: var(--radius-selector);
|
||||
background-color: var(--color-bg-high);
|
||||
padding: 0 var(--s-2);
|
||||
height: var(--selector-size-sm);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--s-0-5);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.pillPlaceholder {
|
||||
visibility: hidden;
|
||||
width: 37px;
|
||||
}
|
||||
|
||||
.editButton {
|
||||
font-size: var(--font-2xs);
|
||||
font-weight: var(--weight-semi);
|
||||
border-radius: var(--radius-selector);
|
||||
background-color: var(--color-bg-higher);
|
||||
padding: 0 var(--s-2);
|
||||
height: var(--selector-size-sm);
|
||||
color: var(--color-text);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--s-1);
|
||||
|
||||
& > svg {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
}
|
||||
}
|
||||
409
apps/web-react/app/features/lfg/components/LFGPost.tsx
Normal file
409
apps/web-react/app/features/lfg/components/LFGPost.tsx
Normal file
@@ -0,0 +1,409 @@
|
||||
import clsx from "clsx";
|
||||
import { SquarePen, Trash } from "lucide-react";
|
||||
import React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Link, useFetcher } from "react-router";
|
||||
import { Avatar } from "~/components/Avatar";
|
||||
import { Divider } from "~/components/Divider";
|
||||
import { SendouButton } from "~/components/elements/Button";
|
||||
import { Flag } from "~/components/Flag";
|
||||
import { FormWithConfirm } from "~/components/FormWithConfirm";
|
||||
import { WeaponImage } from "~/components/Image";
|
||||
import { LocaleTime } from "~/components/LocaleTime";
|
||||
import { NoteAvatar } from "~/components/NoteAvatar";
|
||||
import { lfgNewPostPage } from "~/features/lfg/lfg-urls";
|
||||
import {
|
||||
UserCard,
|
||||
useUserCardData,
|
||||
} from "~/features/user-card/components/UserCard";
|
||||
import { useFormatDistanceToNow } from "~/hooks/intl/useFormatDistanceToNow";
|
||||
import { useHydrated } from "~/hooks/useHydrated";
|
||||
import type { UnifiedLanguageCode } from "~/modules/i18n/config";
|
||||
import { useHasPermission } from "~/modules/permissions/hooks";
|
||||
import { databaseTimestampToDate } from "~/utils/dates";
|
||||
import { hourDifferenceBetweenTimezones } from "../core/timezone";
|
||||
import type { LFGLoaderData } from "../routes/lfg";
|
||||
|
||||
import styles from "./LFGPost.module.css";
|
||||
|
||||
type Post = LFGLoaderData["posts"][number];
|
||||
|
||||
export function LFGPost({ post }: { post: Post }) {
|
||||
if (post.team) {
|
||||
return <TeamLFGPost post={{ ...post, team: post.team }} />;
|
||||
}
|
||||
|
||||
return <UserLFGPost post={post} />;
|
||||
}
|
||||
|
||||
const USER_POST_EXPANDABLE_CRITERIA = 300;
|
||||
function UserLFGPost({ post }: { post: Post }) {
|
||||
const canEdit = useHasPermission(post, "EDIT");
|
||||
const canDelete = useHasPermission(post, "DELETE");
|
||||
const [isExpanded, setIsExpanded] = React.useState(false);
|
||||
|
||||
return (
|
||||
<div className={styles.wideLayout}>
|
||||
<div className={styles.leftRow}>
|
||||
<PostUserHeader
|
||||
author={post.author}
|
||||
includeWeapons={post.type !== "COACH_FOR_TEAM"}
|
||||
/>
|
||||
<PostTime createdAt={post.createdAt} updatedAt={post.updatedAt} />
|
||||
<PostPills
|
||||
languages={post.languages}
|
||||
timezone={post.timezone}
|
||||
canEdit={canEdit}
|
||||
postId={post.id}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<div className="stack horizontal justify-between items-center">
|
||||
<PostTextTypeHeader type={post.type} />
|
||||
{canDelete ? (
|
||||
<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,
|
||||
}: {
|
||||
post: Post & { team: NonNullable<Post["team"]> };
|
||||
}) {
|
||||
const isHydrated = useHydrated();
|
||||
const canEdit = useHasPermission(post, "EDIT");
|
||||
const canDelete = useHasPermission(post, "DELETE");
|
||||
const [isExpanded, setIsExpanded] = React.useState(false);
|
||||
|
||||
return (
|
||||
<div className={styles.wideLayout}>
|
||||
<div className="stack md">
|
||||
<div className="stack xs">
|
||||
<div className="stack horizontal items-center justify-between">
|
||||
<PostTeamLogoHeader team={post.team} />
|
||||
<div className="stack horizontal items-center sm">
|
||||
{isHydrated && <PostTimezonePill timezone={post.timezone} />}
|
||||
{post.languages ? (
|
||||
<PostLanguagePill languages={post.languages} />
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
<Divider />
|
||||
<div className="stack horizontal justify-between items-center">
|
||||
<PostTime createdAt={post.createdAt} updatedAt={post.updatedAt} />
|
||||
{canEdit ? <PostEditButton id={post.id} /> : null}
|
||||
</div>
|
||||
</div>
|
||||
{isExpanded ? (
|
||||
<PostTeamMembersFull team={post.team} />
|
||||
) : (
|
||||
<PostTeamMembersPeek team={post.team} />
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<div className="stack horizontal justify-between">
|
||||
<PostTextTypeHeader type={post.type} />
|
||||
{canDelete ? (
|
||||
<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={team.avatarUrl} /> : null}
|
||||
{team.name}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PostTeamMembersPeek({ team }: { team: NonNullable<Post["team"]> }) {
|
||||
return (
|
||||
<div className="stack sm xs-row horizontal flex-wrap">
|
||||
{team.members.map((member) => (
|
||||
<PostTeamMember key={member.id} member={member} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PostTeamMembersFull({ team }: { team: NonNullable<Post["team"]> }) {
|
||||
return (
|
||||
<div className="stack lg">
|
||||
{team.members.map((member) => (
|
||||
<PostUserHeader key={member.id} author={member} includeWeapons />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PostTeamMember({
|
||||
member,
|
||||
}: {
|
||||
member: NonNullable<Post["team"]>["members"][number];
|
||||
}) {
|
||||
const cardData = useUserCardData(member.id);
|
||||
|
||||
return (
|
||||
<div className="stack sm items-center flex-same-size">
|
||||
<UserCard userId={member.id} withMutualFriends>
|
||||
<span className="stack sm items-center">
|
||||
<NoteAvatar sentiment={cardData?.privateNote?.sentiment} size="sm">
|
||||
<Avatar size="xs" user={member} />
|
||||
</NoteAvatar>
|
||||
<span className={styles.teamMemberName}>{member.username}</span>
|
||||
</span>
|
||||
</UserCard>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PostUserHeader({
|
||||
author,
|
||||
includeWeapons,
|
||||
}: {
|
||||
author: Post["author"];
|
||||
includeWeapons: boolean;
|
||||
}) {
|
||||
const cardData = useUserCardData(author.id);
|
||||
|
||||
return (
|
||||
<div className="stack sm">
|
||||
<div className="stack sm horizontal items-center">
|
||||
<div className="stack horizontal sm items-center text-md font-bold">
|
||||
<UserCard userId={author.id} withMutualFriends>
|
||||
<span className="stack sm horizontal items-center">
|
||||
<NoteAvatar
|
||||
sentiment={cardData?.privateNote?.sentiment}
|
||||
size="md"
|
||||
>
|
||||
<Avatar size="xsm" user={author} />
|
||||
</NoteAvatar>
|
||||
<span className={styles.userName}>{author.username}</span>
|
||||
</span>
|
||||
</UserCard>{" "}
|
||||
{author.country ? <Flag countryCode={author.country} tiny /> : null}
|
||||
</div>
|
||||
</div>
|
||||
{includeWeapons ? (
|
||||
<div className="stack horizontal sm">
|
||||
{author.weaponPool.map((weapon) => (
|
||||
<WeaponImage key={weapon.weaponSplId} weapon={weapon} size={32} />
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PostTime({
|
||||
createdAt,
|
||||
updatedAt,
|
||||
}: {
|
||||
createdAt: number;
|
||||
updatedAt: number;
|
||||
}) {
|
||||
const { t } = useTranslation(["lfg"]);
|
||||
const formatDistanceToNow = useFormatDistanceToNow();
|
||||
|
||||
const createdAtDate = databaseTimestampToDate(createdAt);
|
||||
const updatedAtDate = databaseTimestampToDate(updatedAt);
|
||||
const overDayDifferenceBetween =
|
||||
updatedAtDate.getTime() - createdAtDate.getTime() > 1000 * 60 * 60 * 24;
|
||||
|
||||
return (
|
||||
<div className="text-lighter text-xs font-bold">
|
||||
<LocaleTime
|
||||
date={createdAtDate}
|
||||
options={{
|
||||
month: "numeric",
|
||||
day: "numeric",
|
||||
}}
|
||||
/>{" "}
|
||||
{overDayDifferenceBetween ? (
|
||||
<div className="text-xxs">
|
||||
<i>
|
||||
({t("lfg:post.lastActive")}{" "}
|
||||
{formatDistanceToNow(updatedAtDate, {
|
||||
addSuffix: true,
|
||||
})}
|
||||
)
|
||||
</i>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PostPills({
|
||||
timezone,
|
||||
languages,
|
||||
canEdit,
|
||||
postId,
|
||||
}: {
|
||||
timezone?: string | null;
|
||||
languages?: UnifiedLanguageCode[] | null;
|
||||
canEdit?: boolean;
|
||||
postId: number;
|
||||
}) {
|
||||
const isHydrated = useHydrated();
|
||||
|
||||
return (
|
||||
<div
|
||||
className={clsx("stack sm xs-row horizontal flex-wrap", {
|
||||
invisible: !isHydrated,
|
||||
})}
|
||||
>
|
||||
{typeof timezone === "string" && isHydrated && (
|
||||
<PostTimezonePill timezone={timezone} />
|
||||
)}
|
||||
{!isHydrated && <PostTimezonePillPlaceholder />}
|
||||
{languages ? <PostLanguagePill languages={languages} /> : null}
|
||||
{canEdit && <PostEditButton id={postId} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PostTimezonePillPlaceholder() {
|
||||
return <div className={clsx(styles.pill, styles.pillPlaceholder)} />;
|
||||
}
|
||||
|
||||
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";
|
||||
}
|
||||
if (absDiff <= 6) {
|
||||
return "text-warning";
|
||||
}
|
||||
return "text-error";
|
||||
};
|
||||
|
||||
return (
|
||||
<div title={timezone} className={clsx(styles.pill, textColorClass())}>
|
||||
{diff === 0 ? "±" : ""}
|
||||
{diff > 0 ? "+" : ""}
|
||||
{diff}h
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PostLanguagePill({ languages }: { languages: UnifiedLanguageCode[] }) {
|
||||
return (
|
||||
<div className={styles.pill}>{languages.join(" / ").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={styles.editButton} to={lfgNewPostPage(id)}>
|
||||
<SquarePen />
|
||||
{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}`) as any).toLowerCase()})?`}
|
||||
fields={[
|
||||
["id", id],
|
||||
["_action", "DELETE_POST"],
|
||||
]}
|
||||
fetcher={fetcher}
|
||||
>
|
||||
<SendouButton
|
||||
className="small-text"
|
||||
variant="minimal-destructive"
|
||||
size="small"
|
||||
type="submit"
|
||||
icon={<Trash />}
|
||||
>
|
||||
{t("common:actions.delete")}
|
||||
</SendouButton>
|
||||
</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({
|
||||
[styles.textContainer]: !isExpanded,
|
||||
[styles.textContainerExpanded]: isExpanded,
|
||||
})}
|
||||
>
|
||||
<div className={styles.text}>{text}</div>
|
||||
{isExpandable ? (
|
||||
<SendouButton
|
||||
onPress={() => setIsExpanded(!isExpanded)}
|
||||
className={clsx([styles.showAllButton], {
|
||||
[styles.showAllButtonExpanded]: isExpanded,
|
||||
})}
|
||||
variant="outlined"
|
||||
size="small"
|
||||
>
|
||||
{isExpanded
|
||||
? t("common:actions.showLess")
|
||||
: t("common:actions.showMore")}
|
||||
</SendouButton>
|
||||
) : null}
|
||||
{!isExpanded ? <div className={styles.textCut} /> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
73
apps/web-react/app/features/lfg/core/filtering.test.ts
Normal file
73
apps/web-react/app/features/lfg/core/filtering.test.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import type { LFGFilterValues } from "../lfg-types";
|
||||
import { type FilterablePost, filterPosts } from "./filtering";
|
||||
|
||||
const postOfType = (type: FilterablePost["type"]) =>
|
||||
({
|
||||
type,
|
||||
author: { weaponPool: [] },
|
||||
team: null,
|
||||
}) as unknown as FilterablePost;
|
||||
|
||||
const noFilters: LFGFilterValues = {
|
||||
weapons: [],
|
||||
type: null,
|
||||
timezone: null,
|
||||
language: null,
|
||||
plusTier: null,
|
||||
minTier: null,
|
||||
maxTier: null,
|
||||
};
|
||||
|
||||
describe("filterPosts", () => {
|
||||
test("no weapons selected shows every post", () => {
|
||||
const posts = [postOfType("PLAYER_FOR_TEAM"), postOfType("COACH_FOR_TEAM")];
|
||||
|
||||
const filtered = filterPosts(posts, noFilters, {
|
||||
tiersMap: new Map(),
|
||||
viewerTimezone: null,
|
||||
});
|
||||
|
||||
expect(filtered).toHaveLength(2);
|
||||
});
|
||||
|
||||
describe("timezone filter", () => {
|
||||
test("shows a post from across the date line when local clock times match", () => {
|
||||
// Pacific/Kiritimati (UTC+14) and Pacific/Honolulu (UTC-10) share the
|
||||
// exact same local clock time year round
|
||||
const post = {
|
||||
...postOfType("PLAYER_FOR_TEAM"),
|
||||
timezone: "Pacific/Kiritimati",
|
||||
};
|
||||
|
||||
const filtered = filterPosts(
|
||||
[post],
|
||||
{ ...noFilters, timezone: 3 },
|
||||
{
|
||||
tiersMap: new Map(),
|
||||
viewerTimezone: "Pacific/Honolulu",
|
||||
},
|
||||
);
|
||||
|
||||
expect(filtered).toHaveLength(1);
|
||||
});
|
||||
|
||||
test("skips the timezone filter when the viewer's timezone is unknown", () => {
|
||||
const post = {
|
||||
...postOfType("PLAYER_FOR_TEAM"),
|
||||
timezone: "Asia/Tokyo",
|
||||
};
|
||||
|
||||
const filtered = filterPosts(
|
||||
[post],
|
||||
{ ...noFilters, timezone: 0 },
|
||||
{
|
||||
tiersMap: new Map(),
|
||||
viewerTimezone: null,
|
||||
},
|
||||
);
|
||||
|
||||
expect(filtered).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
186
apps/web-react/app/features/lfg/core/filtering.ts
Normal file
186
apps/web-react/app/features/lfg/core/filtering.ts
Normal file
@@ -0,0 +1,186 @@
|
||||
import type { TierName } from "~/features/mmr/mmr-constants";
|
||||
import { compareTwoTiers } from "~/features/mmr/mmr-utils";
|
||||
import type { TieredSkill } from "~/features/mmr/tiered.server";
|
||||
import type { MainWeaponId } from "~/modules/in-game-lists/types";
|
||||
import {
|
||||
mainWeaponIds,
|
||||
weaponIdToBaseWeaponId,
|
||||
} from "~/modules/in-game-lists/weapon-ids";
|
||||
import type { Unpacked } from "~/utils/types";
|
||||
import type * as LFGRepository from "../LFGRepository.server";
|
||||
import type { LFGFilterValues } from "../lfg-types";
|
||||
import { createTimezoneHourDifference } from "./timezone";
|
||||
|
||||
export type FilterablePost = Unpacked<
|
||||
Awaited<ReturnType<typeof LFGRepository.findAllPosts>>
|
||||
>;
|
||||
|
||||
export type TiersMap = Map<
|
||||
number,
|
||||
{ latest?: TieredSkill["tier"]; previous?: TieredSkill["tier"] }
|
||||
>;
|
||||
|
||||
export interface FilterContext {
|
||||
tiersMap: TiersMap;
|
||||
/** `null` when the viewer's timezone is unknown, which disables the timezone filter. */
|
||||
viewerTimezone: string | null;
|
||||
}
|
||||
|
||||
interface PostFilterContext extends FilterContext {
|
||||
hourDifference: ReturnType<typeof createTimezoneHourDifference>;
|
||||
}
|
||||
|
||||
export function filterPosts(
|
||||
posts: Array<FilterablePost>,
|
||||
filters: LFGFilterValues,
|
||||
context: FilterContext,
|
||||
) {
|
||||
const hourDifference = createTimezoneHourDifference();
|
||||
|
||||
return posts.filter((post) =>
|
||||
postMatchesFilters(post, filters, { ...context, hourDifference }),
|
||||
);
|
||||
}
|
||||
|
||||
function postMatchesFilters(
|
||||
post: FilterablePost,
|
||||
filters: LFGFilterValues,
|
||||
context: PostFilterContext,
|
||||
) {
|
||||
if (
|
||||
post.type === "COACH_FOR_TEAM" &&
|
||||
// not visible in the UI
|
||||
(filters.weapons.length > 0 ||
|
||||
filters.minTier !== null ||
|
||||
filters.maxTier !== null)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (filters.weapons.length > 0 && !matchesWeapons(post, filters.weapons)) {
|
||||
return false;
|
||||
}
|
||||
if (filters.type !== null && post.type !== filters.type) return false;
|
||||
if (
|
||||
filters.timezone !== null &&
|
||||
!matchesTimezone(post, filters.timezone, context)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
if (
|
||||
filters.language !== null &&
|
||||
!post.languages?.includes(filters.language)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
if (filters.plusTier !== null && !matchesPlusTier(post, filters.plusTier)) {
|
||||
return false;
|
||||
}
|
||||
if (
|
||||
filters.maxTier !== null &&
|
||||
!matchesMaxTier(post, filters.maxTier, context.tiersMap)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
if (
|
||||
filters.minTier !== null &&
|
||||
!matchesMinTier(post, filters.minTier, context.tiersMap)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
function matchesWeapons(post: FilterablePost, weapons: MainWeaponId[]) {
|
||||
const weaponIdsWithRelated = weapons.flatMap(weaponIdToRelated);
|
||||
|
||||
return checkMatchesSomeUserInPost(post, (user) =>
|
||||
user.weaponPool.some(({ weaponSplId }) =>
|
||||
weaponIdsWithRelated.includes(weaponSplId),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
function matchesTimezone(
|
||||
post: FilterablePost,
|
||||
maxHourDifference: number,
|
||||
{ viewerTimezone, hourDifference }: PostFilterContext,
|
||||
) {
|
||||
// nothing to compare against until the browser has reported its timezone
|
||||
if (viewerTimezone === null) return true;
|
||||
|
||||
return (
|
||||
Math.abs(hourDifference(post.timezone, viewerTimezone)) <= maxHourDifference
|
||||
);
|
||||
}
|
||||
|
||||
function matchesPlusTier(post: FilterablePost, plusTier: number) {
|
||||
return checkMatchesSomeUserInPost(
|
||||
post,
|
||||
(user) => user.plusTier && user.plusTier <= plusTier,
|
||||
);
|
||||
}
|
||||
|
||||
function matchesMaxTier(
|
||||
post: FilterablePost,
|
||||
maxTier: TierName,
|
||||
tiersMap: TiersMap,
|
||||
) {
|
||||
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;
|
||||
}
|
||||
|
||||
if (tiers.previous && compareTwoTiers(tiers.previous.name, maxTier) >= 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
});
|
||||
}
|
||||
|
||||
function matchesMinTier(
|
||||
post: FilterablePost,
|
||||
minTier: TierName,
|
||||
tiersMap: TiersMap,
|
||||
) {
|
||||
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 (tiers.previous && compareTwoTiers(tiers.previous.name, minTier) <= 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
});
|
||||
}
|
||||
|
||||
const checkMatchesSomeUserInPost = (
|
||||
post: FilterablePost,
|
||||
check: (user: FilterablePost["author"]) => boolean | undefined | null | 0,
|
||||
) => {
|
||||
if (check(post.author)) return true;
|
||||
if (post.team?.members.some(check)) return true;
|
||||
return false;
|
||||
};
|
||||
|
||||
const weaponIdToRelated = (weaponSplId: MainWeaponId) => {
|
||||
const result: MainWeaponId[] = [];
|
||||
|
||||
for (const id of mainWeaponIds) {
|
||||
if (weaponIdToBaseWeaponId(id) === weaponIdToBaseWeaponId(weaponSplId)) {
|
||||
result.push(id);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
};
|
||||
22
apps/web-react/app/features/lfg/core/timezone.test.ts
Normal file
22
apps/web-react/app/features/lfg/core/timezone.test.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { hourDifferenceBetweenTimezones } from "./timezone";
|
||||
|
||||
describe("hourDifferenceBetweenTimezones", () => {
|
||||
// Pacific/Kiritimati is UTC+14 and Pacific/Honolulu is UTC-10, neither
|
||||
// observes DST -> their local clocks show the exact same time year round
|
||||
test("timezones with identical local clock time across the date line have zero difference", () => {
|
||||
expect(
|
||||
hourDifferenceBetweenTimezones("Pacific/Kiritimati", "Pacific/Honolulu"),
|
||||
).toBe(0);
|
||||
});
|
||||
|
||||
// Asia/Tokyo (UTC+9) and Pacific/Honolulu (UTC-10) are 19 hours apart on
|
||||
// paper but their local clocks only differ by 5 hours
|
||||
test("difference is never more than 12 hours in either direction", () => {
|
||||
expect(
|
||||
Math.abs(
|
||||
hourDifferenceBetweenTimezones("Asia/Tokyo", "Pacific/Honolulu"),
|
||||
),
|
||||
).toBeLessThanOrEqual(12);
|
||||
});
|
||||
});
|
||||
56
apps/web-react/app/features/lfg/core/timezone.ts
Normal file
56
apps/web-react/app/features/lfg/core/timezone.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
// timezone example is 'Asia/Tokyo'
|
||||
|
||||
/** Hours between the local clock times of two timezones, wrapped to [-12, 12]. */
|
||||
export function hourDifferenceBetweenTimezones(
|
||||
timezone1: string,
|
||||
timezone2: string,
|
||||
) {
|
||||
return createTimezoneHourDifference()(timezone1, timezone2);
|
||||
}
|
||||
|
||||
/**
|
||||
* Same as {@link hourDifferenceBetweenTimezones}, but resolving each timezone's
|
||||
* offset only once. Resolving an offset is expensive, so comparing many
|
||||
* timezones (filtering a list of posts, say) should share one instance.
|
||||
*/
|
||||
export function createTimezoneHourDifference() {
|
||||
const offsets = new Map<string, number>();
|
||||
|
||||
const offsetOf = (timezone: string) => {
|
||||
const cached = offsets.get(timezone);
|
||||
if (typeof cached === "number") return cached;
|
||||
|
||||
const offset = getTimezoneOffset(timezone);
|
||||
offsets.set(timezone, offset);
|
||||
|
||||
return offset;
|
||||
};
|
||||
|
||||
return (timezone1: string, timezone2: string) => {
|
||||
const rawDifference = (offsetOf(timezone1) - offsetOf(timezone2)) / 60;
|
||||
|
||||
// wrap to [-12, 12] so timezones across the date line compare by local clock time
|
||||
return ((((rawDifference + 12) % 24) + 24) % 24) - 12;
|
||||
};
|
||||
}
|
||||
|
||||
// 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;
|
||||
}
|
||||
376
apps/web-react/app/features/lfg/lfg-constants.ts
Normal file
376
apps/web-react/app/features/lfg/lfg-constants.ts
Normal file
@@ -0,0 +1,376 @@
|
||||
import type { Tables } from "~/db/tables";
|
||||
|
||||
export const LFG_TYPES = [
|
||||
"PLAYER_FOR_TEAM",
|
||||
"PLAYER_FOR_COACH",
|
||||
"TEAM_FOR_PLAYER",
|
||||
"TEAM_FOR_COACH",
|
||||
"TEAM_FOR_SCRIM",
|
||||
"COACH_FOR_TEAM",
|
||||
] as const;
|
||||
|
||||
export type LFGType = (typeof LFG_TYPES)[number];
|
||||
|
||||
export const LFG = {
|
||||
MIN_TEXT_LENGTH: 1,
|
||||
MAX_TEXT_LENGTH: 2_000,
|
||||
POST_FRESHNESS_DAYS: 30 as const,
|
||||
MAX_WEAPON_FILTERS: 10,
|
||||
POSTS_PER_PAGE: 24,
|
||||
types: LFG_TYPES,
|
||||
};
|
||||
|
||||
export const TEAM_POST_TYPES: Array<Tables["LFGPost"]["type"]> = [
|
||||
"TEAM_FOR_COACH",
|
||||
"TEAM_FOR_PLAYER",
|
||||
"TEAM_FOR_SCRIM",
|
||||
];
|
||||
|
||||
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",
|
||||
];
|
||||
51
apps/web-react/app/features/lfg/lfg-schemas.ts
Normal file
51
apps/web-react/app/features/lfg/lfg-schemas.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import { z } from "zod";
|
||||
import { LANGUAGE_OPTIONS } from "~/features/settings/match-profile-schemas";
|
||||
import {
|
||||
checkboxGroup,
|
||||
idConstantOptional,
|
||||
selectDynamic,
|
||||
selectDynamicOptional,
|
||||
textArea,
|
||||
} from "~/form/fields";
|
||||
import { _action, id } from "~/utils/zod";
|
||||
import { LFG, TIMEZONES } from "./lfg-constants";
|
||||
|
||||
export const lfgNewSchema = z
|
||||
.object({
|
||||
postId: idConstantOptional(),
|
||||
type: selectDynamic({ label: "labels.type" }),
|
||||
timezone: selectDynamic({ label: "labels.timezone" }),
|
||||
postText: textArea({
|
||||
label: "labels.text",
|
||||
maxLength: LFG.MAX_TEXT_LENGTH,
|
||||
}),
|
||||
plusTierVisibility: selectDynamicOptional({
|
||||
label: "labels.visibility",
|
||||
}),
|
||||
languages: checkboxGroup({
|
||||
label: "labels.languages",
|
||||
items: LANGUAGE_OPTIONS,
|
||||
}),
|
||||
})
|
||||
.refine(
|
||||
(data) => LFG.types.includes(data.type as (typeof LFG.types)[number]),
|
||||
{
|
||||
message: "Invalid LFG type",
|
||||
path: ["type"],
|
||||
},
|
||||
)
|
||||
.refine((data) => TIMEZONES.includes(data.timezone), {
|
||||
message: "Invalid timezone",
|
||||
path: ["timezone"],
|
||||
});
|
||||
|
||||
export const lfgActionSchema = z.union([
|
||||
z.object({
|
||||
_action: _action("DELETE_POST"),
|
||||
id,
|
||||
}),
|
||||
z.object({
|
||||
_action: _action("BUMP_POST"),
|
||||
id,
|
||||
}),
|
||||
]);
|
||||
48
apps/web-react/app/features/lfg/lfg-search-params.test.ts
Normal file
48
apps/web-react/app/features/lfg/lfg-search-params.test.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import { describe, test } from "vitest";
|
||||
import {
|
||||
assertDecodesToDefault,
|
||||
assertRoundTrips,
|
||||
} from "~/modules/search-params/search-params-test-utils";
|
||||
import { lfgNewSearchParams, lfgSearchParams } from "./lfg-search-params";
|
||||
|
||||
describe("lfgSearchParams", () => {
|
||||
test("round-trips", () => {
|
||||
assertRoundTrips(lfgSearchParams, {
|
||||
page: [1, 2, 9],
|
||||
post: [null, 1, 123],
|
||||
weapons: [[], [0], [0, 10, 4001]],
|
||||
type: [null, "PLAYER_FOR_TEAM", "COACH_FOR_TEAM"],
|
||||
timezone: [null, 0, 3, 12],
|
||||
language: [null, "en", "ja"],
|
||||
plusTier: [null, 1, 3],
|
||||
minTier: [null, "GOLD", "LEVIATHAN"],
|
||||
maxTier: [null, "PLATINUM", "IRON"],
|
||||
});
|
||||
});
|
||||
|
||||
test("decodes garbage to defaults", () => {
|
||||
assertDecodesToDefault(lfgSearchParams, "page", [["0"], ["-1"], ["abc"]]);
|
||||
assertDecodesToDefault(lfgSearchParams, "post", [["abc"], ["0"]]);
|
||||
assertDecodesToDefault(lfgSearchParams, "type", [["NOT_A_TYPE"], [""]]);
|
||||
assertDecodesToDefault(lfgSearchParams, "timezone", [
|
||||
["13"],
|
||||
["-1"],
|
||||
["abc"],
|
||||
]);
|
||||
assertDecodesToDefault(lfgSearchParams, "language", [["xx"]]);
|
||||
assertDecodesToDefault(lfgSearchParams, "plusTier", [["0"], ["4"]]);
|
||||
assertDecodesToDefault(lfgSearchParams, "minTier", [["gold"], ["XX"]]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("lfgNewSearchParams", () => {
|
||||
test("round-trips", () => {
|
||||
assertRoundTrips(lfgNewSearchParams, {
|
||||
postId: [1, 123],
|
||||
});
|
||||
});
|
||||
|
||||
test("garbage decodes to default", () => {
|
||||
assertDecodesToDefault(lfgNewSearchParams, "postId", [["abc"], ["0"]]);
|
||||
});
|
||||
});
|
||||
42
apps/web-react/app/features/lfg/lfg-search-params.ts
Normal file
42
apps/web-react/app/features/lfg/lfg-search-params.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import { z } from "zod";
|
||||
import { TIERS, type TierName } from "~/features/mmr/mmr-constants";
|
||||
import {
|
||||
languagesUnified,
|
||||
type UnifiedLanguageCode,
|
||||
} from "~/modules/i18n/config";
|
||||
import { mainWeaponIds } from "~/modules/in-game-lists/weapon-ids";
|
||||
import * as SearchParams from "~/modules/search-params/search-params";
|
||||
import { SP } from "~/modules/search-params/search-params";
|
||||
import { numericEnum } from "~/utils/zod";
|
||||
import { LFG, LFG_TYPES } from "./lfg-constants";
|
||||
|
||||
const LANGUAGE_CODES = languagesUnified.map((language) => language.code) as [
|
||||
UnifiedLanguageCode,
|
||||
...UnifiedLanguageCode[],
|
||||
];
|
||||
const TIER_NAMES = TIERS.map((tier) => tier.name) as [TierName, ...TierName[]];
|
||||
|
||||
const FILTER_OPTIONS = { loader: true, resets: ["page", "post"] };
|
||||
|
||||
export const lfgSearchParams = SearchParams.define({
|
||||
page: SP.page({ resets: ["post"] }),
|
||||
/** Post to jump to: the loader serves the page containing it, overriding `page`. */
|
||||
post: SP.param(z.number().int().positive().nullable(), { loader: true }),
|
||||
weapons: SP.param(
|
||||
z.array(numericEnum(mainWeaponIds)).max(LFG.MAX_WEAPON_FILTERS),
|
||||
{ default: [], ...FILTER_OPTIONS },
|
||||
),
|
||||
type: SP.param(z.enum(LFG_TYPES).nullable(), FILTER_OPTIONS),
|
||||
timezone: SP.param(
|
||||
z.number().int().min(0).max(12).nullable(),
|
||||
FILTER_OPTIONS,
|
||||
),
|
||||
language: SP.param(z.enum(LANGUAGE_CODES).nullable(), FILTER_OPTIONS),
|
||||
plusTier: SP.param(z.number().int().min(1).max(3).nullable(), FILTER_OPTIONS),
|
||||
minTier: SP.param(z.enum(TIER_NAMES).nullable(), FILTER_OPTIONS),
|
||||
maxTier: SP.param(z.enum(TIER_NAMES).nullable(), FILTER_OPTIONS),
|
||||
});
|
||||
|
||||
export const lfgNewSearchParams = SearchParams.define({
|
||||
postId: SP.param(z.number().int().positive().nullable(), { loader: true }),
|
||||
});
|
||||
14
apps/web-react/app/features/lfg/lfg-types.ts
Normal file
14
apps/web-react/app/features/lfg/lfg-types.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import type { LFGType } from "~/features/lfg/lfg-constants";
|
||||
import type { TierName } from "~/features/mmr/mmr-constants";
|
||||
import type { UnifiedLanguageCode } from "~/modules/i18n/config";
|
||||
import type { MainWeaponId } from "~/modules/in-game-lists/types";
|
||||
|
||||
export interface LFGFilterValues {
|
||||
weapons: MainWeaponId[];
|
||||
type: LFGType | null;
|
||||
timezone: number | null;
|
||||
language: UnifiedLanguageCode | null;
|
||||
plusTier: number | null;
|
||||
minTier: TierName | null;
|
||||
maxTier: TierName | null;
|
||||
}
|
||||
5
apps/web-react/app/features/lfg/lfg-urls.ts
Normal file
5
apps/web-react/app/features/lfg/lfg-urls.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import { LFG_PAGE } from "~/utils/urls";
|
||||
import { lfgNewSearchParams } from "./lfg-search-params";
|
||||
|
||||
export const lfgNewPostPage = (postId?: number) =>
|
||||
lfgNewSearchParams.href(`${LFG_PAGE}/new`, { postId: postId ?? null });
|
||||
48
apps/web-react/app/features/lfg/loaders/lfg.new.server.ts
Normal file
48
apps/web-react/app/features/lfg/loaders/lfg.new.server.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import type { LoaderFunctionArgs } from "react-router";
|
||||
import { requireUser } from "~/features/auth/core/user.server";
|
||||
import * as MatchProfileRepository from "~/features/match-profile/MatchProfileRepository.server";
|
||||
import * as UserRepository from "~/features/user-page/UserRepository.server";
|
||||
import type { Unpacked } from "~/utils/types";
|
||||
import * as LFGRepository from "../LFGRepository.server";
|
||||
import { lfgNewSearchParams } from "../lfg-search-params";
|
||||
|
||||
export const loader = async ({ request }: LoaderFunctionArgs) => {
|
||||
const user = requireUser();
|
||||
|
||||
const userProfileData = await UserRepository.findProfileByIdentifier(
|
||||
String(user.id),
|
||||
);
|
||||
const userMatchProfile = await MatchProfileRepository.findSettingsByUserId(
|
||||
user.id,
|
||||
);
|
||||
const allPosts = await LFGRepository.findAllPosts(user);
|
||||
const postToEdit = searchParamsToBuildToEdit(request, user.id, allPosts);
|
||||
|
||||
return {
|
||||
team: userProfileData?.team,
|
||||
weaponPool: userProfileData?.weapons,
|
||||
languages: postToEdit?.languages ?? userMatchProfile.languages,
|
||||
postToEdit,
|
||||
userPostTypes: userPostTypes(allPosts, user.id),
|
||||
};
|
||||
};
|
||||
|
||||
const searchParamsToBuildToEdit = (
|
||||
request: LoaderFunctionArgs["request"],
|
||||
userId: number,
|
||||
allPosts: Unpacked<ReturnType<typeof LFGRepository.findAllPosts>>,
|
||||
) => {
|
||||
const { postId } = lfgNewSearchParams.parse(request);
|
||||
|
||||
if (postId === null) return;
|
||||
|
||||
const post = allPosts.find((p) => p.id === postId && p.author.id === userId);
|
||||
|
||||
return post;
|
||||
};
|
||||
|
||||
const userPostTypes = (
|
||||
allPosts: Unpacked<ReturnType<typeof LFGRepository.findAllPosts>>,
|
||||
userId: number,
|
||||
) =>
|
||||
allPosts.filter((post) => post.author.id === userId).map((post) => post.type);
|
||||
88
apps/web-react/app/features/lfg/loaders/lfg.server.test.ts
Normal file
88
apps/web-react/app/features/lfg/loaders/lfg.server.test.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
import { beforeEach, describe, expect, test } from "vitest";
|
||||
import * as LFGPostFactory from "~/db/seed/factories/LFGPostFactory";
|
||||
import * as UserFactory from "~/db/seed/factories/UserFactory";
|
||||
import type { SerializeFrom } from "~/utils/remix";
|
||||
import { wrappedLoader } from "~/utils/Test";
|
||||
import { LFG_PAGE } from "~/utils/urls";
|
||||
import * as LFGRepository from "../LFGRepository.server";
|
||||
import { LFG } from "../lfg-constants";
|
||||
import { lfgSearchParams } from "../lfg-search-params";
|
||||
import { loader } from "./lfg.server";
|
||||
|
||||
const users = UserFactory.pool();
|
||||
|
||||
const lfgLoader = wrappedLoader<SerializeFrom<typeof loader>>({ loader });
|
||||
|
||||
// one post per author per type is all the database allows
|
||||
const createPlayerPosts = async () => {
|
||||
for (const authorId of users.ids()) {
|
||||
await LFGPostFactory.create({ authorId, type: "PLAYER_FOR_TEAM" });
|
||||
}
|
||||
};
|
||||
|
||||
const orderedPostIds = async () =>
|
||||
(await LFGRepository.findAllPosts()).map((post) => post.id);
|
||||
|
||||
const postIds = (data: SerializeFrom<typeof loader>) =>
|
||||
data.posts.map((post) => post.id);
|
||||
|
||||
describe("lfg loader", () => {
|
||||
beforeEach(async () => {
|
||||
await users.create(LFG.POSTS_PER_PAGE + 2);
|
||||
});
|
||||
|
||||
test("slices the posts of the page asked for", async () => {
|
||||
await createPlayerPosts();
|
||||
const allIds = await orderedPostIds();
|
||||
|
||||
const firstPage = await lfgLoader({ url: LFG_PAGE });
|
||||
const secondPage = await lfgLoader({
|
||||
url: lfgSearchParams.href(LFG_PAGE, { page: 2 }),
|
||||
});
|
||||
|
||||
expect(firstPage.pagesCount).toBe(2);
|
||||
expect(postIds(firstPage)).toEqual(allIds.slice(0, LFG.POSTS_PER_PAGE));
|
||||
expect(postIds(secondPage)).toEqual(allIds.slice(LFG.POSTS_PER_PAGE));
|
||||
});
|
||||
|
||||
test("serves the page containing the linked post", async () => {
|
||||
await createPlayerPosts();
|
||||
const linkedPostId = (await orderedPostIds())[LFG.POSTS_PER_PAGE];
|
||||
|
||||
const data = await lfgLoader({
|
||||
url: lfgSearchParams.href(LFG_PAGE, { post: linkedPostId }),
|
||||
});
|
||||
|
||||
expect(data.currentPage).toBe(2);
|
||||
expect(postIds(data)).toContain(linkedPostId);
|
||||
});
|
||||
|
||||
test("falls back to the page param when the linked post is filtered out", async () => {
|
||||
await createPlayerPosts();
|
||||
const { id: coachPostId } = await LFGPostFactory.create({
|
||||
authorId: users.id(1),
|
||||
type: "COACH_FOR_TEAM",
|
||||
});
|
||||
|
||||
const data = await lfgLoader({
|
||||
url: lfgSearchParams.href(LFG_PAGE, {
|
||||
post: coachPostId,
|
||||
type: "PLAYER_FOR_TEAM",
|
||||
page: 2,
|
||||
}),
|
||||
});
|
||||
|
||||
expect(data.currentPage).toBe(2);
|
||||
expect(postIds(data)).not.toContain(coachPostId);
|
||||
});
|
||||
|
||||
test("falls back to the page param when the linked post does not exist", async () => {
|
||||
await createPlayerPosts();
|
||||
|
||||
const data = await lfgLoader({
|
||||
url: lfgSearchParams.href(LFG_PAGE, { post: 999_999 }),
|
||||
});
|
||||
|
||||
expect(data.currentPage).toBe(1);
|
||||
});
|
||||
});
|
||||
104
apps/web-react/app/features/lfg/loaders/lfg.server.ts
Normal file
104
apps/web-react/app/features/lfg/loaders/lfg.server.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
import type { LoaderFunctionArgs } from "react-router";
|
||||
import * as R from "remeda";
|
||||
import { getUser } from "~/features/auth/core/user.server";
|
||||
import * as Seasons from "~/features/mmr/core/Seasons";
|
||||
import { userSkills } from "~/features/mmr/tiered.server";
|
||||
import { getViewerTimezone } from "~/features/timezone/timezone-context.server";
|
||||
import * as UserCardRepository from "~/features/user-card/UserCardRepository.server";
|
||||
import { paginate } from "~/utils/remix.server";
|
||||
import type { Unpacked } from "~/utils/types";
|
||||
import { filterPosts, type TiersMap } from "../core/filtering";
|
||||
import * as LFGRepository from "../LFGRepository.server";
|
||||
import { LFG } from "../lfg-constants";
|
||||
import { lfgSearchParams } from "../lfg-search-params";
|
||||
|
||||
export const loader = async ({ request, url }: LoaderFunctionArgs) => {
|
||||
const user = getUser();
|
||||
const { page, post, ...filters } = lfgSearchParams.parse(request);
|
||||
|
||||
const viewerTimezone = getViewerTimezone();
|
||||
|
||||
const allPosts = await LFGRepository.findAllPosts(user);
|
||||
const filteredPosts = filterPosts(allPosts, filters, {
|
||||
tiersMap: await postsUsersTiersMap(allPosts),
|
||||
viewerTimezone,
|
||||
});
|
||||
|
||||
const postIndex =
|
||||
post !== null
|
||||
? filteredPosts.findIndex((filteredPost) => filteredPost.id === post)
|
||||
: -1;
|
||||
const pageContainingPost =
|
||||
postIndex === -1 ? null : Math.floor(postIndex / LFG.POSTS_PER_PAGE) + 1;
|
||||
|
||||
const pagination = paginate({
|
||||
url,
|
||||
page: pageContainingPost ?? page,
|
||||
pageSize: LFG.POSTS_PER_PAGE,
|
||||
totalCount: filteredPosts.length,
|
||||
});
|
||||
|
||||
const posts = filteredPosts.slice(
|
||||
(pagination.currentPage - 1) * LFG.POSTS_PER_PAGE,
|
||||
pagination.currentPage * LFG.POSTS_PER_PAGE,
|
||||
);
|
||||
|
||||
const cardUserIds = R.unique(
|
||||
posts.flatMap((post) => [
|
||||
post.author.id,
|
||||
...(post.team?.members ?? []).map((member) => member.id),
|
||||
]),
|
||||
);
|
||||
|
||||
return {
|
||||
posts,
|
||||
viewerTimezone,
|
||||
...(await UserCardRepository.findAllByUserIdsCached({
|
||||
userIds: cardUserIds,
|
||||
})),
|
||||
...pagination,
|
||||
};
|
||||
};
|
||||
|
||||
async function postsUsersTiersMap(
|
||||
posts: Unpacked<ReturnType<typeof LFGRepository.findAllPosts>>,
|
||||
): Promise<TiersMap> {
|
||||
const latestSeason = Seasons.currentOrPrevious()!.nth;
|
||||
const previousSeason = latestSeason - 1;
|
||||
|
||||
const [
|
||||
{ userSkills: latestSeasonSkills },
|
||||
{ userSkills: previousSeasonSkills },
|
||||
] = await Promise.all([userSkills(latestSeason), userSkills(previousSeason)]);
|
||||
|
||||
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: TiersMap = new Map();
|
||||
|
||||
for (const userId of uniqueUsers) {
|
||||
const tiers = {
|
||||
latest:
|
||||
latestSeasonSkills[userId] && !latestSeasonSkills[userId].approximate
|
||||
? latestSeasonSkills[userId].tier
|
||||
: undefined,
|
||||
previous:
|
||||
previousSeasonSkills[userId] &&
|
||||
!previousSeasonSkills[userId].approximate
|
||||
? previousSeasonSkills[userId].tier
|
||||
: undefined,
|
||||
};
|
||||
|
||||
if (tiers.latest || tiers.previous) {
|
||||
userSkillsMap.set(userId, tiers);
|
||||
}
|
||||
}
|
||||
|
||||
return userSkillsMap;
|
||||
}
|
||||
3
apps/web-react/app/features/lfg/routes/lfg.module.css
Normal file
3
apps/web-react/app/features/lfg/routes/lfg.module.css
Normal file
@@ -0,0 +1,3 @@
|
||||
.post {
|
||||
scroll-margin-top: 6rem;
|
||||
}
|
||||
176
apps/web-react/app/features/lfg/routes/lfg.new.tsx
Normal file
176
apps/web-react/app/features/lfg/routes/lfg.new.tsx
Normal file
@@ -0,0 +1,176 @@
|
||||
import { ChevronLeft } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Link, useLoaderData } from "react-router";
|
||||
import { LinkButton } from "~/components/elements/Button";
|
||||
import { FormMessage } from "~/components/FormMessage";
|
||||
import { WeaponImage } from "~/components/Image";
|
||||
import { Label } from "~/components/Label";
|
||||
import { Main } from "~/components/Main";
|
||||
import type { Tables } from "~/db/tables";
|
||||
import { useUser } from "~/features/auth/core/user";
|
||||
import { FormField } from "~/form/FormField";
|
||||
import { SendouForm, useFormFieldContext } from "~/form/SendouForm";
|
||||
import type { SelectOption } from "~/form/types";
|
||||
import type { UnifiedLanguageCode } from "~/modules/i18n/config";
|
||||
import { useHasRole } from "~/modules/permissions/hooks";
|
||||
import type { SendouRouteHandle } from "~/utils/remix.server";
|
||||
import { LFG_PAGE, navIconUrl, userEditProfilePage } from "~/utils/urls";
|
||||
import { action } from "../actions/lfg.new.server";
|
||||
import { LFG, TEAM_POST_TYPES, TIMEZONES } from "../lfg-constants";
|
||||
import { lfgNewSchema } from "../lfg-schemas";
|
||||
import { loader } from "../loaders/lfg.new.server";
|
||||
|
||||
export { action, loader };
|
||||
|
||||
export const handle: SendouRouteHandle = {
|
||||
i18n: ["lfg"],
|
||||
breadcrumb: () => ({
|
||||
imgPath: navIconUrl("lfg"),
|
||||
href: LFG_PAGE,
|
||||
type: "IMAGE",
|
||||
}),
|
||||
};
|
||||
|
||||
const browserTimezone = () => {
|
||||
try {
|
||||
const tz = Intl.DateTimeFormat().resolvedOptions().timeZone;
|
||||
if (TIMEZONES.includes(tz)) return tz;
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
return TIMEZONES[0];
|
||||
};
|
||||
|
||||
export default function LFGNewPostPage() {
|
||||
const isPlusServerMember = useHasRole("PLUS_SERVER_MEMBER");
|
||||
const data = useLoaderData<typeof loader>();
|
||||
const { t } = useTranslation(["common", "lfg"]);
|
||||
const user = useUser();
|
||||
const availableTypes = useAvailablePostTypes();
|
||||
|
||||
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={<ChevronLeft />}>
|
||||
{t("common:actions.goBack")}
|
||||
</LinkButton>
|
||||
</Main>
|
||||
);
|
||||
}
|
||||
|
||||
const typeOptions: SelectOption[] = availableTypes.map((type) => ({
|
||||
value: type,
|
||||
label: `${t(`lfg:types.${type}`)}${data.team && TEAM_POST_TYPES.includes(type) ? ` (${data.team.name})` : ""}`,
|
||||
}));
|
||||
|
||||
const timezoneOptions: SelectOption[] = TIMEZONES.map((tz) => ({
|
||||
value: tz,
|
||||
label: tz,
|
||||
}));
|
||||
|
||||
const plusTierOptions: SelectOption[] = [1, 2, 3]
|
||||
.filter((tier) => user?.plusTier && tier >= user.plusTier)
|
||||
.map((tier) => ({
|
||||
value: String(tier),
|
||||
label: `+${tier} ${tier > 1 ? t("lfg:filters.orAbove") : ""}`.trim(),
|
||||
}));
|
||||
|
||||
return (
|
||||
<Main halfWidth>
|
||||
<SendouForm
|
||||
schema={lfgNewSchema}
|
||||
title={data.postToEdit ? "Editing LFG post" : "New LFG post"}
|
||||
defaultValues={{
|
||||
postId: data.postToEdit?.id,
|
||||
type: data.postToEdit?.type ?? availableTypes[0],
|
||||
timezone: data.postToEdit?.timezone ?? browserTimezone(),
|
||||
postText: data.postToEdit?.text ?? "",
|
||||
plusTierVisibility: data.postToEdit?.plusTierVisibility
|
||||
? String(data.postToEdit.plusTierVisibility)
|
||||
: null,
|
||||
languages: (data.languages ?? []) as UnifiedLanguageCode[],
|
||||
}}
|
||||
>
|
||||
<FormField
|
||||
name="type"
|
||||
options={typeOptions}
|
||||
disabled={!!data.postToEdit}
|
||||
/>
|
||||
<FormField name="timezone" options={timezoneOptions} />
|
||||
<FormField name="postText" />
|
||||
<ConditionalFields
|
||||
isPlusServerMember={isPlusServerMember}
|
||||
plusTierOptions={plusTierOptions}
|
||||
/>
|
||||
<FormField name="languages" />
|
||||
<ConditionalWeaponPool />
|
||||
</SendouForm>
|
||||
</Main>
|
||||
);
|
||||
}
|
||||
|
||||
function ConditionalFields({
|
||||
isPlusServerMember,
|
||||
plusTierOptions,
|
||||
}: {
|
||||
isPlusServerMember: boolean;
|
||||
plusTierOptions: SelectOption[];
|
||||
}) {
|
||||
const { values } = useFormFieldContext();
|
||||
const type = values.type as Tables["LFGPost"]["type"];
|
||||
|
||||
return isPlusServerMember && type !== "COACH_FOR_TEAM" ? (
|
||||
<FormField name="plusTierVisibility" options={plusTierOptions} />
|
||||
) : null;
|
||||
}
|
||||
|
||||
function ConditionalWeaponPool() {
|
||||
const { values } = useFormFieldContext();
|
||||
const type = values.type as Tables["LFGPost"]["type"];
|
||||
|
||||
return type !== "COACH_FOR_TEAM" ? <WeaponPool /> : null;
|
||||
}
|
||||
|
||||
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!)}>
|
||||
{t("lfg:new.weaponPool.userProfile")}
|
||||
</Link>
|
||||
</FormMessage>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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,
|
||||
)
|
||||
);
|
||||
};
|
||||
370
apps/web-react/app/features/lfg/routes/lfg.tsx
Normal file
370
apps/web-react/app/features/lfg/routes/lfg.tsx
Normal file
@@ -0,0 +1,370 @@
|
||||
import clsx from "clsx";
|
||||
import { add, sub } from "date-fns";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { MetaFunction } from "react-router";
|
||||
import { useLoaderData } from "react-router";
|
||||
import * as R from "remeda";
|
||||
import { ActionButton } from "~/components/ActionButton";
|
||||
import { Alert } from "~/components/Alert";
|
||||
import { SendouButton } from "~/components/elements/Button";
|
||||
import { FilterBar } from "~/components/filter-bar/FilterBar";
|
||||
import { WeaponImage } from "~/components/Image";
|
||||
import { Main } from "~/components/Main";
|
||||
import { Pagination } from "~/components/Pagination";
|
||||
import { WeaponSelect } from "~/components/WeaponSelect";
|
||||
import { useUser } from "~/features/auth/core/user";
|
||||
import { TIERS } from "~/features/mmr/mmr-constants";
|
||||
import { timezoneMiddleware } from "~/features/timezone/timezone-middleware.server";
|
||||
import { useSearchParamPagination } from "~/hooks/useSearchParamPagination";
|
||||
import { languagesUnified } from "~/modules/i18n/config";
|
||||
import type { MainWeaponId } from "~/modules/in-game-lists/types";
|
||||
import { useSearchParamsTyped } from "~/modules/search-params/hooks";
|
||||
import { databaseTimestampToDate } from "~/utils/dates";
|
||||
import { metaTags, type SerializeFrom } from "~/utils/remix";
|
||||
import type { SendouRouteHandle } from "~/utils/remix.server";
|
||||
import type { Unpacked } from "~/utils/types";
|
||||
import { LFG_PAGE, navIconUrl } from "~/utils/urls";
|
||||
import { action } from "../actions/lfg.server";
|
||||
import { LFGPost } from "../components/LFGPost";
|
||||
import { LFG } from "../lfg-constants";
|
||||
import { lfgActionSchema } from "../lfg-schemas";
|
||||
import { lfgSearchParams } from "../lfg-search-params";
|
||||
import { loader } from "../loaders/lfg.server";
|
||||
import type { Route } from "./+types/lfg";
|
||||
import styles from "./lfg.module.css";
|
||||
|
||||
export { action, loader };
|
||||
|
||||
export const middleware: Route.MiddlewareFunction[] = [timezoneMiddleware];
|
||||
|
||||
export const handle: SendouRouteHandle = {
|
||||
i18n: ["lfg", "user", "q"],
|
||||
breadcrumb: () => ({
|
||||
imgPath: navIconUrl("lfg"),
|
||||
href: LFG_PAGE,
|
||||
type: "IMAGE",
|
||||
}),
|
||||
};
|
||||
|
||||
export const meta: MetaFunction = (args) => {
|
||||
return metaTags({
|
||||
title: "LFG",
|
||||
ogTitle: "Splatoon LFG (looking for players, teams & coaches)",
|
||||
description:
|
||||
"Find people to play Splatoon with. Create a post or browse existing ones. For looking players, teams, scrim partners and coaches alike.",
|
||||
location: args.location,
|
||||
});
|
||||
};
|
||||
|
||||
export type LFGLoaderData = SerializeFrom<typeof loader>;
|
||||
|
||||
export default function LFGPage() {
|
||||
const { t } = useTranslation(["common", "lfg"]);
|
||||
const data = useLoaderData<typeof loader>();
|
||||
|
||||
const pagination = useSearchParamPagination({
|
||||
definition: lfgSearchParams,
|
||||
currentPage: data.currentPage,
|
||||
pagesCount: data.pagesCount,
|
||||
});
|
||||
|
||||
return (
|
||||
<Main className="stack xl">
|
||||
<Filters />
|
||||
{data.posts.length > 0 ? (
|
||||
<>
|
||||
<PostsList posts={data.posts} />
|
||||
{data.pagesCount > 1 ? <Pagination {...pagination} /> : null}
|
||||
</>
|
||||
) : (
|
||||
<div className="text-lighter text-lg font-semi-bold text-center mt-6">
|
||||
{t("lfg:noPosts")}
|
||||
</div>
|
||||
)}
|
||||
</Main>
|
||||
);
|
||||
}
|
||||
|
||||
function PostsList({ posts }: { posts: LFGLoaderData["posts"] }) {
|
||||
const user = useUser();
|
||||
|
||||
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 (
|
||||
<>
|
||||
{posts.map((post) => (
|
||||
<div
|
||||
key={post.id}
|
||||
id={String(post.id)}
|
||||
className={clsx("stack sm", styles.post)}
|
||||
>
|
||||
{showExpiryAlert(post) ? <PostExpiryAlert postId={post.id} /> : null}
|
||||
<LFGPost post={post} />
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function Filters() {
|
||||
const { t } = useTranslation(["lfg"]);
|
||||
const { viewerTimezone } = useLoaderData<typeof loader>();
|
||||
const [
|
||||
{ weapons, type, timezone, language, plusTier, minTier, maxTier },
|
||||
setParams,
|
||||
] = useSearchParamsTyped(lfgSearchParams);
|
||||
|
||||
return (
|
||||
<FilterBar
|
||||
pills={[
|
||||
{
|
||||
key: "weapons",
|
||||
name: t("lfg:filters.Weapon"),
|
||||
formattedValue:
|
||||
weapons.length > 0 ? (
|
||||
<span className="stack horizontal xs">
|
||||
{weapons.map((weaponSplId) => (
|
||||
<WeaponImage
|
||||
key={weaponSplId}
|
||||
weaponSplId={weaponSplId}
|
||||
size={18}
|
||||
variant="badge"
|
||||
/>
|
||||
))}
|
||||
</span>
|
||||
) : null,
|
||||
onRemove: () => setParams({ weapons: [] }),
|
||||
popover: (
|
||||
<WeaponsPopover
|
||||
weapons={weapons}
|
||||
onChange={(newWeapons) => setParams({ weapons: newWeapons })}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "type",
|
||||
name: t("lfg:filters.Type"),
|
||||
formattedValue: type !== null ? t(`lfg:types.${type}`) : null,
|
||||
onAdd: () => setParams({ type: "PLAYER_FOR_TEAM" }),
|
||||
onRemove: () => setParams({ type: null }),
|
||||
popover: (
|
||||
<select
|
||||
aria-label={t("lfg:filters.Type")}
|
||||
className="w-full"
|
||||
value={type ?? "PLAYER_FOR_TEAM"}
|
||||
onChange={(e) =>
|
||||
setParams({ type: e.target.value as typeof type })
|
||||
}
|
||||
>
|
||||
{LFG.types.map((option) => (
|
||||
<option key={option} value={option}>
|
||||
{t(`lfg:types.${option}`)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "language",
|
||||
name: t("lfg:filters.Language"),
|
||||
formattedValue:
|
||||
language !== null
|
||||
? (languagesUnified.find((lang) => lang.code === language)
|
||||
?.name ?? language)
|
||||
: null,
|
||||
onAdd: () => setParams({ language: "en" }),
|
||||
onRemove: () => setParams({ language: null }),
|
||||
popover: (
|
||||
<select
|
||||
aria-label={t("lfg:filters.Language")}
|
||||
className="w-full"
|
||||
value={language ?? "en"}
|
||||
onChange={(e) =>
|
||||
setParams({ language: e.target.value as typeof language })
|
||||
}
|
||||
>
|
||||
{languagesUnified.map((option) => (
|
||||
<option key={option.code} value={option.code}>
|
||||
{option.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "plusTier",
|
||||
name: t("lfg:filters.PlusTier"),
|
||||
formattedValue:
|
||||
plusTier !== null
|
||||
? plusTier === 1
|
||||
? "+1"
|
||||
: `+${plusTier} ${t("lfg:filters.orAbove")}`
|
||||
: null,
|
||||
onAdd: () => setParams({ plusTier: 3 }),
|
||||
onRemove: () => setParams({ plusTier: null }),
|
||||
popover: (
|
||||
<select
|
||||
aria-label={t("lfg:filters.PlusTier")}
|
||||
className="w-full"
|
||||
value={plusTier ?? 3}
|
||||
onChange={(e) => setParams({ plusTier: Number(e.target.value) })}
|
||||
>
|
||||
<option value="1">+1</option>
|
||||
<option value="2">+2 {t("lfg:filters.orAbove")}</option>
|
||||
<option value="3">+3 {t("lfg:filters.orAbove")}</option>
|
||||
</select>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "timezone",
|
||||
name: t("lfg:filters.Timezone"),
|
||||
// the filter is skipped by the loader while the viewer's timezone is
|
||||
// unknown, so showing a value would claim a filtering that is not happening
|
||||
formattedValue:
|
||||
timezone !== null && viewerTimezone !== null
|
||||
? `±${timezone}h`
|
||||
: null,
|
||||
onAdd: () => setParams({ timezone: 3 }),
|
||||
onRemove: () => setParams({ timezone: null }),
|
||||
popover: (
|
||||
<input
|
||||
aria-label={t("lfg:filters.Timezone")}
|
||||
className="w-full"
|
||||
type="number"
|
||||
value={timezone ?? 3}
|
||||
min={0}
|
||||
max={12}
|
||||
onChange={(e) => setParams({ timezone: Number(e.target.value) })}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "minTier",
|
||||
name: t("lfg:filters.MinTier"),
|
||||
formattedValue:
|
||||
minTier !== null ? R.capitalize(minTier.toLowerCase()) : null,
|
||||
onAdd: () => setParams({ minTier: "GOLD" }),
|
||||
onRemove: () => setParams({ minTier: null }),
|
||||
popover: (
|
||||
<TierSelect
|
||||
label={t("lfg:filters.MinTier")}
|
||||
value={minTier ?? "GOLD"}
|
||||
onChange={(tier) => setParams({ minTier: tier })}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "maxTier",
|
||||
name: t("lfg:filters.MaxTier"),
|
||||
formattedValue:
|
||||
maxTier !== null ? R.capitalize(maxTier.toLowerCase()) : null,
|
||||
onAdd: () => setParams({ maxTier: "PLATINUM" }),
|
||||
onRemove: () => setParams({ maxTier: null }),
|
||||
popover: (
|
||||
<TierSelect
|
||||
label={t("lfg:filters.MaxTier")}
|
||||
value={maxTier ?? "PLATINUM"}
|
||||
onChange={(tier) => setParams({ maxTier: tier })}
|
||||
/>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function WeaponsPopover({
|
||||
weapons,
|
||||
onChange,
|
||||
}: {
|
||||
weapons: MainWeaponId[];
|
||||
onChange: (weapons: MainWeaponId[]) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="stack sm">
|
||||
<WeaponSelect
|
||||
disabledWeaponIds={weapons}
|
||||
onChange={(weaponId) =>
|
||||
onChange(
|
||||
weapons.length >= LFG.MAX_WEAPON_FILTERS
|
||||
? [...weapons.slice(1, LFG.MAX_WEAPON_FILTERS), weaponId]
|
||||
: [...weapons, weaponId],
|
||||
)
|
||||
}
|
||||
key={weapons.join("-")}
|
||||
/>
|
||||
{weapons.length > 0 ? (
|
||||
<div className="stack horizontal sm flex-wrap">
|
||||
{weapons.map((weapon) => (
|
||||
<SendouButton
|
||||
key={weapon}
|
||||
variant="minimal"
|
||||
onPress={() =>
|
||||
onChange(weapons.filter((weaponId) => weaponId !== weapon))
|
||||
}
|
||||
>
|
||||
<WeaponImage weaponSplId={weapon} size={32} variant="badge" />
|
||||
</SendouButton>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TierSelect({
|
||||
label,
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
label: string;
|
||||
value: (typeof TIERS)[number]["name"];
|
||||
onChange: (tier: (typeof TIERS)[number]["name"]) => void;
|
||||
}) {
|
||||
return (
|
||||
<select
|
||||
aria-label={label}
|
||||
className="w-full"
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value as typeof value)}
|
||||
>
|
||||
{TIERS.map((tier) => (
|
||||
<option key={tier.name} value={tier.name}>
|
||||
{R.capitalize(tier.name.toLowerCase())}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
);
|
||||
}
|
||||
|
||||
function PostExpiryAlert({ postId }: { postId: number }) {
|
||||
const { t } = useTranslation(["common", "lfg"]);
|
||||
|
||||
return (
|
||||
<Alert variation="WARNING">
|
||||
<div className="stack md horizontal items-center">
|
||||
{t("lfg:expiring")}{" "}
|
||||
<ActionButton
|
||||
schema={lfgActionSchema}
|
||||
action="BUMP_POST"
|
||||
fields={{ id: postId }}
|
||||
variant="outlined"
|
||||
size="small"
|
||||
>
|
||||
{t("common:actions.clickHere")}
|
||||
</ActionButton>
|
||||
</div>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user