From 1c9dcacbf27fe9433910d001fc147e26929acbb0 Mon Sep 17 00:00:00 2001 From: Kalle <38327916+Sendouc@users.noreply.github.com> Date: Sat, 14 Sep 2024 12:31:05 +0300 Subject: [PATCH] Join many teams & front page changelog (#1880) * Initial * Progress * Changelog initial * Progress * E2E test --- app/components/icons/BSKYLike.tsx | 19 ++ app/components/icons/BSKYReply.tsx | 19 ++ app/components/icons/BSKYRepost.tsx | 19 ++ app/components/icons/External.tsx | 19 ++ app/constants.ts | 1 + app/db/seed/index.ts | 9 +- app/db/tables.ts | 18 +- .../front-page/core/Changelog.server.ts | 150 ++++++++++++++ .../front-page/loaders/index.server.ts | 16 +- app/features/front-page/routes/index.tsx | 104 ++++++++++ .../img-upload/actions/upload.server.ts | 8 +- app/features/img-upload/routes/upload.tsx | 8 +- app/features/info/routes/support.tsx | 5 + app/features/sendouq/QRepository.server.ts | 23 ++- .../sendouq/queries/syncGroupTeamId.server.ts | 24 ++- app/features/team/TeamRepository.server.ts | 188 +++++++++++++++++- .../team/actions/t.$customUrl.server.ts | 48 +++++ app/features/team/index.ts | 2 - .../team/loaders/t.$customUrl.server.ts | 13 ++ .../team/queries/addNewTeamMember.server.ts | 22 -- .../team/queries/findByIdentifier.server.ts | 108 ---------- app/features/team/queries/leaveTeam.server.ts | 19 -- .../team/routes/t.$customUrl.edit.tsx | 20 +- .../team/routes/t.$customUrl.join.tsx | 44 ++-- .../team/routes/t.$customUrl.roster.tsx | 23 ++- app/features/team/routes/t.$customUrl.tsx | 146 +++++++------- app/features/team/team-constants.ts | 5 + app/features/team/team-schemas.server.ts | 9 + app/features/team/team-types.ts | 35 ---- app/features/team/team-utils.ts | 12 +- app/features/team/team.css | 1 + .../tournament/TournamentRepository.server.ts | 1 + .../actions/to.$id.register.server.ts | 6 +- .../loaders/to.$id.register.server.ts | 4 +- .../tournament/routes/to.$id.register.tsx | 104 ++++++---- .../user-page/UserRepository.server.ts | 20 ++ .../user-page/routes/u.$identifier.index.tsx | 94 +++++++-- app/styles/common.css | 4 + app/styles/front.css | 26 +++ app/styles/u.css | 3 +- app/styles/utils.css | 9 + app/utils/playwright.ts | 6 +- bun.lockb | Bin 490560 -> 490560 bytes e2e/team.spec.ts | 54 ++++- e2e/tournament.spec.ts | 2 +- e2e/user-page.spec.ts | 2 +- e2e/vods.spec.ts | 12 +- locales/da/team.json | 1 - locales/de/team.json | 1 - locales/en/common.json | 1 + locales/en/team.json | 6 +- locales/es-ES/team.json | 1 - locales/es-US/team.json | 1 - locales/fr-CA/team.json | 1 - locales/fr-EU/team.json | 1 - locales/he/team.json | 1 - locales/ja/team.json | 1 - locales/pl/team.json | 1 - locales/pt-BR/team.json | 1 - locales/ru/team.json | 1 - locales/zh/team.json | 1 - migrations/069-many-teams.js | 38 ++++ package.json | 2 +- playwright.config.ts | 3 +- 64 files changed, 1110 insertions(+), 436 deletions(-) create mode 100644 app/components/icons/BSKYLike.tsx create mode 100644 app/components/icons/BSKYReply.tsx create mode 100644 app/components/icons/BSKYRepost.tsx create mode 100644 app/components/icons/External.tsx create mode 100644 app/features/front-page/core/Changelog.server.ts create mode 100644 app/features/team/actions/t.$customUrl.server.ts create mode 100644 app/features/team/loaders/t.$customUrl.server.ts delete mode 100644 app/features/team/queries/addNewTeamMember.server.ts delete mode 100644 app/features/team/queries/findByIdentifier.server.ts delete mode 100644 app/features/team/queries/leaveTeam.server.ts delete mode 100644 app/features/team/team-types.ts create mode 100644 migrations/069-many-teams.js diff --git a/app/components/icons/BSKYLike.tsx b/app/components/icons/BSKYLike.tsx new file mode 100644 index 000000000..2aef55a4c --- /dev/null +++ b/app/components/icons/BSKYLike.tsx @@ -0,0 +1,19 @@ +export function BSKYLikeIcon({ className }: { className?: string }) { + return ( + + Like Icon + + + ); +} diff --git a/app/components/icons/BSKYReply.tsx b/app/components/icons/BSKYReply.tsx new file mode 100644 index 000000000..1bd98a785 --- /dev/null +++ b/app/components/icons/BSKYReply.tsx @@ -0,0 +1,19 @@ +export function BSKYReplyIcon({ className }: { className?: string }) { + return ( + + Reply Icon + + + ); +} diff --git a/app/components/icons/BSKYRepost.tsx b/app/components/icons/BSKYRepost.tsx new file mode 100644 index 000000000..9d035ca04 --- /dev/null +++ b/app/components/icons/BSKYRepost.tsx @@ -0,0 +1,19 @@ +export function BSKYRepostIcon({ className }: { className?: string }) { + return ( + + Repost Icon + + + ); +} diff --git a/app/components/icons/External.tsx b/app/components/icons/External.tsx new file mode 100644 index 000000000..000de22df --- /dev/null +++ b/app/components/icons/External.tsx @@ -0,0 +1,19 @@ +export function ExternalIcon({ className }: { className?: string }) { + return ( + + External Link Icon + + + ); +} diff --git a/app/constants.ts b/app/constants.ts index 11438db89..80aeafe0e 100644 --- a/app/constants.ts +++ b/app/constants.ts @@ -71,6 +71,7 @@ export const MAX_AP = 57; export const TEN_MINUTES_IN_MS = 10 * 60 * 1000; export const HALF_HOUR_IN_MS = 30 * 60 * 1000; export const ONE_HOUR_IN_MS = 60 * 60 * 1000; +export const TWO_HOURS_IN_MS = 2 * 60 * 60 * 1000; export const SPLATOON_3_XP_BADGE_VALUES = [ 3400, 3300, 3200, 3100, 3000, 2900, 2800, 2700, 2600, diff --git a/app/db/seed/index.ts b/app/db/seed/index.ts index 285edf669..7764cfc0c 100644 --- a/app/db/seed/index.ts +++ b/app/db/seed/index.ts @@ -1545,9 +1545,12 @@ function otherTeams() { ); for (let i = 3; i < 50; i++) { - const teamName = `${capitalize(faker.word.adjective())} ${capitalize( - faker.word.noun(), - )}`; + const teamName = + i === 3 + ? "Team Olive" + : `${capitalize(faker.word.adjective())} ${capitalize( + faker.word.noun(), + )}`; const teamCustomUrl = mySlugify(teamName); sql diff --git a/app/db/tables.ts b/app/db/tables.ts index 2f63a8018..e67a3f2f1 100644 --- a/app/db/tables.ts +++ b/app/db/tables.ts @@ -6,6 +6,7 @@ import type { SqlBool, } from "kysely"; import type { TieredSkill } from "~/features/mmr/tiered.server"; +import type { TEAM_MEMBER_ROLES } from "~/features/team"; import type { ParticipantResult } from "~/modules/brackets-model"; import type { Ability, @@ -33,13 +34,16 @@ export interface AllTeam { twitter: string | null; } +export type MemberRole = (typeof TEAM_MEMBER_ROLES)[number]; + export interface AllTeamMember { createdAt: Generated; isOwner: Generated; leftAt: number | null; - role: string | null; + role: MemberRole | null; teamId: number; userId: number; + isMainTeam: number; } export interface Team { @@ -56,15 +60,6 @@ export interface Team { twitter: string | null; } -export interface TeamMember { - createdAt: number | null; - isOwner: number | null; - leftAt: number | null; - role: string | null; - teamId: number; - userId: number; -} - export interface Art { authorId: number; createdAt: Generated; @@ -892,7 +887,8 @@ export interface DB { SplatoonPlayer: SplatoonPlayer; TaggedArt: TaggedArt; Team: Team; - TeamMember: TeamMember; + TeamMember: AllTeamMember; + TeamMemberWithSecondary: AllTeamMember; Tournament: Tournament; TournamentStaff: TournamentStaff; TournamentBadgeOwner: TournamentBadgeOwner; diff --git a/app/features/front-page/core/Changelog.server.ts b/app/features/front-page/core/Changelog.server.ts new file mode 100644 index 000000000..0b11456c5 --- /dev/null +++ b/app/features/front-page/core/Changelog.server.ts @@ -0,0 +1,150 @@ +import { formatDistance } from "date-fns"; +import { z } from "zod"; +import { logger } from "~/utils/logger"; + +const BSKY_URL = + "https://public.api.bsky.app/xrpc/app.bsky.feed.getAuthorFeed?actor=did:plc:3hjmoa7vbx6bsqc3n2vu54v3&filter=posts_no_replies'"; + +const CHANGE_LOG_ITEMS_MAX = 6; + +const postsSchema = z.object({ + feed: z.array( + z.object({ + post: z.object({ + uri: z.string(), + record: z.object({ + $type: z.string(), + createdAt: z.string(), + facets: z + .array( + z.object({ + features: z.array( + z.object({ $type: z.string(), tag: z.string().nullish() }), + ), + index: z.object({ byteEnd: z.number(), byteStart: z.number() }), + }), + ) + .nullish(), + text: z.string(), + }), + embed: z + .object({ + $type: z.string(), + images: z + .array( + z.object({ + thumb: z.string(), + fullsize: z.string(), + alt: z.string(), + aspectRatio: z.object({ + height: z.number(), + width: z.number(), + }), + }), + ) + .nullish(), + }) + .nullish(), + replyCount: z.number(), + repostCount: z.number(), + likeCount: z.number(), + quoteCount: z.number(), + }), + }), + ), +}); + +export async function get() { + let result: ChangelogItem[]; + try { + const data = await fetchPosts(); + result = parsePosts(data) + .filter(postHasSendouInkTag) + .map(rawPostToChangelogItem) + .slice(0, CHANGE_LOG_ITEMS_MAX); + } catch (error) { + if (!(error instanceof Error)) { + throw error; + } + logger.error(`Failed to get changelog: ${error.message}`); + return []; + } + + return result; +} + +type RawPost = z.infer["feed"][number]["post"]; + +export interface ChangelogItem { + id: string; + text: string; + createdAtRelative: string; + postUrl: string; + images: { + thumb: string; + fullsize: string; + aspectRatio: { + height: number; + width: number; + }; + }[]; + stats: { + likes: number; + reposts: number; + replies: number; + }; +} + +async function fetchPosts() { + // returns 50 post (default) can be increased to 100 + const response = await fetch(BSKY_URL); + if (!response.ok) { + throw new Error(`Failed to fetch posts: ${response.statusText}`); + } + + const json = await response.json(); + return json as unknown; +} + +function parsePosts(data: unknown) { + const result = postsSchema.safeParse(data); + if (!result.success) { + throw new Error(`Failed to parse posts: ${result.error.message}`); + } + + return result.data.feed.map((feed) => feed.post); +} + +function postHasSendouInkTag(post: RawPost) { + return post.record.facets?.some((facet) => + facet.features.some( + (feature) => feature.tag?.toLowerCase() === "sendouink", + ), + ); +} + +function rawPostToChangelogItem(post: RawPost): ChangelogItem { + return { + id: post.uri, + text: post.record.text.replace("#sendouink", "").trim(), + createdAtRelative: formatDistance( + new Date(post.record.createdAt), + new Date(), + { + addSuffix: true, + }, + ), + postUrl: `https://bsky.app/profile/did:plc:3hjmoa7vbx6bsqc3n2vu54v3/post/${post.uri.split("/").pop()}`, + images: + post.embed?.images?.map((image) => ({ + thumb: image.thumb, + fullsize: image.fullsize, + aspectRatio: image.aspectRatio, + })) ?? [], + stats: { + likes: post.likeCount, + reposts: post.repostCount + post.quoteCount, + replies: post.replyCount, + }, + }; +} diff --git a/app/features/front-page/loaders/index.server.ts b/app/features/front-page/loaders/index.server.ts index 59363ee0a..9ac5f60bf 100644 --- a/app/features/front-page/loaders/index.server.ts +++ b/app/features/front-page/loaders/index.server.ts @@ -1,5 +1,10 @@ import cachified from "@epic-web/cachified"; -import { ONE_HOUR_IN_MS, TEN_MINUTES_IN_MS } from "~/constants"; +import { + ONE_HOUR_IN_MS, + TEN_MINUTES_IN_MS, + TWO_HOURS_IN_MS, +} from "~/constants"; +import * as Changelog from "~/features/front-page/core/Changelog.server"; import * as TournamentRepository from "~/features/tournament/TournamentRepository.server"; import { cache, ttl } from "~/utils/cache.server"; @@ -14,5 +19,14 @@ export const loader = async () => { return TournamentRepository.forShowcase(); }, }), + changelog: await cachified({ + key: "changelog", + cache, + ttl: ttl(ONE_HOUR_IN_MS), + staleWhileRevalidate: ttl(TWO_HOURS_IN_MS), + async getFreshValue() { + return Changelog.get(); + }, + }), }; }; diff --git a/app/features/front-page/routes/index.tsx b/app/features/front-page/routes/index.tsx index 56687f26b..5487b7744 100644 --- a/app/features/front-page/routes/index.tsx +++ b/app/features/front-page/routes/index.tsx @@ -5,9 +5,14 @@ import * as React from "react"; import { useTranslation } from "react-i18next"; import { Avatar } from "~/components/Avatar"; import { Button } from "~/components/Button"; +import { Divider } from "~/components/Divider"; import { Image } from "~/components/Image"; import { Main } from "~/components/Main"; import { Placement } from "~/components/Placement"; +import { BSKYLikeIcon } from "~/components/icons/BSKYLike"; +import { BSKYReplyIcon } from "~/components/icons/BSKYReply"; +import { BSKYRepostIcon } from "~/components/icons/BSKYRepost"; +import { ExternalIcon } from "~/components/icons/External"; import { GlobeIcon } from "~/components/icons/Globe"; import { LogInIcon } from "~/components/icons/LogIn"; import { LogOutIcon } from "~/components/icons/LogOut"; @@ -17,6 +22,7 @@ import { SelectedThemeIcon } from "~/components/layout/SelectedThemeIcon"; import { ThemeChanger } from "~/components/layout/ThemeChanger"; import navItems from "~/components/layout/nav-items.json"; import { useUser } from "~/features/auth/core/user"; +import type * as Changelog from "~/features/front-page/core/Changelog.server"; import { useTheme } from "~/features/theme/core/provider"; import { HACKY_resolvePicture, @@ -116,6 +122,7 @@ export default function FrontPage() { ) : null} + ); @@ -246,6 +253,103 @@ function LogInButton() { ); } +function ChangelogList() { + const data = useLoaderData(); + + if (data.changelog.length === 0) return null; + + return ( +
+ + Updates + + {data.changelog.map((item) => ( + + +
+
+ ))} + + View past updates + +
+ ); +} + +const ADMIN_PFP_URL = + "https://cdn.discordapp.com/avatars/79237403620945920/6fc41a44b069a0d2152ac06d1e496c6c.webp?size=80"; + +function ChangelogItem({ + item, +}: { + item: Changelog.ChangelogItem; +}) { + return ( +
+ +
+
+ Sendou{" "} + {item.createdAtRelative} +
+ {item.text} + {item.images.length > 0 ? ( +
+ {item.images.map((image) => ( + + ))} +
+ ) : null} +
+ + + + + + + + + +
+
+
+ ); +} + +function BSKYIconLink({ + children, + count, + postUrl, +}: { children: React.ReactNode; count: number; postUrl: string }) { + return ( + + {children} + + {count} + + + ); +} + function Drawings({ filters, }: { diff --git a/app/features/img-upload/actions/upload.server.ts b/app/features/img-upload/actions/upload.server.ts index cafecc294..c56b68b5a 100644 --- a/app/features/img-upload/actions/upload.server.ts +++ b/app/features/img-upload/actions/upload.server.ts @@ -7,7 +7,7 @@ import { } from "@remix-run/node"; import { z } from "zod"; import { requireUser } from "~/features/auth/core/user.server"; -import { findByIdentifier, isTeamOwner } from "~/features/team"; +import { isTeamOwner } from "~/features/team"; import * as TeamRepository from "~/features/team/TeamRepository.server"; import * as TournamentOrganizationRepository from "~/features/tournament-organization/TournamentOrganizationRepository.server"; import { canEditTournamentOrganization } from "~/features/tournament-organization/tournament-organization-utils"; @@ -88,12 +88,12 @@ export const action = async ({ request }: ActionFunctionArgs) => { }; async function validatedTeam(user: { id: number }) { - const team = await TeamRepository.findByUserId(user.id); + const team = await TeamRepository.findMainByUserId(user.id); validate(team, "You must be on a team to upload images"); - const detailed = findByIdentifier(team.customUrl); + const detailedTeam = await TeamRepository.findByCustomUrl(team.customUrl); validate( - detailed && isTeamOwner({ team: detailed.team, user }), + detailedTeam && isTeamOwner({ team: detailedTeam, user }), "You must be the team owner to upload images", ); diff --git a/app/features/img-upload/routes/upload.tsx b/app/features/img-upload/routes/upload.tsx index dbe64cc7f..711ad973c 100644 --- a/app/features/img-upload/routes/upload.tsx +++ b/app/features/img-upload/routes/upload.tsx @@ -7,7 +7,7 @@ import { useTranslation } from "react-i18next"; import { Button } from "~/components/Button"; import { Main } from "~/components/Main"; import { requireUser } from "~/features/auth/core/user.server"; -import { findByIdentifier, isTeamOwner } from "~/features/team"; +import { isTeamOwner } from "~/features/team"; import * as TeamRepository from "~/features/team/TeamRepository.server"; import invariant from "~/utils/invariant"; import { countUnvalidatedImg } from "../queries/countUnvalidatedImg.server"; @@ -27,12 +27,12 @@ export const loader = async ({ request }: LoaderFunctionArgs) => { } if (validatedType === "team-pfp" || validatedType === "team-banner") { - const team = await TeamRepository.findByUserId(user.id); + const team = await TeamRepository.findMainByUserId(user.id); if (!team) throw redirect("/"); - const detailed = findByIdentifier(team.customUrl); + const detailedTeam = await TeamRepository.findByCustomUrl(team.customUrl); - if (!detailed || !isTeamOwner({ team: detailed.team, user })) { + if (!detailedTeam || !isTeamOwner({ team: detailedTeam, user })) { throw redirect("/"); } } diff --git a/app/features/info/routes/support.tsx b/app/features/info/routes/support.tsx index 1f7f5fb94..767ce2199 100644 --- a/app/features/info/routes/support.tsx +++ b/app/features/info/routes/support.tsx @@ -88,6 +88,11 @@ const PERKS = [ name: "previewQ", extraInfo: false, }, + { + tier: 2, + name: "joinFive", + extraInfo: false, + }, ] as const; export default function SupportPage() { diff --git a/app/features/sendouq/QRepository.server.ts b/app/features/sendouq/QRepository.server.ts index 2fb66ca72..b3ac34015 100644 --- a/app/features/sendouq/QRepository.server.ts +++ b/app/features/sendouq/QRepository.server.ts @@ -278,20 +278,21 @@ export function deletePrivateUserNote({ } export async function usersThatTrusted(userId: number) { + const teamIds = await db + .selectFrom("TeamMemberWithSecondary") + .select("teamId") + .where("userId", "=", userId) + .execute(); + const rows = await db - .selectFrom("TeamMember") - .innerJoin("User", "User.id", "TeamMember.userId") + .selectFrom("TeamMemberWithSecondary") + .innerJoin("User", "User.id", "TeamMemberWithSecondary.userId") .innerJoin("UserFriendCode", "UserFriendCode.userId", "User.id") .select([...COMMON_USER_FIELDS, "User.inGameName"]) - .where((eb) => - eb( - "TeamMember.teamId", - "=", - eb - .selectFrom("TeamMember") - .select("TeamMember.teamId") - .where("TeamMember.userId", "=", userId), - ), + .where( + "TeamMemberWithSecondary.teamId", + "in", + teamIds.map((t) => t.teamId), ) .union((eb) => eb diff --git a/app/features/sendouq/queries/syncGroupTeamId.server.ts b/app/features/sendouq/queries/syncGroupTeamId.server.ts index 4eda9b007..15b23b151 100644 --- a/app/features/sendouq/queries/syncGroupTeamId.server.ts +++ b/app/features/sendouq/queries/syncGroupTeamId.server.ts @@ -1,11 +1,9 @@ import { sql } from "~/db/sql"; -import invariant from "~/utils/invariant"; -import { FULL_GROUP_SIZE } from "../q-constants"; const memberTeamIdsStm = sql.prepare(/* sql */ ` - select "TeamMember"."teamId" + select "TeamMemberWithSecondary"."teamId" from "GroupMember" - left join "TeamMember" on "TeamMember"."userId" = "GroupMember"."userId" + left join "TeamMemberWithSecondary" on "TeamMemberWithSecondary"."userId" = "GroupMember"."userId" where "groupId" = @groupId `); @@ -19,14 +17,18 @@ export function syncGroupTeamId(groupId: number) { const teamIds = memberTeamIdsStm .all({ groupId }) .map((row: any) => row.teamId); - invariant(teamIds.length === FULL_GROUP_SIZE, "Group to sync is not full"); - const set = new Set(teamIds); + const counts = new Map(); - if (set.size === 1) { - const teamId = teamIds[0]; - updateStm.run({ groupId, teamId }); - } else { - updateStm.run({ groupId, teamId: null }); + // note if there are multiple teams with 4 members we just choose one of them + for (const teamId of teamIds) { + const newCount = (counts.get(teamId) ?? 0) + 1; + if (newCount === 4) { + return updateStm.run({ groupId, teamId }); + } + + counts.set(teamId, newCount); } + + return updateStm.run({ groupId, teamId: null }); } diff --git a/app/features/team/TeamRepository.server.ts b/app/features/team/TeamRepository.server.ts index 9715ba477..8586e9490 100644 --- a/app/features/team/TeamRepository.server.ts +++ b/app/features/team/TeamRepository.server.ts @@ -1,6 +1,12 @@ +import type { Transaction } from "kysely"; +import { jsonArrayFrom } from "kysely/helpers/sqlite"; import { db } from "~/db/sql"; +import type { DB } from "~/db/tables"; +import { databaseTimestampNow } from "~/utils/dates"; +import invariant from "~/utils/invariant"; +import { COMMON_USER_FIELDS } from "~/utils/kysely.server"; -export function findByUserId(userId: number) { +export function findMainByUserId(userId: number) { return db .selectFrom("TeamMember") .innerJoin("Team", "Team.id", "TeamMember.teamId") @@ -14,3 +20,183 @@ export function findByUserId(userId: number) { .where("TeamMember.userId", "=", userId) .executeTakeFirst(); } + +export function findAllMemberOfByUserId(userId: number) { + return db + .selectFrom("TeamMemberWithSecondary") + .innerJoin("Team", "Team.id", "TeamMemberWithSecondary.teamId") + .leftJoin("UserSubmittedImage", "UserSubmittedImage.id", "Team.avatarImgId") + .select([ + "Team.id", + "Team.customUrl", + "Team.name", + "UserSubmittedImage.url as logoUrl", + ]) + .where("TeamMemberWithSecondary.userId", "=", userId) + .execute(); +} + +export type findByCustomUrl = NonNullable< + Awaited> +>; + +export function findByCustomUrl(customUrl: string) { + return db + .selectFrom("Team") + .leftJoin( + "UserSubmittedImage as AvatarImage", + "AvatarImage.id", + "Team.avatarImgId", + ) + .leftJoin( + "UserSubmittedImage as BannerImage", + "BannerImage.id", + "Team.bannerImgId", + ) + .select(({ eb }) => [ + "Team.id", + "Team.name", + "Team.twitter", + "Team.bio", + "Team.customUrl", + "Team.css", + "AvatarImage.url as avatarSrc", + "BannerImage.url as bannerSrc", + jsonArrayFrom( + eb + .selectFrom("TeamMemberWithSecondary") + .innerJoin("User", "User.id", "TeamMemberWithSecondary.userId") + .select(({ eb: innerEb }) => [ + ...COMMON_USER_FIELDS, + "TeamMemberWithSecondary.role", + "TeamMemberWithSecondary.isOwner", + "TeamMemberWithSecondary.isMainTeam", + "User.country", + "User.patronTier", + jsonArrayFrom( + innerEb + .selectFrom("UserWeapon") + .select(["UserWeapon.weaponSplId", "UserWeapon.isFavorite"]) + .whereRef("UserWeapon.userId", "=", "User.id"), + ).as("weapons"), + ]) + .whereRef("TeamMemberWithSecondary.teamId", "=", "Team.id"), + ).as("members"), + ]) + .where("Team.customUrl", "=", customUrl.toLowerCase()) + .executeTakeFirst(); +} + +export async function teamsByMemberUserId( + userId: number, + trx?: Transaction, +) { + return (trx ?? db) + .selectFrom("TeamMemberWithSecondary") + .select([ + "TeamMemberWithSecondary.teamId as id", + "TeamMemberWithSecondary.isOwner", + ]) + .where("userId", "=", userId) + .execute(); +} + +export function switchMainTeam({ + userId, + teamId, +}: { + userId: number; + teamId: number; +}) { + return db.transaction().execute(async (trx) => { + const currentTeams = await teamsByMemberUserId(userId, trx); + + const teamToSwitchTo = currentTeams.find((team) => team.id === teamId); + invariant(teamToSwitchTo, "User is not a member of this team"); + + await trx + .updateTable("AllTeamMember") + .set({ + isMainTeam: 0, + }) + .where("userId", "=", userId) + .execute(); + + await trx + .updateTable("AllTeamMember") + .set({ + isMainTeam: 1, + }) + .where("userId", "=", userId) + .where("teamId", "=", teamId) + .execute(); + }); +} + +export function addNewTeamMember({ + userId, + teamId, + maxTeamsAllowed, +}: { + userId: number; + teamId: number; + maxTeamsAllowed: number; +}) { + return db.transaction().execute(async (trx) => { + const teamCount = (await teamsByMemberUserId(userId, trx)).length; + + if (teamCount >= maxTeamsAllowed) { + throw new Error("Trying to exceed allowed team count"); + } + + const isMainTeam = Number(teamCount === 0); + + await trx + .insertInto("AllTeamMember") + .values({ userId, teamId, isMainTeam }) + .onConflict((oc) => + oc.columns(["userId", "teamId"]).doUpdateSet({ + leftAt: null, + isMainTeam, + }), + ) + .execute(); + }); +} + +export function removeTeamMember({ + userId, + teamId, +}: { + userId: number; + teamId: number; +}) { + return db.transaction().execute(async (trx) => { + const currentTeams = await teamsByMemberUserId(userId, trx); + + const teamToLeave = currentTeams.find((team) => team.id === teamId); + invariant(teamToLeave, "User is not a member of this team"); + invariant(!teamToLeave.isOwner, "Owner cannot leave the team"); + + const newMainTeam = currentTeams.find((team) => team.id !== teamId); + if (newMainTeam) { + await trx + .updateTable("AllTeamMember") + .set({ + isMainTeam: 1, + }) + .where("userId", "=", userId) + .where("teamId", "=", newMainTeam.id) + .execute(); + } + + await trx + .updateTable("AllTeamMember") + .set({ + leftAt: databaseTimestampNow(), + }) + .where("userId", "=", userId) + .where("teamId", "=", teamId) + .execute(); + }); +} diff --git a/app/features/team/actions/t.$customUrl.server.ts b/app/features/team/actions/t.$customUrl.server.ts new file mode 100644 index 000000000..46155a1e6 --- /dev/null +++ b/app/features/team/actions/t.$customUrl.server.ts @@ -0,0 +1,48 @@ +import type { ActionFunction } from "@remix-run/node"; +import { requireUserId } from "~/features/auth/core/user.server"; +import { notFoundIfFalsy, parseRequestPayload, validate } from "~/utils/remix"; +import { assertUnreachable } from "~/utils/types"; +import * as TeamRepository from "../TeamRepository.server"; +import { + teamParamsSchema, + teamProfilePageActionSchema, +} from "../team-schemas.server"; +import { isTeamMember, isTeamOwner } from "../team-utils"; + +export const action: ActionFunction = async ({ request, params }) => { + const user = await requireUserId(request); + const data = await parseRequestPayload({ + request, + schema: teamProfilePageActionSchema, + }); + + const { customUrl } = teamParamsSchema.parse(params); + const team = notFoundIfFalsy(await TeamRepository.findByCustomUrl(customUrl)); + + validate( + isTeamMember({ user, team }) && !isTeamOwner({ user, team }), + "You are not a regular member of this team", + ); + + switch (data._action) { + case "LEAVE_TEAM": { + await TeamRepository.removeTeamMember({ + teamId: team.id, + userId: user.id, + }); + break; + } + case "MAKE_MAIN_TEAM": { + await TeamRepository.switchMainTeam({ + userId: user.id, + teamId: team.id, + }); + break; + } + default: { + assertUnreachable(data); + } + } + + return null; +}; diff --git a/app/features/team/index.ts b/app/features/team/index.ts index 495eae150..ecac30131 100644 --- a/app/features/team/index.ts +++ b/app/features/team/index.ts @@ -1,5 +1,3 @@ export { TEAM_MEMBER_ROLES } from "./team-constants"; -export { findByIdentifier } from "./queries/findByIdentifier.server"; - export { isTeamOwner } from "./team-utils"; diff --git a/app/features/team/loaders/t.$customUrl.server.ts b/app/features/team/loaders/t.$customUrl.server.ts new file mode 100644 index 000000000..3e8cd311c --- /dev/null +++ b/app/features/team/loaders/t.$customUrl.server.ts @@ -0,0 +1,13 @@ +import type { LoaderFunctionArgs } from "@remix-run/node"; +import { notFoundIfFalsy } from "~/utils/remix"; +import * as TeamRepository from "../TeamRepository.server"; +import { teamParamsSchema } from "../team-schemas.server"; +import { canAddCustomizedColors } from "../team-utils"; + +export const loader = async ({ params }: LoaderFunctionArgs) => { + const { customUrl } = teamParamsSchema.parse(params); + + const team = notFoundIfFalsy(await TeamRepository.findByCustomUrl(customUrl)); + + return { team, css: canAddCustomizedColors(team) ? team.css : null }; +}; diff --git a/app/features/team/queries/addNewTeamMember.server.ts b/app/features/team/queries/addNewTeamMember.server.ts deleted file mode 100644 index 0c30f79f2..000000000 --- a/app/features/team/queries/addNewTeamMember.server.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { sql } from "~/db/sql"; - -const stm = sql.prepare(/* sql */ ` - insert into "AllTeamMember" - ("teamId", "userId") - values - (@teamId, @userId) - on conflict("teamId", "userId") do - update - set - "leftAt" = null -`); - -export function addNewTeamMember({ - teamId, - userId, -}: { - teamId: number; - userId: number; -}) { - stm.run({ teamId, userId }); -} diff --git a/app/features/team/queries/findByIdentifier.server.ts b/app/features/team/queries/findByIdentifier.server.ts deleted file mode 100644 index bc9df07fc..000000000 --- a/app/features/team/queries/findByIdentifier.server.ts +++ /dev/null @@ -1,108 +0,0 @@ -import { sql } from "~/db/sql"; -import type { Team, TeamMember, User } from "~/db/types"; -import { removeDuplicates } from "~/utils/arrays"; -import { parseDBJsonArray } from "~/utils/sql"; -import type { DetailedTeam } from "../team-types"; - -const teamStm = sql.prepare(/*sql*/ ` - select - "t"."id", - "t"."name", - "t"."twitter", - "t"."bio", - "t"."customUrl", - "t"."css", - "ia"."url" as "avatarSrc", - "ib"."url" as "bannerSrc", - json_group_array("User"."country") as "countries" - from "Team" as "t" - left join "UserSubmittedImage" as "ia" on "avatarImgId" = "ia"."id" - left join "UserSubmittedImage" as "ib" on "bannerImgId" = "ib"."id" - left join "TeamMember" on "TeamMember"."teamId" = "t"."id" - left join "User" on "User"."id" = "TeamMember"."userId" - where "t"."customUrl" = @customUrl - group by "t"."id" -`); - -const membersStm = sql.prepare(/*sql*/ ` - select - "User"."id", - "User"."username", - "User"."discordAvatar", - "User"."discordId", - "User"."patronTier", - "TeamMember"."role", - "TeamMember"."isOwner", - json_group_array( - json_object( - 'weaponSplId', "UserWeapon"."weaponSplId", - 'isFavorite', "UserWeapon"."isFavorite" - ) - ) as "weapons" - from "TeamMember" - join "User" on "User"."id" = "TeamMember"."userId" - left join "UserWeapon" on "UserWeapon"."userId" = "User"."id" - where "TeamMember"."teamId" = @teamId - group by "User"."id" - order by "UserWeapon"."order" asc -`); - -type TeamRow = - | (Pick & { - avatarSrc: string; - bannerSrc: string; - countries: string; - }) - | null; - -type MemberRows = Array< - Pick & - Pick & { weapons: string } ->; - -export function findByIdentifier( - customUrl: string, -): { team: DetailedTeam; css: Record | null } | null { - const team = teamStm.get({ customUrl: customUrl.toLowerCase() }) as TeamRow; - - if (!team) return null; - - const members = membersStm.all({ teamId: team.id }) as MemberRows; - - return { - css: team.css ? (JSON.parse(team.css) as Record) : null, - team: { - id: team.id, - name: team.name, - customUrl: team.customUrl, - twitter: team.twitter ?? undefined, - bio: team.bio ?? undefined, - avatarSrc: team.avatarSrc, - bannerSrc: team.bannerSrc, - countries: removeDuplicates(JSON.parse(team.countries).filter(Boolean)), - members: members.map((member) => ({ - id: member.id, - discordAvatar: member.discordAvatar, - discordId: member.discordId, - username: member.username, - patronTier: member.patronTier, - role: member.role ?? undefined, - isOwner: Boolean(member.isOwner), - weapons: parseDBJsonArray(member.weapons), - })), - // results: { - // count: 23, - // placements: [ - // { - // count: 10, - // placement: 1, - // }, - // { - // count: 5, - // placement: 2, - // }, - // ], - // }, - }, - }; -} diff --git a/app/features/team/queries/leaveTeam.server.ts b/app/features/team/queries/leaveTeam.server.ts deleted file mode 100644 index 715ba3d43..000000000 --- a/app/features/team/queries/leaveTeam.server.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { sql } from "~/db/sql"; - -const stm = sql.prepare(/* sql */ ` - update "AllTeamMember" - set "leftAt" = strftime('%s', 'now') - where "teamId" = @teamId - and "userId" = @userId - and "isOwner" = 0 -`); // isOwner check to make sure the owner doesn't leave causing a bad state - -export function leaveTeam({ - teamId, - userId, -}: { - teamId: number; - userId: number; -}) { - stm.run({ teamId, userId }); -} diff --git a/app/features/team/routes/t.$customUrl.edit.tsx b/app/features/team/routes/t.$customUrl.edit.tsx index a5d58daa5..2457efd70 100644 --- a/app/features/team/routes/t.$customUrl.edit.tsx +++ b/app/features/team/routes/t.$customUrl.edit.tsx @@ -19,6 +19,7 @@ import { Main } from "~/components/Main"; import { SubmitButton } from "~/components/SubmitButton"; import { requireUserId } from "~/features/auth/core/user.server"; import * as LFGRepository from "~/features/lfg/LFGRepository.server"; +import { isAdmin } from "~/permissions"; import { type SendouRouteHandle, notFoundIfFalsy, @@ -34,9 +35,9 @@ import { teamPage, uploadImagePage, } from "~/utils/urls"; +import * as TeamRepository from "../TeamRepository.server"; import { deleteTeam } from "../queries/deleteTeam.server"; import { edit } from "../queries/edit.server"; -import { findByIdentifier } from "../queries/findByIdentifier.server"; import { TEAM } from "../team-constants"; import { editTeamSchema, teamParamsSchema } from "../team-schemas.server"; import { canAddCustomizedColors, isTeamOwner } from "../team-utils"; @@ -75,9 +76,12 @@ export const action: ActionFunction = async ({ request, params }) => { const user = await requireUserId(request); const { customUrl } = teamParamsSchema.parse(params); - const { team } = notFoundIfFalsy(findByIdentifier(customUrl)); + const team = notFoundIfFalsy(await TeamRepository.findByCustomUrl(customUrl)); - validate(isTeamOwner({ team, user }), "You are not the team owner"); + validate( + isTeamOwner({ team, user }) || isAdmin(user), + "You are not the team owner", + ); const data = await parseRequestPayload({ request, @@ -93,10 +97,10 @@ export const action: ActionFunction = async ({ request, params }) => { } case "EDIT": { const newCustomUrl = mySlugify(data.name); - const existing = findByIdentifier(newCustomUrl); + const existingTeam = await TeamRepository.findByCustomUrl(newCustomUrl); // can't take someone else's custom url - if (existing && existing.team.id !== team.id) { + if (existingTeam && existingTeam.id !== team.id) { return { errors: ["forms.errors.duplicateName"], }; @@ -120,13 +124,13 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => { const user = await requireUserId(request); const { customUrl } = teamParamsSchema.parse(params); - const { team, css } = notFoundIfFalsy(findByIdentifier(customUrl)); + const team = notFoundIfFalsy(await TeamRepository.findByCustomUrl(customUrl)); - if (!isTeamOwner({ team, user })) { + if (!isTeamOwner({ team, user }) && !isAdmin(user)) { throw redirect(teamPage(customUrl)); } - return { team, css }; + return { team, css: team.css }; }; export default function EditTeamPage() { diff --git a/app/features/team/routes/t.$customUrl.join.tsx b/app/features/team/routes/t.$customUrl.join.tsx index f7fa0a79b..b2cd24b56 100644 --- a/app/features/team/routes/t.$customUrl.join.tsx +++ b/app/features/team/routes/t.$customUrl.join.tsx @@ -6,18 +6,16 @@ import { Main } from "~/components/Main"; import { SubmitButton } from "~/components/SubmitButton"; import { INVITE_CODE_LENGTH } from "~/constants"; import { requireUser } from "~/features/auth/core/user.server"; -import * as UserRepository from "~/features/user-page/UserRepository.server"; import { type SendouRouteHandle, notFoundIfFalsy, validate, } from "~/utils/remix"; import { teamPage } from "~/utils/urls"; -import { addNewTeamMember } from "../queries/addNewTeamMember.server"; -import { findByIdentifier } from "../queries/findByIdentifier.server"; +import * as TeamRepository from "../TeamRepository.server"; import { inviteCodeById } from "../queries/inviteCodeById.server"; +import { TEAM } from "../team-constants"; import { teamParamsSchema } from "../team-schemas.server"; -import type { DetailedTeam } from "../team-types"; import { isTeamFull, isTeamMember } from "../team-utils"; import "../team.css"; @@ -26,7 +24,7 @@ export const action: ActionFunction = async ({ request, params }) => { const user = await requireUser(request); const { customUrl } = teamParamsSchema.parse(params); - const { team } = notFoundIfFalsy(findByIdentifier(customUrl)); + const team = notFoundIfFalsy(await TeamRepository.findByCustomUrl(customUrl)); const inviteCode = new URL(request.url).searchParams.get("code") ?? ""; const realInviteCode = inviteCodeById(team.id)!; @@ -37,14 +35,19 @@ export const action: ActionFunction = async ({ request, params }) => { realInviteCode, team, user, - isInTeam: Boolean( - (await UserRepository.findProfileByIdentifier(String(user.id)))?.team, - ), + reachedTeamCountLimit: false, // checked in the DB transaction }) === "VALID", "Invite code is invalid", ); - addNewTeamMember({ teamId: team.id, userId: user.id }); + await TeamRepository.addNewTeamMember({ + maxTeamsAllowed: + user.patronTier && user.patronTier >= 2 + ? TEAM.MAX_TEAM_COUNT_PATRON + : TEAM.MAX_TEAM_COUNT_NON_PATRON, + teamId: team.id, + userId: user.id, + }); throw redirect(teamPage(team.customUrl)); }; @@ -57,19 +60,22 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => { const user = await requireUser(request); const { customUrl } = teamParamsSchema.parse(params); - const { team } = notFoundIfFalsy(findByIdentifier(customUrl)); + const team = notFoundIfFalsy(await TeamRepository.findByCustomUrl(customUrl)); const inviteCode = new URL(request.url).searchParams.get("code") ?? ""; const realInviteCode = inviteCodeById(team.id)!; + const teamCount = (await TeamRepository.teamsByMemberUserId(user.id)).length; + const validation = validateInviteCode({ inviteCode, realInviteCode, team, user, - isInTeam: Boolean( - (await UserRepository.findProfileByIdentifier(String(user.id)))?.team, - ), + reachedTeamCountLimit: + user.patronTier && user.patronTier >= 2 + ? teamCount >= TEAM.MAX_TEAM_COUNT_PATRON + : teamCount >= TEAM.MAX_TEAM_COUNT_NON_PATRON, }); if (validation === "ALREADY_JOINED") { @@ -87,13 +93,13 @@ function validateInviteCode({ realInviteCode, team, user, - isInTeam, + reachedTeamCountLimit, }: { inviteCode: string; realInviteCode: string; - team: DetailedTeam; + team: TeamRepository.findByCustomUrl; user?: { id: number; team?: { name: string } }; - isInTeam: boolean; + reachedTeamCountLimit: boolean; }) { if (inviteCode.length !== INVITE_CODE_LENGTH) { return "SHORT_CODE"; @@ -107,8 +113,8 @@ function validateInviteCode({ if (isTeamMember({ team, user })) { return "ALREADY_JOINED"; } - if (isInTeam) { - return "ALREADY_IN_DIFFERENT_TEAM"; + if (reachedTeamCountLimit) { + return "REACHED_TEAM_COUNT_LIMIT"; } return "VALID"; @@ -122,7 +128,7 @@ export default function JoinTeamPage() { | "SHORT_CODE" | "INVITE_CODE_WRONG" | "TEAM_FULL" - | "ALREADY_IN_DIFFERENT_TEAM" + | "REACHED_TEAM_COUNT_LIMIT" | "VALID"; teamName: string; }>(); diff --git a/app/features/team/routes/t.$customUrl.roster.tsx b/app/features/team/routes/t.$customUrl.roster.tsx index 50c5b75bc..fb275ff85 100644 --- a/app/features/team/routes/t.$customUrl.roster.tsx +++ b/app/features/team/routes/t.$customUrl.roster.tsx @@ -17,6 +17,7 @@ import { Main } from "~/components/Main"; import { SubmitButton } from "~/components/SubmitButton"; import { useUser } from "~/features/auth/core/user"; import { requireUserId } from "~/features/auth/core/user.server"; +import { isAdmin } from "~/permissions"; import type { SendouRouteHandle } from "~/utils/remix"; import { notFoundIfFalsy, parseRequestPayload, validate } from "~/utils/remix"; import { makeTitle } from "~/utils/strings"; @@ -27,15 +28,13 @@ import { navIconUrl, teamPage, } from "~/utils/urls"; +import * as TeamRepository from "../TeamRepository.server"; import { editRole } from "../queries/editRole.server"; -import { findByIdentifier } from "../queries/findByIdentifier.server"; import { inviteCodeById } from "../queries/inviteCodeById.server"; -import { leaveTeam } from "../queries/leaveTeam.server"; import { resetInviteLink } from "../queries/resetInviteLink.server"; import { transferOwnership } from "../queries/transferOwnership.server"; import { TEAM_MEMBER_ROLES } from "../team-constants"; import { manageRosterSchema, teamParamsSchema } from "../team-schemas.server"; -import type { DetailedTeamMember } from "../team-types"; import { isTeamFull, isTeamOwner } from "../team-utils"; import "../team.css"; @@ -50,8 +49,11 @@ export const action: ActionFunction = async ({ request, params }) => { const user = await requireUserId(request); const { customUrl } = teamParamsSchema.parse(params); - const { team } = notFoundIfFalsy(findByIdentifier(customUrl)); - validate(isTeamOwner({ team, user }), "Only team owner can manage roster"); + const team = notFoundIfFalsy(await TeamRepository.findByCustomUrl(customUrl)); + validate( + isTeamOwner({ team, user }) || isAdmin(user), + "Only team owner can manage roster", + ); const data = await parseRequestPayload({ request, @@ -61,7 +63,10 @@ export const action: ActionFunction = async ({ request, params }) => { switch (data._action) { case "DELETE_MEMBER": { validate(data.userId !== user.id, "Can't delete yourself"); - leaveTeam({ teamId: team.id, userId: data.userId }); + await TeamRepository.removeTeamMember({ + teamId: team.id, + userId: data.userId, + }); break; } case "RESET_INVITE_LINK": { @@ -119,9 +124,9 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => { const user = await requireUserId(request); const { customUrl } = teamParamsSchema.parse(params); - const { team } = notFoundIfFalsy(findByIdentifier(customUrl)); + const team = notFoundIfFalsy(await TeamRepository.findByCustomUrl(customUrl)); - if (!isTeamOwner({ team, user })) { + if (!isTeamOwner({ team, user }) && !isAdmin(user)) { throw redirect(teamPage(customUrl)); } @@ -204,7 +209,7 @@ function MemberRow({ member, number, }: { - member: DetailedTeamMember; + member: TeamRepository.findByCustomUrl["members"][number]; number: number; }) { const { team } = useLoaderData(); diff --git a/app/features/team/routes/t.$customUrl.tsx b/app/features/team/routes/t.$customUrl.tsx index a2f9d44d7..019e22eeb 100644 --- a/app/features/team/routes/t.$customUrl.tsx +++ b/app/features/team/routes/t.$customUrl.tsx @@ -1,10 +1,5 @@ -import type { - ActionFunction, - LoaderFunctionArgs, - MetaFunction, - SerializeFrom, -} from "@remix-run/node"; -import { Link, useLoaderData } from "@remix-run/react"; +import type { MetaFunction, SerializeFrom } from "@remix-run/node"; +import { Link, useFetcher, useLoaderData } from "@remix-run/react"; import clsx from "clsx"; import React from "react"; import { useTranslation } from "react-i18next"; @@ -14,17 +9,15 @@ import { Flag } from "~/components/Flag"; import { FormWithConfirm } from "~/components/FormWithConfirm"; import { WeaponImage } from "~/components/Image"; import { Main } from "~/components/Main"; -import { Placement } from "~/components/Placement"; +import { SubmitButton } from "~/components/SubmitButton"; import { EditIcon } from "~/components/icons/Edit"; +import { StarIcon } from "~/components/icons/Star"; import { TwitterIcon } from "~/components/icons/Twitter"; import { UsersIcon } from "~/components/icons/Users"; import { useUser } from "~/features/auth/core/user"; -import { requireUserId } from "~/features/auth/core/user.server"; -import { - type SendouRouteHandle, - notFoundIfFalsy, - validate, -} from "~/utils/remix"; +import { isAdmin } from "~/permissions"; +import { removeDuplicates } from "~/utils/arrays"; +import type { SendouRouteHandle } from "~/utils/remix"; import { makeTitle } from "~/utils/strings"; import { TEAM_SEARCH_PAGE, @@ -36,15 +29,12 @@ import { userPage, userSubmittedImage, } from "~/utils/urls"; -import { findByIdentifier } from "../queries/findByIdentifier.server"; -import { leaveTeam } from "../queries/leaveTeam.server"; -import { teamParamsSchema } from "../team-schemas.server"; -import type { DetailedTeamMember, TeamResultPeek } from "../team-types"; -import { - canAddCustomizedColors, - isTeamMember, - isTeamOwner, -} from "../team-utils"; +import type * as TeamRepository from "../TeamRepository.server"; +import { isTeamMember, isTeamOwner } from "../team-utils"; + +import { action } from "../actions/t.$customUrl.server"; +import { loader } from "../loaders/t.$customUrl.server"; +export { action, loader }; import "../team.css"; @@ -57,22 +47,6 @@ export const meta: MetaFunction = ({ data }) => { ]; }; -export const action: ActionFunction = async ({ request, params }) => { - const user = await requireUserId(request); - - const { customUrl } = teamParamsSchema.parse(params); - const { team } = notFoundIfFalsy(findByIdentifier(customUrl)); - - validate( - isTeamMember({ user, team }) && !isTeamOwner({ user, team }), - "You are not a regular member of this team", - ); - - leaveTeam({ userId: user.id, teamId: team.id }); - - return null; -}; - export const handle: SendouRouteHandle = { i18n: ["team"], breadcrumb: ({ match }) => { @@ -95,14 +69,6 @@ export const handle: SendouRouteHandle = { }, }; -export const loader = ({ params }: LoaderFunctionArgs) => { - const { customUrl } = teamParamsSchema.parse(params); - - const { team, css } = notFoundIfFalsy(findByIdentifier(customUrl)); - - return { team, css: canAddCustomizedColors(team) ? css : null }; -}; - export default function TeamPage() { const { team } = useLoaderData(); @@ -114,7 +80,7 @@ export default function TeamPage() { - {team.results ? : null} + {/* {team.results ? : null} */} {team.bio ?
{team.bio}
: null}
{team.members.map((member, i) => ( @@ -151,7 +117,11 @@ function TeamBanner() {
) : null}
- {team.countries.map((country) => { + {removeDuplicates( + team.members + .map((member) => member.country) + .filter((country) => country !== null), + ).map((country) => { return ; })}
@@ -170,7 +140,11 @@ function MobileTeamNameCountry() { return (
- {team.countries.map((country) => { + {removeDuplicates( + team.members + .map((member) => member.country) + .filter((country) => country !== null), + ).map((country) => { return ; })}
@@ -205,16 +179,24 @@ function ActionButtons() { const user = useUser(); const { team } = useLoaderData(); - if (!isTeamMember({ user, team })) { + if (!isTeamMember({ user, team }) && !isAdmin(user)) { return null; } + const isMainTeam = team.members.find( + (member) => user?.id === member.id && member.isMainTeam, + ); + return (
- {!isTeamOwner({ user, team }) ? ( + {isTeamMember({ user, team }) && !isMainTeam ? ( + + ) : null} + {!isTeamOwner({ user, team }) && isTeamMember({ user, team }) ? (