mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-10-26 20:43:36 -05:00
UI work
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
10
app/features/tournament-lfg/tournament-lfg-schemas.ts
Normal file
10
app/features/tournament-lfg/tournament-lfg-schemas.ts
Normal 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",
|
||||
}),
|
||||
});
|
||||
@@ -182,5 +182,7 @@
|
||||
"options.artSource.ALL": "",
|
||||
"options.artSource.MADE-BY": "",
|
||||
"options.artSource.MADE-OF": "",
|
||||
"labels.tierListUrl": ""
|
||||
"labels.tierListUrl": "",
|
||||
"labels.stayAsSub": "",
|
||||
"bottomTexts.stayAsSub": ""
|
||||
}
|
||||
|
||||
@@ -182,5 +182,7 @@
|
||||
"options.artSource.ALL": "",
|
||||
"options.artSource.MADE-BY": "",
|
||||
"options.artSource.MADE-OF": "",
|
||||
"labels.tierListUrl": ""
|
||||
"labels.tierListUrl": "",
|
||||
"labels.stayAsSub": "",
|
||||
"bottomTexts.stayAsSub": ""
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -182,5 +182,7 @@
|
||||
"options.artSource.ALL": "",
|
||||
"options.artSource.MADE-BY": "",
|
||||
"options.artSource.MADE-OF": "",
|
||||
"labels.tierListUrl": ""
|
||||
"labels.tierListUrl": "",
|
||||
"labels.stayAsSub": "",
|
||||
"bottomTexts.stayAsSub": ""
|
||||
}
|
||||
|
||||
@@ -182,5 +182,7 @@
|
||||
"options.artSource.ALL": "",
|
||||
"options.artSource.MADE-BY": "",
|
||||
"options.artSource.MADE-OF": "",
|
||||
"labels.tierListUrl": ""
|
||||
"labels.tierListUrl": "",
|
||||
"labels.stayAsSub": "",
|
||||
"bottomTexts.stayAsSub": ""
|
||||
}
|
||||
|
||||
@@ -182,5 +182,7 @@
|
||||
"options.artSource.ALL": "",
|
||||
"options.artSource.MADE-BY": "",
|
||||
"options.artSource.MADE-OF": "",
|
||||
"labels.tierListUrl": ""
|
||||
"labels.tierListUrl": "",
|
||||
"labels.stayAsSub": "",
|
||||
"bottomTexts.stayAsSub": ""
|
||||
}
|
||||
|
||||
@@ -182,5 +182,7 @@
|
||||
"options.artSource.ALL": "",
|
||||
"options.artSource.MADE-BY": "",
|
||||
"options.artSource.MADE-OF": "",
|
||||
"labels.tierListUrl": ""
|
||||
"labels.tierListUrl": "",
|
||||
"labels.stayAsSub": "",
|
||||
"bottomTexts.stayAsSub": ""
|
||||
}
|
||||
|
||||
@@ -182,5 +182,7 @@
|
||||
"options.artSource.ALL": "",
|
||||
"options.artSource.MADE-BY": "",
|
||||
"options.artSource.MADE-OF": "",
|
||||
"labels.tierListUrl": ""
|
||||
"labels.tierListUrl": "",
|
||||
"labels.stayAsSub": "",
|
||||
"bottomTexts.stayAsSub": ""
|
||||
}
|
||||
|
||||
@@ -182,5 +182,7 @@
|
||||
"options.artSource.ALL": "",
|
||||
"options.artSource.MADE-BY": "",
|
||||
"options.artSource.MADE-OF": "",
|
||||
"labels.tierListUrl": ""
|
||||
"labels.tierListUrl": "",
|
||||
"labels.stayAsSub": "",
|
||||
"bottomTexts.stayAsSub": ""
|
||||
}
|
||||
|
||||
@@ -182,5 +182,7 @@
|
||||
"options.artSource.ALL": "",
|
||||
"options.artSource.MADE-BY": "",
|
||||
"options.artSource.MADE-OF": "",
|
||||
"labels.tierListUrl": ""
|
||||
"labels.tierListUrl": "",
|
||||
"labels.stayAsSub": "",
|
||||
"bottomTexts.stayAsSub": ""
|
||||
}
|
||||
|
||||
@@ -182,5 +182,7 @@
|
||||
"options.artSource.ALL": "",
|
||||
"options.artSource.MADE-BY": "",
|
||||
"options.artSource.MADE-OF": "",
|
||||
"labels.tierListUrl": ""
|
||||
"labels.tierListUrl": "",
|
||||
"labels.stayAsSub": "",
|
||||
"bottomTexts.stayAsSub": ""
|
||||
}
|
||||
|
||||
@@ -182,5 +182,7 @@
|
||||
"options.artSource.ALL": "",
|
||||
"options.artSource.MADE-BY": "",
|
||||
"options.artSource.MADE-OF": "",
|
||||
"labels.tierListUrl": ""
|
||||
"labels.tierListUrl": "",
|
||||
"labels.stayAsSub": "",
|
||||
"bottomTexts.stayAsSub": ""
|
||||
}
|
||||
|
||||
@@ -182,5 +182,7 @@
|
||||
"options.artSource.ALL": "",
|
||||
"options.artSource.MADE-BY": "",
|
||||
"options.artSource.MADE-OF": "",
|
||||
"labels.tierListUrl": ""
|
||||
"labels.tierListUrl": "",
|
||||
"labels.stayAsSub": "",
|
||||
"bottomTexts.stayAsSub": ""
|
||||
}
|
||||
|
||||
@@ -182,5 +182,7 @@
|
||||
"options.artSource.ALL": "",
|
||||
"options.artSource.MADE-BY": "",
|
||||
"options.artSource.MADE-OF": "",
|
||||
"labels.tierListUrl": ""
|
||||
"labels.tierListUrl": "",
|
||||
"labels.stayAsSub": "",
|
||||
"bottomTexts.stayAsSub": ""
|
||||
}
|
||||
|
||||
@@ -182,5 +182,7 @@
|
||||
"options.artSource.ALL": "",
|
||||
"options.artSource.MADE-BY": "",
|
||||
"options.artSource.MADE-OF": "",
|
||||
"labels.tierListUrl": ""
|
||||
"labels.tierListUrl": "",
|
||||
"labels.stayAsSub": "",
|
||||
"bottomTexts.stayAsSub": ""
|
||||
}
|
||||
|
||||
@@ -182,5 +182,7 @@
|
||||
"options.artSource.ALL": "",
|
||||
"options.artSource.MADE-BY": "",
|
||||
"options.artSource.MADE-OF": "",
|
||||
"labels.tierListUrl": ""
|
||||
"labels.tierListUrl": "",
|
||||
"labels.stayAsSub": "",
|
||||
"bottomTexts.stayAsSub": ""
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user