mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-09-30 07:12:36 -05:00
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:
@@ -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>
|
||||
|
||||
18
app/components/icons/Microphone.tsx
Normal file
18
app/components/icons/Microphone.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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]);
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
2
app/features/tournament-subs/index.tsx
Normal file
2
app/features/tournament-subs/index.tsx
Normal file
@@ -0,0 +1,2 @@
|
||||
export { deleteSub } from "./queries/deleteSub.server";
|
||||
export { findSubsByTournamentId } from "./queries/findSubsByTournamentId.server";
|
||||
15
app/features/tournament-subs/queries/deleteSub.server.ts
Normal file
15
app/features/tournament-subs/queries/deleteSub.server.ts
Normal 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);
|
||||
}
|
||||
@@ -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),
|
||||
}));
|
||||
}
|
||||
35
app/features/tournament-subs/queries/upsertSub.server.ts
Normal file
35
app/features/tournament-subs/queries/upsertSub.server.ts
Normal 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);
|
||||
}
|
||||
314
app/features/tournament-subs/routes/to.$id.subs.new.tsx
Normal file
314
app/features/tournament-subs/routes/to.$id.subs.new.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
188
app/features/tournament-subs/routes/to.$id.subs.tsx
Normal file
188
app/features/tournament-subs/routes/to.$id.subs.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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"),
|
||||
});
|
||||
110
app/features/tournament-subs/tournament-subs.css
Normal file
110
app/features/tournament-subs/tournament-subs.css
Normal 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;
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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")}
|
||||
|
||||
@@ -81,7 +81,7 @@ const TRANSLATORS: Array<{
|
||||
language: "ru",
|
||||
},
|
||||
{
|
||||
translators: ["たここ", "ShanglinMo", "gellneko", "zenpk"],
|
||||
translators: ["たここ", "ShanglinMo", "gellneko", "zenpk", "chenyenru"],
|
||||
language: "zh",
|
||||
},
|
||||
];
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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];
|
||||
}
|
||||
|
||||
@@ -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}` : ""}`;
|
||||
|
||||
19
migrations/027-tournament-subs.js
Normal file
19
migrations/027-tournament-subs.js
Normal 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();
|
||||
};
|
||||
@@ -182,5 +182,7 @@
|
||||
|
||||
"build.private": "Private",
|
||||
|
||||
"or": "Or"
|
||||
"or": "Or",
|
||||
"yes": "Yes",
|
||||
"no": "No"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user