From 734688296e52a378638149eaed22e63544c9f4eb Mon Sep 17 00:00:00 2001 From: Kalle <38327916+Sendouc@users.noreply.github.com> Date: Fri, 16 Jun 2023 20:21:22 +0300 Subject: [PATCH] Tournament subs (#1394) * Initial * Progress * Layout work * New sub page initial * Edit sub posting * Delete sub posting * Improve sub nav looks on mobile * Finalized --- app/components/SubNav.tsx | 4 +- app/components/icons/Microphone.tsx | 18 + app/db/seed/index.ts | 89 ++++- app/db/types.ts | 11 + app/features/tournament-subs/index.tsx | 2 + .../queries/deleteSub.server.ts | 15 + .../queries/findSubsByTournamentId.server.ts | 70 ++++ .../queries/upsertSub.server.ts | 35 ++ .../routes/to.$id.subs.new.tsx | 314 ++++++++++++++++++ .../tournament-subs/routes/to.$id.subs.tsx | 188 +++++++++++ .../tournament-subs-constants.ts | 6 + .../tournament-subs-schemas.server.ts | 41 +++ .../tournament-subs/tournament-subs.css | 110 ++++++ .../queries/joinLeaveTeam.server.ts | 7 + .../tournament/routes/to.$id.admin.tsx | 5 +- .../tournament/routes/to.$id.join.tsx | 1 + .../tournament/routes/to.$id.register.tsx | 24 +- app/features/tournament/routes/to.$id.tsx | 49 ++- app/routes/contributions.tsx | 2 +- app/styles/common.css | 6 +- app/utils/arrays.ts | 17 + app/utils/urls.ts | 3 + migrations/027-tournament-subs.js | 19 ++ public/locales/en/common.json | 4 +- public/locales/en/tournament.json | 22 +- remix.config.js | 9 + 26 files changed, 1043 insertions(+), 28 deletions(-) create mode 100644 app/components/icons/Microphone.tsx create mode 100644 app/features/tournament-subs/index.tsx create mode 100644 app/features/tournament-subs/queries/deleteSub.server.ts create mode 100644 app/features/tournament-subs/queries/findSubsByTournamentId.server.ts create mode 100644 app/features/tournament-subs/queries/upsertSub.server.ts create mode 100644 app/features/tournament-subs/routes/to.$id.subs.new.tsx create mode 100644 app/features/tournament-subs/routes/to.$id.subs.tsx create mode 100644 app/features/tournament-subs/tournament-subs-constants.ts create mode 100644 app/features/tournament-subs/tournament-subs-schemas.server.ts create mode 100644 app/features/tournament-subs/tournament-subs.css create mode 100644 migrations/027-tournament-subs.js diff --git a/app/components/SubNav.tsx b/app/components/SubNav.tsx index 398750c64..fa5d81c45 100644 --- a/app/components/SubNav.tsx +++ b/app/components/SubNav.tsx @@ -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 ( - +
{children}
diff --git a/app/components/icons/Microphone.tsx b/app/components/icons/Microphone.tsx new file mode 100644 index 000000000..412968f14 --- /dev/null +++ b/app/components/icons/Microphone.tsx @@ -0,0 +1,18 @@ +export function MicrophoneIcon({ className }: { className?: string }) { + return ( + + + + ); +} diff --git a/app/db/seed/index.ts b/app/db/seed/index.ts index d385765fa..1de55bbd3 100644 --- a/app/db/seed/index.ts +++ b/app/db/seed/index.ts @@ -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]); diff --git a/app/db/types.ts b/app/db/types.ts index 1892022ec..d45aafc51 100644 --- a/app/db/types.ts +++ b/app/db/types.ts @@ -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; diff --git a/app/features/tournament-subs/index.tsx b/app/features/tournament-subs/index.tsx new file mode 100644 index 000000000..8fdbfc2c3 --- /dev/null +++ b/app/features/tournament-subs/index.tsx @@ -0,0 +1,2 @@ +export { deleteSub } from "./queries/deleteSub.server"; +export { findSubsByTournamentId } from "./queries/findSubsByTournamentId.server"; diff --git a/app/features/tournament-subs/queries/deleteSub.server.ts b/app/features/tournament-subs/queries/deleteSub.server.ts new file mode 100644 index 000000000..61977c41b --- /dev/null +++ b/app/features/tournament-subs/queries/deleteSub.server.ts @@ -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 +) { + stm.run(args); +} diff --git a/app/features/tournament-subs/queries/findSubsByTournamentId.server.ts b/app/features/tournament-subs/queries/findSubsByTournamentId.server.ts new file mode 100644 index 000000000..38a5710b8 --- /dev/null +++ b/app/features/tournament-subs/queries/findSubsByTournamentId.server.ts @@ -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), + })); +} diff --git a/app/features/tournament-subs/queries/upsertSub.server.ts b/app/features/tournament-subs/queries/upsertSub.server.ts new file mode 100644 index 000000000..ba8380b82 --- /dev/null +++ b/app/features/tournament-subs/queries/upsertSub.server.ts @@ -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) { + stm.run(args); +} diff --git a/app/features/tournament-subs/routes/to.$id.subs.new.tsx b/app/features/tournament-subs/routes/to.$id.subs.new.tsx new file mode 100644 index 000000000..b1c12a4f9 --- /dev/null +++ b/app/features/tournament-subs/routes/to.$id.subs.new.tsx @@ -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(); + const [bestWeapons, setBestWeapons] = React.useState( + data.sub?.bestWeapons ?? [] + ); + const [okWeapons, setOkWeapons] = React.useState( + data.sub?.okWeapons ?? [] + ); + + return ( +
+
+

{t("tournament:subs.addPost")}

+ + + + + {user?.plusTier ? : null} + {t("common:actions.save")} + +
+ ); +} + +function VCRadios() { + const { t } = useTranslation(["common", "tournament"]); + const data = useLoaderData(); + + return ( +
+ +
+
+ + +
+
+ + +
+
+
+ ); +} + +function Message() { + const { t } = useTranslation(["tournament"]); + const data = useLoaderData(); + const [value, setValue] = React.useState(data.sub?.message ?? ""); + + return ( +
+ +