From c3bf60e4d87c027ee518dda373790a3dfce8943d Mon Sep 17 00:00:00 2001 From: Kalle <38327916+Sendouc@users.noreply.github.com> Date: Wed, 26 Nov 2025 19:41:22 +0200 Subject: [PATCH] Tournament subs: remember previous selections, also delete post when creating a team & refactor to use Kysely & CSS modules --- app/db/tables.ts | 2 + .../TournamentSubRepository.server.ts | 164 ++++++++++++++++++ .../actions/to.$id.subs.new.server.ts | 8 +- app/features/tournament-subs/index.tsx | 2 - .../loaders/to.$id.subs.new.server.ts | 13 +- .../loaders/to.$id.subs.server.ts | 25 +-- .../queries/findSubsByTournamentId.server.ts | 68 -------- .../queries/upsertSub.server.ts | 35 ---- .../to.$id.subs.module.css} | 50 +++--- .../routes/to.$id.subs.new.module.css | 9 + .../routes/to.$id.subs.new.tsx | 45 +++-- .../tournament-subs/routes/to.$id.subs.tsx | 55 ++++-- .../tournament-subs-schemas.server.ts | 30 +--- .../tournament/actions/to.$id.admin.server.ts | 2 + .../actions/to.$id.register.server.ts | 2 + .../queries/joinLeaveTeam.server.ts | 2 +- .../user-page/UserRepository.server.ts | 29 ++++ locales/da/tournament.json | 2 + locales/de/tournament.json | 2 + locales/en/tournament.json | 2 + locales/es-ES/tournament.json | 2 + locales/es-US/tournament.json | 2 + locales/fr-CA/tournament.json | 2 + locales/fr-EU/tournament.json | 2 + locales/he/tournament.json | 2 + locales/it/tournament.json | 2 + locales/ja/tournament.json | 2 + locales/ko/tournament.json | 2 + locales/nl/tournament.json | 2 + locales/pl/tournament.json | 2 + locales/pt-BR/tournament.json | 2 + locales/ru/tournament.json | 2 + locales/zh/tournament.json | 2 + migrations/107-user-last-sub-message.js | 5 + 34 files changed, 366 insertions(+), 212 deletions(-) create mode 100644 app/features/tournament-subs/TournamentSubRepository.server.ts delete mode 100644 app/features/tournament-subs/index.tsx delete mode 100644 app/features/tournament-subs/queries/findSubsByTournamentId.server.ts delete mode 100644 app/features/tournament-subs/queries/upsertSub.server.ts rename app/features/tournament-subs/{tournament-subs.css => routes/to.$id.subs.module.css} (71%) create mode 100644 app/features/tournament-subs/routes/to.$id.subs.new.module.css create mode 100644 migrations/107-user-last-sub-message.js diff --git a/app/db/tables.ts b/app/db/tables.ts index 86d767590..fea653137 100644 --- a/app/db/tables.ts +++ b/app/db/tables.ts @@ -890,6 +890,8 @@ export interface User { preferences: JSONColumnTypeNullable; /** User creation date. Can be null because we did not always save this. */ createdAt: number | null; + /** Last message used when creating a tournament sub post */ + lastSubMessage: string | null; } /** Represents User joined with PlusTier table */ diff --git a/app/features/tournament-subs/TournamentSubRepository.server.ts b/app/features/tournament-subs/TournamentSubRepository.server.ts new file mode 100644 index 000000000..a5de09926 --- /dev/null +++ b/app/features/tournament-subs/TournamentSubRepository.server.ts @@ -0,0 +1,164 @@ +import { sql } from "kysely"; +import { db } from "~/db/sql"; +import type { TablesInsertable } from "~/db/tables"; +import type { MainWeaponId } from "~/modules/in-game-lists/types"; + +export async function findSubsVisibleToUser({ + tournamentId, + userId, +}: { + tournamentId: number; + userId?: number; +}) { + const userPlusTier = await getUserPlusTier(userId); + + const rows = await baseQuery(tournamentId) + .where((eb) => + eb.or([ + eb("TournamentSub.visibility", "=", "ALL"), + eb.and([ + eb("TournamentSub.visibility", "=", "+1"), + eb(sql`${userPlusTier}`, "=", 1), + ]), + eb.and([ + eb("TournamentSub.visibility", "=", "+2"), + eb(sql`${userPlusTier}`, "<=", 2), + ]), + eb.and([ + eb("TournamentSub.visibility", "=", "+3"), + eb(sql`${userPlusTier}`, "<=", 3), + ]), + ]), + ) + .orderBy((eb) => + eb + .case() + .when("TournamentSub.userId", "=", userId ?? null) + .then(0) + .else(1) + .end(), + ) + .orderBy((eb) => + eb + .case() + .when(eb("PlusTier.tier", "is", null)) + .then(4) + .else(eb.ref("PlusTier.tier")) + .end(), + ) + .orderBy("TournamentSub.createdAt", "desc") + .execute(); + + return mapRows(rows); +} + +export async function findUserSubPost({ + tournamentId, + userId, +}: { + tournamentId: number; + userId: number; +}) { + const row = await baseQuery(tournamentId) + .where("TournamentSub.userId", "=", userId) + .executeTakeFirst(); + + if (!row) return null; + + return mapRows([row])[0]; +} + +export function upsert( + args: Omit & { + bestWeapons: MainWeaponId[]; + okWeapons: MainWeaponId[]; + }, +) { + return db.transaction().execute(async (trx) => { + const bestWeaponsStr = args.bestWeapons.join(","); + const okWeaponsStr = + args.okWeapons.length > 0 ? args.okWeapons.join(",") : null; + + await trx + .insertInto("TournamentSub") + .values({ + userId: args.userId, + tournamentId: args.tournamentId, + canVc: args.canVc, + bestWeapons: bestWeaponsStr, + okWeapons: okWeaponsStr, + message: args.message, + visibility: args.visibility, + }) + .onConflict((oc) => + oc.columns(["userId", "tournamentId"]).doUpdateSet({ + canVc: args.canVc, + bestWeapons: bestWeaponsStr, + okWeapons: okWeaponsStr, + message: args.message, + visibility: args.visibility, + }), + ) + .execute(); + + await trx + .updateTable("User") + .set({ lastSubMessage: args.message }) + .where("id", "=", args.userId) + .execute(); + }); +} + +async function getUserPlusTier(userId?: number) { + if (!userId) return 4; + + const row = await db + .selectFrom("PlusTier") + .select("tier") + .where("userId", "=", userId) + .executeTakeFirst(); + + return row?.tier ?? 4; +} + +function baseQuery(tournamentId: number) { + return db + .selectFrom("TournamentSub") + .innerJoin("User", "User.id", "TournamentSub.userId") + .leftJoin("PlusTier", "PlusTier.userId", "User.id") + .select([ + "TournamentSub.canVc", + "TournamentSub.bestWeapons", + "TournamentSub.okWeapons", + "TournamentSub.message", + "TournamentSub.visibility", + "TournamentSub.createdAt", + "TournamentSub.userId", + "User.username", + "User.discordAvatar", + "User.country", + "User.discordId", + "User.customUrl", + "PlusTier.tier as plusTier", + ]) + .where("TournamentSub.tournamentId", "=", tournamentId); +} + +function mapRows< + T extends { + bestWeapons: string; + okWeapons: string | null; + }, +>(rows: T[]) { + return rows.map((row) => ({ + ...row, + bestWeapons: parseWeaponsArray(row.bestWeapons)!, + okWeapons: parseWeaponsArray(row.okWeapons), + })); +} + +function parseWeaponsArray(value: string | null) { + if (!value) return null; + + return value.split(",").map(Number) as MainWeaponId[]; +} diff --git a/app/features/tournament-subs/actions/to.$id.subs.new.server.ts b/app/features/tournament-subs/actions/to.$id.subs.new.server.ts index b432fc66b..54f47e9ce 100644 --- a/app/features/tournament-subs/actions/to.$id.subs.new.server.ts +++ b/app/features/tournament-subs/actions/to.$id.subs.new.server.ts @@ -11,7 +11,7 @@ import { } from "~/utils/remix.server"; import { tournamentSubsPage } from "~/utils/urls"; import { idObject } from "~/utils/zod"; -import { upsertSub } from "../queries/upsertSub.server"; +import * as TournamentSubRepository from "../TournamentSubRepository.server"; import { subSchema } from "../tournament-subs-schemas.server"; export const action: ActionFunction = async ({ params, request }) => { @@ -36,9 +36,9 @@ export const action: ActionFunction = async ({ params, request }) => { "Can't register as a sub and be in a team at the same time", ); - upsertSub({ - bestWeapons: data.bestWeapons.join(","), - okWeapons: data.okWeapons.join(","), + await TournamentSubRepository.upsert({ + bestWeapons: data.bestWeapons, + okWeapons: data.okWeapons, canVc: data.canVc, visibility: data.visibility, message: data.message ?? null, diff --git a/app/features/tournament-subs/index.tsx b/app/features/tournament-subs/index.tsx deleted file mode 100644 index 8fdbfc2c3..000000000 --- a/app/features/tournament-subs/index.tsx +++ /dev/null @@ -1,2 +0,0 @@ -export { deleteSub } from "./queries/deleteSub.server"; -export { findSubsByTournamentId } from "./queries/findSubsByTournamentId.server"; diff --git a/app/features/tournament-subs/loaders/to.$id.subs.new.server.ts b/app/features/tournament-subs/loaders/to.$id.subs.new.server.ts index e0e70f7bf..bb1c5761e 100644 --- a/app/features/tournament-subs/loaders/to.$id.subs.new.server.ts +++ b/app/features/tournament-subs/loaders/to.$id.subs.new.server.ts @@ -1,10 +1,11 @@ import { type LoaderFunctionArgs, redirect } from "@remix-run/node"; import { requireUser } from "~/features/auth/core/user.server"; import { tournamentFromDB } from "~/features/tournament-bracket/core/Tournament.server"; +import * as UserRepository from "~/features/user-page/UserRepository.server"; import { parseParams } from "~/utils/remix.server"; import { tournamentSubsPage } from "~/utils/urls"; import { idObject } from "~/utils/zod"; -import { findSubsByTournamentId } from "../queries/findSubsByTournamentId.server"; +import * as TournamentSubRepository from "../TournamentSubRepository.server"; export const loader = async ({ request, params }: LoaderFunctionArgs) => { const user = await requireUser(request); @@ -18,11 +19,11 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => { throw redirect(tournamentSubsPage(tournamentId)); } - const sub = findSubsByTournamentId({ tournamentId }).find( - (sub) => sub.userId === user.id, - ); - return { - sub, + sub: await TournamentSubRepository.findUserSubPost({ + tournamentId, + userId: user.id, + }), + userDefaults: await UserRepository.findSubDefaultsByUserId(user.id), }; }; diff --git a/app/features/tournament-subs/loaders/to.$id.subs.server.ts b/app/features/tournament-subs/loaders/to.$id.subs.server.ts index 6d8f42ecf..1586c73c9 100644 --- a/app/features/tournament-subs/loaders/to.$id.subs.server.ts +++ b/app/features/tournament-subs/loaders/to.$id.subs.server.ts @@ -2,10 +2,9 @@ import { type LoaderFunctionArgs, redirect } from "@remix-run/node"; import { getUser } from "~/features/auth/core/user.server"; import { tournamentFromDB } from "~/features/tournament-bracket/core/Tournament.server"; import { parseParams } from "~/utils/remix.server"; -import { assertUnreachable } from "~/utils/types"; import { tournamentRegisterPage } from "~/utils/urls"; import { idObject } from "~/utils/zod"; -import { findSubsByTournamentId } from "../queries/findSubsByTournamentId.server"; +import * as TournamentSubRepository from "../TournamentSubRepository.server"; export const loader = async ({ params, request }: LoaderFunctionArgs) => { const user = await getUser(request); @@ -19,29 +18,9 @@ export const loader = async ({ params, request }: LoaderFunctionArgs) => { throw redirect(tournamentRegisterPage(tournamentId)); } - const subs = findSubsByTournamentId({ + const subs = await TournamentSubRepository.findSubsVisibleToUser({ tournamentId, userId: user?.id, - // biome-ignore lint/suspicious/useIterableCallbackReturn: Biome 2.3.1 upgrade - }).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 { diff --git a/app/features/tournament-subs/queries/findSubsByTournamentId.server.ts b/app/features/tournament-subs/queries/findSubsByTournamentId.server.ts deleted file mode 100644 index 758055395..000000000 --- a/app/features/tournament-subs/queries/findSubsByTournamentId.server.ts +++ /dev/null @@ -1,68 +0,0 @@ -import { sql } from "~/db/sql"; -import type { Tables, UserWithPlusTier } from "~/db/tables"; -import type { MainWeaponId } from "~/modules/in-game-lists/types"; - -const stm = sql.prepare(/* sql */ ` - select - "TournamentSub"."canVc", - "TournamentSub"."bestWeapons", - "TournamentSub"."okWeapons", - "TournamentSub"."message", - "TournamentSub"."visibility", - "TournamentSub"."createdAt", - "TournamentSub"."userId", - "User"."username", - "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: Tables["TournamentSub"]["canVc"]; - bestWeapons: MainWeaponId[]; - okWeapons: MainWeaponId[] | null; - message: Tables["TournamentSub"]["message"]; - visibility: Tables["TournamentSub"]["visibility"]; - createdAt: Tables["TournamentSub"]["createdAt"]; - userId: Tables["TournamentSub"]["userId"]; - username: UserWithPlusTier["username"]; - 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: userId ?? null }) 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 deleted file mode 100644 index f0e7524c9..000000000 --- a/app/features/tournament-subs/queries/upsertSub.server.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { sql } from "~/db/sql"; -import type { Tables } from "~/db/tables"; - -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/tournament-subs.css b/app/features/tournament-subs/routes/to.$id.subs.module.css similarity index 71% rename from app/features/tournament-subs/tournament-subs.css rename to app/features/tournament-subs/routes/to.$id.subs.module.css index b9532b2a8..fcdffe741 100644 --- a/app/features/tournament-subs/tournament-subs.css +++ b/app/features/tournament-subs/routes/to.$id.subs.module.css @@ -1,4 +1,12 @@ -.sub__section { +.listPageContainer { + max-width: 52rem; + display: flex; + flex-direction: column; + gap: var(--s-8); + margin-inline: auto; +} + +.section { display: grid; grid-template-columns: max-content 1fr; grid-template-areas: @@ -14,17 +22,17 @@ column-gap: var(--s-2-5); } -.sub__section__avatar { +.sectionAvatar { grid-area: avatar; align-self: center; } -.sub__section__name { +.sectionName { grid-area: name; align-self: flex-end; } -.sub__section__info { +.sectionInfo { grid-area: info; font-size: var(--fonts-sm); gap: var(--s-2); @@ -35,59 +43,59 @@ align-items: center; } -.sub__section__info__vc { +.sectionInfoVc { display: flex; align-items: center; gap: var(--s-0-5); } -.sub__section__info__vc > svg { +.sectionInfoVc > svg { width: 18px; } -.sub__section__message { +.sectionMessage { grid-area: message; font-size: var(--fonts-sm); padding-block-start: var(--s-4); white-space: pre-wrap; } -.sub__section__weapon-text { +.sectionWeaponText { font-weight: var(--semi-bold); font-size: var(--fonts-xs); align-self: center; } -.sub__section__weapon-top-text { +.sectionWeaponTopText { grid-area: weapon-top-text; } -.sub__section__weapon-bottom-text { +.sectionWeaponBottomText { grid-area: weapon-bottom-text; } -.sub__section__weapon-images { +.sectionWeaponImages { display: flex; gap: var(--s-1); width: 11rem; align-items: center; } -.sub__section__weapon-top-images { +.sectionWeaponTopImages { grid-area: weapon-top-images; } -.sub__section__weapon-bottom-images { +.sectionWeaponBottomImages { grid-area: weapon-bottom-images; } -.sub__section__spacer { +.sectionSpacer { grid-area: spacer; height: 18px; } @media screen and (min-width: 640px) { - .sub__section { + .section { grid-template-columns: max-content 1fr max-content max-content; grid-template-areas: "avatar name weapon-top-text weapon-top-images" @@ -95,17 +103,7 @@ "message message message message"; } - .sub__section__spacer { + .sectionSpacer { display: none; } } - -.sub__selected-weapon { - padding: var(--s-2); - border-radius: 100%; - background-color: var(--bg-lighter); -} - -.sub__weapon-pool { - width: 20rem; -} diff --git a/app/features/tournament-subs/routes/to.$id.subs.new.module.css b/app/features/tournament-subs/routes/to.$id.subs.new.module.css new file mode 100644 index 000000000..7e0e885c7 --- /dev/null +++ b/app/features/tournament-subs/routes/to.$id.subs.new.module.css @@ -0,0 +1,9 @@ +.selectedWeapon { + padding: var(--s-2); + border-radius: 100%; + background-color: var(--bg-lighter); +} + +.weaponPool { + width: 20rem; +} diff --git a/app/features/tournament-subs/routes/to.$id.subs.new.tsx b/app/features/tournament-subs/routes/to.$id.subs.new.tsx index 9e5c5cfcc..8a6f45ad2 100644 --- a/app/features/tournament-subs/routes/to.$id.subs.new.tsx +++ b/app/features/tournament-subs/routes/to.$id.subs.new.tsx @@ -1,4 +1,4 @@ -import { Form, useLoaderData } from "@remix-run/react"; +import { Form, Link, useLoaderData } from "@remix-run/react"; import React from "react"; import { useTranslation } from "react-i18next"; import { SendouButton } from "~/components/elements/Button"; @@ -12,12 +12,14 @@ import { WeaponSelect } from "~/components/WeaponSelect"; import { useUser } from "~/features/auth/core/user"; import type { MainWeaponId } from "~/modules/in-game-lists/types"; import type { SendouRouteHandle } from "~/utils/remix.server"; +import { SENDOUQ_SETTINGS_PAGE } from "~/utils/urls"; import { action } from "../actions/to.$id.subs.new.server"; import { loader } from "../loaders/to.$id.subs.new.server"; import { TOURNAMENT_SUB } from "../tournament-subs-constants"; export { action, loader }; -import "../tournament-subs.css"; +import clsx from "clsx"; +import styles from "./to.$id.subs.new.module.css"; export const handle: SendouRouteHandle = { i18n: ["user"], @@ -27,17 +29,26 @@ export default function NewTournamentSubPage() { const user = useUser(); const { t } = useTranslation(["common", "tournament"]); const data = useLoaderData(); + const [bestWeapons, setBestWeapons] = React.useState( - data.sub?.bestWeapons ?? [], + data.sub?.bestWeapons ?? data.userDefaults?.bestWeapons ?? [], ); const [okWeapons, setOkWeapons] = React.useState( - data.sub?.okWeapons ?? [], + data.sub?.okWeapons ?? data.userDefaults?.okWeapons ?? [], ); return (
-

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

+
+

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

+ + {t("tournament:subs.defaultsNote")}{" "} + + {t("tournament:subs.defaultsPage")} + + +
(); + const isDefaultChecked = (value: number) => { + if (data.sub) { + return data.sub.canVc === value; + } + + const defaultCanVc = data.userDefaults?.canVc ?? 1; + + return defaultCanVc === value; + }; + return (
@@ -85,7 +106,7 @@ function VCRadios() { name="canVc" value="1" required - defaultChecked={data.sub && data.sub.canVc === 1} + defaultChecked={isDefaultChecked(1)} />