This commit is contained in:
Kalle
2026-02-22 19:25:05 +02:00
parent 012b5c6ce1
commit dec684e489
23 changed files with 241 additions and 56 deletions

View File

@@ -702,6 +702,7 @@ export interface TournamentLFGGroup {
createdAt: Generated<number>;
}
// xxx: duplicate state with tournament team member... keeping these up to date might be annoying
export interface TournamentLFGGroupMember {
groupId: number;
tournamentId: number;

View File

@@ -10,6 +10,39 @@ import {
} from "~/utils/kysely.server";
import { errorIsSqliteForeignKeyConstraintFailure } from "~/utils/sql";
type CreateGroupFromTeamArgs = {
tournamentId: number;
tournamentTeamId: number;
members: Array<{ userId: number; isOwner: boolean }>;
};
export function createGroupFromTeam(args: CreateGroupFromTeamArgs) {
return db.transaction().execute(async (trx) => {
const createdGroup = await trx
.insertInto("TournamentLFGGroup")
.values({
tournamentId: args.tournamentId,
tournamentTeamId: args.tournamentTeamId,
chatCode: shortNanoid(),
})
.returning("id")
.executeTakeFirstOrThrow();
for (const member of args.members) {
await trx
.insertInto("TournamentLFGGroupMember")
.values({
groupId: createdGroup.id,
tournamentId: args.tournamentId,
userId: member.userId,
role: member.isOwner ? "OWNER" : "REGULAR",
})
.execute();
}
return createdGroup;
});
}
type CreateGroupArgs = {
tournamentId: number;
userId: number;

View File

@@ -39,15 +39,43 @@ export const action = async ({ request, params }: ActionFunctionArgs) => {
switch (data._action) {
case "JOIN_QUEUE": {
// xxx: handle already in a team
const existingGroup = await findOwnGroup();
if (existingGroup) return null;
await TournamentLFGRepository.createGroup({
const tournament = await tournamentFromDBCached({
tournamentId,
userId: user.id,
isStayAsSub: data.stayAsSub ?? false,
user,
});
const team = tournament.teamMemberOfByUser(user);
if (team) {
const groups =
await TournamentLFGRepository.findGroupsByTournamentId(tournamentId);
const teamAlreadyLinked = groups.some(
(g) => g.tournamentTeamId === team.id,
);
if (teamAlreadyLinked) return null;
const memberAlreadyInGroup = team.members.some((m) =>
groups.some((g) => g.members.some((gm) => gm.id === m.userId)),
);
if (memberAlreadyInGroup) return null;
await TournamentLFGRepository.createGroupFromTeam({
tournamentId,
tournamentTeamId: team.id,
members: team.members.map((m) => ({
userId: m.userId,
isOwner: Boolean(m.isOwner),
})),
});
} else {
await TournamentLFGRepository.createGroup({
tournamentId,
userId: user.id,
isStayAsSub: data.stayAsSub ?? false,
});
}
break;
}

View File

@@ -1,6 +1,7 @@
import type { LoaderFunctionArgs } from "react-router";
import type { Pronouns } from "~/db/tables";
import { getUser } from "~/features/auth/core/user.server";
import { tournamentFromDBCached } from "~/features/tournament-bracket/core/Tournament.server";
import type { MainWeaponId } from "~/modules/in-game-lists/types";
import { parseParams } from "~/utils/remix.server";
import { idObject } from "~/utils/zod";
@@ -36,15 +37,75 @@ export const loader = async ({ params }: LoaderFunctionArgs) => {
? await TournamentLFGRepository.allLikesByGroupId(ownGroup.id)
: { given: [], received: [] };
const ownTeam = await resolveOwnTeam({
user,
tournamentId,
ownGroup,
groups,
});
return {
groups: otherGroups,
ownGroup,
ownTeam,
likes,
lastUpdated: Date.now(),
tournamentId,
};
};
async function resolveOwnTeam({
user,
tournamentId,
ownGroup,
groups,
}: {
user: ReturnType<typeof getUser>;
tournamentId: number;
ownGroup: LFGGroup | null;
groups: LFGGroup[];
}): Promise<LFGGroup | null> {
if (!user) return null;
if (ownGroup) return null;
const tournament = await tournamentFromDBCached({
tournamentId,
user,
});
const team = tournament.teamMemberOfByUser(user);
if (!team) return null;
const teamAlreadyLinked = groups.some((g) => g.tournamentTeamId === team.id);
if (teamAlreadyLinked) return null;
const members: LFGGroupMember[] = team.members.map((m) => ({
id: m.userId,
username: m.username,
discordId: m.discordId,
discordAvatar: m.discordAvatar,
customUrl: m.customUrl,
languages: [],
vc: null,
pronouns: null,
role: m.isOwner ? "OWNER" : "REGULAR",
note: null,
isStayAsSub: false,
weapons: null,
chatNameColor: null,
plusTier: m.plusTier,
}));
return {
id: -1,
tournamentTeamId: team.id,
teamName: team.name,
teamAvatarUrl: team.pickupAvatarUrl,
members,
usersRole: members.find((m) => m.id === user.id)?.role ?? null,
};
}
function transformMembers(
rawMembers: Awaited<
ReturnType<typeof TournamentLFGRepository.findGroupsByTournamentId>

View File

@@ -1,5 +1,5 @@
import clsx from "clsx";
import * as React from "react";
import type * as React from "react";
import { Flipper } from "react-flip-toolkit";
import { useTranslation } from "react-i18next";
import { useFetcher, useLoaderData } from "react-router";
@@ -13,12 +13,14 @@ import {
import { FormWithConfirm } from "~/components/FormWithConfirm";
import { Placeholder } from "~/components/Placeholder";
import { SubmitButton } from "~/components/SubmitButton";
import { SendouForm } from "~/form/SendouForm";
import { useAutoRefresh } from "~/hooks/useAutoRefresh";
import { useIsMounted } from "~/hooks/useIsMounted";
import { useWindowSize } from "~/hooks/useWindowSize";
import type { SendouRouteHandle } from "~/utils/remix.server";
import { LFGGroupCard } from "../components/LFGGroupCard";
import type { loader } from "../loaders/to.$id.looking.server";
import { joinQueueFormSchema } from "../tournament-lfg-schemas";
export { action } from "../actions/to.$id.looking.server";
export { loader } from "../loaders/to.$id.looking.server";
@@ -26,7 +28,7 @@ export { loader } from "../loaders/to.$id.looking.server";
import styles from "./to.$id.looking.module.css";
export const handle: SendouRouteHandle = {
i18n: ["q", "tournament"],
i18n: ["q", "tournament", "forms"],
};
export default function TournamentLFGShell() {
@@ -82,6 +84,13 @@ function Groups() {
</div>
);
const hasOwnSection = data.ownGroup ?? data.ownTeam;
const ownTabMemberCount = data.ownGroup
? data.ownGroup.members.length
: data.ownTeam
? data.ownTeam.members.length
: 0;
const ownGroupElement = data.ownGroup ? (
<div className="stack md">
<LFGGroupCard group={data.ownGroup} ownGroup={data.ownGroup} />
@@ -92,6 +101,14 @@ function Groups() {
</div>
) : null;
const leftColumnContent = data.ownGroup ? (
ownGroupElement
) : data.ownTeam ? (
<TeamQueueSection />
) : (
<JoinQueueForm />
);
return (
<Flipper flipKey={flipKey}>
<div
@@ -103,13 +120,13 @@ function Groups() {
<div>
<SendouTabs>
<SendouTabList>
{data.ownGroup ? (
<SendouTab id="own" number={data.ownGroup.members.length}>
{hasOwnSection ? (
<SendouTab id="own" number={ownTabMemberCount}>
{t("q:looking.columns.myGroup")}
</SendouTab>
) : null}
</SendouTabList>
<SendouTabPanel id="own">{ownGroupElement}</SendouTabPanel>
<SendouTabPanel id="own">{leftColumnContent}</SendouTabPanel>
</SendouTabs>
</div>
) : null}
@@ -127,8 +144,8 @@ function Groups() {
{t("q:looking.columns.invitations")}
</SendouTab>
) : null}
{isMobile && data.ownGroup ? (
<SendouTab id="own" number={data.ownGroup.members.length}>
{isMobile ? (
<SendouTab id="own" number={ownTabMemberCount}>
{t("q:looking.columns.myGroup")}
</SendouTab>
) : null}
@@ -136,7 +153,6 @@ function Groups() {
<SendouTabPanel id="groups">
<div className="stack sm">
<ColumnHeader>{t("q:looking.columns.available")}</ColumnHeader>
{!data.ownGroup ? <JoinQueueForm /> : null}
{(isMobile
? data.groups.filter(
(group) =>
@@ -161,7 +177,6 @@ function Groups() {
</SendouTabPanel>
<SendouTabPanel id="received">
<div className="stack sm">
{!data.ownGroup ? <JoinQueueForm /> : null}
{groupsReceivedLikesFrom.map((group) => (
<LFGGroupCard
key={group.id}
@@ -172,13 +187,12 @@ function Groups() {
))}
</div>
</SendouTabPanel>
<SendouTabPanel id="own">{ownGroupElement}</SendouTabPanel>
<SendouTabPanel id="own">{leftColumnContent}</SendouTabPanel>
</SendouTabs>
</div>
{!isMobile ? (
<div className="stack sm">
<ColumnHeader>{t("q:looking.columns.invitations")}</ColumnHeader>
{!data.ownGroup ? <JoinQueueForm /> : null}
{groupsReceivedLikesFrom.map((group) => (
<LFGGroupCard
key={group.id}
@@ -206,28 +220,37 @@ function ColumnHeader({ children }: { children: React.ReactNode }) {
function JoinQueueForm() {
const { t } = useTranslation(["q"]);
const fetcher = useFetcher();
const [stayAsSub, setStayAsSub] = React.useState(false);
return (
<fetcher.Form method="post" className="stack sm items-center">
<label className="stack horizontal xs items-center text-xs">
<input
type="checkbox"
name="stayAsSub"
checked={stayAsSub}
onChange={(e) => setStayAsSub(e.target.checked)}
/>
{t("q:looking.groups.stayAsSub")}
</label>
<SubmitButton
_action="JOIN_QUEUE"
state={fetcher.state}
variant="outlined"
>
{t("q:looking.joinQPrompt")}
</SubmitButton>
</fetcher.Form>
<SendouForm
schema={joinQueueFormSchema}
submitButtonText={t("q:looking.joinQPrompt")}
>
{({ FormField }) => <FormField name="stayAsSub" />}
</SendouForm>
);
}
function TeamQueueSection() {
const { t } = useTranslation(["q"]);
const data = useLoaderData<typeof loader>();
const fetcher = useFetcher();
if (!data.ownTeam) return null;
return (
<div className="stack md">
<LFGGroupCard group={data.ownTeam} />
<fetcher.Form method="post" className="stack items-center">
<SubmitButton
_action="JOIN_QUEUE"
state={fetcher.state}
variant="outlined"
>
{t("q:looking.joinQPrompt")}
</SubmitButton>
</fetcher.Form>
</div>
);
}

View File

@@ -1,13 +1,10 @@
import { z } from "zod";
import { _action, checkboxValueToBoolean, falsyToNull, id } from "~/utils/zod";
import { TOURNAMENT_LFG } from "./tournament-lfg-constants";
import { joinQueueFormSchema } from "./tournament-lfg-schemas";
export const lookingSchema = z.union([
z.object({
_action: _action("JOIN_QUEUE"),
// xxx: use SendouForm style
stayAsSub: z.preprocess(checkboxValueToBoolean, z.boolean().optional()),
}),
joinQueueFormSchema,
z.object({
_action: _action("LIKE"),
targetGroupId: id,

View File

@@ -0,0 +1,10 @@
import { z } from "zod";
import { stringConstant, toggle } from "~/form/fields";
export const joinQueueFormSchema = z.object({
_action: stringConstant("JOIN_QUEUE"),
stayAsSub: toggle({
label: "labels.stayAsSub",
bottomText: "bottomTexts.stayAsSub",
}),
});

View File

@@ -182,5 +182,7 @@
"options.artSource.ALL": "",
"options.artSource.MADE-BY": "",
"options.artSource.MADE-OF": "",
"labels.tierListUrl": ""
"labels.tierListUrl": "",
"labels.stayAsSub": "",
"bottomTexts.stayAsSub": ""
}

View File

@@ -182,5 +182,7 @@
"options.artSource.ALL": "",
"options.artSource.MADE-BY": "",
"options.artSource.MADE-OF": "",
"labels.tierListUrl": ""
"labels.tierListUrl": "",
"labels.stayAsSub": "",
"bottomTexts.stayAsSub": ""
}

View File

@@ -182,5 +182,7 @@
"options.artSource.ALL": "All",
"options.artSource.MADE-BY": "Made by me",
"options.artSource.MADE-OF": "Made of me",
"labels.tierListUrl": "Tier List URL"
"labels.tierListUrl": "Tier List URL",
"labels.stayAsSub": "Stay as sub",
"bottomTexts.stayAsSub": "If you don't find a full team before registration ends, you will stay as a solo sub for the duration of the tournament"
}

View File

@@ -182,5 +182,7 @@
"options.artSource.ALL": "",
"options.artSource.MADE-BY": "",
"options.artSource.MADE-OF": "",
"labels.tierListUrl": ""
"labels.tierListUrl": "",
"labels.stayAsSub": "",
"bottomTexts.stayAsSub": ""
}

View File

@@ -182,5 +182,7 @@
"options.artSource.ALL": "",
"options.artSource.MADE-BY": "",
"options.artSource.MADE-OF": "",
"labels.tierListUrl": ""
"labels.tierListUrl": "",
"labels.stayAsSub": "",
"bottomTexts.stayAsSub": ""
}

View File

@@ -182,5 +182,7 @@
"options.artSource.ALL": "",
"options.artSource.MADE-BY": "",
"options.artSource.MADE-OF": "",
"labels.tierListUrl": ""
"labels.tierListUrl": "",
"labels.stayAsSub": "",
"bottomTexts.stayAsSub": ""
}

View File

@@ -182,5 +182,7 @@
"options.artSource.ALL": "",
"options.artSource.MADE-BY": "",
"options.artSource.MADE-OF": "",
"labels.tierListUrl": ""
"labels.tierListUrl": "",
"labels.stayAsSub": "",
"bottomTexts.stayAsSub": ""
}

View File

@@ -182,5 +182,7 @@
"options.artSource.ALL": "",
"options.artSource.MADE-BY": "",
"options.artSource.MADE-OF": "",
"labels.tierListUrl": ""
"labels.tierListUrl": "",
"labels.stayAsSub": "",
"bottomTexts.stayAsSub": ""
}

View File

@@ -182,5 +182,7 @@
"options.artSource.ALL": "",
"options.artSource.MADE-BY": "",
"options.artSource.MADE-OF": "",
"labels.tierListUrl": ""
"labels.tierListUrl": "",
"labels.stayAsSub": "",
"bottomTexts.stayAsSub": ""
}

View File

@@ -182,5 +182,7 @@
"options.artSource.ALL": "",
"options.artSource.MADE-BY": "",
"options.artSource.MADE-OF": "",
"labels.tierListUrl": ""
"labels.tierListUrl": "",
"labels.stayAsSub": "",
"bottomTexts.stayAsSub": ""
}

View File

@@ -182,5 +182,7 @@
"options.artSource.ALL": "",
"options.artSource.MADE-BY": "",
"options.artSource.MADE-OF": "",
"labels.tierListUrl": ""
"labels.tierListUrl": "",
"labels.stayAsSub": "",
"bottomTexts.stayAsSub": ""
}

View File

@@ -182,5 +182,7 @@
"options.artSource.ALL": "",
"options.artSource.MADE-BY": "",
"options.artSource.MADE-OF": "",
"labels.tierListUrl": ""
"labels.tierListUrl": "",
"labels.stayAsSub": "",
"bottomTexts.stayAsSub": ""
}

View File

@@ -182,5 +182,7 @@
"options.artSource.ALL": "",
"options.artSource.MADE-BY": "",
"options.artSource.MADE-OF": "",
"labels.tierListUrl": ""
"labels.tierListUrl": "",
"labels.stayAsSub": "",
"bottomTexts.stayAsSub": ""
}

View File

@@ -182,5 +182,7 @@
"options.artSource.ALL": "",
"options.artSource.MADE-BY": "",
"options.artSource.MADE-OF": "",
"labels.tierListUrl": ""
"labels.tierListUrl": "",
"labels.stayAsSub": "",
"bottomTexts.stayAsSub": ""
}

View File

@@ -182,5 +182,7 @@
"options.artSource.ALL": "",
"options.artSource.MADE-BY": "",
"options.artSource.MADE-OF": "",
"labels.tierListUrl": ""
"labels.tierListUrl": "",
"labels.stayAsSub": "",
"bottomTexts.stayAsSub": ""
}

View File

@@ -182,5 +182,7 @@
"options.artSource.ALL": "",
"options.artSource.MADE-BY": "",
"options.artSource.MADE-OF": "",
"labels.tierListUrl": ""
"labels.tierListUrl": "",
"labels.stayAsSub": "",
"bottomTexts.stayAsSub": ""
}