mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-03-21 18:04:39 -05:00
LFG (#1732)
* 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:
parent
335fe487d2
commit
4beb2bdfdd
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
148
app/features/lfg/LFGRepository.server.ts
Normal file
148
app/features/lfg/LFGRepository.server.ts
Normal 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();
|
||||
}
|
||||
78
app/features/lfg/actions/lfg.new.server.ts
Normal file
78
app/features/lfg/actions/lfg.new.server.ts
Normal 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");
|
||||
};
|
||||
44
app/features/lfg/actions/lfg.server.ts
Normal file
44
app/features/lfg/actions/lfg.server.ts
Normal 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,
|
||||
}),
|
||||
]);
|
||||
55
app/features/lfg/components/LFGAddFilterButton.tsx
Normal file
55
app/features/lfg/components/LFGAddFilterButton.tsx
Normal 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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
294
app/features/lfg/components/LFGFilters.tsx
Normal file
294
app/features/lfg/components/LFGFilters.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
514
app/features/lfg/components/LFGPost.tsx
Normal file
514
app/features/lfg/components/LFGPost.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
161
app/features/lfg/core/filtering.ts
Normal file
161
app/features/lfg/core/filtering.ts
Normal 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);
|
||||
};
|
||||
31
app/features/lfg/core/timezone.ts
Normal file
31
app/features/lfg/core/timezone.ts
Normal 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;
|
||||
}
|
||||
367
app/features/lfg/lfg-constants.ts
Normal file
367
app/features/lfg/lfg-constants.ts
Normal 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",
|
||||
];
|
||||
47
app/features/lfg/lfg-types.ts
Normal file
47
app/features/lfg/lfg-types.ts
Normal 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
137
app/features/lfg/lfg.css
Normal 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);
|
||||
}
|
||||
53
app/features/lfg/loaders/lfg.new.server.ts
Normal file
53
app/features/lfg/loaders/lfg.new.server.ts
Normal 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);
|
||||
54
app/features/lfg/loaders/lfg.server.ts
Normal file
54
app/features/lfg/loaders/lfg.server.ts
Normal 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());
|
||||
}
|
||||
271
app/features/lfg/routes/lfg.new.tsx
Normal file
271
app/features/lfg/routes/lfg.new.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
134
app/features/lfg/routes/lfg.tsx
Normal file
134
app/features/lfg/routes/lfg.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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)
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -225,6 +225,7 @@ export const namespaceJsonsToPreloadObj: Record<
|
|||
vods: true,
|
||||
art: true,
|
||||
q: true,
|
||||
lfg: true,
|
||||
};
|
||||
const namespaceJsonsToPreload = Object.keys(namespaceJsonsToPreloadObj);
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
24
e2e/lfg.spec.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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
30
locales/en/lfg.json
Normal 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
29
migrations/055-lfg.js
Normal 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();
|
||||
})();
|
||||
}
|
||||
BIN
public/static-assets/img/layout/lfg.avif
Normal file
BIN
public/static-assets/img/layout/lfg.avif
Normal file
Binary file not shown.
BIN
public/static-assets/img/layout/lfg.png
Normal file
BIN
public/static-assets/img/layout/lfg.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 26 KiB |
2
types/react-i18next.d.ts
vendored
2
types/react-i18next.d.ts
vendored
|
|
@ -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;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user