Sort user profile builds Closes #1233

This commit is contained in:
Kalle
2024-06-26 15:58:46 +03:00
parent 3b24f029e2
commit 093e4d2e66
14 changed files with 725 additions and 37 deletions

View File

@@ -210,7 +210,10 @@ export function BuildCard({ build, owner, canEdit = false }: BuildProps) {
</LinkButton>
<FormWithConfirm
dialogHeading={t("builds:deleteConfirm", { title })}
fields={[["buildToDeleteId", id]]}
fields={[
["buildToDeleteId", id],
["_action", "DELETE_BUILD"],
]}
>
<Button
className="build__small-text"

View File

@@ -0,0 +1,18 @@
export function SortIcon({ className }: { className?: string }) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
strokeWidth="1.5"
stroke="currentColor"
className={className}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M3 4.5h14.25M3 9h9.75M3 13.5h9.75m4.5-4.5v12m0 0-3.75-3.75M17.25 21 21 17.25"
/>
</svg>
);
}

View File

@@ -660,6 +660,20 @@ export interface UserMapModePreferences {
}>;
}
export const BUILD_SORT_IDENTIFIERS = [
"UPDATED_AT",
"TOP_500",
"WEAPON_POOL",
"WEAPON_IN_GAME_ORDER",
"ALPHABETICAL_TITLE",
"MODE",
"HEADGEAR_ID",
"CLOTHES_ID",
"SHOES_ID",
] as const;
export type BuildSort = (typeof BUILD_SORT_IDENTIFIERS)[number];
export interface User {
/** 1 = permabanned, timestamp = ban active till then */
banned: Generated<number | null>;
@@ -702,6 +716,7 @@ export interface User {
qWeaponPool: ColumnType<MainWeaponId[] | null, string | null, string | null>;
plusSkippedForSeasonNth: number | null;
noScreen: Generated<number>;
buildSorting: ColumnType<BuildSort[] | null, string | null, string | null>;
}
export interface UserResultHighlight {

View File

@@ -77,7 +77,6 @@ export async function allByUserId({
])
.where("Build.ownerId", "=", userId)
.$if(!showPrivate, (qb) => qb.where("Build.private", "=", 0))
.orderBy("Build.updatedAt", "desc")
.execute();
return rows.map((row) => ({

View File

@@ -2,7 +2,7 @@ import type { ExpressionBuilder, FunctionModule } from "kysely";
import { sql } from "kysely";
import { jsonArrayFrom, jsonObjectFrom } from "kysely/helpers/sqlite";
import { db, sql as dbDirect } from "~/db/sql";
import type { DB, TablesInsertable } from "~/db/tables";
import type { BuildSort, DB, TablesInsertable } from "~/db/tables";
import type { User } from "~/db/types";
import { dateToDatabaseTimestamp } from "~/utils/dates";
import type { CommonUser } from "~/utils/kysely.server";
@@ -31,6 +31,30 @@ export function identifierToUserId(identifier: string) {
return identifierToUserIdQuery(identifier).executeTakeFirst();
}
export async function identifierToBuildFields(identifier: string) {
const row = await identifierToUserIdQuery(identifier)
.select(({ eb }) => [
"User.buildSorting",
jsonArrayFrom(
eb
.selectFrom("UserWeapon")
.select("UserWeapon.weaponSplId")
.whereRef("UserWeapon.userId", "=", "User.id")
.orderBy("UserWeapon.order", "asc"),
).as("weapons"),
])
.executeTakeFirst();
if (!row) {
return null;
}
return {
...row,
weapons: row.weapons.map((row) => row.weaponSplId),
};
}
export function findByIdentifier(identifier: string) {
return identifierToUserIdQuery(identifier)
.leftJoin("PlusTier", "PlusTier.userId", "User.id")
@@ -60,6 +84,7 @@ export function findByIdentifier(identifier: string) {
"User.commissionText",
"User.commissionsOpen",
"User.patronTier",
"User.buildSorting",
"PlusTier.tier as plusTier",
jsonArrayFrom(
eb
@@ -513,6 +538,17 @@ export function updateResultHighlights(args: UpdateResultHighlightsArgs) {
});
}
export function updateBuildSorting({
userId,
buildSorting,
}: { userId: number; buildSorting: BuildSort[] | null }) {
return db
.updateTable("User")
.set({ buildSorting: buildSorting ? JSON.stringify(buildSorting) : null })
.where("id", "=", userId)
.execute();
}
export type UpdatePatronDataArgs = Array<
Pick<User, "discordId" | "patronTier" | "patronSince">
>;

View File

@@ -1,43 +1,83 @@
import type { ActionFunction } from "@remix-run/node";
import { type ActionFunction, redirect } from "@remix-run/node";
import { z } from "zod";
import { requireUserId } from "~/features/auth/core/user.server";
import { BUILD_SORT_IDENTIFIERS } from "~/db/tables";
import { requireUser } from "~/features/auth/core/user.server";
import * as BuildRepository from "~/features/builds/BuildRepository.server";
import { refreshBuildsCacheByWeaponSplIds } from "~/features/builds/core/cached-builds.server";
import * as UserRepository from "~/features/user-page/UserRepository.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),
});
import { assertUnreachable } from "~/utils/types";
import { userBuildsPage } from "~/utils/urls";
import {
_action,
actualNumber,
emptyArrayToNull,
id,
processMany,
removeDuplicates,
safeJSONParse,
} from "~/utils/zod";
export const action: ActionFunction = async ({ request }) => {
const user = await requireUserId(request);
const user = await requireUser(request);
const data = await parseRequestFormData({
request,
schema: buildsActionSchema,
});
const usersBuilds = await BuildRepository.allByUserId({
userId: user.id,
showPrivate: true,
});
switch (data._action) {
case "DELETE_BUILD": {
const usersBuilds = await BuildRepository.allByUserId({
userId: user.id,
showPrivate: true,
});
const buildToDelete = usersBuilds.find(
(build) => build.id === data.buildToDeleteId,
);
const buildToDelete = usersBuilds.find(
(build) => build.id === data.buildToDeleteId,
);
validate(buildToDelete);
validate(buildToDelete);
await BuildRepository.deleteById(data.buildToDeleteId);
await BuildRepository.deleteById(data.buildToDeleteId);
try {
refreshBuildsCacheByWeaponSplIds(
buildToDelete.weapons.map((weapon) => weapon.weaponSplId),
);
} catch (error) {
logger.warn("Error refreshing builds cache", error);
try {
refreshBuildsCacheByWeaponSplIds(
buildToDelete.weapons.map((weapon) => weapon.weaponSplId),
);
} catch (error) {
logger.warn("Error refreshing builds cache", error);
}
break;
}
case "UPDATE_SORTING": {
await UserRepository.updateBuildSorting({
userId: user.id,
buildSorting: data.buildSorting,
});
break;
}
default: {
assertUnreachable(data);
}
}
return null;
return redirect(userBuildsPage(user));
};
const buildsActionSchema = z.union([
z.object({
_action: _action("DELETE_BUILD"),
buildToDeleteId: z.preprocess(actualNumber, id),
}),
z.object({
_action: _action("UPDATE_SORTING"),
buildSorting: z.preprocess(
processMany(safeJSONParse, removeDuplicates, emptyArrayToNull),
z.array(z.enum(BUILD_SORT_IDENTIFIERS)).nullable(),
),
}),
]);

View File

@@ -0,0 +1,286 @@
import { suite } from "uvu";
import * as assert from "uvu/assert";
import { databaseTimestampNow } from "~/utils/dates";
import { sortBuilds } from "./build-sorting.server";
const BuildSorting = suite("sortBuilds()");
type BuildSortingBuildArg = Parameters<
typeof sortBuilds
>[number]["builds"][number];
const mockBuild = (
partialBuild: Partial<BuildSortingBuildArg>,
): BuildSortingBuildArg => {
return {
id: 0,
abilities: [
["ISM", "ISM", "ISM", "ISM"],
["ISM", "ISM", "ISM", "ISM"],
["ISM", "ISM", "ISM", "ISM"],
],
headGearSplId: 0,
clothesGearSplId: 0,
shoesGearSplId: 0,
description: null,
modes: ["SZ"],
private: 0,
title: "",
updatedAt: databaseTimestampNow(),
weapons: [{ weaponSplId: 0, maxPower: null, minRank: null }],
...partialBuild,
};
};
BuildSorting("sorts by UPDATED_AT", () => {
const builds = [
mockBuild({ id: 1, updatedAt: 1 }),
mockBuild({ id: 2, updatedAt: 3 }),
mockBuild({ id: 3, updatedAt: 2 }),
];
const sortedBuilds = sortBuilds({
builds,
buildSorting: ["UPDATED_AT"],
weaponPool: [],
});
assert.equal(sortedBuilds[0].id, 2);
assert.equal(sortedBuilds[1].id, 3);
});
BuildSorting("sorts by TOP_500", () => {
const builds = [
mockBuild({ id: 1 }),
mockBuild({
id: 2,
weapons: [{ weaponSplId: 1, maxPower: 3000, minRank: 1 }],
}),
mockBuild({
id: 3,
weapons: [
{ weaponSplId: 0, maxPower: null, minRank: null },
{ weaponSplId: 1, maxPower: 2900, minRank: 1 },
],
}),
];
const sortedBuilds = sortBuilds({
builds,
buildSorting: ["TOP_500"],
weaponPool: [],
});
// highest XP first
assert.equal(sortedBuilds[0].id, 2);
assert.equal(sortedBuilds[1].id, 3);
});
BuildSorting("sorts by WEAPON_POOL", () => {
const builds = [
mockBuild({
id: 1,
weapons: [{ weaponSplId: 1000, maxPower: null, minRank: null }],
}),
mockBuild({
id: 2,
weapons: [{ weaponSplId: 10, maxPower: null, minRank: null }],
}),
mockBuild({
id: 3,
weapons: [{ weaponSplId: 1, maxPower: null, minRank: null }],
}),
];
const sortedBuilds = sortBuilds({
builds,
buildSorting: ["WEAPON_POOL"],
weaponPool: [1, 10],
});
assert.equal(sortedBuilds[0].id, 3);
assert.equal(sortedBuilds[1].id, 2);
});
BuildSorting("sorts by ALPHABETICAL_TITLE", () => {
const builds = [
mockBuild({
id: 1,
title: "C",
}),
mockBuild({
id: 2,
title: "B",
}),
mockBuild({
id: 3,
title: "A",
}),
];
const sortedBuilds = sortBuilds({
builds,
buildSorting: ["ALPHABETICAL_TITLE"],
weaponPool: [],
});
assert.equal(sortedBuilds[0].id, 3);
assert.equal(sortedBuilds[1].id, 2);
});
BuildSorting("sorts by WEAPON_IN_GAME_ORDER", () => {
const builds = [
mockBuild({
id: 1,
weapons: [{ weaponSplId: 1, maxPower: null, minRank: null }],
}),
mockBuild({
id: 2,
weapons: [{ weaponSplId: 10, maxPower: null, minRank: null }],
}),
mockBuild({
id: 3,
weapons: [
{ weaponSplId: 1000, maxPower: null, minRank: null },
{ weaponSplId: 1, maxPower: null, minRank: null },
],
}),
];
const sortedBuilds = sortBuilds({
builds,
buildSorting: ["WEAPON_IN_GAME_ORDER"],
weaponPool: [],
});
assert.equal(sortedBuilds[2].id, 2);
});
BuildSorting("sorts by MODE", () => {
const builds = [
mockBuild({
id: 1,
modes: ["SZ"],
}),
mockBuild({
id: 2,
modes: ["CB"],
}),
mockBuild({
id: 3,
modes: ["SZ", "TC", "CB"],
}),
];
const sortedBuilds = sortBuilds({
builds,
buildSorting: ["MODE"],
weaponPool: [],
});
assert.equal(sortedBuilds[2].id, 2);
});
BuildSorting("sorts by MODE (no mode last)", () => {
const builds = [
mockBuild({
id: 1,
modes: [],
}),
mockBuild({
id: 2,
modes: ["CB"],
}),
mockBuild({
id: 3,
modes: ["SZ", "TC", "CB"],
}),
];
const sortedBuilds = sortBuilds({
builds,
buildSorting: ["MODE"],
weaponPool: [],
});
assert.equal(sortedBuilds[2].id, 1);
});
for (const identifier of ["HEADGEAR_ID", "CLOTHES_ID", "SHOES_ID"]) {
BuildSorting(`sorts by ${identifier}`, () => {
const key = (
{
HEADGEAR_ID: "headGearSplId",
CLOTHES_ID: "clothesGearSplId",
SHOES_ID: "shoesGearSplId",
} as const
)[identifier]!;
const builds = [
mockBuild({
id: 1,
[key]: 3,
}),
mockBuild({
id: 2,
[key]: 1,
}),
mockBuild({
id: 3,
[key]: 1,
}),
];
const sortedBuilds = sortBuilds({
builds,
buildSorting: [identifier as any],
weaponPool: [],
});
assert.equal(sortedBuilds[2].id, 1);
});
}
BuildSorting("sorts when buildSort not given", () => {
const builds = [mockBuild({}), mockBuild({}), mockBuild({})];
sortBuilds({
builds,
weaponPool: [],
buildSorting: null,
});
});
BuildSorting("sorts by UPDATED_AT and ALPHABETICAL_TITLE", () => {
const builds = [
mockBuild({ id: 1, updatedAt: 3, title: "C" }),
mockBuild({ id: 2, updatedAt: 2, title: "B" }),
mockBuild({ id: 3, updatedAt: 2, title: "A" }),
];
const sortedBuilds = sortBuilds({
builds,
buildSorting: ["UPDATED_AT", "ALPHABETICAL_TITLE"],
weaponPool: [],
});
assert.equal(sortedBuilds[0].id, 1);
assert.equal(sortedBuilds[1].id, 3);
});
BuildSorting("sorts by ALPHABETICAL_TITLE and UPDATED_AT (reverse)", () => {
const builds = [
mockBuild({ id: 1, updatedAt: 3, title: "C" }),
mockBuild({ id: 2, updatedAt: 2, title: "B" }),
mockBuild({ id: 3, updatedAt: 2, title: "A" }),
];
const sortedBuilds = sortBuilds({
builds,
buildSorting: ["ALPHABETICAL_TITLE", "UPDATED_AT"],
weaponPool: [],
});
assert.equal(sortedBuilds[0].id, 3);
});
BuildSorting.run();

View File

@@ -0,0 +1,77 @@
import type { BuildSort } from "~/db/tables";
import type * as BuildRepository from "~/features/builds/BuildRepository.server";
import { type MainWeaponId, modesShort } from "~/modules/in-game-lists";
import { DEFAULT_BUILD_SORT } from "../user-page-constants";
interface SortBuildsArgs {
builds: Awaited<ReturnType<typeof BuildRepository.allByUserId>>;
buildSorting: BuildSort[] | null;
weaponPool: MainWeaponId[];
}
export function sortBuilds({
builds,
buildSorting,
weaponPool,
}: SortBuildsArgs) {
const sorters: Record<
BuildSort,
(
a: SortBuildsArgs["builds"][number],
b: SortBuildsArgs["builds"][number],
) => number
> = {
ALPHABETICAL_TITLE: (a, b) => a.title.localeCompare(b.title),
WEAPON_IN_GAME_ORDER: (a, b) =>
Math.min(...a.weapons.map((wpn) => wpn.weaponSplId)) -
Math.min(...b.weapons.map((wpn) => wpn.weaponSplId)),
UPDATED_AT: (a, b) => b.updatedAt - a.updatedAt,
HEADGEAR_ID: (a, b) => a.headGearSplId - b.headGearSplId,
CLOTHES_ID: (a, b) => a.clothesGearSplId - b.clothesGearSplId,
SHOES_ID: (a, b) => a.shoesGearSplId - b.shoesGearSplId,
MODE: (a, b) => {
const aLowestModeIdx = modesShort.findIndex((mode) =>
a.modes?.includes(mode),
);
const bLowestModeIdx = modesShort.findIndex((mode) =>
b.modes?.includes(mode),
);
if (aLowestModeIdx === -1 && bLowestModeIdx !== -1) return 1;
if (aLowestModeIdx !== -1 && bLowestModeIdx === -1) return -1;
return aLowestModeIdx - bLowestModeIdx;
},
TOP_500: (a, b) => {
const aHas = a.weapons.some((wpn) => wpn.maxPower !== null);
const bHas = b.weapons.some((wpn) => wpn.maxPower !== null);
if (aHas && !bHas) return -1;
if (!aHas && bHas) return 1;
return 0;
},
WEAPON_POOL: (a, b) => {
const aLowestWeaponIdx = weaponPool.findIndex((wp) =>
a.weapons.map((wpn) => wpn.weaponSplId).includes(wp),
);
const bLowestWeaponIdx = weaponPool.findIndex((wp) =>
b.weapons.map((wpn) => wpn.weaponSplId).includes(wp),
);
if (aLowestWeaponIdx === -1 && bLowestWeaponIdx !== -1) return 1;
if (aLowestWeaponIdx !== -1 && bLowestWeaponIdx === -1) return -1;
return aLowestWeaponIdx - bLowestWeaponIdx;
},
};
return builds.slice().sort((a, b) => {
for (const sort of buildSorting ?? DEFAULT_BUILD_SORT) {
const result = sorters[sort](a, b);
if (result !== 0) return result;
}
return 0;
});
}

View File

@@ -4,13 +4,14 @@ 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 { sortBuilds } from "../core/build-sorting.server";
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),
await UserRepository.identifierToBuildFields(identifier),
);
const builds = await BuildRepository.allByUserId({
@@ -22,8 +23,15 @@ export const loader = async ({ params, request }: LoaderFunctionArgs) => {
throw new Response(null, { status: 404 });
}
return privatelyCachedJson({
const sortedBuilds = sortBuilds({
builds,
buildSorting: user.buildSorting,
weaponPool: user.weapons,
});
return privatelyCachedJson({
buildSorting: user.buildSorting,
builds: sortedBuilds,
weaponCounts: calculateWeaponCounts(),
});

View File

@@ -1,10 +1,19 @@
import { useLoaderData, useMatches } from "@remix-run/react";
import { useFetcher, useLoaderData, useMatches } from "@remix-run/react";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { BuildCard } from "~/components/BuildCard";
import { Button, LinkButton } from "~/components/Button";
import { Dialog } from "~/components/Dialog";
import { FormMessage } from "~/components/FormMessage";
import { WeaponImage } from "~/components/Image";
import { Popover } from "~/components/Popover";
import { SubmitButton } from "~/components/SubmitButton";
import { LockIcon } from "~/components/icons/Lock";
import { PlusIcon } from "~/components/icons/Plus";
import { SortIcon } from "~/components/icons/Sort";
import { TrashIcon } from "~/components/icons/Trash";
import { BUILD } from "~/constants";
import { BUILD_SORT_IDENTIFIERS, type BuildSort } from "~/db/tables";
import { useUser } from "~/features/auth/core/user";
import { useSearchParamState } from "~/hooks/useSearchParamState";
import type { MainWeaponId } from "~/modules/in-game-lists";
@@ -12,6 +21,7 @@ import { mainWeaponIds } from "~/modules/in-game-lists";
import { atOrError } from "~/utils/arrays";
import type { SendouRouteHandle } from "~/utils/remix";
import { userNewBuildPage } from "~/utils/urls";
import { DEFAULT_BUILD_SORT } from "../user-page-constants";
import type { UserPageLoaderData } from "./u.$identifier";
import { action } from "../actions/u.$identifier.builds.server";
@@ -39,6 +49,16 @@ export default function UserBuildsPage() {
});
const isOwnPage = user?.id === parentPageData.id;
const [changingSorting, setChangingSorting] = useSearchParamState({
defaultValue: false,
name: "sorting",
revive: (value) => value === "true" && isOwnPage,
});
const closeSortingDialog = React.useCallback(
() => setChangingSorting(false),
[setChangingSorting],
);
const builds =
weaponFilter === "ALL"
@@ -55,23 +75,40 @@ export default function UserBuildsPage() {
return (
<div className="stack lg">
{changingSorting ? (
<ChangeSortingDialog close={closeSortingDialog} />
) : null}
{isOwnPage && (
<div className="stack sm horizontal items-center justify-end">
<Button
onClick={() => setChangingSorting(true)}
size="tiny"
variant="outlined"
icon={<SortIcon />}
>
{t("user:builds.sorting.changeButton")}
</Button>
{data.builds.length < BUILD.MAX_COUNT ? (
<LinkButton
to={userNewBuildPage(parentPageData)}
size="tiny"
testId="new-build-button"
icon={<PlusIcon />}
>
{t("addBuild")}
</LinkButton>
) : (
<>
<span className="info-message">{t("reachBuildMaxCount")}</span>
<button className="tiny" disabled type="button">
{t("addBuild")}
</button>
</>
<Popover
buttonChildren={
<>
<PlusIcon className="button-icon" />
{t("addBuild")}
</>
}
triggerClassName="tiny"
>
{t("reachBuildMaxCount")}
</Popover>
)}
</div>
)}
@@ -169,3 +206,146 @@ function BuildsFilters({
</div>
);
}
const MISSING_SORT_VALUE = "null";
function ChangeSortingDialog({ close }: { close: () => void }) {
const data = useLoaderData<typeof loader>();
const [buildSorting, setBuildSorting] = React.useState<
ReadonlyArray<BuildSort | null>
>(() => {
if (!data.buildSorting) return [...DEFAULT_BUILD_SORT, null];
if (data.buildSorting.length === BUILD_SORT_IDENTIFIERS.length)
return data.buildSorting;
return [...data.buildSorting, null];
});
const { t } = useTranslation(["common", "user"]);
const fetcher = useFetcher();
React.useEffect(() => {
if (fetcher.state !== "loading") return;
close();
}, [fetcher.state, close]);
const canAddMoreSorting = buildSorting.length < BUILD_SORT_IDENTIFIERS.length;
const changeSorting = (idx: number, newIdentifier: BuildSort | null) => {
const newSorting = buildSorting.map((oldIdentifier, i) =>
i === idx ? newIdentifier : oldIdentifier,
);
if (canAddMoreSorting && newSorting[newSorting.length - 1] !== null) {
newSorting.push(null);
}
setBuildSorting(newSorting);
};
const deleteLastSorting = () => {
setBuildSorting((prev) => [...prev.filter(Boolean).slice(0, -1), null]);
};
return (
<Dialog isOpen close={close}>
<fetcher.Form method="post">
<input
type="hidden"
name="buildSorting"
value={JSON.stringify(buildSorting.filter(Boolean))}
/>
<h2 className="text-lg">{t("user:builds.sorting.header")}</h2>
<div className="stack lg">
<div className="stack md">
<FormMessage type="info">
{t("user:builds.sorting.info")}
</FormMessage>
<Button
className="ml-auto"
variant="minimal"
size="tiny"
onClick={() => setBuildSorting([...DEFAULT_BUILD_SORT, null])}
>
{t("user:builds.sorting.backToDefaults")}
</Button>
{buildSorting.map((sort, i) => {
const isLast = i === buildSorting.length - 1;
const isSecondToLast = i === buildSorting.length - 2;
if (isLast && canAddMoreSorting) {
return (
<ChangeSortingDialogSelect
key={i}
identifiers={BUILD_SORT_IDENTIFIERS.filter(
(identifier) =>
!buildSorting.slice(0, -1).includes(identifier),
)}
value={sort}
changeValue={(newValue) => changeSorting(i, newValue)}
/>
);
}
return (
<div key={i} className="stack horizontal justify-between">
<div className="font-bold">
{i + 1}) {t(`user:builds.sorting.${sort}`)}
</div>
{(isLast && !canAddMoreSorting) ||
(canAddMoreSorting && isSecondToLast) ? (
<Button
icon={<TrashIcon />}
variant="minimal-destructive"
onClick={deleteLastSorting}
/>
) : null}
</div>
);
})}
</div>
<div className="stack sm horizontal justify-center">
<SubmitButton _action="UPDATE_SORTING">
{t("common:actions.save")}
</SubmitButton>
<Button variant="destructive" onClick={close}>
{t("common:actions.cancel")}
</Button>
</div>
</div>
</fetcher.Form>
</Dialog>
);
}
function ChangeSortingDialogSelect({
identifiers,
value,
changeValue,
}: {
identifiers: BuildSort[];
value: BuildSort | null;
changeValue: (value: BuildSort | null) => void;
}) {
const { t } = useTranslation(["user"]);
return (
<select
value={value ?? MISSING_SORT_VALUE}
onChange={(e) => {
if (e.target.value === MISSING_SORT_VALUE) changeValue(null);
changeValue(e.target.value as BuildSort);
}}
>
<option value={MISSING_SORT_VALUE}>-</option>
{identifiers.map((identifier) => {
return (
<option key={identifier} value={identifier}>
{t(`user:builds.sorting.${identifier}`)}
</option>
);
})}
</select>
);
}

View File

@@ -1 +1,2 @@
export const MATCHES_PER_SEASONS_PAGE = 8;
export const DEFAULT_BUILD_SORT = ["WEAPON_POOL", "UPDATED_AT"] as const;

View File

@@ -166,6 +166,12 @@ export function toArray<T>(value: T | Array<T>) {
return [value];
}
export function emptyArrayToNull(value: unknown) {
if (Array.isArray(value) && value.length === 0) return null;
return value;
}
export function checkboxValueToBoolean(value: unknown) {
if (!value) return false;

View File

@@ -63,5 +63,19 @@
"seasons.noReportedWeapons": "No reported weapons yet",
"seasons.clickARow": "Click a row to see weapon usage stats",
"seasons.loading": "Loading...",
"seasons.matchBeingProcessed": "This match has not been processed yet"
"seasons.matchBeingProcessed": "This match has not been processed yet",
"builds.sorting.changeButton": "Change sorting",
"builds.sorting.header": "Change build sorting",
"builds.sorting.backToDefaults": "Back to defaults",
"builds.sorting.info": "Change how builds are ordered on your profile. Affects both you as well as visitors viewing your builds.",
"builds.sorting.UPDATED_AT": "Last updated",
"builds.sorting.TOP_500": "X Rank Top 500",
"builds.sorting.WEAPON_POOL": "Weapon pool",
"builds.sorting.WEAPON_IN_GAME_ORDER": "Weapons in-game order",
"builds.sorting.ALPHABETICAL_TITLE": "Title alphabetical",
"builds.sorting.MODE": "Mode",
"builds.sorting.HEADGEAR_ID": "Headgear in-game order",
"builds.sorting.CLOTHES_ID": "Clothing in-game order",
"builds.sorting.SHOES_ID": "Shoes in-game order"
}

View File

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