sendou.ink/app/features/user-page/user-page-schemas.ts
2026-03-04 16:33:51 +01:00

352 lines
8.7 KiB
TypeScript

import { z } from "zod";
import { OBJECT_PRONOUNS, SUBJECT_PRONOUNS } from "~/db/tables";
import { BADGE } from "~/features/badges/badges-constants";
import * as Seasons from "~/features/mmr/core/Seasons";
import {
badges,
checkboxGroup,
customField,
dualSelectOptional,
idConstantOptional,
selectDynamicOptional,
stringConstant,
textAreaOptional,
textAreaRequired,
textFieldOptional,
textFieldRequired,
toggle,
weaponPool,
} from "~/form/fields";
import {
clothesGearIds,
headGearIds,
shoesGearIds,
} from "~/modules/in-game-lists/gear-ids";
import { rawSensToString } from "~/utils/strings";
import { isCustomUrl } from "~/utils/urls";
import {
_action,
clothesMainSlotAbility,
headMainSlotAbility,
id,
safeJSONParse,
shoesMainSlotAbility,
stackableAbility,
} from "~/utils/zod";
import { allWidgetsFlat, findWidgetById } from "./core/widgets/portfolio";
import {
HIGHLIGHT_CHECKBOX_NAME,
HIGHLIGHT_TOURNAMENT_CHECKBOX_NAME,
IN_GAME_NAME_REGEXP,
USER,
} from "./user-page-constants";
export const userParamsSchema = z.object({ identifier: z.string() });
export const seasonsSearchParamsSchema = z.object({
page: z.coerce.number().optional(),
info: z.enum(["weapons", "stages", "mates", "enemies"]).optional(),
season: z.coerce
.number()
.optional()
.refine((nth) => !nth || Seasons.allStarted(new Date()).includes(nth)),
});
const SENS_ITEMS = [
-50, -45, -40, -35, -30, -25, -20, -15, -10, -5, 0, 5, 10, 15, 20, 25, 30, 35,
40, 45, 50,
].map((val) => ({
label: () => rawSensToString(val),
value: String(val),
}));
export const userEditProfileBaseSchema = z.object({
customName: textFieldOptional({
label: "labels.profileCustomName",
bottomText: "bottomTexts.profileCustomName",
maxLength: USER.CUSTOM_NAME_MAX_LENGTH,
}),
customUrl: textFieldOptional({
label: "labels.profileCustomUrl",
bottomText: "bottomTexts.profileCustomUrl",
leftAddon: "https://sendou.ink/u/",
maxLength: USER.CUSTOM_URL_MAX_LENGTH,
toLowerCase: true,
regExp: {
pattern: /^[a-zA-Z0-9-_]+$/,
message: "forms:errors.profileCustomUrlStrangeChar",
},
validate: {
func: isCustomUrl,
message: "forms:errors.profileCustomUrlNumbers",
},
}),
inGameName: textFieldOptional({
label: "labels.profileInGameName",
bottomText: "bottomTexts.profileInGameName",
maxLength: 10 + 1 + 5, // 10 for name, 1 for #, 5 for discriminator
regExp: {
pattern: IN_GAME_NAME_REGEXP,
message: "forms:errors.profileInGameName",
},
}),
sensitivity: dualSelectOptional({
fields: [
{ label: "labels.profileMotionSens", items: SENS_ITEMS },
{ label: "labels.profileStickSens", items: SENS_ITEMS },
],
validate: {
func: ([motion, stick]) => {
if (motion !== null && stick === null) return false;
return true;
},
message: "errors.profileSensBothOrNeither",
},
}),
pronouns: dualSelectOptional({
bottomText: "bottomTexts.profilePronouns",
fields: [
{
label: "labels.pronoun",
items: SUBJECT_PRONOUNS.map((p) => ({ label: () => p, value: p })),
},
{
label: "labels.pronoun",
items: OBJECT_PRONOUNS.map((p) => ({ label: () => p, value: p })),
},
],
validate: {
func: ([subject, object]) => {
if (subject === null && object === null) return true;
if (subject !== null && object !== null) return true;
return false;
},
message: "errors.profilePronounsBothOrNeither",
},
}),
battlefy: textFieldOptional({
label: "labels.profileBattlefy",
bottomText: "bottomTexts.profileBattlefy",
leftAddon: "https://battlefy.com/users/",
maxLength: USER.BATTLEFY_MAX_LENGTH,
}),
country: selectDynamicOptional({
label: "labels.profileCountry",
searchable: true,
}),
favoriteBadgeIds: badges({
label: "labels.profileFavoriteBadges",
maxCount: BADGE.SMALL_BADGES_PER_DISPLAY_PAGE + 1,
}),
weapons: weaponPool({
label: "labels.weaponPool",
maxCount: USER.WEAPON_POOL_MAX_SIZE,
}),
bio: textAreaOptional({
label: "labels.bio",
maxLength: USER.BIO_MAX_LENGTH,
}),
showDiscordUniqueName: toggle({
label: "labels.profileShowDiscordUniqueName",
bottomText: "bottomTexts.profileShowDiscordUniqueName",
}),
commissionsOpen: toggle({
label: "labels.profileCommissionsOpen",
bottomText: "bottomTexts.profileCommissionsOpen",
}),
commissionText: textAreaOptional({
label: "labels.profileCommissionText",
bottomText: "bottomTexts.profileCommissionText",
maxLength: USER.COMMISSION_TEXT_MAX_LENGTH,
}),
newProfileEnabled: toggle({
label: "labels.profileNewProfileEnabled",
bottomText: "bottomTexts.profileNewProfileEnabled",
}),
});
export const editHighlightsActionSchema = z.object({
[HIGHLIGHT_CHECKBOX_NAME]: z.optional(
z.union([z.array(z.string()), z.string()]),
),
[HIGHLIGHT_TOURNAMENT_CHECKBOX_NAME]: z.optional(
z.union([z.array(z.string()), z.string()]),
),
});
export const addModNoteSchema = z.object({
_action: stringConstant("ADD_MOD_NOTE"),
value: textAreaRequired({
label: "labels.text",
bottomText: "bottomTexts.modNote",
maxLength: USER.MOD_NOTE_MAX_LENGTH,
}),
});
const deleteModNoteSchema = z.object({
_action: _action("DELETE_MOD_NOTE"),
noteId: id,
});
export const adminTabActionSchema = z.union([
addModNoteSchema,
deleteModNoteSchema,
]);
export const userResultsPageSearchParamsSchema = z.object({
all: z.stringbool().catch(false),
page: z.coerce.number().min(1).max(1_000).catch(1),
});
const widgetSettingsSchemas = allWidgetsFlat().map((widget) => {
if ("schema" in widget) {
return z.object({
id: z.literal(widget.id),
settings: widget.schema,
});
}
return z.object({
id: z.literal(widget.id),
});
});
const widgetSettingsSchema = z.union(widgetSettingsSchemas);
export const widgetsEditSchema = z.object({
widgets: z.preprocess(
safeJSONParse,
z
.array(widgetSettingsSchema)
.max(USER.MAX_MAIN_WIDGETS + USER.MAX_SIDE_WIDGETS)
.refine((widgets) => {
let mainCount = 0;
let sideCount = 0;
for (const w of widgets) {
const def = findWidgetById(w.id);
if (!def) return false;
if (def.slot === "main") mainCount++;
else sideCount++;
}
return (
mainCount <= USER.MAX_MAIN_WIDGETS &&
sideCount <= USER.MAX_SIDE_WIDGETS
);
}),
),
});
const headGearIdSchema = z
.number()
.nullable()
.refine(
(val) =>
val === null || headGearIds.includes(val as (typeof headGearIds)[number]),
);
const clothesGearIdSchema = z
.number()
.nullable()
.refine(
(val) =>
val === null ||
clothesGearIds.includes(val as (typeof clothesGearIds)[number]),
);
const shoesGearIdSchema = z
.number()
.nullable()
.refine(
(val) =>
val === null ||
shoesGearIds.includes(val as (typeof shoesGearIds)[number]),
);
const abilitiesSchema = z.tuple([
z.tuple([
headMainSlotAbility,
stackableAbility,
stackableAbility,
stackableAbility,
]),
z.tuple([
clothesMainSlotAbility,
stackableAbility,
stackableAbility,
stackableAbility,
]),
z.tuple([
shoesMainSlotAbility,
stackableAbility,
stackableAbility,
stackableAbility,
]),
]);
const modeItems = [
{ label: "modes.TW" as const, value: "TW" as const },
{ label: "modes.SZ" as const, value: "SZ" as const },
{ label: "modes.TC" as const, value: "TC" as const },
{ label: "modes.RM" as const, value: "RM" as const },
{ label: "modes.CB" as const, value: "CB" as const },
];
export const newBuildBaseSchema = z.object({
buildToEditId: idConstantOptional(),
weapons: weaponPool({
label: "labels.buildWeapons",
minCount: 1,
maxCount: 5,
disableSorting: true,
disableFavorites: true,
}),
head: customField({ initialValue: null }, headGearIdSchema),
clothes: customField({ initialValue: null }, clothesGearIdSchema),
shoes: customField({ initialValue: null }, shoesGearIdSchema),
abilities: customField(
{
initialValue: [
["UNKNOWN", "UNKNOWN", "UNKNOWN", "UNKNOWN"],
["UNKNOWN", "UNKNOWN", "UNKNOWN", "UNKNOWN"],
["UNKNOWN", "UNKNOWN", "UNKNOWN", "UNKNOWN"],
],
},
abilitiesSchema,
),
title: textFieldRequired({
label: "labels.buildTitle",
maxLength: 50,
}),
description: textAreaOptional({
label: "labels.description",
maxLength: 280,
}),
modes: checkboxGroup({
label: "labels.buildModes",
items: modeItems,
}),
private: toggle({
label: "labels.buildPrivate",
bottomText: "bottomTexts.buildPrivate",
}),
});
function validateGearAllOrNone(data: {
head: number | null;
clothes: number | null;
shoes: number | null;
}) {
const gearFilled = [data.head, data.clothes, data.shoes].filter(
(g) => g !== null,
);
return gearFilled.length === 0 || gearFilled.length === 3;
}
export const gearAllOrNoneRefine = {
fn: validateGearAllOrNone,
opts: { message: "forms:errors.gearAllOrNone", path: ["head"] },
};
export const newBuildSchema = newBuildBaseSchema.refine(
gearAllOrNoneRefine.fn,
gearAllOrNoneRefine.opts,
);