mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-03-21 18:04:39 -05:00
Join many teams & front page changelog (#1880)
* Initial * Progress * Changelog initial * Progress * E2E test
This commit is contained in:
parent
f74d6cc4de
commit
1c9dcacbf2
19
app/components/icons/BSKYLike.tsx
Normal file
19
app/components/icons/BSKYLike.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
19
app/components/icons/BSKYReply.tsx
Normal file
19
app/components/icons/BSKYReply.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
19
app/components/icons/BSKYRepost.tsx
Normal file
19
app/components/icons/BSKYRepost.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
19
app/components/icons/External.tsx
Normal file
19
app/components/icons/External.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
150
app/features/front-page/core/Changelog.server.ts
Normal file
150
app/features/front-page/core/Changelog.server.ts
Normal 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,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
@ -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();
|
||||
},
|
||||
}),
|
||||
};
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}: {
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -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("/");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -88,6 +88,11 @@ const PERKS = [
|
|||
name: "previewQ",
|
||||
extraInfo: false,
|
||||
},
|
||||
{
|
||||
tier: 2,
|
||||
name: "joinFive",
|
||||
extraInfo: false,
|
||||
},
|
||||
] as const;
|
||||
|
||||
export default function SupportPage() {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
}
|
||||
|
|
|
|||
48
app/features/team/actions/t.$customUrl.server.ts
Normal file
48
app/features/team/actions/t.$customUrl.server.ts
Normal 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;
|
||||
};
|
||||
|
|
@ -1,5 +1,3 @@
|
|||
export { TEAM_MEMBER_ROLES } from "./team-constants";
|
||||
|
||||
export { findByIdentifier } from "./queries/findByIdentifier.server";
|
||||
|
||||
export { isTeamOwner } from "./team-utils";
|
||||
|
|
|
|||
13
app/features/team/loaders/t.$customUrl.server.ts
Normal file
13
app/features/team/loaders/t.$customUrl.server.ts
Normal 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 };
|
||||
};
|
||||
|
|
@ -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 });
|
||||
}
|
||||
|
|
@ -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,
|
||||
// },
|
||||
// ],
|
||||
// },
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
@ -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 });
|
||||
}
|
||||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}>();
|
||||
|
|
|
|||
|
|
@ -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>();
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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,
|
||||
);
|
||||
|
|
|
|||
|
|
@ -158,6 +158,7 @@
|
|||
display: flex;
|
||||
justify-content: center;
|
||||
gap: var(--s-2);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.team__results {
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
};
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -958,6 +958,10 @@ dialog::backdrop {
|
|||
gap: var(--s-12);
|
||||
}
|
||||
|
||||
.stack.xxl {
|
||||
gap: var(--s-16);
|
||||
}
|
||||
|
||||
.stack.horizontal {
|
||||
flex-direction: row;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
||||
|
|
|
|||
|
|
@ -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}}?"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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?"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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}}?"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}}?"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}}?"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}} ?"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}} ?"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -34,6 +34,5 @@
|
|||
"validation.SHORT_CODE": "קוד ההזמנה אינו באורך המתאים. העתקתם את כתובת האתר המלאה?",
|
||||
"validation.TEAM_FULL": "הצוות שאתם מנסים להצטרף אליו מלא.",
|
||||
"validation.INVITE_CODE_WRONG": "קוד ההזמנה שגוי.",
|
||||
"validation.ALREADY_IN_DIFFERENT_TEAM": "הנכם בצוות אחר. עזבו אותו קודם לפני הצטרפות לצוות אחר.",
|
||||
"validation.VALID": "הצטרפו ל-{{teamName}}?"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -34,6 +34,5 @@
|
|||
"validation.SHORT_CODE": "招待コードの長さが正しくありません。URL をすべてコピーしましたか?",
|
||||
"validation.TEAM_FULL": "参加しようとしているチームはすでに満員です。",
|
||||
"validation.INVITE_CODE_WRONG": "招待コードが正しくありません。",
|
||||
"validation.ALREADY_IN_DIFFERENT_TEAM": "すでに別のチームに参加済みです。まず参加済みのチームを抜けてから新しいチームに参加してください。",
|
||||
"validation.VALID": "{{teamName}} に参加しますか?"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}}?"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}}?"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -34,6 +34,5 @@
|
|||
"validation.SHORT_CODE": "Код приглашения неверной длинны. Вы скопировали URL целиком?",
|
||||
"validation.TEAM_FULL": "Команда, в которую вы пытаетесь попасть, уже заполнена.",
|
||||
"validation.INVITE_CODE_WRONG": "Код приглашения неверен.",
|
||||
"validation.ALREADY_IN_DIFFERENT_TEAM": "Вы уже в другой команде. Покиньте эту, прежде чем вступить в новую.",
|
||||
"validation.VALID": "Присоединиться к {{teamName}}?"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -34,6 +34,5 @@
|
|||
"validation.SHORT_CODE": "邀请码长度不符,请确保您复制了完整的URL。",
|
||||
"validation.TEAM_FULL": "您想要加入的队伍成员已满。",
|
||||
"validation.INVITE_CODE_WRONG": "邀请码有误。",
|
||||
"validation.ALREADY_IN_DIFFERENT_TEAM": "您已经在另一支队伍里了。如想要加入新队伍,请退出该队。",
|
||||
"validation.VALID": "加入 {{teamName}} ?"
|
||||
}
|
||||
|
|
|
|||
38
migrations/069-many-teams.js
Normal file
38
migrations/069-many-teams.js
Normal 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();
|
||||
})();
|
||||
}
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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"],
|
||||
},
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user