Tournament subs (#1394)

* Initial

* Progress

* Layout work

* New sub page initial

* Edit sub posting

* Delete sub posting

* Improve sub nav looks on mobile

* Finalized
This commit is contained in:
Kalle
2023-06-16 20:21:22 +03:00
committed by GitHub
parent 1cba20c858
commit 734688296e
26 changed files with 1043 additions and 28 deletions

View File

@@ -14,12 +14,14 @@ export function SubNav({ children }: { children: React.ReactNode }) {
export function SubNavLink({
children,
className,
end = true,
...props
}: LinkProps & {
end?: boolean;
children: React.ReactNode;
}) {
return (
<NavLink className={"sub-nav__link__container"} end {...props}>
<NavLink className={"sub-nav__link__container"} end={end} {...props}>
<div className={clsx("sub-nav__link", className)}>{children}</div>
<div className="sub-nav__border-guy" />
</NavLink>

View File

@@ -0,0 +1,18 @@
export function MicrophoneIcon({ 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}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M12 18.75a6 6 0 006-6v-1.5m-6 7.5a6 6 0 01-6-6v-1.5m6 7.5v3.75m-3.75 0h7.5M12 15.75a3 3 0 01-3-3V4.5a3 3 0 116 0v8.25a3 3 0 01-3 3z"
/>
</svg>
);
}

View File

@@ -5,6 +5,7 @@ import invariant from "tiny-invariant";
import { ADMIN_DISCORD_ID, ADMIN_ID, INVITE_CODE_LENGTH } from "~/constants";
import { db } from "~/db";
import { sql } from "~/db/sql";
import type { MainWeaponId } from "~/modules/in-game-lists";
import {
abilities,
clothesGearIds,
@@ -40,6 +41,7 @@ import {
// eslint-disable-next-line @typescript-eslint/no-restricted-imports
import { TOURNAMENT } from "~/features/tournament/tournament-constants";
import type { SeedVariation } from "~/routes/seed";
import { nullFilledArray, pickRandomItem } from "~/utils/arrays";
const calendarEventWithToToolsSz = () => calendarEventWithToTools(true);
const calendarEventWithToToolsTeamsSz = () =>
@@ -73,6 +75,7 @@ const basicSeeds = (variation?: SeedVariation | null) => [
variation === "NO_TOURNAMENT_TEAMS"
? undefined
: calendarEventWithToToolsTeamsSz,
tournamentSubs,
adminBuilds,
manySplattershotBuilds,
detailedTeam,
@@ -215,13 +218,18 @@ function userProfiles() {
for (let id = 3; id < 500; id++) {
if (Math.random() < 0.25) continue; // 75% have bio
sql.prepare(`UPDATE "User" SET bio = $bio WHERE id = $id`).run({
id,
bio: faker.lorem.paragraphs(
faker.helpers.arrayElement([1, 1, 1, 2, 3, 4]),
"\n\n"
),
});
sql
.prepare(
`UPDATE "User" SET bio = $bio, country = $country WHERE id = $id`
)
.run({
id,
bio: faker.lorem.paragraphs(
faker.helpers.arrayElement([1, 1, 1, 2, 3, 4]),
"\n\n"
),
country: Math.random() > 0.5 ? faker.location.countryCode() : null,
});
}
}
@@ -877,6 +885,73 @@ function calendarEventWithToToolsTeams(sz?: boolean) {
}
}
function tournamentSubs() {
for (let id = 100; id < 120; id++) {
const includedWeaponIds: MainWeaponId[] = [];
sql
.prepare(
/* sql */ `
insert into "TournamentSub" (
"userId",
"tournamentId",
"canVc",
"bestWeapons",
"okWeapons",
"message",
"visibility"
) values (
@userId,
@tournamentId,
@canVc,
@bestWeapons,
@okWeapons,
@message,
@visibility
)
`
)
.run({
userId: id,
tournamentId: 1,
canVc: Number(Math.random() > 0.5),
bestWeapons: nullFilledArray(
faker.helpers.arrayElement([1, 1, 1, 2, 2, 3, 4, 5])
)
.map(() => {
while (true) {
const weaponId = pickRandomItem(mainWeaponIds);
if (!includedWeaponIds.includes(weaponId)) {
includedWeaponIds.push(weaponId);
return weaponId;
}
}
})
.join(","),
okWeapons:
Math.random() > 0.5
? null
: nullFilledArray(
faker.helpers.arrayElement([1, 1, 1, 2, 2, 3, 4, 5])
)
.map(() => {
while (true) {
const weaponId = pickRandomItem(mainWeaponIds);
if (!includedWeaponIds.includes(weaponId)) {
includedWeaponIds.push(weaponId);
return weaponId;
}
}
})
.join(","),
message: Math.random() > 0.5 ? null : faker.lorem.paragraph(),
visibility: id < 105 ? "+1" : id < 110 ? "+2" : id < 115 ? "+2" : "ALL",
});
}
return null;
}
const randomAbility = (legalTypes: AbilityType[]) => {
const randomOrderAbilities = shuffle([...abilities]);

View File

@@ -317,6 +317,17 @@ export interface TournamentMatchGameResultParticipant {
userId: number;
}
export interface TournamentSub {
userId: number;
tournamentId: number;
canVc: number;
bestWeapons: string;
okWeapons: string | null;
message: string | null;
visibility: "+1" | "+2" | "+3" | "ALL";
createdAt: number;
}
export interface TrustRelationship {
trustGiverUserId: number;
trustReceiverUserId: number;

View File

@@ -0,0 +1,2 @@
export { deleteSub } from "./queries/deleteSub.server";
export { findSubsByTournamentId } from "./queries/findSubsByTournamentId.server";

View File

@@ -0,0 +1,15 @@
import { sql } from "~/db/sql";
import type { TournamentSub } from "~/db/types";
const stm = sql.prepare(/* sql */ `
delete from "TournamentSub"
where
"userId" = @userId and
"tournamentId" = @tournamentId
`);
export function deleteSub(
args: Pick<TournamentSub, "userId" | "tournamentId">
) {
stm.run(args);
}

View File

@@ -0,0 +1,70 @@
import { sql } from "~/db/sql";
import type { TournamentSub, UserWithPlusTier } from "~/db/types";
import type { MainWeaponId } from "~/modules/in-game-lists";
const stm = sql.prepare(/* sql */ `
select
"TournamentSub"."canVc",
"TournamentSub"."bestWeapons",
"TournamentSub"."okWeapons",
"TournamentSub"."message",
"TournamentSub"."visibility",
"TournamentSub"."createdAt",
"TournamentSub"."userId",
"User"."discordName",
"User"."discordDiscriminator",
"User"."discordAvatar",
"User"."country",
"User"."discordId",
"User"."customUrl",
"PlusTier"."tier" as "plusTier"
from "TournamentSub"
left join "User" on "User"."id" = "TournamentSub"."userId"
left join "PlusTier" on "PlusTier"."userId" = "User"."id"
where "TournamentSub"."tournamentId" = @tournamentId
order by
"TournamentSub"."userId" = @userId desc,
case
when "plusTier" is null then 4
else "plusTier"
end asc,
"TournamentSub"."createdAt" desc
`);
export interface SubByTournamentId {
canVc: TournamentSub["canVc"];
bestWeapons: MainWeaponId[];
okWeapons: MainWeaponId[] | null;
message: TournamentSub["message"];
visibility: TournamentSub["visibility"];
createdAt: TournamentSub["createdAt"];
userId: TournamentSub["userId"];
discordName: UserWithPlusTier["discordName"];
discordDiscriminator: UserWithPlusTier["discordDiscriminator"];
discordAvatar: UserWithPlusTier["discordAvatar"];
discordId: UserWithPlusTier["discordId"];
customUrl: UserWithPlusTier["customUrl"];
country: UserWithPlusTier["country"];
plusTier: UserWithPlusTier["plusTier"];
}
const parseWeaponsArray = (value: string | null) => {
if (!value) return null;
return value.split(",").map(Number);
};
export function findSubsByTournamentId({
tournamentId,
userId,
}: {
tournamentId: number;
userId?: number;
}): SubByTournamentId[] {
const rows = stm.all({ tournamentId, userId }) as any[];
return rows.map((row) => ({
...row,
bestWeapons: parseWeaponsArray(row.bestWeapons),
okWeapons: parseWeaponsArray(row.okWeapons),
}));
}

View File

@@ -0,0 +1,35 @@
import { sql } from "~/db/sql";
import type { TournamentSub } from "~/db/types";
const stm = sql.prepare(/* sql */ `
insert into "TournamentSub" (
"userId",
"tournamentId",
"canVc",
"bestWeapons",
"okWeapons",
"message",
"visibility"
)
values (
@userId,
@tournamentId,
@canVc,
@bestWeapons,
@okWeapons,
@message,
@visibility
)
on conflict ("userId", "tournamentId") do
update
set
"canVc" = @canVc,
"bestWeapons" = @bestWeapons,
"okWeapons" = @okWeapons,
"message" = @message,
"visibility" = @visibility
`);
export function upsertSub(args: Omit<TournamentSub, "createdAt">) {
stm.run(args);
}

View File

@@ -0,0 +1,314 @@
import {
redirect,
type ActionFunction,
type LinksFunction,
type LoaderArgs,
} from "@remix-run/node";
import { Form, useLoaderData } from "@remix-run/react";
import React from "react";
import { Label } from "~/components/Label";
import { SubmitButton } from "~/components/SubmitButton";
import { useTranslation } from "~/hooks/useTranslation";
import styles from "../tournament-subs.css";
import { requireUser, useUser } from "~/modules/auth";
import type { MainWeaponId } from "~/modules/in-game-lists";
import { WeaponCombobox } from "~/components/Combobox";
import { TOURNAMENT_SUB } from "../tournament-subs-constants";
import { WeaponImage } from "~/components/Image";
import { Button } from "~/components/Button";
import { TrashIcon } from "~/components/icons/Trash";
import { findSubsByTournamentId } from "../queries/findSubsByTournamentId.server";
import { tournamentIdFromParams } from "~/features/tournament";
import { parseRequestFormData, type SendouRouteHandle } from "~/utils/remix";
import { FormMessage } from "~/components/FormMessage";
import { subSchema } from "../tournament-subs-schemas.server";
import { upsertSub } from "../queries/upsertSub.server";
import { tournamentSubsPage } from "~/utils/urls";
export const handle: SendouRouteHandle = {
i18n: ["user"],
};
export const links: LinksFunction = () => {
return [
{
rel: "stylesheet",
href: styles,
},
];
};
export const action: ActionFunction = async ({ params, request }) => {
const user = await requireUser(request);
const data = await parseRequestFormData({
request,
schema: subSchema,
});
const tournamentId = tournamentIdFromParams(params);
// TODO: validate tournament is not finalized
upsertSub({
bestWeapons: data.bestWeapons.join(","),
okWeapons: data.okWeapons.join(","),
canVc: data.canVc,
visibility: data.visibility,
message: data.message ?? null,
tournamentId,
userId: user.id,
});
return redirect(tournamentSubsPage(tournamentId));
};
export const loader = async ({ request, params }: LoaderArgs) => {
const user = await requireUser(request);
const tournamentId = tournamentIdFromParams(params);
const sub = findSubsByTournamentId({ tournamentId }).find(
(sub) => sub.userId === user.id
);
return {
sub,
};
};
export default function NewTournamentSubPage() {
const user = useUser();
const { t } = useTranslation(["common", "tournament"]);
const data = useLoaderData<typeof loader>();
const [bestWeapons, setBestWeapons] = React.useState<MainWeaponId[]>(
data.sub?.bestWeapons ?? []
);
const [okWeapons, setOkWeapons] = React.useState<MainWeaponId[]>(
data.sub?.okWeapons ?? []
);
return (
<div className="half-width">
<Form method="post" className="stack md items-start">
<h2>{t("tournament:subs.addPost")}</h2>
<VCRadios />
<WeaponPoolSelect
label={t("tournament:subs.weapons.prefer.header")}
weapons={bestWeapons}
otherWeapons={okWeapons}
setWeapons={setBestWeapons}
id="bestWeapons"
infoText={t("tournament:subs.weapons.info", {
min: 1,
max: TOURNAMENT_SUB.WEAPON_POOL_MAX_SIZE,
})}
required
/>
<WeaponPoolSelect
label={t("tournament:subs.weapons.ok.header")}
weapons={okWeapons}
otherWeapons={bestWeapons}
setWeapons={setOkWeapons}
id="okWeapons"
infoText={t("tournament:subs.weapons.info", {
min: 0,
max: TOURNAMENT_SUB.WEAPON_POOL_MAX_SIZE,
})}
/>
<Message />
{user?.plusTier ? <VisibilityRadios /> : null}
<SubmitButton>{t("common:actions.save")}</SubmitButton>
</Form>
</div>
);
}
function VCRadios() {
const { t } = useTranslation(["common", "tournament"]);
const data = useLoaderData<typeof loader>();
return (
<div>
<Label required>{t("tournament:subs.vc.header")}</Label>
<div className="stack xs">
<div className="stack horizontal sm items-center">
<input
type="radio"
id="vc-true"
name="canVc"
value="on"
required
defaultChecked={data.sub && Boolean(data.sub.canVc)}
/>
<label htmlFor="vc-true" className="mb-0">
{t("common:yes")}
</label>
</div>
<div className="stack horizontal sm items-center">
<input
type="radio"
id="vc-false"
name="canVc"
value="off"
defaultChecked={data.sub && !data.sub.canVc}
/>
<label htmlFor="vc-false" className="mb-0">
{t("common:no")}
</label>
</div>
</div>
</div>
);
}
function Message() {
const { t } = useTranslation(["tournament"]);
const data = useLoaderData<typeof loader>();
const [value, setValue] = React.useState(data.sub?.message ?? "");
return (
<div className="u-edit__bio-container">
<Label
htmlFor="message"
valueLimits={{
current: value.length,
max: TOURNAMENT_SUB.MESSAGE_MAX_LENGTH,
}}
>
{t("tournament:subs.message.header")}
</Label>
<textarea
id="message"
name="message"
value={value}
onChange={(e) => setValue(e.target.value)}
maxLength={TOURNAMENT_SUB.MESSAGE_MAX_LENGTH}
/>
</div>
);
}
function VisibilityRadios() {
const { t } = useTranslation(["tournament"]);
const data = useLoaderData<typeof loader>();
const user = useUser();
const userPlusTier = user?.plusTier ?? 4;
return (
<div>
<Label required>{t("tournament:subs.visibility.header")}</Label>
<div className="stack xs">
{[1, 2, 3]
.filter((tier) => tier >= userPlusTier)
.map((tier) => {
const id = `+${tier}`;
return (
<div key={tier} className="stack horizontal sm items-center">
<input
type="radio"
id={id}
name="visibility"
value={id}
defaultChecked={data.sub?.visibility === id}
/>
<label htmlFor={id} className="mb-0">
+{tier} {tier !== 1 && <>(and above)</>}
</label>
</div>
);
})}
<div className="stack horizontal sm items-center">
<input
type="radio"
id="all"
name="visibility"
value="ALL"
required
defaultChecked={data.sub?.visibility === "ALL"}
/>
<label htmlFor="all" className="mb-0">
{t("tournament:subs.visibility.everyone")}
</label>
</div>
</div>
</div>
);
}
function WeaponPoolSelect({
weapons,
otherWeapons,
setWeapons,
label,
id,
infoText,
required = false,
}: {
weapons: Array<MainWeaponId>;
otherWeapons: Array<MainWeaponId>;
setWeapons: (weapons: Array<MainWeaponId>) => void;
label: string;
id: string;
infoText: string;
required?: boolean;
}) {
const { t } = useTranslation(["user"]);
return (
<div className="stack md sub__weapon-pool">
<input type="hidden" name={id} value={JSON.stringify(weapons)} />
<div>
<Label htmlFor={id} required={required}>
{label}
</Label>
{weapons.length < TOURNAMENT_SUB.WEAPON_POOL_MAX_SIZE ? (
<>
<WeaponCombobox
inputName={id}
id={id}
onChange={(weapon) => {
if (!weapon) return;
setWeapons([...weapons, Number(weapon.value) as MainWeaponId]);
}}
// empty on selection
key={weapons[weapons.length - 1]}
weaponIdsToOmit={new Set([...weapons, ...otherWeapons])}
fullWidth
/>
<FormMessage type="info">{infoText}</FormMessage>
</>
) : (
<span className="text-xs text-warning">
{t("user:forms.errors.maxWeapons")}
</span>
)}
</div>
{weapons.length > 0 ? (
<div className="stack horizontal sm justify-center">
{weapons.map((weapon) => {
return (
<div key={weapon} className="stack xs">
<div className="sub__selected-weapon">
<WeaponImage
weaponSplId={weapon}
variant="badge"
width={38}
height={38}
/>
</div>
<Button
icon={<TrashIcon />}
variant="minimal-destructive"
aria-label="Delete weapon"
onClick={() =>
setWeapons(weapons.filter((w) => w !== weapon))
}
/>
</div>
);
})}
</div>
) : null}
</div>
);
}

View File

@@ -0,0 +1,188 @@
import {
type TournamentLoaderData,
tournamentIdFromParams,
} from "~/features/tournament";
import {
type SubByTournamentId,
findSubsByTournamentId,
} from "../queries/findSubsByTournamentId.server";
import type {
ActionFunction,
LinksFunction,
LoaderArgs,
} from "@remix-run/node";
import { Link, useLoaderData, useOutletContext } from "@remix-run/react";
import { getUser, requireUser, useUser } from "~/modules/auth";
import { assertUnreachable } from "~/utils/types";
import styles from "../tournament-subs.css";
import { Avatar } from "~/components/Avatar";
import { discordFullName } from "~/utils/strings";
import { userPage } from "~/utils/urls";
import { WeaponImage } from "~/components/Image";
import { Flag } from "~/components/Flag";
import { MicrophoneIcon } from "~/components/icons/Microphone";
import { Button, LinkButton } from "~/components/Button";
import { deleteSub } from "../queries/deleteSub.server";
import { FormWithConfirm } from "~/components/FormWithConfirm";
import { TrashIcon } from "~/components/icons/Trash";
import { useTranslation } from "~/hooks/useTranslation";
import React from "react";
export const links: LinksFunction = () => {
return [
{
rel: "stylesheet",
href: styles,
},
];
};
export const action: ActionFunction = async ({ request, params }) => {
const user = await requireUser(request);
const tournamentId = tournamentIdFromParams(params);
deleteSub({
tournamentId,
userId: user.id,
});
return null;
};
export const loader = async ({ params, request }: LoaderArgs) => {
const user = await getUser(request);
const tournamentId = tournamentIdFromParams(params);
const subs = findSubsByTournamentId({
tournamentId,
userId: user?.id,
// eslint-disable-next-line array-callback-return
}).filter((sub) => {
if (sub.visibility === "ALL") return true;
const userPlusTier = user?.plusTier ?? 4;
switch (sub.visibility) {
case "+1": {
return userPlusTier === 1;
}
case "+2": {
return userPlusTier <= 2;
}
case "+3": {
return userPlusTier <= 3;
}
default: {
assertUnreachable(sub.visibility);
}
}
});
return {
subs,
hasOwnSubPost: subs.some((sub) => sub.userId === user?.id),
};
};
export default function TournamentSubsPage() {
const { t } = useTranslation(["tournament"]);
const data = useLoaderData<typeof loader>();
const parentRouteData = useOutletContext<TournamentLoaderData>();
return (
<div className="stack lg">
{!parentRouteData.teamMemberOfName ? (
<div className="stack items-end">
<LinkButton to="new" size="tiny">
{data.hasOwnSubPost
? t("tournament:subs.editPost")
: t("tournament:subs.addPost")}
</LinkButton>
</div>
) : null}
{data.subs.map((sub) => {
return <SubInfoSection key={sub.userId} sub={sub} />;
})}
</div>
);
}
function SubInfoSection({ sub }: { sub: SubByTournamentId }) {
const { t } = useTranslation(["common", "tournament"]);
const user = useUser();
const infos = [
<div key="vc" className="sub__section__info__vc">
<MicrophoneIcon className={sub.canVc ? "text-success" : "text-warning"} />
{sub.canVc ? t("tournament:subs.canVC") : t("tournament:subs.noVC")}
</div>,
];
if (sub.plusTier) {
infos.push(<React.Fragment key="slash-1">/</React.Fragment>);
infos.push(<div key="plus">+{sub.plusTier}</div>);
}
if (sub.country) {
infos.push(<React.Fragment key="slash-2">/</React.Fragment>);
infos.push(<Flag key="flag" countryCode={sub.country} tiny />);
}
return (
<div>
<section className="sub__section">
<Avatar user={sub} size="sm" className="sub__section__avatar" />
<Link to={userPage(sub)} className="sub__section__name">
{discordFullName(sub)}
</Link>
<div className="sub__section__spacer" />
<div className="sub__section__info">{infos}</div>
<div className="sub__section__weapon-top-text sub__section__weapon-text">
{t("tournament:subs.prefersToPlay")}
</div>
<div className="sub__section__weapon-top-images sub__section__weapon-images">
{sub.bestWeapons.map((wpn) => (
<WeaponImage
key={wpn}
weaponSplId={wpn}
size={32}
variant="badge"
/>
))}
</div>
{sub.okWeapons ? (
<>
<div className="sub__section__weapon-bottom-text sub__section__weapon-text">
{t("tournament:subs.canPlay")}
</div>
<div className="sub__section__weapon-bottom-images sub__section__weapon-images">
{sub.okWeapons.map((wpn) => (
<WeaponImage
key={wpn}
weaponSplId={wpn}
size={32}
variant="badge"
/>
))}
</div>
</>
) : null}
{sub.message ? (
<div className="sub__section__message">{sub.message}</div>
) : null}
</section>
{user?.id === sub.userId ? (
<div className="stack mt-1 items-end">
<FormWithConfirm dialogHeading="Delete your sub post?">
<Button
variant="minimal-destructive"
size="tiny"
type="submit"
icon={<TrashIcon />}
>
{t("common:actions.delete")}
</Button>
</FormWithConfirm>
</div>
) : null}
</div>
);
}

View File

@@ -0,0 +1,6 @@
import { TWEET_LENGTH_MAX_LENGTH } from "~/constants";
export const TOURNAMENT_SUB = {
WEAPON_POOL_MAX_SIZE: 5,
MESSAGE_MAX_LENGTH: TWEET_LENGTH_MAX_LENGTH,
};

View File

@@ -0,0 +1,41 @@
import { z } from "zod";
import {
checkboxValueToDbBoolean,
dbBoolean,
processMany,
removeDuplicates,
safeJSONParse,
} from "~/utils/zod";
import { TOURNAMENT_SUB } from "./tournament-subs-constants";
import { mainWeaponIds } from "~/modules/in-game-lists";
export const subSchema = z.object({
canVc: z.preprocess(checkboxValueToDbBoolean, dbBoolean),
bestWeapons: z.preprocess(
processMany(safeJSONParse, removeDuplicates),
z
.array(
z
.number()
.refine((val) =>
mainWeaponIds.includes(val as (typeof mainWeaponIds)[number])
)
)
.min(1)
.max(TOURNAMENT_SUB.WEAPON_POOL_MAX_SIZE)
),
okWeapons: z.preprocess(
processMany(safeJSONParse, removeDuplicates),
z
.array(
z
.number()
.refine((val) =>
mainWeaponIds.includes(val as (typeof mainWeaponIds)[number])
)
)
.max(TOURNAMENT_SUB.WEAPON_POOL_MAX_SIZE)
),
message: z.string().max(TOURNAMENT_SUB.MESSAGE_MAX_LENGTH).nullish(),
visibility: z.enum(["+1", "+2", "+3", "ALL"]).default("ALL"),
});

View File

@@ -0,0 +1,110 @@
.sub__section {
display: grid;
grid-template-columns: max-content 1fr;
grid-template-areas:
"avatar name"
"avatar info"
"spacer spacer"
"weapon-top-text weapon-top-images"
"weapon-bottom-text weapon-bottom-images"
"message message";
border-radius: var(--rounded);
background-color: var(--bg-lighter);
padding: var(--s-4);
column-gap: var(--s-2-5);
}
.sub__section__avatar {
grid-area: avatar;
align-self: center;
}
.sub__section__name {
grid-area: name;
align-self: flex-end;
}
.sub__section__info {
grid-area: info;
font-size: var(--fonts-sm);
gap: var(--s-2);
color: var(--text-lighter);
display: flex;
height: 32px;
white-space: nowrap;
align-items: center;
}
.sub__section__info__vc {
display: flex;
align-items: center;
gap: var(--s-0-5);
}
.sub__section__info__vc > svg {
width: 18px;
}
.sub__section__message {
grid-area: message;
font-size: var(--fonts-sm);
padding-block-start: var(--s-4);
}
.sub__section__weapon-text {
font-weight: var(--semi-bold);
font-size: var(--fonts-xs);
align-self: center;
}
.sub__section__weapon-top-text {
grid-area: weapon-top-text;
}
.sub__section__weapon-bottom-text {
grid-area: weapon-bottom-text;
}
.sub__section__weapon-images {
display: flex;
gap: var(--s-1);
width: 11rem;
align-items: center;
}
.sub__section__weapon-top-images {
grid-area: weapon-top-images;
}
.sub__section__weapon-bottom-images {
grid-area: weapon-bottom-images;
}
.sub__section__spacer {
grid-area: spacer;
height: 18px;
}
@media screen and (min-width: 640px) {
.sub__section {
grid-template-columns: max-content 1fr max-content max-content;
grid-template-areas:
"avatar name weapon-top-text weapon-top-images"
"avatar info weapon-bottom-text weapon-bottom-images"
"message message message message";
}
.sub__section__spacer {
display: none;
}
}
.sub__selected-weapon {
padding: var(--s-2);
border-radius: 100%;
background-color: var(--bg-lighter);
}
.sub__weapon-pool {
width: 20rem;
}

View File

@@ -1,6 +1,7 @@
import invariant from "tiny-invariant";
import { sql } from "~/db/sql";
import { checkOut } from "./checkOut.server";
import { deleteSub } from "~/features/tournament-subs";
const createTeamMemberStm = sql.prepare(/*sql*/ `
insert into "TournamentTeamMember" (
@@ -30,12 +31,14 @@ export const joinTeam = sql.transaction(
whatToDoWithPreviousTeam,
newTeamId,
userId,
tournamentId,
checkOutTeam = false,
}: {
previousTeamId?: number;
whatToDoWithPreviousTeam?: "LEAVE" | "DELETE";
newTeamId: number;
userId: number;
tournamentId: number;
checkOutTeam?: boolean;
}) => {
if (whatToDoWithPreviousTeam === "DELETE") {
@@ -44,6 +47,10 @@ export const joinTeam = sql.transaction(
deleteMemberStm.run({ tournamentTeamId: previousTeamId, userId });
}
if (!previousTeamId) {
deleteSub({ tournamentId, userId });
}
if (checkOutTeam) {
invariant(
previousTeamId,

View File

@@ -45,8 +45,8 @@ export const action: ActionFunction = async ({ request, params }) => {
schema: adminActionSchema,
});
const eventId = tournamentIdFromParams(params);
const event = notFoundIfFalsy(findByIdentifier(eventId));
const tournamentId = tournamentIdFromParams(params);
const event = notFoundIfFalsy(findByIdentifier(tournamentId));
const teams = findTeamsByTournamentId(event.id);
validate(canAdminTournament({ user, event }), "Unauthorized", 401);
@@ -136,6 +136,7 @@ export const action: ActionFunction = async ({ request, params }) => {
previousTeamId: previousTeam?.id,
// this team is not checked in so we can simply delete it
whatToDoWithPreviousTeam: previousTeam ? "DELETE" : undefined,
tournamentId,
});
break;
}

View File

@@ -79,6 +79,7 @@ export const action: ActionFunction = async ({ request, params }) => {
previousTeam &&
previousTeam.members.length <= TOURNAMENT.TEAM_MIN_MEMBERS_FOR_FULL,
whatToDoWithPreviousTeam,
tournamentId,
});
if (data.trust) {
const inviterUserId = teamToJoin.members.find(

View File

@@ -3,7 +3,12 @@ import {
type ActionFunction,
type LoaderArgs,
} from "@remix-run/node";
import { useFetcher, useLoaderData, useOutletContext } from "@remix-run/react";
import {
Link,
useFetcher,
useLoaderData,
useOutletContext,
} from "@remix-run/react";
import clsx from "clsx";
import * as React from "react";
import { useCopyToClipboard } from "react-use";
@@ -52,6 +57,7 @@ import {
navIconUrl,
tournamentBracketsPage,
tournamentJoinPage,
tournamentSubsPage,
} from "~/utils/urls";
import { checkIn } from "../queries/checkIn.server";
import { createTeam } from "../queries/createTeam.server";
@@ -193,6 +199,7 @@ export const action: ActionFunction = async ({ request, params }) => {
joinTeam({
userId: data.userId,
newTeamId: ownTeam.id,
tournamentId,
});
break;
}
@@ -242,11 +249,10 @@ export const loader = async ({ request, params }: LoaderArgs) => {
export default function TournamentRegisterPage() {
const isMounted = useIsMounted();
const { t, i18n } = useTranslation(["tournament"]);
const user = useUser();
const parentRouteData = useOutletContext<TournamentLoaderData>();
const teamRegularMemberOf = parentRouteData.teams.find((team) =>
team.members.some((member) => member.userId === user?.id && !member.isOwner)
const isRegularMemberOfATeam = Boolean(
parentRouteData.teamMemberOfName && !parentRouteData.ownTeam
);
return (
@@ -291,11 +297,19 @@ export default function TournamentRegisterPage() {
</div>
</div>
<div>{parentRouteData.event.description}</div>
{teamRegularMemberOf ? (
{isRegularMemberOfATeam ? (
<Alert>{t("tournament:pre.inATeam")}</Alert>
) : (
<RegistrationForms ownTeam={parentRouteData?.ownTeam} />
)}
{!parentRouteData.teamMemberOfName ? (
<Link
to={tournamentSubsPage(parentRouteData.event.id)}
className="text-xs text-center"
>
{t("tournament:pre.sub.prompt")}
</Link>
) : null}
</div>
);
}

View File

@@ -15,11 +15,11 @@ import { SubNav, SubNavLink } from "~/components/SubNav";
import { db } from "~/db";
import { useTranslation } from "~/hooks/useTranslation";
import { useUser } from "~/modules/auth";
import { getUserId } from "~/modules/auth/user.server";
import { getUser } from "~/modules/auth/user.server";
import { canAdminTournament } from "~/permissions";
import { notFoundIfFalsy, type SendouRouteHandle } from "~/utils/remix";
import { makeTitle } from "~/utils/strings";
import type { Unpacked } from "~/utils/types";
import { assertUnreachable, type Unpacked } from "~/utils/types";
import { streamsByTournamentId } from "../core/streams.server";
import { findByIdentifier } from "../queries/findByIdentifier.server";
import { findTeamsByTournamentId } from "../queries/findTeamsByTournamentId.server";
@@ -27,6 +27,7 @@ import hasTournamentStarted from "../queries/hasTournamentStarted.server";
import { teamHasCheckedIn, tournamentIdFromParams } from "../tournament-utils";
import styles from "../tournament.css";
import { findOwnTeam } from "../queries/findOwnTeam.server";
import { findSubsByTournamentId } from "~/features/tournament-subs";
export const shouldRevalidate: ShouldRevalidateFunction = (args) => {
const wasMutation = args.formMethod === "post";
@@ -65,7 +66,7 @@ export type TournamentLoaderTeam = Unpacked<TournamentLoaderData["teams"]>;
export type TournamentLoaderData = SerializeFrom<typeof loader>;
export const loader = async ({ params, request }: LoaderArgs) => {
const user = await getUserId(request);
const user = await getUser(request);
const tournamentId = tournamentIdFromParams(params);
const event = notFoundIfFalsy(findByIdentifier(tournamentId));
@@ -79,6 +80,31 @@ export const loader = async ({ params, request }: LoaderArgs) => {
team.members.some((member) => member.userId === user?.id)
)?.name;
const subsCount = findSubsByTournamentId({
tournamentId,
userId: user?.id,
// eslint-disable-next-line array-callback-return
}).filter((sub) => {
if (sub.visibility === "ALL") return true;
const userPlusTier = user?.plusTier ?? 4;
switch (sub.visibility) {
case "+1": {
return userPlusTier === 1;
}
case "+2": {
return userPlusTier <= 2;
}
case "+3": {
return userPlusTier <= 3;
}
default: {
assertUnreachable(sub.visibility);
}
}
}).length;
return {
event,
tieBreakerMapPool: db.calendarEvents.findTieBreakerMapPoolByEventId(
@@ -93,6 +119,7 @@ export const loader = async ({ params, request }: LoaderArgs) => {
teamMemberOfName,
teams,
hasStarted,
subsCount,
streamsCount: hasStarted
? (await streamsByTournamentId(tournamentId)).length
: 0,
@@ -117,20 +144,28 @@ export default function TournamentLayout() {
</SubNavLink>
) : null}
<SubNavLink to="brackets" data-testid="brackets-tab">
Brackets
{t("tournament:tabs.brackets")}
</SubNavLink>
{data.event.showMapListGenerator ? (
<SubNavLink to="maps">{t("tournament:tabs.maps")}</SubNavLink>
) : null}
<SubNavLink to="teams">
<SubNavLink to="teams" end={false}>
{t("tournament:tabs.teams", { count: data.teams.length })}
</SubNavLink>
{/* TODO: don't show when tournament finalized */}
<SubNavLink to="subs" end={false}>
{t("tournament:tabs.subs", { count: data.subsCount })}
</SubNavLink>
{/* TODO: don't show when tournament finalized */}
{data.hasStarted ? (
<SubNavLink to="streams">Streams ({data.streamsCount})</SubNavLink>
<SubNavLink to="streams">
{t("tournament:tabs.streams", { count: data.streamsCount })}
</SubNavLink>
) : null}
{canAdminTournament({ user, event: data.event }) &&
!data.hasStarted && <SubNavLink to="seeds">Seeds</SubNavLink>}
!data.hasStarted && (
<SubNavLink to="seeds">{t("tournament:tabs.seeds")}</SubNavLink>
)}
{canAdminTournament({ user, event: data.event }) && (
<SubNavLink to="admin" data-testid="admin-tab">
{t("tournament:tabs.admin")}

View File

@@ -81,7 +81,7 @@ const TRANSLATORS: Array<{
language: "ru",
},
{
translators: ["たここ", "ShanglinMo", "gellneko", "zenpk"],
translators: ["たここ", "ShanglinMo", "gellneko", "zenpk", "chenyenru"],
language: "zh",
},
];

View File

@@ -492,7 +492,7 @@ dialog::backdrop {
.sub-nav__link {
width: 100%;
padding: var(--s-1) var(--s-2);
padding: var(--s-1) var(--s-4);
border-radius: var(--rounded);
background-color: var(--bg-lightest);
cursor: pointer;
@@ -502,15 +502,15 @@ dialog::backdrop {
}
.sub-nav__border-guy {
display: none;
width: 78%;
height: 3px;
border-radius: var(--rounded);
background-color: var(--bg-lightest);
visibility: hidden;
}
.sub-nav__link__container.active > .sub-nav__border-guy {
display: block;
visibility: initial;
}
.popover-content {

View File

@@ -1,5 +1,10 @@
// TODO: when more examples of permissions profile difference between
// this implementation and one that takes arrays
import clone from "just-clone";
import shuffle from "just-shuffle";
import invariant from "tiny-invariant";
// (not all arrays need to necessarily run but they need to be defined)
export function allTruthy(arr: unknown[]) {
return arr.every(Boolean);
@@ -64,3 +69,15 @@ export function removeDuplicatesByProperty<T>(
return true;
});
}
export function nullFilledArray(size: number) {
return new Array(size).fill(null);
}
export function pickRandomItem<T>(array: T[]): T {
invariant(array.length > 0, "Can't pick from empty array");
const shuffled = shuffle(clone(array));
return shuffled[0];
}

View File

@@ -214,6 +214,9 @@ export const tournamentJoinPage = ({
eventId: number;
inviteCode: string;
}) => `/to/${eventId}/join?code=${inviteCode}`;
export const tournamentSubsPage = (tournamentId: number) => {
return `/to/${tournamentId}/subs`;
};
export const mapsPage = (eventId?: MapPoolMap["calendarEventId"]) =>
`/maps${eventId ? `?eventId=${eventId}` : ""}`;

View File

@@ -0,0 +1,19 @@
module.exports.up = function (db) {
db.prepare(
/*sql*/ `
create table "TournamentSub" (
"userId" integer not null,
"tournamentId" integer not null,
"canVc" integer not null,
"bestWeapons" text not null,
"okWeapons" text,
"message" text,
"visibility" text not null,
"createdAt" integer default (strftime('%s', 'now')) not null,
foreign key ("userId") references "User"("id") on delete cascade,
foreign key ("tournamentId") references "Tournament"("id") on delete cascade,
unique("userId", "tournamentId") on conflict rollback
) strict
`
).run();
};

View File

@@ -182,5 +182,7 @@
"build.private": "Private",
"or": "Or"
"or": "Or",
"yes": "Yes",
"no": "No"
}

View File

@@ -4,6 +4,10 @@
"tabs.admin": "Admin",
"tabs.register": "Register",
"tabs.maps": "Maps",
"tabs.brackets": "Brackets",
"tabs.seeds": "Seeds",
"tabs.streams": "Streams ({{count}})",
"tabs.subs": "Subs ({{count}})",
"pre.steps.header": "Complete these steps to play",
"pre.steps.name": "Team name",
@@ -35,6 +39,8 @@
"pre.pool.tiebreaker": "Tiebreaker: {{stage}}",
"pre.pool.pick": "Pick {{number}}",
"pre.sub.prompt": "No team in mind for this event? You can also join the list of subs.",
"bracket.type.DE_WINNERS": "Winners Round",
"bracket.type.DE_LOSERS": "Losers Round",
"bracket.type.SE": "Round",
@@ -112,5 +118,19 @@
"join.error.ALREADY_JOINED": "You're already a member of this team.",
"join.error.NOT_LOGGED_IN": "You must be logged in to join a team.",
"join.VALID": "Join {{teamName}} for {{eventName}}?",
"join.giveTrust": "Trust {{name}} to add you on their own to future tournaments?"
"join.giveTrust": "Trust {{name}} to add you on their own to future tournaments?",
"subs.prefersToPlay": "Prefers to play",
"subs.canPlay": "Can play",
"subs.canVC": "Can VC",
"subs.noVC": "No VC",
"subs.editPost": "Edit sub post",
"subs.addPost": "Add yourself as sub",
"subs.vc.header": "Can you voice chat?",
"subs.weapons.prefer.header": "Weapons you prefer to play",
"subs.weapons.ok.header": "Weapons you are ok with playing",
"subs.weapons.info": "Choose between {{min}} and {{max}}",
"subs.message.header": "Message",
"subs.visibility.header": "Visibility",
"subs.visibility.everyone": "Everyone"
}

View File

@@ -34,6 +34,15 @@ module.exports = {
"features/tournament/routes/to.$id.streams.tsx"
);
route(
"/to/:id/subs",
"features/tournament-subs/routes/to.$id.subs.tsx"
);
route(
"/to/:id/subs/new",
"features/tournament-subs/routes/to.$id.subs.new.tsx"
);
route(
"/to/:id/brackets",
"features/tournament-bracket/routes/to.$id.brackets.tsx"