diff --git a/app/components/Image.tsx b/app/components/Image.tsx index 675098687..4236f343b 100644 --- a/app/components/Image.tsx +++ b/app/components/Image.tsx @@ -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; -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 ( -
+
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, + }); +} diff --git a/app/db/tables.ts b/app/db/tables.ts index 1ebd17ae9..87b8bc2af 100644 --- a/app/db/tables.ts +++ b/app/db/tables.ts @@ -255,6 +255,23 @@ export interface LogInLink { userId: number; } +export interface LFGPost { + id: GeneratedAlways; + 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; + createdAt: GeneratedAlways; +} + 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; diff --git a/app/features/lfg/LFGRepository.server.ts b/app/features/lfg/LFGRepository.server.ts new file mode 100644 index 000000000..1faf019e6 --- /dev/null +++ b/app/features/lfg/LFGRepository.server.ts @@ -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, +) { + return db.insertInto("LFGPost").values(args).execute(); +} + +export function updatePost( + postId: number, + args: Omit, +) { + 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(); +} diff --git a/app/features/lfg/actions/lfg.new.server.ts b/app/features/lfg/actions/lfg.new.server.ts new file mode 100644 index 000000000..bec7fc46f --- /dev/null +++ b/app/features/lfg/actions/lfg.new.server.ts @@ -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"); +}; diff --git a/app/features/lfg/actions/lfg.server.ts b/app/features/lfg/actions/lfg.server.ts new file mode 100644 index 000000000..00a5954f7 --- /dev/null +++ b/app/features/lfg/actions/lfg.server.ts @@ -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, + }), +]); diff --git a/app/features/lfg/components/LFGAddFilterButton.tsx b/app/features/lfg/components/LFGAddFilterButton.tsx new file mode 100644 index 000000000..b54abc912 --- /dev/null +++ b/app/features/lfg/components/LFGAddFilterButton.tsx @@ -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 ( + + ); +}); + +const defaultFilters: Record = { + 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 ( + ({ + id: tag, + text: t(`lfg:filters.${tag}`), + disabled: filters.some((filter) => filter._tag === tag), + onClick: () => addFilter(defaultFilter), + }))} + button={FilterMenuButton} + /> + ); +} diff --git a/app/features/lfg/components/LFGFilters.tsx b/app/features/lfg/components/LFGFilters.tsx new file mode 100644 index 000000000..28f442c1b --- /dev/null +++ b/app/features/lfg/components/LFGFilters.tsx @@ -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 ( +
+ {filters.map((filter) => ( + removeFilterByTag(filter._tag)} + /> + ))} +
+ ); +} + +function Filter({ + filter, + changeFilter, + removeFilter, +}: { + filter: LFGFilter; + changeFilter: (newFilter: LFGFilter) => void; + removeFilter: () => void; +}) { + const { t } = useTranslation(["lfg"]); + + return ( +
+
+ +
+
+ {filter._tag === "Weapon" && ( + + )} + {filter._tag === "Type" && ( + + )} + {filter._tag === "Timezone" && ( + + )} + {filter._tag === "Language" && ( + + )} + {filter._tag === "PlusTier" && ( + + )} + {filter._tag === "MaxTier" && ( + + )} + {filter._tag === "MinTier" && ( + + )} +
+
+ ); +} + +function WeaponFilterFields({ + value, + changeFilter, +}: { + value: MainWeaponId[]; + changeFilter: (newFilter: LFGFilter) => void; +}) { + return ( +
+ + 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) => ( + + ))} +
+ ); +} + +function TypeFilterFields({ + value, + changeFilter, +}: { + value: Tables["LFGPost"]["type"]; + changeFilter: (newFilter: LFGFilter) => void; +}) { + const { t } = useTranslation(["lfg"]); + + return ( +
+ +
+ ); +} + +function TimezoneFilterFields({ + value, + changeFilter, +}: { + value: number; + changeFilter: (newFilter: LFGFilter) => void; +}) { + return ( +
+ { + changeFilter({ + _tag: "Timezone", + maxHourDifference: Number(e.target.value), + }); + }} + /> +
+ ); +} + +function LanguageFilterFields({ + value, + changeFilter, +}: { + value: string; + changeFilter: (newFilter: LFGFilter) => void; +}) { + return ( +
+ +
+ ); +} + +function PlusTierFilterFields({ + value, + changeFilter, +}: { + value: number; + changeFilter: (newFilter: LFGFilter) => void; +}) { + const { t } = useTranslation(["lfg"]); + + return ( +
+ +
+ ); +} + +function TierFilterFields({ + _tag, + value, + changeFilter, +}: { + _tag: "MaxTier" | "MinTier"; + value: TierName; + changeFilter: (newFilter: LFGFilter) => void; +}) { + return ( +
+ +
+ ); +} diff --git a/app/features/lfg/components/LFGPost.tsx b/app/features/lfg/components/LFGPost.tsx new file mode 100644 index 000000000..7c9bd0f9d --- /dev/null +++ b/app/features/lfg/components/LFGPost.tsx @@ -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 ( + + ); + } + + return ; +} + +const USER_POST_EXPANDABLE_CRITERIA = 500; +function UserLFGPost({ post, tiersMap }: { post: Post; tiersMap: TiersMap }) { + const user = useUser(); + const [isExpanded, setIsExpanded] = React.useState(false); + + return ( +
+
+ + + +
+
+
+ + {isAdmin(user) || post.author.id === user?.id ? ( + + ) : null} +
+ +
+
+ ); +} + +function TeamLFGPost({ + post, + tiersMap, +}: { + post: Post & { team: NonNullable }; + tiersMap: TiersMap; +}) { + const isMounted = useIsMounted(); + const user = useUser(); + const [isExpanded, setIsExpanded] = React.useState(false); + + return ( +
+
+
+
+ + {isMounted && } +
+ +
+ + {post.author.id === user?.id ? ( + + ) : null} +
+
+ {isExpanded ? ( + + ) : ( + + )} +
+
+
+ + {isAdmin(user) || post.author.id === user?.id ? ( + + ) : null} +
+ +
+
+ ); +} + +function PostTeamLogoHeader({ team }: { team: NonNullable }) { + return ( +
+ {team.avatarUrl ? ( + + ) : null} + {team.name} +
+ ); +} + +function PostTeamMembersPeek({ + team, + tiersMap, +}: { + team: NonNullable; + tiersMap: TiersMap; +}) { + return ( +
+ {team.members.map((member) => ( + + ))} +
+ ); +} + +function PostTeamMembersFull({ + team, + tiersMap, + postId, +}: { + team: NonNullable; + tiersMap: TiersMap; + postId: number; +}) { + return ( +
+ {team.members.map((member) => ( +
+ + +
+ ))} +
+ ); +} + +function PostTeamMember({ + member, + tiersMap, +}: { + member: NonNullable["members"][number]; + tiersMap: TiersMap; +}) { + const tiers = tiersMap.get(member.id); + const tier = tiers?.latest ?? tiers?.previous; + + return ( +
+
+ + + {member.discordName} + + {tier ? : null} +
+
+ ); +} + +function PostUserHeader({ + author, + includeWeapons, +}: { + author: Post["author"]; + includeWeapons: boolean; +}) { + return ( +
+
+ +
+
+ + {author.discordName} + {" "} + {author.country ? : null} +
+
+
+ {includeWeapons ? ( +
+ {author.weaponPool.map(({ weaponSplId, isFavorite }) => ( + + ))} +
+ ) : null} +
+ ); +} + +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 ( +
+ {createdAtDate.toLocaleString(i18n.language, { + month: "long", + day: "numeric", + })}{" "} + {overDayDifferenceBetween ? ( + + ({t("lfg:post.lastActive")}{" "} + {formatDistanceToNow(updatedAtDate, { + addSuffix: true, + })} + ) + + ) : null} +
+ ); +} + +function PostPills({ + timezone, + plusTier, + languages, + tiers, + canEdit, + postId, +}: { + timezone?: string | null; + plusTier?: number | null; + languages?: string | null; + tiers?: NonNullable>; + canEdit?: boolean; + postId: number; +}) { + const isMounted = useIsMounted(); + + return ( +
+ {typeof timezone === "string" && isMounted && ( + + )} + {!isMounted && } + {typeof plusTier === "number" && ( + + )} + {tiers && } + {typeof languages === "string" && ( + + )} + {canEdit && } +
+ ); +} + +function PostTimezonePillPlaceholder() { + return
; +} + +const currentSeasonNth = currentOrPreviousSeason(new Date())!.nth; + +function PostSkillPills({ + tiers, +}: { + tiers: NonNullable>; +}) { + const hasBoth = tiers.latest && tiers.previous; + + return ( +
+ {tiers.latest ? ( + + ) : null} + {tiers.previous ? ( + + ) : null} +
+ ); +} + +function PostSkillPill({ + seasonNth, + tier, + cut, +}: { + seasonNth: number; + tier: TieredSkill["tier"]; + cut?: "START" | "END"; +}) { + return ( +
+ S{seasonNth} + +
+ ); +} + +function PostPlusServerPill({ plusTier }: { plusTier: number }) { + return ( +
+ + {plusTier} +
+ ); +} + +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 ( +
+ {diff === 0 ? "±" : ""} + {diff > 0 ? "+" : ""} + {diff}h +
+ ); +} + +function PostLanguagePill({ languages }: { languages: string }) { + return ( +
+ {languages.replace(/,/g, " / ").toUpperCase()} +
+ ); +} + +function PostTextTypeHeader({ type }: { type: Post["type"] }) { + const { t } = useTranslation(["lfg"]); + + return ( +
+ {t(`lfg:types.${type}`)} +
+ ); +} + +function PostEditButton({ id }: { id: number }) { + const { t } = useTranslation(["common"]); + + return ( + + + {t("common:actions.edit")} + + ); +} + +function PostDeleteButton({ id, type }: { id: number; type: Post["type"] }) { + const fetcher = useFetcher(); + const { t } = useTranslation(["common", "lfg"]); + + return ( + + + + ); +} + +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 ( +
+
{text}
+ {isExpandable ? ( + + ) : null} + {!isExpanded ?
: null} +
+ ); +} diff --git a/app/features/lfg/core/filtering.ts b/app/features/lfg/core/filtering.ts new file mode 100644 index 000000000..2984eeb3b --- /dev/null +++ b/app/features/lfg/core/filtering.ts @@ -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([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(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); +}; diff --git a/app/features/lfg/core/timezone.ts b/app/features/lfg/core/timezone.ts new file mode 100644 index 000000000..b29cb5f24 --- /dev/null +++ b/app/features/lfg/core/timezone.ts @@ -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; +} diff --git a/app/features/lfg/lfg-constants.ts b/app/features/lfg/lfg-constants.ts new file mode 100644 index 000000000..6e8ce6aaf --- /dev/null +++ b/app/features/lfg/lfg-constants.ts @@ -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 = [ + "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", +]; diff --git a/app/features/lfg/lfg-types.ts b/app/features/lfg/lfg-types.ts new file mode 100644 index 000000000..a1af465d0 --- /dev/null +++ b/app/features/lfg/lfg-types.ts @@ -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; +}; diff --git a/app/features/lfg/lfg.css b/app/features/lfg/lfg.css new file mode 100644 index 000000000..ddda07add --- /dev/null +++ b/app/features/lfg/lfg.css @@ -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); +} diff --git a/app/features/lfg/loaders/lfg.new.server.ts b/app/features/lfg/loaders/lfg.new.server.ts new file mode 100644 index 000000000..612eec2a4 --- /dev/null +++ b/app/features/lfg/loaders/lfg.new.server.ts @@ -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>, +) => { + 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>, + userId: number, +) => + allPosts.filter((post) => post.author.id === userId).map((post) => post.type); diff --git a/app/features/lfg/loaders/lfg.server.ts b/app/features/lfg/loaders/lfg.server.ts new file mode 100644 index 000000000..ed1101843 --- /dev/null +++ b/app/features/lfg/loaders/lfg.server.ts @@ -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>, +) { + const latestSeason = currentOrPreviousSeason(new Date())!.nth; + const previousSeason = latestSeason - 1; + + const latestSeasonSkills = userSkills(latestSeason).userSkills; + const previousSeasonSkills = userSkills(previousSeason).userSkills; + + const uniqueUsers = new Set(); + 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()); +} diff --git a/app/features/lfg/routes/lfg.new.tsx b/app/features/lfg/routes/lfg.new.tsx new file mode 100644 index 000000000..6989dd5ff --- /dev/null +++ b/app/features/lfg/routes/lfg.new.tsx @@ -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(); + 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 ( +
+

{t("lfg:new.noMorePosts")}

+ }> + {t("common:actions.goBack")} + +
+ ); + } + + return ( +
+

+ {data.postToEdit ? "Editing LFG post" : "New LFG post"} +

+ + {data.postToEdit ? ( + + ) : null} + + +