Tournament subs: remember previous selections, also delete post when creating a team & refactor to use Kysely & CSS modules

This commit is contained in:
Kalle 2025-11-26 19:41:22 +02:00
parent 4792982cb7
commit c3bf60e4d8
34 changed files with 366 additions and 212 deletions

View File

@ -890,6 +890,8 @@ export interface User {
preferences: JSONColumnTypeNullable<UserPreferences>;
/** 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 */

View File

@ -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<TablesInsertable["TournamentSub"], "bestWeapons" | "okWeapons"> & {
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[];
}

View File

@ -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,

View File

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

View File

@ -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),
};
};

View File

@ -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 {

View File

@ -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),
}));
}

View File

@ -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<Tables["TournamentSub"], "createdAt">) {
stm.run(args);
}

View File

@ -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;
}

View File

@ -0,0 +1,9 @@
.selectedWeapon {
padding: var(--s-2);
border-radius: 100%;
background-color: var(--bg-lighter);
}
.weaponPool {
width: 20rem;
}

View File

@ -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<typeof loader>();
const [bestWeapons, setBestWeapons] = React.useState<MainWeaponId[]>(
data.sub?.bestWeapons ?? [],
data.sub?.bestWeapons ?? data.userDefaults?.bestWeapons ?? [],
);
const [okWeapons, setOkWeapons] = React.useState<MainWeaponId[]>(
data.sub?.okWeapons ?? [],
data.sub?.okWeapons ?? data.userDefaults?.okWeapons ?? [],
);
return (
<div className="half-width">
<Form method="post" className="stack md items-start">
<h2>{t("tournament:subs.addPost")}</h2>
<div className="stack">
<h2>{t("tournament:subs.addPost")}</h2>
<FormMessage type="info">
{t("tournament:subs.defaultsNote")}{" "}
<Link to={SENDOUQ_SETTINGS_PAGE}>
{t("tournament:subs.defaultsPage")}
</Link>
</FormMessage>
</div>
<VCRadios />
<WeaponPoolSelect
label={t("tournament:subs.weapons.prefer.header")}
@ -74,6 +85,16 @@ function VCRadios() {
const { t } = useTranslation(["common", "tournament"]);
const data = useLoaderData<typeof loader>();
const isDefaultChecked = (value: number) => {
if (data.sub) {
return data.sub.canVc === value;
}
const defaultCanVc = data.userDefaults?.canVc ?? 1;
return defaultCanVc === value;
};
return (
<div>
<Label required>{t("tournament:subs.vc.header")}</Label>
@ -85,7 +106,7 @@ function VCRadios() {
name="canVc"
value="1"
required
defaultChecked={data.sub && data.sub.canVc === 1}
defaultChecked={isDefaultChecked(1)}
/>
<label htmlFor="vc-true" className="mb-0">
{t("common:yes")}
@ -97,7 +118,7 @@ function VCRadios() {
id="vc-false"
name="canVc"
value="0"
defaultChecked={data.sub && data.sub.canVc === 0}
defaultChecked={isDefaultChecked(0)}
/>
<label htmlFor="vc-false" className="mb-0">
{t("common:no")}
@ -109,7 +130,7 @@ function VCRadios() {
id="vc-listen-only"
name="canVc"
value="2"
defaultChecked={data.sub && data.sub.canVc === 2}
defaultChecked={isDefaultChecked(2)}
/>
<label htmlFor="vc-listen-only" className="mb-0">
{t("tournament:subs.listenOnlyVC")}
@ -123,7 +144,9 @@ function VCRadios() {
function Message() {
const { t } = useTranslation(["tournament"]);
const data = useLoaderData<typeof loader>();
const [value, setValue] = React.useState(data.sub?.message ?? "");
const [value, setValue] = React.useState(
data.sub?.message ?? data.userDefaults?.message ?? "",
);
return (
<div className="u-edit__bio-container">
@ -216,7 +239,7 @@ function WeaponPoolSelect({
const { t } = useTranslation(["user"]);
return (
<div className="stack md sub__weapon-pool">
<div className={clsx("stack md", styles.weaponPool)}>
<RequiredHiddenInput
isValid={!required || weapons.length > 0}
name={id}
@ -245,7 +268,7 @@ function WeaponPoolSelect({
{weapons.map((weapon) => {
return (
<div key={weapon} className="stack xs">
<div className="sub__selected-weapon">
<div className={styles.selectedWeapon}>
<WeaponImage
weaponSplId={weapon}
variant="badge"

View File

@ -1,4 +1,5 @@
import { Link, useLoaderData } from "@remix-run/react";
import clsx from "clsx";
import React from "react";
import { useTranslation } from "react-i18next";
import { Avatar } from "~/components/Avatar";
@ -12,13 +13,13 @@ import { TrashIcon } from "~/components/icons/Trash";
import { Redirect } from "~/components/Redirect";
import { useUser } from "~/features/auth/core/user";
import { useTournament } from "~/features/tournament/routes/to.$id";
import type { SerializeFrom } from "~/utils/remix";
import { tournamentRegisterPage, userPage } from "~/utils/urls";
import { action } from "../actions/to.$id.subs.server";
import { loader } from "../loaders/to.$id.subs.server";
import type { SubByTournamentId } from "../queries/findSubsByTournamentId.server";
export { action, loader };
import "../tournament-subs.css";
import styles from "./to.$id.subs.module.css";
export default function TournamentSubsPage() {
const user = useUser();
@ -30,7 +31,7 @@ export default function TournamentSubsPage() {
}
return (
<div className="stack lg">
<div className={styles.listPageContainer}>
{!tournament.teamMemberOfByUser(user) && user ? (
<div className="stack items-end">
<AddOrEditSubButton />
@ -71,13 +72,17 @@ function AddOrEditSubButton() {
);
}
function SubInfoSection({ sub }: { sub: SubByTournamentId }) {
function SubInfoSection({
sub,
}: {
sub: SerializeFrom<typeof loader>["subs"][number];
}) {
const { t } = useTranslation(["common", "tournament"]);
const user = useUser();
const tournament = useTournament();
const infos = [
<div key="vc" className="sub__section__info__vc">
<div key="vc" className={styles.sectionInfoVc}>
<MicrophoneIcon
className={
sub.canVc === 1
@ -105,17 +110,27 @@ function SubInfoSection({ sub }: { sub: SubByTournamentId }) {
return (
<div>
<section className="sub__section">
<Avatar user={sub} size="sm" className="sub__section__avatar" />
<Link to={userPage(sub)} className="sub__section__name">
<section className={styles.section}>
<Avatar user={sub} size="sm" className={styles.sectionAvatar} />
<Link to={userPage(sub)} className={styles.sectionName}>
{sub.username}
</Link>
<div className="sub__section__spacer" />
<div className="sub__section__info">{infos}</div>
<div className="sub__section__weapon-top-text sub__section__weapon-text">
<div className={styles.sectionSpacer} />
<div className={styles.sectionInfo}>{infos}</div>
<div
className={clsx(
styles.sectionWeaponTopText,
styles.sectionWeaponText,
)}
>
{t("tournament:subs.prefersToPlay")}
</div>
<div className="sub__section__weapon-top-images sub__section__weapon-images">
<div
className={clsx(
styles.sectionWeaponTopImages,
styles.sectionWeaponImages,
)}
>
{sub.bestWeapons.map((wpn) => (
<WeaponImage
key={wpn}
@ -127,10 +142,20 @@ function SubInfoSection({ sub }: { sub: SubByTournamentId }) {
</div>
{sub.okWeapons ? (
<>
<div className="sub__section__weapon-bottom-text sub__section__weapon-text">
<div
className={clsx(
styles.sectionWeaponBottomText,
styles.sectionWeaponText,
)}
>
{t("tournament:subs.canPlay")}
</div>
<div className="sub__section__weapon-bottom-images sub__section__weapon-images">
<div
className={clsx(
styles.sectionWeaponBottomImages,
styles.sectionWeaponImages,
)}
>
{sub.okWeapons.map((wpn) => (
<WeaponImage
key={wpn}
@ -143,7 +168,7 @@ function SubInfoSection({ sub }: { sub: SubByTournamentId }) {
</>
) : null}
{sub.message ? (
<div className="sub__section__message">{sub.message}</div>
<div className={styles.sectionMessage}>{sub.message}</div>
) : null}
</section>
{user?.id === sub.userId || tournament.isOrganizer(user) ? (

View File

@ -1,34 +1,22 @@
import { z } from "zod/v4";
import { mainWeaponIds } from "~/modules/in-game-lists/weapon-ids";
import { id, processMany, removeDuplicates, safeJSONParse } from "~/utils/zod";
import {
id,
processMany,
removeDuplicates,
safeJSONParse,
weaponSplId,
} from "~/utils/zod";
import { TOURNAMENT_SUB } from "./tournament-subs-constants";
export const subSchema = z.object({
canVc: z.coerce.number().int().min(0).max(2),
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),
z.array(weaponSplId).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),
z.array(weaponSplId).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

@ -9,6 +9,7 @@ import {
clearTournamentDataCache,
tournamentFromDB,
} from "~/features/tournament-bracket/core/Tournament.server";
import { deleteSub } from "~/features/tournament-subs/queries/deleteSub.server";
import invariant from "~/utils/invariant";
import { logger } from "~/utils/logger";
import {
@ -71,6 +72,7 @@ export const action: ActionFunction = async ({ request, params }) => {
userId: data.userId,
tournamentId,
});
deleteSub({ tournamentId, userId: data.userId });
ShowcaseTournaments.addToCached({
tournamentId,

View File

@ -10,6 +10,7 @@ import {
clearTournamentDataCache,
tournamentFromDB,
} from "~/features/tournament-bracket/core/Tournament.server";
import { deleteSub } from "~/features/tournament-subs/queries/deleteSub.server";
import * as UserRepository from "~/features/user-page/UserRepository.server";
import { logger } from "~/utils/logger";
import {
@ -131,6 +132,7 @@ export const action: ActionFunction = async ({ request, params }) => {
tournamentId,
avatarFileName,
});
deleteSub({ tournamentId, userId: user.id });
ShowcaseTournaments.addToCached({
tournamentId,

View File

@ -1,5 +1,5 @@
import { sql } from "~/db/sql";
import { deleteSub } from "~/features/tournament-subs";
import { deleteSub } from "~/features/tournament-subs/queries/deleteSub.server";
import invariant from "~/utils/invariant";
import { checkOut } from "./checkOut.server";

View File

@ -313,6 +313,35 @@ export function findBannedStatusByUserId(userId: number) {
.executeTakeFirst();
}
export async function findSubDefaultsByUserId(userId: number) {
const user = await db
.selectFrom("User")
.select(["User.vc", "User.qWeaponPool", "User.lastSubMessage"])
.where("User.id", "=", userId)
.executeTakeFirst();
if (!user) return null;
const vcToCanVc = (vc: "YES" | "NO" | "LISTEN_ONLY"): 0 | 1 | 2 => {
if (vc === "YES") return 1;
if (vc === "NO") return 0;
return 2;
};
const qWeaponPool = user.qWeaponPool ?? [];
return {
canVc: vcToCanVc(user.vc),
bestWeapons: qWeaponPool
.filter((w) => w.isFavorite === 1)
.map((w) => w.weaponSplId),
okWeapons: qWeaponPool
.filter((w) => w.isFavorite === 0)
.map((w) => w.weaponSplId),
message: user.lastSubMessage,
};
}
export async function findLeanById(id: number) {
const user = await db
.selectFrom("User")

View File

@ -131,6 +131,8 @@
"subs.message.header": "Besked",
"subs.visibility.header": "Synlighed",
"subs.visibility.everyone": "Alle",
"subs.defaultsNote": "",
"subs.defaultsPage": "",
"progression.error.PLACEMENTS_PARSE_ERROR": "",
"progression.error.NOT_RESOLVING_WINNER": "",
"progression.error.SAME_PLACEMENT_TO_MULTIPLE_BRACKETS": "",

View File

@ -131,6 +131,8 @@
"subs.message.header": "Nachricht",
"subs.visibility.header": "Sichtbarkeit",
"subs.visibility.everyone": "Jeder",
"subs.defaultsNote": "",
"subs.defaultsPage": "",
"progression.error.PLACEMENTS_PARSE_ERROR": "",
"progression.error.NOT_RESOLVING_WINNER": "",
"progression.error.SAME_PLACEMENT_TO_MULTIPLE_BRACKETS": "",

View File

@ -131,6 +131,8 @@
"subs.message.header": "Message",
"subs.visibility.header": "Visibility",
"subs.visibility.everyone": "Everyone",
"subs.defaultsNote": "Voice chat and weapon pool defaults can be edited on",
"subs.defaultsPage": "SendouQ settings page",
"progression.error.PLACEMENTS_PARSE_ERROR": "Error parsing placements",
"progression.error.NOT_RESOLVING_WINNER": "Progression does not resolve winner",
"progression.error.SAME_PLACEMENT_TO_MULTIPLE_BRACKETS": "Same placement leads to multiple brackets",

View File

@ -133,6 +133,8 @@
"subs.message.header": "Mensaje",
"subs.visibility.header": "Visibilidad",
"subs.visibility.everyone": "Todos",
"subs.defaultsNote": "",
"subs.defaultsPage": "",
"progression.error.PLACEMENTS_PARSE_ERROR": "",
"progression.error.NOT_RESOLVING_WINNER": "",
"progression.error.SAME_PLACEMENT_TO_MULTIPLE_BRACKETS": "",

View File

@ -133,6 +133,8 @@
"subs.message.header": "Mensaje",
"subs.visibility.header": "Visibilidad",
"subs.visibility.everyone": "Todos",
"subs.defaultsNote": "",
"subs.defaultsPage": "",
"progression.error.PLACEMENTS_PARSE_ERROR": "",
"progression.error.NOT_RESOLVING_WINNER": "",
"progression.error.SAME_PLACEMENT_TO_MULTIPLE_BRACKETS": "",

View File

@ -133,6 +133,8 @@
"subs.message.header": "Message",
"subs.visibility.header": "Visibilité",
"subs.visibility.everyone": "Tout le monde",
"subs.defaultsNote": "",
"subs.defaultsPage": "",
"progression.error.PLACEMENTS_PARSE_ERROR": "",
"progression.error.NOT_RESOLVING_WINNER": "",
"progression.error.SAME_PLACEMENT_TO_MULTIPLE_BRACKETS": "",

View File

@ -133,6 +133,8 @@
"subs.message.header": "Message",
"subs.visibility.header": "Visibilité",
"subs.visibility.everyone": "Tout le monde",
"subs.defaultsNote": "",
"subs.defaultsPage": "",
"progression.error.PLACEMENTS_PARSE_ERROR": "Error parsing placements",
"progression.error.NOT_RESOLVING_WINNER": "Progression does not resolve winner",
"progression.error.SAME_PLACEMENT_TO_MULTIPLE_BRACKETS": "Same placement leads to multiple brackets",

View File

@ -133,6 +133,8 @@
"subs.message.header": "הודעה",
"subs.visibility.header": "ראות",
"subs.visibility.everyone": "כולם",
"subs.defaultsNote": "",
"subs.defaultsPage": "",
"progression.error.PLACEMENTS_PARSE_ERROR": "",
"progression.error.NOT_RESOLVING_WINNER": "",
"progression.error.SAME_PLACEMENT_TO_MULTIPLE_BRACKETS": "",

View File

@ -133,6 +133,8 @@
"subs.message.header": "Messaggio",
"subs.visibility.header": "Visibilità",
"subs.visibility.everyone": "Tutti",
"subs.defaultsNote": "",
"subs.defaultsPage": "",
"progression.error.PLACEMENTS_PARSE_ERROR": "Errore nell'analisi delle posizioni",
"progression.error.NOT_RESOLVING_WINNER": "La progressione non risolve al vincitore",
"progression.error.SAME_PLACEMENT_TO_MULTIPLE_BRACKETS": "La stessa posizione porta a più bracket",

View File

@ -127,6 +127,8 @@
"subs.message.header": "メッセージ",
"subs.visibility.header": "公開範囲",
"subs.visibility.everyone": "すべてのユーザー",
"subs.defaultsNote": "",
"subs.defaultsPage": "",
"progression.error.PLACEMENTS_PARSE_ERROR": "順位のパースに失敗しました",
"progression.error.NOT_RESOLVING_WINNER": "現在の進行から勝者は導けません",
"progression.error.SAME_PLACEMENT_TO_MULTIPLE_BRACKETS": "同じ順位はブラケットを多数作ります",

View File

@ -127,6 +127,8 @@
"subs.message.header": "",
"subs.visibility.header": "",
"subs.visibility.everyone": "",
"subs.defaultsNote": "",
"subs.defaultsPage": "",
"progression.error.PLACEMENTS_PARSE_ERROR": "",
"progression.error.NOT_RESOLVING_WINNER": "",
"progression.error.SAME_PLACEMENT_TO_MULTIPLE_BRACKETS": "",

View File

@ -131,6 +131,8 @@
"subs.message.header": "",
"subs.visibility.header": "",
"subs.visibility.everyone": "",
"subs.defaultsNote": "",
"subs.defaultsPage": "",
"progression.error.PLACEMENTS_PARSE_ERROR": "",
"progression.error.NOT_RESOLVING_WINNER": "",
"progression.error.SAME_PLACEMENT_TO_MULTIPLE_BRACKETS": "",

View File

@ -135,6 +135,8 @@
"subs.message.header": "",
"subs.visibility.header": "",
"subs.visibility.everyone": "",
"subs.defaultsNote": "",
"subs.defaultsPage": "",
"progression.error.PLACEMENTS_PARSE_ERROR": "",
"progression.error.NOT_RESOLVING_WINNER": "",
"progression.error.SAME_PLACEMENT_TO_MULTIPLE_BRACKETS": "",

View File

@ -133,6 +133,8 @@
"subs.message.header": "Mensagem",
"subs.visibility.header": "Visibilidade",
"subs.visibility.everyone": "Todos",
"subs.defaultsNote": "",
"subs.defaultsPage": "",
"progression.error.PLACEMENTS_PARSE_ERROR": "",
"progression.error.NOT_RESOLVING_WINNER": "",
"progression.error.SAME_PLACEMENT_TO_MULTIPLE_BRACKETS": "",

View File

@ -135,6 +135,8 @@
"subs.message.header": "Сообщение",
"subs.visibility.header": "Видимость",
"subs.visibility.everyone": "Все",
"subs.defaultsNote": "",
"subs.defaultsPage": "",
"progression.error.PLACEMENTS_PARSE_ERROR": "Ошибка анализа мест",
"progression.error.NOT_RESOLVING_WINNER": "Прогрессия не может определить победителя",
"progression.error.SAME_PLACEMENT_TO_MULTIPLE_BRACKETS": "Одинаковые места приводят к нескольким сеткам",

View File

@ -127,6 +127,8 @@
"subs.message.header": "信息",
"subs.visibility.header": "可见范围",
"subs.visibility.everyone": "所有人",
"subs.defaultsNote": "",
"subs.defaultsPage": "",
"progression.error.PLACEMENTS_PARSE_ERROR": "",
"progression.error.NOT_RESOLVING_WINNER": "",
"progression.error.SAME_PLACEMENT_TO_MULTIPLE_BRACKETS": "",

View File

@ -0,0 +1,5 @@
export function up(db) {
db.transaction(() => {
db.prepare(/* sql */ `alter table "User" add "lastSubMessage" text`).run();
})();
}