mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-04-25 15:56:19 -05:00
Builds new cache style + actions/loaders folder convention initial
This commit is contained in:
parent
246a94279c
commit
a804894870
|
|
@ -1 +1,3 @@
|
|||
export const MAX_BUILD_FILTERS = 6;
|
||||
|
||||
export const FILTER_SEARCH_PARAM_KEY = "f";
|
||||
|
|
|
|||
22
app/features/builds/core/cached-builds.server.ts
Normal file
22
app/features/builds/core/cached-builds.server.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
63
app/features/builds/loaders/builds.$slug.server.tsx
Normal file
63
app/features/builds/loaders/builds.$slug.server.tsx
Normal 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 : [],
|
||||
};
|
||||
};
|
||||
|
|
@ -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,
|
||||
}: {
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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;
|
||||
};
|
||||
|
|
@ -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]>,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
|
@ -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>,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user