mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-08-27 21:55:15 -05:00
Sort user profile builds Closes #1233
This commit is contained in:
@@ -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"
|
||||
|
||||
18
app/components/icons/Sort.tsx
Normal file
18
app/components/icons/Sort.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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) => ({
|
||||
|
||||
@@ -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">
|
||||
>;
|
||||
|
||||
@@ -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(),
|
||||
),
|
||||
}),
|
||||
]);
|
||||
|
||||
286
app/features/user-page/core/build-sorting.server.test.ts
Normal file
286
app/features/user-page/core/build-sorting.server.test.ts
Normal 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();
|
||||
77
app/features/user-page/core/build-sorting.server.ts
Normal file
77
app/features/user-page/core/build-sorting.server.ts
Normal 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;
|
||||
});
|
||||
}
|
||||
@@ -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(),
|
||||
});
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1 +1,2 @@
|
||||
export const MATCHES_PER_SEASONS_PAGE = 8;
|
||||
export const DEFAULT_BUILD_SORT = ["WEAPON_POOL", "UPDATED_AT"] as const;
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
5
migrations/064-build-sorting.js
Normal file
5
migrations/064-build-sorting.js
Normal file
@@ -0,0 +1,5 @@
|
||||
export function up(db) {
|
||||
db.transaction(() => {
|
||||
db.prepare(/* sql */ `alter table "User" add "buildSorting" text`).run();
|
||||
})();
|
||||
}
|
||||
Reference in New Issue
Block a user