Builds new cache style + actions/loaders folder convention initial

This commit is contained in:
Kalle 2024-04-07 11:31:36 +03:00
parent 246a94279c
commit a804894870
12 changed files with 433 additions and 352 deletions

View File

@ -1 +1,3 @@
export const MAX_BUILD_FILTERS = 6;
export const FILTER_SEARCH_PARAM_KEY = "f";

View File

@ -0,0 +1,22 @@
import type { MainWeaponId } from "~/modules/in-game-lists";
import { cache, syncCached } from "~/utils/cache.server";
import { buildsByWeaponId } from "../queries/buildsBy.server";
import { BUILDS_PAGE_MAX_BUILDS } from "~/constants";
const buildsCacheKey = (weaponSplId: MainWeaponId) => `builds-${weaponSplId}`;
export function cachedBuildsByWeaponId(weaponSplId: MainWeaponId) {
return syncCached(buildsCacheKey(weaponSplId), () =>
buildsByWeaponId({
weaponId: weaponSplId,
limit: BUILDS_PAGE_MAX_BUILDS,
}),
);
}
export function refreshBuildsCacheByWeaponSplIds(weaponSplIds: MainWeaponId[]) {
for (const weaponSplId of weaponSplIds) {
cache.delete(buildsCacheKey(weaponSplId));
cachedBuildsByWeaponId(weaponSplId);
}
}

View File

@ -0,0 +1,63 @@
import { type LoaderFunctionArgs } from "@remix-run/node";
import { BUILDS_PAGE_BATCH_SIZE, BUILDS_PAGE_MAX_BUILDS } from "~/constants";
import { i18next } from "~/modules/i18n/i18next.server";
import { weaponIdIsNotAlt } from "~/modules/in-game-lists";
import { makeTitle } from "~/utils/strings";
import { weaponNameSlugToId } from "~/utils/unslugify.server";
import { mySlugify } from "~/utils/urls";
import { buildFiltersSearchParams } from "../builds-schemas.server";
import { cachedBuildsByWeaponId } from "../core/cached-builds.server";
import { filterBuilds } from "../core/filter.server";
import { FILTER_SEARCH_PARAM_KEY } from "../builds-constants";
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
const t = await i18next.getFixedT(request, ["weapons", "common"], {
lng: "en",
});
const weaponId = weaponNameSlugToId(params["slug"]);
if (typeof weaponId !== "number" || !weaponIdIsNotAlt(weaponId)) {
throw new Response(null, { status: 404 });
}
const url = new URL(request.url);
const limit = Math.min(
Number(url.searchParams.get("limit") ?? BUILDS_PAGE_BATCH_SIZE),
BUILDS_PAGE_MAX_BUILDS,
);
const weaponName = t(`weapons:MAIN_${weaponId}`);
const slug = mySlugify(t(`weapons:MAIN_${weaponId}`, { lng: "en" }));
const cachedBuilds = cachedBuildsByWeaponId(weaponId);
const rawFilters = url.searchParams.get(FILTER_SEARCH_PARAM_KEY);
const filters = buildFiltersSearchParams.safeParse(rawFilters ?? "[]");
if (!filters.success) {
console.error(
"Invalid filters",
JSON.stringify(filters.error.errors, null, 2),
);
}
const filteredBuilds =
filters.success && filters.data && filters.data.length > 0
? filterBuilds({
builds: cachedBuilds,
filters: filters.data,
count: limit,
})
: cachedBuilds.slice(0, limit);
return {
weaponId,
weaponName,
title: makeTitle([weaponName, t("common:pages.builds")]),
builds: filteredBuilds,
limit,
slug,
filters: filters.success ? filters.data : [],
};
};

View File

@ -1,9 +1,4 @@
import { cachified } from "@epic-web/cachified";
import {
type LoaderFunctionArgs,
type MetaFunction,
type SerializeFrom,
} from "@remix-run/node";
import { type MetaFunction, type SerializeFrom } from "@remix-run/node";
import {
useLoaderData,
useSearchParams,
@ -26,35 +21,27 @@ import { MapIcon } from "~/components/icons/Map";
import {
BUILDS_PAGE_BATCH_SIZE,
BUILDS_PAGE_MAX_BUILDS,
ONE_HOUR_IN_MS,
PATCHES,
} from "~/constants";
import { i18next } from "~/modules/i18n/i18next.server";
import { weaponIdIsNotAlt } from "~/modules/in-game-lists";
import { cache, ttl } from "~/utils/cache.server";
import { safeJSONParse } from "~/utils/json";
import { type SendouRouteHandle } from "~/utils/remix";
import { makeTitle } from "~/utils/strings";
import type { Unpacked } from "~/utils/types";
import { weaponNameSlugToId } from "~/utils/unslugify.server";
import {
BUILDS_PAGE,
mySlugify,
navIconUrl,
outlinedMainWeaponImageUrl,
weaponBuildPage,
} from "~/utils/urls";
import { MAX_BUILD_FILTERS } from "../builds-constants";
import {
buildFiltersSearchParams,
type BuildFiltersFromSearchParams,
} from "../builds-schemas.server";
FILTER_SEARCH_PARAM_KEY,
MAX_BUILD_FILTERS,
} from "../builds-constants";
import { type BuildFiltersFromSearchParams } from "../builds-schemas.server";
import type { AbilityBuildFilter, BuildFilter } from "../builds-types";
import { filterBuilds } from "../core/filter.server";
import { buildsByWeaponId } from "../queries/buildsBy.server";
import { FilterSection } from "../components/FilterSection";
const FILTER_SEARCH_PARAM_KEY = "f";
import { loader } from "../loaders/builds.$slug.server";
export { loader };
const filterOutMeaninglessFilters = (
filter: Unpacked<BuildFiltersFromSearchParams>,
@ -163,69 +150,6 @@ export const handle: SendouRouteHandle = {
},
};
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
const t = await i18next.getFixedT(request, ["weapons", "common"], {
lng: "en",
});
const weaponId = weaponNameSlugToId(params["slug"]);
if (typeof weaponId !== "number" || !weaponIdIsNotAlt(weaponId)) {
throw new Response(null, { status: 404 });
}
const url = new URL(request.url);
const limit = Math.min(
Number(url.searchParams.get("limit") ?? BUILDS_PAGE_BATCH_SIZE),
BUILDS_PAGE_MAX_BUILDS,
);
const weaponName = t(`weapons:MAIN_${weaponId}`);
const slug = mySlugify(t(`weapons:MAIN_${weaponId}`, { lng: "en" }));
const cachedBuilds = await cachified({
key: `builds-${weaponId}`,
cache,
ttl: ttl(ONE_HOUR_IN_MS),
// eslint-disable-next-line @typescript-eslint/require-await
async getFreshValue() {
return buildsByWeaponId({
weaponId,
limit: BUILDS_PAGE_MAX_BUILDS,
});
},
});
const rawFilters = url.searchParams.get(FILTER_SEARCH_PARAM_KEY);
const filters = buildFiltersSearchParams.safeParse(rawFilters ?? "[]");
if (!filters.success) {
console.error(
"Invalid filters",
JSON.stringify(filters.error.errors, null, 2),
);
}
const filteredBuilds =
filters.success && filters.data && filters.data.length > 0
? filterBuilds({
builds: cachedBuilds,
filters: filters.data,
count: limit,
})
: cachedBuilds.slice(0, limit);
return {
weaponId,
weaponName,
title: makeTitle([weaponName, t("common:pages.builds")]),
builds: filteredBuilds,
limit,
slug,
filters: filters.success ? filters.data : [],
};
};
const BuildCards = React.memo(function BuildCards({
data,
}: {

View File

@ -105,6 +105,7 @@ import { CrossIcon } from "~/components/icons/Cross";
import { SPLATTERCOLOR_SCREEN_ID } from "~/modules/in-game-lists/weapon-ids";
import { currentOrPreviousSeason, currentSeason } from "~/features/mmr/season";
import { refreshUserSkills } from "~/features/mmr/tiered.server";
import { logger } from "~/utils/logger";
import "../q.css";
@ -289,7 +290,11 @@ export const action = async ({ request, params }: ActionFunctionArgs) => {
if (clearCaches) {
// this is kind of useless to do when admin reports since skills don't change
// but it's not the most common case so it's ok
refreshUserSkills(currentOrPreviousSeason(new Date())!.nth);
try {
refreshUserSkills(currentOrPreviousSeason(new Date())!.nth);
} catch (error) {
logger.warn("Error refreshing user skills", error);
}
refreshStreamsCache();
}

View File

@ -177,7 +177,11 @@ export const action: ActionFunction = async ({ params, request }) => {
});
if (tournament.ranked) {
refreshUserSkills(season!);
try {
refreshUserSkills(season!);
} catch (error) {
logger.warn("Error refreshing user skills", error);
}
}
break;

View File

@ -0,0 +1,179 @@
import { redirect, type ActionFunction } from "@remix-run/node";
import { z } from "zod";
import { BUILD } from "~/constants";
import { requireUser } from "~/features/auth/core/user.server";
import type { BuildWeaponWithTop500Info } from "~/features/builds";
import { buildsByUserId } from "~/features/builds";
import * as BuildRepository from "~/features/builds/BuildRepository.server";
import { refreshBuildsCacheByWeaponSplIds } from "~/features/builds/core/cached-builds.server";
import {
clothesGearIds,
headGearIds,
modesShort,
shoesGearIds,
} from "~/modules/in-game-lists";
import type {
BuildAbilitiesTuple,
MainWeaponId,
} from "~/modules/in-game-lists/types";
import { removeDuplicates } from "~/utils/arrays";
import { logger } from "~/utils/logger";
import { parseRequestFormData, validate } from "~/utils/remix";
import type { Nullish } from "~/utils/types";
import { userBuildsPage } from "~/utils/urls";
import {
actualNumber,
checkboxValueToBoolean,
checkboxValueToDbBoolean,
clothesMainSlotAbility,
dbBoolean,
falsyToNull,
headMainSlotAbility,
id,
processMany,
removeDuplicates as removeDuplicatesZod,
safeJSONParse,
shoesMainSlotAbility,
stackableAbility,
toArray,
weaponSplId,
} from "~/utils/zod";
export const action: ActionFunction = async ({ request }) => {
const user = await requireUser(request);
const data = await parseRequestFormData({
request,
schema: newBuildActionSchema,
});
const usersBuilds = buildsByUserId({
userId: user.id,
loggedInUserId: user.id,
});
if (usersBuilds.length >= BUILD.MAX_COUNT) {
throw new Response("Max amount of builds reached", { status: 400 });
}
validate(
!data.buildToEditId ||
usersBuilds.some((build) => build.id === data.buildToEditId),
);
const commonArgs = {
title: data.title,
description: data.description,
abilities: data.abilities as BuildAbilitiesTuple,
headGearSplId: data["HEAD[value]"],
clothesGearSplId: data["CLOTHES[value]"],
shoesGearSplId: data["SHOES[value]"],
modes: modesShort.filter((mode) => data[mode]),
weaponSplIds: data["weapon[value]"],
ownerId: user.id,
private: data.private,
};
if (data.buildToEditId) {
await BuildRepository.update({ id: data.buildToEditId, ...commonArgs });
} else {
await BuildRepository.create(commonArgs);
}
try {
refreshCache({
newWeaponSplIds: commonArgs.weaponSplIds,
oldBuilds: usersBuilds,
buildToEditId: data.buildToEditId,
});
} catch (error) {
logger.warn("Error refreshing builds cache", error);
}
return redirect(userBuildsPage(user));
};
const newBuildActionSchema = z.object({
buildToEditId: z.preprocess(actualNumber, id.nullish()),
title: z.string().min(BUILD.TITLE_MIN_LENGTH).max(BUILD.TITLE_MAX_LENGTH),
description: z.preprocess(
falsyToNull,
z.string().max(BUILD.DESCRIPTION_MAX_LENGTH).nullable(),
),
TW: z.preprocess(checkboxValueToBoolean, z.boolean()),
SZ: z.preprocess(checkboxValueToBoolean, z.boolean()),
TC: z.preprocess(checkboxValueToBoolean, z.boolean()),
RM: z.preprocess(checkboxValueToBoolean, z.boolean()),
CB: z.preprocess(checkboxValueToBoolean, z.boolean()),
private: z.preprocess(checkboxValueToDbBoolean, dbBoolean),
"weapon[value]": z.preprocess(
processMany(toArray, removeDuplicatesZod),
z.array(weaponSplId).min(1).max(BUILD.MAX_WEAPONS_COUNT),
),
"HEAD[value]": z.preprocess(
actualNumber,
z
.number()
.refine((val) =>
headGearIds.includes(val as (typeof headGearIds)[number]),
),
),
"CLOTHES[value]": z.preprocess(
actualNumber,
z
.number()
.refine((val) =>
clothesGearIds.includes(val as (typeof clothesGearIds)[number]),
),
),
"SHOES[value]": z.preprocess(
actualNumber,
z
.number()
.refine((val) =>
shoesGearIds.includes(val as (typeof shoesGearIds)[number]),
),
),
abilities: z.preprocess(
safeJSONParse,
z.tuple([
z.tuple([
headMainSlotAbility,
stackableAbility,
stackableAbility,
stackableAbility,
]),
z.tuple([
clothesMainSlotAbility,
stackableAbility,
stackableAbility,
stackableAbility,
]),
z.tuple([
shoesMainSlotAbility,
stackableAbility,
stackableAbility,
stackableAbility,
]),
]),
),
});
function refreshCache({
newWeaponSplIds,
oldBuilds,
buildToEditId,
}: {
newWeaponSplIds: Array<MainWeaponId>;
buildToEditId: Nullish<number>;
oldBuilds: Array<{ id: number; weapons: BuildWeaponWithTop500Info[] }>;
}) {
const oldBuildWeapons =
oldBuilds.find((build) => build.id === buildToEditId)?.weapons ?? [];
const allWeaponSplIds = [
...newWeaponSplIds,
...oldBuildWeapons.map(({ weaponSplId }) => weaponSplId),
];
const dedupedWeaponSplIds = removeDuplicates(allWeaponSplIds);
refreshBuildsCacheByWeaponSplIds(dedupedWeaponSplIds);
}

View File

@ -0,0 +1,43 @@
import type { ActionFunction } from "@remix-run/node";
import { z } from "zod";
import { requireUserId } from "~/features/auth/core/user.server";
import * as BuildRepository from "~/features/builds/BuildRepository.server";
import { refreshBuildsCacheByWeaponSplIds } from "~/features/builds/core/cached-builds.server";
import { logger } from "~/utils/logger";
import { parseRequestFormData, validate } from "~/utils/remix";
import { actualNumber, id } from "~/utils/zod";
const buildsActionSchema = z.object({
buildToDeleteId: z.preprocess(actualNumber, id),
});
export const action: ActionFunction = async ({ request }) => {
const user = await requireUserId(request);
const data = await parseRequestFormData({
request,
schema: buildsActionSchema,
});
const usersBuilds = await BuildRepository.allByUserId({
userId: user.id,
showPrivate: true,
});
const buildToDelete = usersBuilds.find(
(build) => build.id === data.buildToDeleteId,
);
validate(buildToDelete);
await BuildRepository.deleteById(data.buildToDeleteId);
try {
refreshBuildsCacheByWeaponSplIds(
buildToDelete.weapons.map((weapon) => weapon.weaponSplId),
);
} catch (error) {
logger.warn("Error refreshing builds cache", error);
}
return null;
};

View File

@ -0,0 +1,45 @@
import { json, type LoaderFunctionArgs } from "@remix-run/node";
import { z } from "zod";
import { requireUserId } from "~/features/auth/core/user.server";
import { buildsByUserId } from "~/features/builds";
import { type Ability } from "~/modules/in-game-lists";
import { actualNumber, id } from "~/utils/zod";
const newBuildLoaderParamsSchema = z.object({
buildId: z.preprocess(actualNumber, id),
});
export const loader = async ({ request }: LoaderFunctionArgs) => {
const user = await requireUserId(request);
const url = new URL(request.url);
const params = newBuildLoaderParamsSchema.safeParse(
Object.fromEntries(url.searchParams),
);
const usersBuilds = buildsByUserId({
userId: user.id,
loggedInUserId: user.id,
});
const buildToEdit = usersBuilds.find(
(b) => params.success && b.id === params.data.buildId,
);
return json({
buildToEdit,
gearIdToAbilities: resolveGearIdToAbilities(),
});
function resolveGearIdToAbilities() {
return usersBuilds.reduce(
(acc, build) => {
acc[`HEAD_${build.headGearSplId}`] = build.abilities[0];
acc[`CLOTHES_${build.clothesGearSplId}`] = build.abilities[1];
acc[`SHOES_${build.shoesGearSplId}`] = build.abilities[2];
return acc;
},
{} as Record<string, [Ability, Ability, Ability, Ability]>,
);
}
};

View File

@ -0,0 +1,42 @@
import type { LoaderFunctionArgs } from "@remix-run/node";
import { getUserId } from "~/features/auth/core/user.server";
import * as BuildRepository from "~/features/builds/BuildRepository.server";
import * as UserRepository from "~/features/user-page/UserRepository.server";
import type { MainWeaponId } from "~/modules/in-game-lists";
import { notFoundIfFalsy, privatelyCachedJson } from "~/utils/remix";
import { userParamsSchema } from "../user-page-schemas.server";
export const loader = async ({ params, request }: LoaderFunctionArgs) => {
const loggedInUser = await getUserId(request);
const { identifier } = userParamsSchema.parse(params);
const user = notFoundIfFalsy(
await UserRepository.identifierToUserId(identifier),
);
const builds = await BuildRepository.allByUserId({
userId: user.id,
showPrivate: loggedInUser?.id === user.id,
});
if (builds.length === 0 && loggedInUser?.id !== user.id) {
throw new Response(null, { status: 404 });
}
return privatelyCachedJson({
builds,
weaponCounts: calculateWeaponCounts(),
});
function calculateWeaponCounts() {
return builds.reduce(
(acc, build) => {
for (const weapon of build.weapons) {
acc[weapon.weaponSplId] = (acc[weapon.weaponSplId] ?? 0) + 1;
}
return acc;
},
{} as Record<MainWeaponId, number>,
);
}
};

View File

@ -1,221 +1,40 @@
import {
json,
redirect,
type ActionFunction,
type LoaderFunctionArgs,
} from "@remix-run/node";
import { Form, useLoaderData, useSearchParams } from "@remix-run/react";
import clone from "just-clone";
import * as React from "react";
import { z } from "zod";
import { useTranslation } from "react-i18next";
import { AbilitiesSelector } from "~/components/AbilitiesSelector";
import { Button } from "~/components/Button";
import { GearCombobox, WeaponCombobox } from "~/components/Combobox";
import { FormMessage } from "~/components/FormMessage";
import { CrossIcon } from "~/components/icons/Cross";
import { PlusIcon } from "~/components/icons/Plus";
import { Image } from "~/components/Image";
import { Label } from "~/components/Label";
import { RequiredHiddenInput } from "~/components/RequiredHiddenInput";
import { SubmitButton } from "~/components/SubmitButton";
import { CrossIcon } from "~/components/icons/Cross";
import { PlusIcon } from "~/components/icons/Plus";
import { BUILD } from "~/constants";
import type { GearType } from "~/db/types";
import {
validatedBuildFromSearchParams,
validatedWeaponIdFromSearchParams,
} from "~/features/build-analyzer";
import { buildsByUserId } from "~/features/builds";
import { useTranslation } from "react-i18next";
import { requireUser } from "~/features/auth/core/user.server";
import { requireUserId } from "~/features/auth/core/user.server";
import {
type Ability,
clothesGearIds,
headGearIds,
modesShort,
shoesGearIds,
} from "~/modules/in-game-lists";
import { modesShort } from "~/modules/in-game-lists";
import { rankedModesShort } from "~/modules/in-game-lists/modes";
import type {
BuildAbilitiesTuple,
BuildAbilitiesTupleWithUnknown,
MainWeaponId,
} from "~/modules/in-game-lists/types";
import {
parseRequestFormData,
validate,
type SendouRouteHandle,
} from "~/utils/remix";
import { modeImageUrl, userBuildsPage } from "~/utils/urls";
import {
actualNumber,
checkboxValueToBoolean,
checkboxValueToDbBoolean,
clothesMainSlotAbility,
dbBoolean,
falsyToNull,
headMainSlotAbility,
id,
processMany,
removeDuplicates,
safeJSONParse,
shoesMainSlotAbility,
stackableAbility,
toArray,
weaponSplId,
} from "~/utils/zod";
import * as BuildRepository from "~/features/builds/BuildRepository.server";
import { type SendouRouteHandle } from "~/utils/remix";
import { modeImageUrl } from "~/utils/urls";
const newBuildActionSchema = z.object({
buildToEditId: z.preprocess(actualNumber, id.nullish()),
title: z.string().min(BUILD.TITLE_MIN_LENGTH).max(BUILD.TITLE_MAX_LENGTH),
description: z.preprocess(
falsyToNull,
z.string().max(BUILD.DESCRIPTION_MAX_LENGTH).nullable(),
),
TW: z.preprocess(checkboxValueToBoolean, z.boolean()),
SZ: z.preprocess(checkboxValueToBoolean, z.boolean()),
TC: z.preprocess(checkboxValueToBoolean, z.boolean()),
RM: z.preprocess(checkboxValueToBoolean, z.boolean()),
CB: z.preprocess(checkboxValueToBoolean, z.boolean()),
private: z.preprocess(checkboxValueToDbBoolean, dbBoolean),
"weapon[value]": z.preprocess(
processMany(toArray, removeDuplicates),
z.array(weaponSplId).min(1).max(BUILD.MAX_WEAPONS_COUNT),
),
"HEAD[value]": z.preprocess(
actualNumber,
z
.number()
.refine((val) =>
headGearIds.includes(val as (typeof headGearIds)[number]),
),
),
"CLOTHES[value]": z.preprocess(
actualNumber,
z
.number()
.refine((val) =>
clothesGearIds.includes(val as (typeof clothesGearIds)[number]),
),
),
"SHOES[value]": z.preprocess(
actualNumber,
z
.number()
.refine((val) =>
shoesGearIds.includes(val as (typeof shoesGearIds)[number]),
),
),
abilities: z.preprocess(
safeJSONParse,
z.tuple([
z.tuple([
headMainSlotAbility,
stackableAbility,
stackableAbility,
stackableAbility,
]),
z.tuple([
clothesMainSlotAbility,
stackableAbility,
stackableAbility,
stackableAbility,
]),
z.tuple([
shoesMainSlotAbility,
stackableAbility,
stackableAbility,
stackableAbility,
]),
]),
),
});
export const action: ActionFunction = async ({ request }) => {
const user = await requireUser(request);
const data = await parseRequestFormData({
request,
schema: newBuildActionSchema,
});
const usersBuilds = buildsByUserId({
userId: user.id,
loggedInUserId: user.id,
});
if (usersBuilds.length >= BUILD.MAX_COUNT) {
throw new Response("Max amount of builds reached", { status: 400 });
}
validate(
!data.buildToEditId ||
usersBuilds.some((build) => build.id === data.buildToEditId),
);
const commonArgs = {
title: data.title,
description: data.description,
abilities: data.abilities as BuildAbilitiesTuple,
headGearSplId: data["HEAD[value]"],
clothesGearSplId: data["CLOTHES[value]"],
shoesGearSplId: data["SHOES[value]"],
modes: modesShort.filter((mode) => data[mode]),
weaponSplIds: data["weapon[value]"],
ownerId: user.id,
private: data.private,
};
if (data.buildToEditId) {
await BuildRepository.update({ id: data.buildToEditId, ...commonArgs });
} else {
await BuildRepository.create(commonArgs);
}
throw redirect(userBuildsPage(user));
};
import { loader } from "../loaders/u.$identifier.builds.new.server";
import { action } from "../actions/u.$identifier.builds.new.server";
export { loader, action };
export const handle: SendouRouteHandle = {
i18n: ["weapons", "builds", "gear"],
};
const newBuildLoaderParamsSchema = z.object({
buildId: z.preprocess(actualNumber, id),
});
export const loader = async ({ request }: LoaderFunctionArgs) => {
const user = await requireUserId(request);
const url = new URL(request.url);
const params = newBuildLoaderParamsSchema.safeParse(
Object.fromEntries(url.searchParams),
);
const usersBuilds = buildsByUserId({
userId: user.id,
loggedInUserId: user.id,
});
const buildToEdit = usersBuilds.find(
(b) => params.success && b.id === params.data.buildId,
);
return json({
buildToEdit,
gearIdToAbilities: resolveGearIdToAbilities(),
});
function resolveGearIdToAbilities() {
return usersBuilds.reduce(
(acc, build) => {
acc[`HEAD_${build.headGearSplId}`] = build.abilities[0];
acc[`CLOTHES_${build.clothesGearSplId}`] = build.abilities[1];
acc[`SHOES_${build.shoesGearSplId}`] = build.abilities[2];
return acc;
},
{} as Record<string, [Ability, Ability, Ability, Ability]>,
);
}
};
export default function NewBuildPage() {
const { buildToEdit } = useLoaderData<typeof loader>();
const { t } = useTranslation();

View File

@ -1,93 +1,26 @@
import type { ActionFunction, LoaderFunctionArgs } from "@remix-run/node";
import { useLoaderData, useMatches } from "@remix-run/react";
import { z } from "zod";
import { useTranslation } from "react-i18next";
import { BuildCard } from "~/components/BuildCard";
import { Button, LinkButton } from "~/components/Button";
import { WeaponImage } from "~/components/Image";
import { BUILD } from "~/constants";
import { useTranslation } from "react-i18next";
import { useUser } from "~/features/auth/core/user";
import { getUserId, requireUserId } from "~/features/auth/core/user.server";
import { atOrError } from "~/utils/arrays";
import {
notFoundIfFalsy,
parseRequestFormData,
privatelyCachedJson,
validate,
type SendouRouteHandle,
} from "~/utils/remix";
import { userNewBuildPage } from "~/utils/urls";
import { actualNumber, id } from "~/utils/zod";
import { type UserPageLoaderData } from "./u.$identifier";
import { useSearchParamState } from "~/hooks/useSearchParamState";
import type { MainWeaponId } from "~/modules/in-game-lists";
import { mainWeaponIds } from "~/modules/in-game-lists";
import { WeaponImage } from "~/components/Image";
import { useSearchParamState } from "~/hooks/useSearchParamState";
import * as BuildRepository from "~/features/builds/BuildRepository.server";
import * as UserRepository from "~/features/user-page/UserRepository.server";
import { userParamsSchema } from "../user-page-schemas.server";
import { atOrError } from "~/utils/arrays";
import { type SendouRouteHandle } from "~/utils/remix";
import { userNewBuildPage } from "~/utils/urls";
import { type UserPageLoaderData } from "./u.$identifier";
const buildsActionSchema = z.object({
buildToDeleteId: z.preprocess(actualNumber, id),
});
export const action: ActionFunction = async ({ request }) => {
const user = await requireUserId(request);
const data = await parseRequestFormData({
request,
schema: buildsActionSchema,
});
const usersBuilds = await BuildRepository.allByUserId({
userId: user.id,
showPrivate: true,
});
validate(usersBuilds.some((build) => build.id === data.buildToDeleteId));
await BuildRepository.deleteById(data.buildToDeleteId);
return null;
};
import { loader } from "../loaders/u.$identifier.builds.server";
import { action } from "../actions/u.$identifier.builds.server";
export { loader, action };
export const handle: SendouRouteHandle = {
i18n: ["weapons", "builds", "gear"],
};
export const loader = async ({ params, request }: LoaderFunctionArgs) => {
const loggedInUser = await getUserId(request);
const { identifier } = userParamsSchema.parse(params);
const user = notFoundIfFalsy(
await UserRepository.identifierToUserId(identifier),
);
const builds = await BuildRepository.allByUserId({
userId: user.id,
showPrivate: loggedInUser?.id === user.id,
});
if (builds.length === 0 && loggedInUser?.id !== user.id) {
throw new Response(null, { status: 404 });
}
return privatelyCachedJson({
builds,
weaponCounts: calculateWeaponCounts(),
});
function calculateWeaponCounts() {
return builds.reduce(
(acc, build) => {
for (const weapon of build.weapons) {
acc[weapon.weaponSplId] = (acc[weapon.weaponSplId] ?? 0) + 1;
}
return acc;
},
{} as Record<MainWeaponId, number>,
);
}
};
export default function UserBuildsPage() {
const { t } = useTranslation("builds");
const user = useUser();