Join many teams & front page changelog (#1880)

* Initial

* Progress

* Changelog initial

* Progress

* E2E test
This commit is contained in:
Kalle 2024-09-14 12:31:05 +03:00 committed by GitHub
parent f74d6cc4de
commit 1c9dcacbf2
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
64 changed files with 1110 additions and 436 deletions

View File

@ -0,0 +1,19 @@
export function BSKYLikeIcon({ className }: { className?: string }) {
return (
<svg
fill="none"
width="18"
viewBox="0 0 24 24"
height="18"
className={className}
>
<title>Like Icon</title>
<path
fill="currentColor"
fillRule="evenodd"
clipRule="evenodd"
d="M16.734 5.091c-1.238-.276-2.708.047-4.022 1.38a1 1 0 0 1-1.424 0C9.974 5.137 8.504 4.814 7.266 5.09c-1.263.282-2.379 1.206-2.92 2.556C3.33 10.18 4.252 14.84 12 19.348c7.747-4.508 8.67-9.168 7.654-11.7-.541-1.351-1.657-2.275-2.92-2.557Zm4.777 1.812c1.604 4-.494 9.69-9.022 14.47a1 1 0 0 1-.978 0C2.983 16.592.885 10.902 2.49 6.902c.779-1.942 2.414-3.334 4.342-3.764 1.697-.378 3.552.003 5.169 1.286 1.617-1.283 3.472-1.664 5.17-1.286 1.927.43 3.562 1.822 4.34 3.764Z"
/>
</svg>
);
}

View File

@ -0,0 +1,19 @@
export function BSKYReplyIcon({ className }: { className?: string }) {
return (
<svg
fill="none"
width="18"
viewBox="0 0 24 24"
height="18"
className={className}
>
<title>Reply Icon</title>
<path
fill="currentColor"
fillRule="evenodd"
clipRule="evenodd"
d="M2.002 6a3 3 0 0 1 3-3h14a3 3 0 0 1 3 3v10a3 3 0 0 1-3 3H12.28l-4.762 2.858A1 1 0 0 1 6.002 21v-2h-1a3 3 0 0 1-3-3V6Zm3-1a1 1 0 0 0-1 1v10a1 1 0 0 0 1 1h2a1 1 0 0 1 1 1v1.234l3.486-2.092a1 1 0 0 1 .514-.142h7a1 1 0 0 0 1-1V6a1 1 0 0 0-1-1h-14Z"
/>
</svg>
);
}

View File

@ -0,0 +1,19 @@
export function BSKYRepostIcon({ className }: { className?: string }) {
return (
<svg
fill="none"
width="18"
viewBox="0 0 24 24"
height="18"
className={className}
>
<title>Repost Icon</title>
<path
fill="currentColor"
fillRule="evenodd"
clipRule="evenodd"
d="M17.957 2.293a1 1 0 1 0-1.414 1.414L17.836 5H6a3 3 0 0 0-3 3v3a1 1 0 1 0 2 0V8a1 1 0 0 1 1-1h11.836l-1.293 1.293a1 1 0 0 0 1.414 1.414l2.47-2.47a1.75 1.75 0 0 0 0-2.474l-2.47-2.47ZM20 12a1 1 0 0 1 1 1v3a3 3 0 0 1-3 3H6.164l1.293 1.293a1 1 0 1 1-1.414 1.414l-2.47-2.47a1.75 1.75 0 0 1 0-2.474l2.47-2.47a1 1 0 0 1 1.414 1.414L6.164 17H18a1 1 0 0 0 1-1v-3a1 1 0 0 1 1-1Z"
/>
</svg>
);
}

View File

@ -0,0 +1,19 @@
export function ExternalIcon({ className }: { className?: string }) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
strokeWidth={1.5}
stroke="currentColor"
className={className}
>
<title>External Link Icon</title>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M13.5 6H5.25A2.25 2.25 0 0 0 3 8.25v10.5A2.25 2.25 0 0 0 5.25 21h10.5A2.25 2.25 0 0 0 18 18.75V10.5m-10.5 6L21 3m0 0h-5.25M21 3v5.25"
/>
</svg>
);
}

View File

@ -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,

View File

@ -1545,7 +1545,10 @@ function otherTeams() {
);
for (let i = 3; i < 50; i++) {
const teamName = `${capitalize(faker.word.adjective())} ${capitalize(
const teamName =
i === 3
? "Team Olive"
: `${capitalize(faker.word.adjective())} ${capitalize(
faker.word.noun(),
)}`;
const teamCustomUrl = mySlugify(teamName);

View File

@ -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<number>;
isOwner: Generated<number>;
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<number>;
@ -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;

View File

@ -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<typeof postsSchema>["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,
},
};
}

View File

@ -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();
},
}),
};
};

View File

@ -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() {
</form>
</div>
) : null}
<ChangelogList />
<Drawings filters={filters} />
</Main>
);
@ -246,6 +253,103 @@ function LogInButton() {
);
}
function ChangelogList() {
const data = useLoaderData<typeof loader>();
if (data.changelog.length === 0) return null;
return (
<div className="stack md">
<Divider smallText className="text-uppercase text-xs font-bold">
Updates
</Divider>
{data.changelog.map((item) => (
<React.Fragment key={item.id}>
<ChangelogItem item={item} />
<br />
</React.Fragment>
))}
<a
href="https://bsky.app/hashtag/sendouink?author=sendou.ink"
target="_blank"
rel="noopener noreferrer"
className="stack horizontal sm mx-auto text-xs font-bold"
>
View past updates <ExternalIcon className="front__external-link-icon" />
</a>
</div>
);
}
const ADMIN_PFP_URL =
"https://cdn.discordapp.com/avatars/79237403620945920/6fc41a44b069a0d2152ac06d1e496c6c.webp?size=80";
function ChangelogItem({
item,
}: {
item: Changelog.ChangelogItem;
}) {
return (
<div className="stack sm horizontal">
<Avatar size="sm" url={ADMIN_PFP_URL} />
<div className="whitespace-pre-wrap">
<div className="font-bold">
Sendou{" "}
<span className="text-xs text-lighter">{item.createdAtRelative}</span>
</div>
{item.text}
{item.images.length > 0 ? (
<div className="mt-4 stack horizontal sm flex-wrap">
{item.images.map((image) => (
<img
key={image.thumb}
src={image.thumb}
alt=""
className="front__change-log__img"
/>
))}
</div>
) : null}
<div className="mt-4 stack xxl horizontal">
<BSKYIconLink count={item.stats.replies} postUrl={item.postUrl}>
<BSKYReplyIcon />
</BSKYIconLink>
<BSKYIconLink count={item.stats.reposts} postUrl={item.postUrl}>
<BSKYRepostIcon />
</BSKYIconLink>
<BSKYIconLink count={item.stats.likes} postUrl={item.postUrl}>
<BSKYLikeIcon />
</BSKYIconLink>
</div>
</div>
</div>
);
}
function BSKYIconLink({
children,
count,
postUrl,
}: { children: React.ReactNode; count: number; postUrl: string }) {
return (
<a
href={postUrl}
target="_blank"
rel="noopener noreferrer"
className="front__change-log__icon-button"
>
{children}
<span
className={clsx({
invisible: count === 0,
})}
>
{count}
</span>
</a>
);
}
function Drawings({
filters,
}: {

View File

@ -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",
);

View File

@ -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("/");
}
}

View File

@ -88,6 +88,11 @@ const PERKS = [
name: "previewQ",
extraInfo: false,
},
{
tier: 2,
name: "joinFive",
extraInfo: false,
},
] as const;
export default function SupportPage() {

View File

@ -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

View File

@ -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<number, number>();
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 });
}

View File

@ -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<ReturnType<typeof findByCustomUrl>>
>;
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<DB>,
) {
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();
});
}

View File

@ -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;
};

View File

@ -1,5 +1,3 @@
export { TEAM_MEMBER_ROLES } from "./team-constants";
export { findByIdentifier } from "./queries/findByIdentifier.server";
export { isTeamOwner } from "./team-utils";

View File

@ -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 };
};

View File

@ -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 });
}

View File

@ -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<Team, "id" | "name" | "twitter" | "bio" | "customUrl" | "css"> & {
avatarSrc: string;
bannerSrc: string;
countries: string;
})
| null;
type MemberRows = Array<
Pick<User, "id" | "username" | "discordAvatar" | "discordId" | "patronTier"> &
Pick<TeamMember, "role" | "isOwner"> & { weapons: string }
>;
export function findByIdentifier(
customUrl: string,
): { team: DetailedTeam; css: Record<string, string> | 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<string, string>) : 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,
// },
// ],
// },
},
};
}

View File

@ -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 });
}

View File

@ -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() {

View File

@ -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;
}>();

View File

@ -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<typeof loader>();

View File

@ -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<typeof loader> = ({ 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<typeof loader>();
@ -114,7 +80,7 @@ export default function TeamPage() {
</div>
<MobileTeamNameCountry />
<ActionButtons />
{team.results ? <ResultsBanner results={team.results} /> : null}
{/* {team.results ? <ResultsBanner results={team.results} /> : null} */}
{team.bio ? <article data-testid="team-bio">{team.bio}</article> : null}
<div className="stack lg">
{team.members.map((member, i) => (
@ -151,7 +117,11 @@ function TeamBanner() {
</div>
) : null}
<div className="team__banner__flags">
{team.countries.map((country) => {
{removeDuplicates(
team.members
.map((member) => member.country)
.filter((country) => country !== null),
).map((country) => {
return <Flag key={country} countryCode={country} />;
})}
</div>
@ -170,7 +140,11 @@ function MobileTeamNameCountry() {
return (
<div className="team__mobile-name-country">
<div className="stack horizontal sm">
{team.countries.map((country) => {
{removeDuplicates(
team.members
.map((member) => member.country)
.filter((country) => country !== null),
).map((country) => {
return <Flag key={country} countryCode={country} tiny />;
})}
</div>
@ -205,16 +179,24 @@ function ActionButtons() {
const user = useUser();
const { team } = useLoaderData<typeof loader>();
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 (
<div className="team__action-buttons">
{!isTeamOwner({ user, team }) ? (
{isTeamMember({ user, team }) && !isMainTeam ? (
<ChangeMainTeamButton />
) : null}
{!isTeamOwner({ user, team }) && isTeamMember({ user, team }) ? (
<FormWithConfirm
dialogHeading={t("team:leaveTeam.header", { teamName: team.name })}
deleteButtonText={t("team:actionButtons.leaveTeam.confirm")}
fields={[["_action", "LEAVE_TEAM"]]}
>
<Button
size="tiny"
@ -225,7 +207,7 @@ function ActionButtons() {
</Button>
</FormWithConfirm>
) : null}
{isTeamOwner({ user, team }) ? (
{isTeamOwner({ user, team }) || isAdmin(user) ? (
<LinkButton
size="tiny"
to={manageTeamRosterPage(team.customUrl)}
@ -237,7 +219,7 @@ function ActionButtons() {
{t("team:actionButtons.manageRoster")}
</LinkButton>
) : null}
{isTeamOwner({ user, team }) ? (
{isTeamOwner({ user, team }) || isAdmin(user) ? (
<LinkButton
size="tiny"
to={editTeamPage(team.customUrl)}
@ -253,34 +235,56 @@ function ActionButtons() {
);
}
function ResultsBanner({ results }: { results: TeamResultPeek }) {
function ChangeMainTeamButton() {
const { t } = useTranslation(["team"]);
const fetcher = useFetcher();
return (
<Link className="team__results" to="results">
<div>View {results.count} results</div>
<ul className="team__results__placements">
{results.placements.map(({ placement, count }) => {
return (
<li key={placement}>
<Placement placement={placement} />×{count}
</li>
);
})}
</ul>
</Link>
<fetcher.Form method="post">
<SubmitButton
_action="MAKE_MAIN_TEAM"
size="tiny"
variant="outlined"
icon={<StarIcon />}
testId="make-main-team-button"
>
{t("team:actionButtons.makeMainTeam")}
</SubmitButton>
</fetcher.Form>
);
}
// function ResultsBanner({ results }: { results: TeamResultPeek }) {
// return (
// <Link className="team__results" to="results">
// <div>View {results.count} results</div>
// <ul className="team__results__placements">
// {results.placements.map(({ placement, count }) => {
// return (
// <li key={placement}>
// <Placement placement={placement} />×{count}
// </li>
// );
// })}
// </ul>
// </Link>
// );
// }
function MemberRow({
member,
number,
}: {
member: DetailedTeamMember;
member: TeamRepository.findByCustomUrl["members"][number];
number: number;
}) {
const { t } = useTranslation(["team"]);
return (
<div className="team__member">
<div
className="team__member"
data-testid={member.isOwner ? `member-owner-${member.id}` : undefined}
>
{member.role ? (
<span
className="team__member__role"
@ -315,7 +319,9 @@ function MemberRow({
);
}
function MobileMemberCard({ member }: { member: DetailedTeamMember }) {
function MobileMemberCard({
member,
}: { member: TeamRepository.findByCustomUrl["members"][number] }) {
const { t } = useTranslation(["team"]);
return (

View File

@ -4,17 +4,22 @@ export const TEAM = {
BIO_MAX_LENGTH: 2000,
TWITTER_MAX_LENGTH: 50,
MAX_MEMBER_COUNT: 8,
MAX_TEAM_COUNT_NON_PATRON: 2,
MAX_TEAM_COUNT_PATRON: 5,
};
export const TEAM_MEMBER_ROLES = [
"CAPTAIN",
"CO_CAPTAIN",
"FRONTLINE",
"SKIRMISHER",
"SUPPORT",
"MIDLINE",
"BACKLINE",
"FLEX",
"SUB",
"COACH",
"CHEERLEADER",
] as const;
export const TEAMS_PER_PAGE = 40;

View File

@ -8,6 +8,15 @@ export const createTeamSchema = z.object({
name: z.string().min(TEAM.NAME_MIN_LENGTH).max(TEAM.NAME_MAX_LENGTH),
});
export const teamProfilePageActionSchema = z.union([
z.object({
_action: _action("LEAVE_TEAM"),
}),
z.object({
_action: _action("MAKE_MAIN_TEAM"),
}),
]);
export const editTeamSchema = z.union([
z.object({
_action: _action("DELETE"),

View File

@ -1,35 +0,0 @@
import type { MemberRole, UserWeapon } from "~/db/types";
export interface DetailedTeam {
id: number;
customUrl: string;
name: string;
bio?: string;
twitter?: string;
avatarSrc?: string;
bannerSrc?: string;
countries: string[];
members: DetailedTeamMember[];
results?: TeamResultPeek;
}
export interface DetailedTeamMember {
id: number;
username: string;
discordId: string;
discordAvatar: string | null;
isOwner: boolean;
weapons: Array<Pick<UserWeapon, "weaponSplId" | "isFavorite">>;
role?: MemberRole;
patronTier: number | null;
}
export interface TeamResultPeek {
count: number;
placements: Array<TeamResultPeekPlacement>;
}
export interface TeamResultPeekPlacement {
placement: number;
count: number;
}

View File

@ -1,11 +1,11 @@
import type * as TeamRepository from "./TeamRepository.server";
import { TEAM } from "./team-constants";
import type { DetailedTeam } from "./team-types";
export function isTeamOwner({
team,
user,
}: {
team: DetailedTeam;
team: TeamRepository.findByCustomUrl;
user?: { id: number };
}) {
if (!user) return false;
@ -17,7 +17,7 @@ export function isTeamMember({
team,
user,
}: {
team: DetailedTeam;
team: TeamRepository.findByCustomUrl;
user?: { id: number };
}) {
if (!user) return false;
@ -25,11 +25,13 @@ export function isTeamMember({
return team.members.some((member) => member.id === user.id);
}
export function isTeamFull(team: DetailedTeam) {
export function isTeamFull(team: TeamRepository.findByCustomUrl) {
return team.members.length >= TEAM.MAX_MEMBER_COUNT;
}
export function canAddCustomizedColors(team: DetailedTeam) {
export function canAddCustomizedColors(team: {
members: { patronTier: number | null }[];
}) {
return team.members.some(
(member) => member.patronTier && member.patronTier >= 2,
);

View File

@ -158,6 +158,7 @@
display: flex;
justify-content: center;
gap: var(--s-2);
flex-wrap: wrap;
}
.team__results {

View File

@ -208,6 +208,7 @@ export async function findById(id: number) {
)
.whereRef("AllTeam.id", "=", "TournamentTeam.teamId")
.select([
"AllTeam.id",
"AllTeam.customUrl",
"UserSubmittedImage.url as logoUrl",
"AllTeam.deletedAt",

View File

@ -60,8 +60,10 @@ export const action: ActionFunction = async ({ request, params }) => {
case "UPSERT_TEAM": {
validate(
!data.teamId ||
(await TeamRepository.findByUserId(user.id))?.id === data.teamId,
"Team id does not match the team you are in",
(await TeamRepository.findAllMemberOfByUserId(user.id)).some(
(team) => team.id === data.teamId,
),
"Team id does not match any of the teams you are in",
);
if (ownTeam) {

View File

@ -18,13 +18,13 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
return {
mapPool: null,
trusterPlayers: null,
team: await TeamRepository.findByUserId(user.id),
teams: await TeamRepository.findAllMemberOfByUserId(user.id),
};
return {
mapPool: findMapPoolByTeamId(ownTournamentTeam.id),
trusterPlayers: await QRepository.usersThatTrusted(user.id),
team: await TeamRepository.findByUserId(user.id),
teams: await TeamRepository.findAllMemberOfByUserId(user.id),
};
};

View File

@ -20,7 +20,6 @@ import { NewTabs } from "~/components/NewTabs";
import { Popover } from "~/components/Popover";
import { Section } from "~/components/Section";
import { SubmitButton } from "~/components/SubmitButton";
import { Toggle } from "~/components/Toggle";
import { CheckmarkIcon } from "~/components/icons/Checkmark";
import { ClockIcon } from "~/components/icons/Clock";
import { CrossIcon } from "~/components/icons/Cross";
@ -588,17 +587,20 @@ function TeamInfo({
const [teamName, setTeamName] = React.useState(ownTeam?.name ?? "");
const user = useUser();
const ref = React.useRef<HTMLFormElement>(null);
const [signUpWithTeam, setSignUpWithTeam] = React.useState(() =>
Boolean(tournament.ownedTeamByUser(user)?.team),
const [signUpWithTeamId, setSignUpWithTeamId] = React.useState(
() => tournament.ownedTeamByUser(user)?.team?.id ?? null,
);
const [uploadedAvatar, setUploadedAvatar] = React.useState<File | null>(null);
const handleSignUpWithTeamChange = (checked: boolean) => {
if (!checked) {
setSignUpWithTeam(false);
} else if (data?.team) {
setSignUpWithTeam(true);
setTeamName(data.team.name);
const handleSignUpWithTeamChange = (teamId: number | null) => {
if (!teamId) {
setSignUpWithTeamId(null);
} else {
setSignUpWithTeamId(teamId);
const teamName = data?.teams.find((team) => team.id === teamId)?.name;
invariant(teamName, "team name should exist");
setTeamName(teamName);
}
};
@ -624,11 +626,13 @@ function TeamInfo({
};
const avatarUrl = (() => {
if (signUpWithTeam) {
if (ownTeam?.team?.logoUrl) {
return userSubmittedImage(ownTeam.team.logoUrl);
}
return data?.team?.logoUrl ? userSubmittedImage(data.team.logoUrl) : null;
if (signUpWithTeamId) {
const teamToSignUpWith = data?.teams.find(
(team) => team.id === signUpWithTeamId,
);
return teamToSignUpWith?.logoUrl
? userSubmittedImage(teamToSignUpWith.logoUrl)
: null;
}
if (uploadedAvatar) return URL.createObjectURL(uploadedAvatar);
if (ownTeam?.pickupAvatarUrl) {
@ -640,7 +644,7 @@ function TeamInfo({
const canEditAvatar =
tournament.registrationOpen &&
!signUpWithTeam &&
!signUpWithTeamId &&
uploadedAvatar &&
!ownTeam?.pickupAvatarUrl;
@ -671,25 +675,42 @@ function TeamInfo({
<section className="tournament__section">
<Form method="post" className="stack md items-center" ref={ref}>
<input type="hidden" name="_action" value="UPSERT_TEAM" />
{signUpWithTeam && data?.team ? (
<input type="hidden" name="teamId" value={data.team.id} />
{signUpWithTeamId ? (
<input type="hidden" name="teamId" value={signUpWithTeamId} />
) : null}
<div className="stack sm items-center">
{data?.team && tournament.registrationOpen ? (
<div className="stack sm-plus items-center">
{data && data.teams.length > 0 && tournament.registrationOpen ? (
<div className="tournament__section__input-container">
<Label htmlFor="signUpAsTeam">
Sign up as {data.team.name}
</Label>
<Toggle
id="signUpAsTeam"
checked={signUpWithTeam}
setChecked={handleSignUpWithTeamChange}
/>
<Label htmlFor="signingUpAs">Team signing up as</Label>
<select
id="signingUpAs"
onChange={(e) => {
if (e.target.value === "") {
handleSignUpWithTeamChange(null);
} else {
handleSignUpWithTeamChange(Number(e.target.value));
}
}}
>
<option value="">Sign up with pick-up</option>
{data.teams.map((team) => {
return (
<option key={team.id} value={team.id}>
{team.name}
</option>
);
})}
</select>
</div>
) : null}
{!signUpWithTeamId ? (
<div className="tournament__section__input-container">
<Label htmlFor="teamName">{t("tournament:pre.steps.name")}</Label>
<Label htmlFor="teamName">
{data && data.teams.length > 0
? "Pick-up name"
: t("tournament:pre.steps.name")}
</Label>
<Input
name="teamName"
id="teamName"
@ -697,9 +718,14 @@ function TeamInfo({
maxLength={TOURNAMENT.TEAM_NAME_MAX_LENGTH}
value={teamName}
onChange={(e) => setTeamName(e.target.value)}
readOnly={!tournament.registrationOpen || signUpWithTeam}
readOnly={
!tournament.registrationOpen || Boolean(signUpWithTeamId)
}
/>
</div>
) : (
<input type="hidden" name="teamName" value={teamName} />
)}
{tournament.registrationOpen || avatarUrl ? (
<div className="tournament__section__input-container">
<Label htmlFor="logo">Logo</Label>

View File

@ -171,10 +171,30 @@ export async function findProfileByIdentifier(
"Team.name",
"Team.customUrl",
"Team.id",
"TeamMember.role as userTeamRole",
"UserSubmittedImage.url as avatarUrl",
])
.whereRef("TeamMember.userId", "=", "User.id"),
).as("team"),
jsonArrayFrom(
eb
.selectFrom("TeamMemberWithSecondary")
.innerJoin("Team", "Team.id", "TeamMemberWithSecondary.teamId")
.leftJoin(
"UserSubmittedImage",
"UserSubmittedImage.id",
"Team.avatarImgId",
)
.select([
"Team.name",
"Team.customUrl",
"Team.id",
"TeamMemberWithSecondary.role as userTeamRole",
"UserSubmittedImage.url as avatarUrl",
])
.whereRef("TeamMemberWithSecondary.userId", "=", "User.id")
.where("TeamMemberWithSecondary.isMainTeam", "=", 0),
).as("secondaryTeams"),
jsonArrayFrom(
eb
.selectFrom("BadgeOwner")

View File

@ -25,11 +25,12 @@ import {
} from "~/utils/urls";
import type { UserPageLoaderData } from "./u.$identifier";
import { Popover } from "~/components/Popover";
import { loader } from "../loaders/u.$identifier.index.server";
export { loader };
export const handle: SendouRouteHandle = {
i18n: "badges",
i18n: ["badges", "team"],
};
export default function UserInfoPage() {
@ -79,23 +80,90 @@ export default function UserInfoPage() {
}
function TeamInfo() {
const { t } = useTranslation(["team"]);
const data = useLoaderData<typeof loader>();
if (!data.user.team) return null;
return (
<Link to={teamPage(data.user.team.customUrl)} className="u__team">
<div className="stack horizontal sm">
<Link
to={teamPage(data.user.team.customUrl)}
className="u__team"
data-testid="main-team-link"
>
{data.user.team.avatarUrl ? (
<img
alt=""
src={userSubmittedImage(data.user.team.avatarUrl)}
width={32}
height={32}
className="rounded-full"
/>
) : null}
<div>
{data.user.team.name}
{data.user.team.userTeamRole ? (
<div className="text-xxs text-lighter font-bold">
{t(`team:roles.${data.user.team.userTeamRole}`)}
</div>
) : null}
</div>
</Link>
<SecondaryTeamsPopover />
</div>
);
}
function SecondaryTeamsPopover() {
const { t } = useTranslation(["team"]);
const data = useLoaderData<typeof loader>();
if (data.user.secondaryTeams.length === 0) return null;
return (
<Popover
buttonChildren={
<span
className="text-sm font-bold text-main-forced"
data-testid="secondary-team-trigger"
>
+{data.user.secondaryTeams.length}
</span>
}
triggerClassName="minimal tiny focus-text-decoration"
>
<div className="stack sm">
{data.user.secondaryTeams.map((team) => (
<div
key={team.customUrl}
className="stack horizontal md items-center"
>
<Link
to={teamPage(team.customUrl)}
className="u__team text-main-forced"
>
{team.avatarUrl ? (
<img
alt=""
src={userSubmittedImage(team.avatarUrl)}
width={24}
height={24}
className="rounded-full"
/>
) : null}
{data.user.team.name}
{team.name}
</Link>
{team.userTeamRole ? (
<div className="text-xxs text-lighter font-bold">
{t(`team:roles.${team.userTeamRole}`)}
</div>
) : null}
</div>
))}
</div>
</Popover>
);
}

View File

@ -958,6 +958,10 @@ dialog::backdrop {
gap: var(--s-12);
}
.stack.xxl {
gap: var(--s-16);
}
.stack.horizontal {
flex-direction: row;
}

View File

@ -172,3 +172,29 @@
background-color: var(--card-text);
color: var(--card-bg);
}
.front__change-log__img {
max-height: 300px;
border-radius: var(--rounded-sm);
}
.front__change-log__icon-button {
display: flex;
flex-direction: row;
gap: var(--s-1-5);
justify-content: center;
align-items: center;
font-weight: var(--bold);
font-size: var(--fonts-xs);
border-radius: 100%;
color: var(--text-lighter);
}
.front__change-log__icon-button:hover {
color: var(--theme);
}
.front__external-link-icon {
width: 18px;
stroke-width: 2px;
}

View File

@ -18,10 +18,11 @@
.u__team {
display: flex;
color: var(--text-lighter);
font-weight: var(--bold);
color: var(--text);
gap: var(--s-1-5);
grid-area: team;
align-items: center;
}
.u__name {

View File

@ -42,6 +42,10 @@
color: var(--text-lighter) !important;
}
.text-theme-transparent {
color: var(--theme-transparent);
}
.text-error {
color: var(--theme-error);
}
@ -406,6 +410,11 @@
outline: 2px solid var(--theme);
}
.focus-text-decoration:focus-visible {
outline: none !important;
text-decoration: underline;
}
.label-no-spacing {
--label-margin: 0;
}

View File

@ -27,7 +27,7 @@ export async function selectUser({
await expect(combobox).not.toBeDisabled();
await combobox.clear();
await combobox.type(userName);
await combobox.fill(userName);
await expect(page.getByTestId("combobox-option-0")).toBeVisible();
await page.keyboard.press("Enter");
}
@ -68,11 +68,11 @@ export function impersonate(page: Page, userId = ADMIN_ID) {
return page.request.post(`/auth/impersonate?id=${userId}`);
}
export async function submit(page: Page) {
export async function submit(page: Page, testId?: string) {
const responsePromise = page.waitForResponse(
(res) => res.request().method() === "POST",
);
await page.getByTestId("submit-button").click();
await page.getByTestId(testId ?? "submit-button").click();
await responsePromise;
}

BIN
bun.lockb

Binary file not shown.

View File

@ -1,5 +1,5 @@
import { expect, test } from "@playwright/test";
import { ADMIN_ID } from "~/constants";
import { ADMIN_DISCORD_ID, ADMIN_ID } from "~/constants";
import { NZAP_TEST_ID } from "~/db/seed/constants";
import {
impersonate,
@ -9,7 +9,7 @@ import {
seed,
submit,
} from "~/utils/playwright";
import { TEAM_SEARCH_PAGE, teamPage } from "~/utils/urls";
import { TEAM_SEARCH_PAGE, teamPage, userPage } from "~/utils/urls";
test.describe("Team search page", () => {
test("filters teams", async ({ page }) => {
@ -24,7 +24,7 @@ test.describe("Team search page", () => {
await expect(firstTeamName).toHaveText("Alliance Rogue");
await expect(secondTeamName).toBeVisible();
await searchInput.type("Alliance Rogue");
await searchInput.fill("Alliance Rogue");
await expect(secondTeamName).not.toBeVisible();
await firstTeamName.click();
@ -38,10 +38,10 @@ test.describe("Team search page", () => {
await page.getByTestId("new-team-button").click();
await expect(page).toHaveURL(/new=true/);
await page.getByTestId("new-team-name-input").type("Team Olive");
await page.getByTestId("new-team-name-input").fill("Chimera");
await submit(page);
await expect(page).toHaveURL(/team-olive/);
await expect(page).toHaveURL(/chimera/);
});
});
@ -54,13 +54,13 @@ test.describe("Team page", () => {
await page.getByTestId("edit-team-button").click();
await page.getByTestId("name-input").clear();
await page.getByTestId("name-input").type("Better Alliance Rogue");
await page.getByTestId("name-input").fill("Better Alliance Rogue");
await page.getByTestId("twitter-input").clear();
await page.getByTestId("twitter-input").type("BetterAllianceRogue");
await page.getByTestId("twitter-input").fill("BetterAllianceRogue");
await page.getByTestId("bio-textarea").clear();
await page.getByTestId("bio-textarea").type("shorter bio");
await page.getByTestId("bio-textarea").fill("shorter bio");
await page.getByTestId("edit-team-submit-button").click();
@ -77,6 +77,9 @@ test.describe("Team page", () => {
await impersonate(page, ADMIN_ID);
await navigate({ page, url: teamPage("alliance-rogue") });
// Owner is Sendou
await expect(page.getByTestId(`member-owner-${ADMIN_ID}`)).toBeVisible();
await page.getByTestId("manage-roster-button").click();
await page.getByTestId("role-select-0").selectOption("SUPPORT");
@ -92,7 +95,9 @@ test.describe("Team page", () => {
await expect(page.getByTestId("member-row-role-0")).toHaveText("Support");
await expect(page).not.toHaveURL(/roster/);
await isNotVisible(page.getByTestId("manage-roster-button"));
// Owner is not Sendou
await isNotVisible(page.getByTestId(`member-owner-${ADMIN_ID}`));
});
test("deletes team", async ({ page }) => {
@ -138,4 +143,35 @@ test.describe("Team page", () => {
await page.getByTestId("leave-team-button").isVisible();
});
test("joins a secondary team, makes main team & leaves making the seconary team the main one", async ({
page,
}) => {
await seed(page);
await impersonate(page, ADMIN_ID);
await navigate({ page, url: teamPage("team-olive") });
await page.getByTestId("manage-roster-button").click();
const inviteLink = await page.getByTestId("invite-link").innerText();
await navigate({ page, url: inviteLink });
await submit(page);
await submit(page, "make-main-team-button");
await navigate({ page, url: userPage({ discordId: ADMIN_DISCORD_ID }) });
await expect(page.getByTestId("secondary-team-trigger")).toBeVisible();
await isNotVisible(page.getByText("Alliance Rogue"));
await page.getByTestId("main-team-link").click();
await page.getByTestId("leave-team-button").click();
await modalClickConfirmButton(page);
await navigate({ page, url: userPage({ discordId: ADMIN_DISCORD_ID }) });
await isNotVisible(page.getByTestId("secondary-team-trigger"));
await expect(page.getByText("Alliance Rogue")).toBeVisible();
});
});

View File

@ -61,7 +61,7 @@ test.describe("Tournament", () => {
await page.getByTestId("tab-Register").click();
await page.getByLabel("Team name").type("Chimera");
await page.getByLabel("Pick-up name").fill("Chimera");
await page.getByTestId("save-team-button").click();
await page.getByTestId("add-player-button").click();

View File

@ -29,7 +29,7 @@ test.describe("User page", () => {
await page.getByLabel("R-stick sens").selectOption("0");
await page.getByLabel("Motion sens").selectOption("-50");
await page.getByLabel("Country").selectOption("SE");
await page.getByLabel("Bio").type("My awesome bio");
await page.getByLabel("Bio").fill("My awesome bio");
await submitEditForm(page);
await page.getByTestId("flag-SV").isVisible();

View File

@ -21,11 +21,11 @@ test.describe("VoDs page", () => {
await page
.getByLabel("YouTube URL")
.type("https://www.youtube.com/watch?v=o7kWlMZP3lM");
.fill("https://www.youtube.com/watch?v=o7kWlMZP3lM");
await page
.getByLabel("Video title")
.type("ITZXI Finals - Team Olive vs. Astral [CAMO TENTA PoV]");
.fill("ITZXI Finals - Team Olive vs. Astral [CAMO TENTA PoV]");
await page.getByLabel("Video date").fill("2021-06-20");
@ -49,8 +49,8 @@ test.describe("VoDs page", () => {
await page.getByTestId("add-match").click();
await page.getByTestId("match-2-seconds").type("55");
await page.getByTestId("match-2-minutes").type("5");
await page.getByTestId("match-2-seconds").fill("55");
await page.getByTestId("match-2-minutes").fill("5");
await page.getByTestId("match-2-mode").selectOption("RM");
await page.getByTestId("match-2-stage").selectOption("6");
await selectWeapon({
@ -76,11 +76,11 @@ test.describe("VoDs page", () => {
await page
.getByLabel("YouTube URL")
.type("https://www.youtube.com/watch?v=QFk1Gf91SwI");
.fill("https://www.youtube.com/watch?v=QFk1Gf91SwI");
await page
.getByLabel("Video title")
.type("BIG ! vs Starburst - Splatoon 3 Grand Finals - The Big House 10");
.fill("BIG ! vs Starburst - Splatoon 3 Grand Finals - The Big House 10");
await page.getByLabel("Video date").fill("2022-07-21");

View File

@ -35,6 +35,5 @@
"validation.SHORT_CODE": "Invitationskoden har ikke den korrekte længde. Har du kopieret hele URL-adressen? ",
"validation.TEAM_FULL": "Holdet, som du prøver at blive medlem af, er fuldt",
"validation.INVITE_CODE_WRONG": "Ukorrekt invitationskode",
"validation.ALREADY_IN_DIFFERENT_TEAM": "Du er allerede medlem af et hold. Du skal forlade det hold for at blive medlem af et andet.",
"validation.VALID": "Bliv medlem af {{teamName}}?"
}

View File

@ -33,6 +33,5 @@
"validation.SHORT_CODE": "Einladungscode hat nicht die richtige Länge. Hast du die vollständige URL kopiert?",
"validation.TEAM_FULL": "Das Team, dem du beitreten willst, ist bereits voll.",
"validation.INVITE_CODE_WRONG": "Einladungscode ist falsch.",
"validation.ALREADY_IN_DIFFERENT_TEAM": "Du bist bereits in einem anderen Team. Verlasse es, bevor du einem neuen beitrittst.",
"validation.VALID": "{{teamName}} beitreten?"
}

View File

@ -187,6 +187,7 @@
"support.perk.prioritySupport": "Priority support",
"support.perk.prioritySupport.extra": "Access to a separate helpdesk that I prioritize for support requests",
"support.perk.previewQ": "Preview groups in SendouQ before joining",
"support.perk.joinFive": "Join up to 5 teams",
"custom.colors.title": "Custom colors",
"custom.colors.bg": "Background",

View File

@ -3,6 +3,7 @@
"newTeam.header": "Creating a new team",
"teamSearch.placeholder": "Search for a team or player...",
"actionButtons.leaveTeam": "Leave Team",
"actionButtons.makeMainTeam": "Make main team",
"leaveTeam.header": "Are you sure you want to leave {{teamName}}?",
"actionButtons.leaveTeam.confirm": "Leave",
"actionButtons.editTeam": "Edit Team",
@ -15,13 +16,16 @@
"actionButtons.transferOwnership.confirm": "Transfer",
"deleteTeam.header": "Are you sure you want to delete {{teamName}}?",
"roles.CAPTAIN": "Captain",
"roles.CO_CAPTAIN": "Co-Captain",
"roles.FRONTLINE": "Frontline",
"roles.SKIRMISHER": "Skirmisher",
"roles.SUPPORT": "Support",
"roles.MIDLINE": "Midline",
"roles.BACKLINE": "Backline",
"roles.FLEX": "Flex",
"roles.SUB": "Sub",
"roles.COACH": "Coach",
"roles.CHEERLEADER": "Cheerleader",
"forms.fields.teamTwitter": "Team Twitter",
"forms.fields.bio": "Bio",
"forms.fields.uploadImages": "Upload images",
@ -35,6 +39,6 @@
"validation.SHORT_CODE": "Invite code is not the right length. Did you copy the full URL?",
"validation.TEAM_FULL": "Team you are trying to join is full.",
"validation.INVITE_CODE_WRONG": "Invite code is wrong.",
"validation.ALREADY_IN_DIFFERENT_TEAM": "You are already in a different team. Leave it first before joining a new one.",
"validation.REACHED_TEAM_COUNT_LIMIT": "You have reached the maximum number of teams you can join. (Max 5 for patrons and 2 for others)",
"validation.VALID": "Join {{teamName}}?"
}

View File

@ -34,6 +34,5 @@
"validation.SHORT_CODE": "El código de invitación no tiene la longitud correcta. ¿Copiaste la URL completa?",
"validation.TEAM_FULL": "El equipo al que te intentas unir ya esta lleno.",
"validation.INVITE_CODE_WRONG": "El código de invitación es incorrecto.",
"validation.ALREADY_IN_DIFFERENT_TEAM": "Ya eres parte de otro equipo. Debes salir de este antes de entrar a otro.",
"validation.VALID": "¿Entrar a {{teamName}}?"
}

View File

@ -35,6 +35,5 @@
"validation.SHORT_CODE": "El código de invitación no tiene la longitud correcta. ¿Copiaste la URL completa?",
"validation.TEAM_FULL": "El equipo al que te intentas unir ya esta lleno.",
"validation.INVITE_CODE_WRONG": "El código de invitación es incorrecto.",
"validation.ALREADY_IN_DIFFERENT_TEAM": "Ya eres parte de otro equipo. Debes salir de este antes de entrar a otro.",
"validation.VALID": "¿Entrar a {{teamName}}?"
}

View File

@ -34,6 +34,5 @@
"validation.SHORT_CODE": "Le code d'invitation n'a pas la bonne longueur. Avez-vous bien copier l'URL au complet ?",
"validation.TEAM_FULL": "L'équipe que vous essayez de rejoindre est complète.",
"validation.INVITE_CODE_WRONG": "Le code d'invitation est incorrect.",
"validation.ALREADY_IN_DIFFERENT_TEAM": "Vous êtes déjà dans une autre équipe. Quittez-la avant d'en rejoindre une nouvelle.",
"validation.VALID": "Rejoindre {{teamName}} ?"
}

View File

@ -34,6 +34,5 @@
"validation.SHORT_CODE": "Le code d'invitation n'a pas la bonne longueur. Avez-vous bien copier l'URL au complet ?",
"validation.TEAM_FULL": "L'équipe que vous essayez de rejoindre est complète.",
"validation.INVITE_CODE_WRONG": "Le code d'invitation est incorrect.",
"validation.ALREADY_IN_DIFFERENT_TEAM": "Vous êtes déjà dans une autre équipe. Quittez-la avant d'en rejoindre une nouvelle.",
"validation.VALID": "Rejoindre {{teamName}} ?"
}

View File

@ -34,6 +34,5 @@
"validation.SHORT_CODE": "קוד ההזמנה אינו באורך המתאים. העתקתם את כתובת האתר המלאה?",
"validation.TEAM_FULL": "הצוות שאתם מנסים להצטרף אליו מלא.",
"validation.INVITE_CODE_WRONG": "קוד ההזמנה שגוי.",
"validation.ALREADY_IN_DIFFERENT_TEAM": "הנכם בצוות אחר. עזבו אותו קודם לפני הצטרפות לצוות אחר.",
"validation.VALID": "הצטרפו ל-{{teamName}}?"
}

View File

@ -34,6 +34,5 @@
"validation.SHORT_CODE": "招待コードの長さが正しくありません。URL をすべてコピーしましたか?",
"validation.TEAM_FULL": "参加しようとしているチームはすでに満員です。",
"validation.INVITE_CODE_WRONG": "招待コードが正しくありません。",
"validation.ALREADY_IN_DIFFERENT_TEAM": "すでに別のチームに参加済みです。まず参加済みのチームを抜けてから新しいチームに参加してください。",
"validation.VALID": "{{teamName}} に参加しますか?"
}

View File

@ -32,6 +32,5 @@
"validation.SHORT_CODE": "Zaproszenie jest złej długości. Czy napewno skopiowałeś/aś pełne URL?",
"validation.TEAM_FULL": "Drużyna, do której próbujesz dołączyć, jest pełna.",
"validation.INVITE_CODE_WRONG": "Zaproszenie niepoprawne.",
"validation.ALREADY_IN_DIFFERENT_TEAM": "Jesteś już na innej drużynie. Opuść ją zanim dołączysz do nowej.",
"validation.VALID": "Dołączyć do {{teamName}}?"
}

View File

@ -34,6 +34,5 @@
"validation.SHORT_CODE": "O código de convite não está no comprimento certo. Você copiou o URL completo?",
"validation.TEAM_FULL": "O time que você está tentando se juntar está cheio.",
"validation.INVITE_CODE_WRONG": "O código de convite está incorreto.",
"validation.ALREADY_IN_DIFFERENT_TEAM": "Você já está em um time diferente. Saia dele primeiro antes de entrar em outro.",
"validation.VALID": "Entrar no(a) {{teamName}}?"
}

View File

@ -34,6 +34,5 @@
"validation.SHORT_CODE": "Код приглашения неверной длинны. Вы скопировали URL целиком?",
"validation.TEAM_FULL": "Команда, в которую вы пытаетесь попасть, уже заполнена.",
"validation.INVITE_CODE_WRONG": "Код приглашения неверен.",
"validation.ALREADY_IN_DIFFERENT_TEAM": "Вы уже в другой команде. Покиньте эту, прежде чем вступить в новую.",
"validation.VALID": "Присоединиться к {{teamName}}?"
}

View File

@ -34,6 +34,5 @@
"validation.SHORT_CODE": "邀请码长度不符请确保您复制了完整的URL。",
"validation.TEAM_FULL": "您想要加入的队伍成员已满。",
"validation.INVITE_CODE_WRONG": "邀请码有误。",
"validation.ALREADY_IN_DIFFERENT_TEAM": "您已经在另一支队伍里了。如想要加入新队伍,请退出该队。",
"validation.VALID": "加入 {{teamName}} "
}

View File

@ -0,0 +1,38 @@
export function up(db) {
db.transaction(() => {
db.prepare(
/* sql */ `alter table "AllTeamMember" add "isMainTeam" integer default 1`,
).run();
db.prepare(/*sql */ `drop view "TeamMember"`).run();
db.prepare(
/*sql*/ `
create view "TeamMember"
as
select "AllTeamMember".*
from "AllTeamMember"
left join "Team" on "Team"."id" = "AllTeamMember"."teamId"
where "AllTeamMember"."leftAt" is null
and
-- if team id is null the team is deleted
"Team"."id" is not null
and
"AllTeamMember"."isMainTeam" = 1
`,
).run();
db.prepare(
/*sql*/ `
create view "TeamMemberWithSecondary"
as
select "AllTeamMember".*
from "AllTeamMember"
left join "Team" on "Team"."id" = "AllTeamMember"."teamId"
where "AllTeamMember"."leftAt" is null
and
-- if team id is null the team is deleted
"Team"."id" is not null
`,
).run();
})();
}

View File

@ -88,7 +88,7 @@
},
"devDependencies": {
"@biomejs/biome": "1.8.3",
"@playwright/test": "^1.47.0",
"@playwright/test": "^1.47.1",
"@remix-run/dev": "^2.11.2",
"@types/better-sqlite3": "^7.6.11",
"@types/bun": "^1.1.8",

View File

@ -37,8 +37,7 @@ const config: PlaywrightTestConfig = {
/* Base URL to use in actions like `await page.goto('/')`. */
baseURL: "http://localhost:5173",
// disabled because https://github.com/microsoft/playwright/issues/27048
trace: "off",
trace: "retain-on-failure",
permissions: ["clipboard-read"],
},