diff --git a/app/components/BuildCard.tsx b/app/components/BuildCard.tsx index d969bb24f..3e7aa1bc9 100644 --- a/app/components/BuildCard.tsx +++ b/app/components/BuildCard.tsx @@ -52,6 +52,7 @@ interface BuildProps { | "private" > & { abilities: BuildAbilitiesTuple; + unsortedAbilities: BuildAbilitiesTuple; modes: ModeShort[] | null; weapons: Array<{ weaponSplId: BuildWeapon["weaponSplId"]; @@ -61,9 +62,15 @@ interface BuildProps { }; owner?: Pick; canEdit?: boolean; + withAbilitySorting?: boolean; } -export function BuildCard({ build, owner, canEdit = false }: BuildProps) { +export function BuildCard({ + build, + owner, + canEdit = false, + withAbilitySorting = true, +}: BuildProps) { const user = useUser(); const { t } = useTranslation(["weapons", "builds", "common", "game-misc"]); const { i18n } = useTranslation(); @@ -77,11 +84,14 @@ export function BuildCard({ build, owner, canEdit = false }: BuildProps) { headGearSplId, shoesGearSplId, updatedAt, - abilities, modes, weapons, } = build; + const abilities = withAbilitySorting + ? build.abilities + : build.unsortedAbilities; + const isNoGear = [headGearSplId, clothesGearSplId, shoesGearSplId].some( (id) => id === -1, ); diff --git a/app/db/tables.ts b/app/db/tables.ts index 099b324d4..2ec212347 100644 --- a/app/db/tables.ts +++ b/app/db/tables.ts @@ -754,6 +754,10 @@ export const BUILD_SORT_IDENTIFIERS = [ export type BuildSort = (typeof BUILD_SORT_IDENTIFIERS)[number]; +export interface UserPreferences { + disableBuildAbilitySorting?: boolean; +} + export interface User { /** 1 = permabanned, timestamp = ban active till then */ banned: Generated; @@ -798,6 +802,7 @@ export interface User { plusSkippedForSeasonNth: number | null; noScreen: Generated; buildSorting: ColumnType; + preferences: ColumnType; } export interface UserResultHighlight { diff --git a/app/features/builds/queries/buildsBy.server.ts b/app/features/builds/queries/buildsBy.server.ts index c592c65cf..1173b4dbc 100644 --- a/app/features/builds/queries/buildsBy.server.ts +++ b/app/features/builds/queries/buildsBy.server.ts @@ -175,15 +175,18 @@ function augmentBuild({ const weapons = ( JSON.parse(rawWeapons) as Array ).sort((a, b) => a.weaponSplId - b.weaponSplId); - const abilities = JSON.parse(rawAbilities) as Array< - Pick - >; + const abilities = dbAbilitiesToArrayOfArrays( + JSON.parse(rawAbilities) as Array< + Pick + >, + ); return { ...row, modes, weapons, - abilities: sortAbilities(dbAbilitiesToArrayOfArrays(abilities)), + abilities: sortAbilities(abilities), + unsortedAbilities: abilities, }; } diff --git a/app/features/builds/routes/builds.$slug.tsx b/app/features/builds/routes/builds.$slug.tsx index e58f32361..39ddf75a0 100644 --- a/app/features/builds/routes/builds.$slug.tsx +++ b/app/features/builds/routes/builds.$slug.tsx @@ -23,6 +23,7 @@ import { BUILDS_PAGE_MAX_BUILDS, PATCHES, } from "~/constants"; +import { useUser } from "~/features/auth/core/user"; import { safeJSONParse } from "~/utils/json"; import { isRevalidation, metaTags } from "~/utils/remix"; import type { SendouRouteHandle } from "~/utils/remix.server"; @@ -163,6 +164,8 @@ const BuildCards = React.memo(function BuildCards({ }: { data: SerializeFrom; }) { + const user = useUser(); + return (
{data.builds.map((build) => { @@ -172,6 +175,7 @@ const BuildCards = React.memo(function BuildCards({ build={build} owner={build} canEdit={false} + withAbilitySorting={!user?.preferences.disableBuildAbilitySorting} /> ); })} diff --git a/app/features/settings/actions/settings.server.ts b/app/features/settings/actions/settings.server.ts new file mode 100644 index 000000000..8db6a9c23 --- /dev/null +++ b/app/features/settings/actions/settings.server.ts @@ -0,0 +1,31 @@ +import type { ActionFunctionArgs } from "@remix-run/node"; +import { requireUser } from "~/features/auth/core/user.server"; +import * as UserRepository from "~/features/user-page/UserRepository.server"; +import { parseRequestPayload } from "~/utils/remix.server"; +import { assertUnreachable } from "~/utils/types"; +import { settingsEditSchema } from "../settings-schemas"; + +export const action = async ({ request }: ActionFunctionArgs) => { + const user = await requireUser(request); + const data = await parseRequestPayload({ + request, + schema: settingsEditSchema, + }); + + switch (data._action) { + case "UPDATE_DISABLE_BUILD_ABILITY_SORTING": { + await UserRepository.updatePreferences(user.id, { + disableBuildAbilitySorting: data.newValue, + }); + break; + } + case "PLACEHOLDER": { + break; + } + default: { + assertUnreachable(data); + } + } + + return null; +}; diff --git a/app/features/settings/routes/settings.tsx b/app/features/settings/routes/settings.tsx index 90b88a9f0..7c4a7f4f0 100644 --- a/app/features/settings/routes/settings.tsx +++ b/app/features/settings/routes/settings.tsx @@ -1,13 +1,18 @@ import type { MetaFunction } from "@remix-run/node"; -import { useNavigate, useSearchParams } from "@remix-run/react"; +import { useFetcher, useNavigate, useSearchParams } from "@remix-run/react"; import { useTranslation } from "react-i18next"; +import { FormMessage } from "~/components/FormMessage"; import { Label } from "~/components/Label"; import { Main } from "~/components/Main"; +import { SendouSwitch } from "~/components/elements/Switch"; +import { useUser } from "~/features/auth/core/user"; import { Theme, useTheme } from "~/features/theme/core/provider"; import { languages } from "~/modules/i18n/config"; import { metaTags } from "~/utils/remix"; import type { SendouRouteHandle } from "~/utils/remix.server"; import { SETTINGS_PAGE, navIconUrl } from "~/utils/urls"; +import { action } from "../actions/settings.server"; +export { action }; export const handle: SendouRouteHandle = { breadcrumb: () => ({ @@ -18,6 +23,7 @@ export const handle: SendouRouteHandle = { }; export default function SettingsPage() { + const user = useUser(); const { t } = useTranslation(["common"]); return ( @@ -26,6 +32,20 @@ export default function SettingsPage() {

{t("common:pages.settings")}

+
+ +
); @@ -103,3 +123,38 @@ function ThemeSelector() { ); } + +function PreferenceSelectorSwitch({ + _action, + label, + bottomText, + defaultSelected, +}: { + _action: string; + label: string; + bottomText: string; + defaultSelected: boolean; +}) { + const fetcher = useFetcher(); + + const onChange = (isSelected: boolean) => { + fetcher.submit( + { _action, newValue: isSelected }, + { method: "post", encType: "application/json" }, + ); + }; + + return ( +
+ + {label} + + {bottomText} +
+ ); +} diff --git a/app/features/settings/settings-schemas.ts b/app/features/settings/settings-schemas.ts new file mode 100644 index 000000000..493e0239a --- /dev/null +++ b/app/features/settings/settings-schemas.ts @@ -0,0 +1,12 @@ +import { z } from "zod"; +import { _action } from "~/utils/zod"; + +export const settingsEditSchema = z.union([ + z.object({ + _action: _action("UPDATE_DISABLE_BUILD_ABILITY_SORTING"), + newValue: z.boolean(), + }), + z.object({ + _action: _action("PLACEHOLDER"), + }), +]); diff --git a/app/features/user-page/UserRepository.server.ts b/app/features/user-page/UserRepository.server.ts index 581c447c3..e77be5cdd 100644 --- a/app/features/user-page/UserRepository.server.ts +++ b/app/features/user-page/UserRepository.server.ts @@ -2,7 +2,12 @@ import type { ExpressionBuilder, FunctionModule, NotNull } from "kysely"; import { sql } from "kysely"; import { jsonArrayFrom, jsonObjectFrom } from "kysely/helpers/sqlite"; import { db, sql as dbDirect } from "~/db/sql"; -import type { BuildSort, DB, TablesInsertable } from "~/db/tables"; +import type { + BuildSort, + DB, + TablesInsertable, + UserPreferences, +} from "~/db/tables"; import type { User } from "~/db/types"; import { dateToDatabaseTimestamp } from "~/utils/dates"; import invariant from "~/utils/invariant"; @@ -291,6 +296,7 @@ export function findLeanById(id: number) { "User.favoriteBadgeId", "User.languages", "User.inGameName", + "User.preferences", "PlusTier.tier as plusTier", eb .selectFrom("UserFriendCode") @@ -659,6 +665,35 @@ export function updateProfile(args: UpdateProfileArgs) { }); } +export function updatePreferences( + userId: number, + newPreferences: UserPreferences, +) { + return db.transaction().execute(async (trx) => { + const current = + ( + await trx + .selectFrom("User") + .select("User.preferences") + .where("id", "=", userId) + .executeTakeFirstOrThrow() + ).preferences ?? {}; + + const mergedPreferences = { + ...current, + ...newPreferences, + }; + + await trx + .updateTable("User") + .set({ + preferences: JSON.stringify(mergedPreferences), + }) + .where("id", "=", userId) + .execute(); + }); +} + type UpdateResultHighlightsArgs = { userId: number; resultTeamIds: Array; diff --git a/app/features/user-page/loaders/u.$identifier.builds.server.ts b/app/features/user-page/loaders/u.$identifier.builds.server.ts index 635ae783e..74da0db17 100644 --- a/app/features/user-page/loaders/u.$identifier.builds.server.ts +++ b/app/features/user-page/loaders/u.$identifier.builds.server.ts @@ -20,26 +20,19 @@ export const loader = async ({ params, request }: LoaderFunctionArgs) => { showPrivate: loggedInUser?.id === user.id, }); - const skippedViaSearchParams = - new URL(request.url).searchParams.get("exact") === "true"; - const skipAbilitySorting = - loggedInUser?.id === user.id || skippedViaSearchParams; - const buildsWithAbilitiesSorted = skipAbilitySorting - ? builds - : builds.map((build) => ({ - ...build, - abilities: sortAbilities(build.abilities), - })); - - if (buildsWithAbilitiesSorted.length === 0 && loggedInUser?.id !== user.id) { + if (builds.length === 0 && loggedInUser?.id !== user.id) { throw new Response(null, { status: 404 }); } const sortedBuilds = sortBuilds({ - builds: buildsWithAbilitiesSorted, + builds, buildSorting: user.buildSorting, weaponPool: user.weapons, - }); + }).map((build) => ({ + ...build, + abilities: sortAbilities(build.abilities), + unsortedAbilities: build.abilities, + })); return privatelyCachedJson({ buildSorting: user.buildSorting, diff --git a/app/features/user-page/routes/u.$identifier.builds.tsx b/app/features/user-page/routes/u.$identifier.builds.tsx index 9b5cbaba8..6c62dccaf 100644 --- a/app/features/user-page/routes/u.$identifier.builds.tsx +++ b/app/features/user-page/routes/u.$identifier.builds.tsx @@ -98,7 +98,14 @@ export default function UserBuildsPage() { {builds.length > 0 ? (
{builds.map((build) => ( - + ))}
) : ( diff --git a/app/root.tsx b/app/root.tsx index 0bddfa1f1..65bc00c59 100644 --- a/app/root.tsx +++ b/app/root.tsx @@ -108,6 +108,7 @@ export const loader = async ({ request }: LoaderFunctionArgs) => { isTournamentOrganizer: user.isTournamentOrganizer, inGameName: user.inGameName, friendCode: user.friendCode, + preferences: user.preferences ?? {}, languages: user.languages ? user.languages.split(",") : [], } : undefined, diff --git a/db-test.sqlite3 b/db-test.sqlite3 index a53a25b58..b0f1a1b0d 100644 Binary files a/db-test.sqlite3 and b/db-test.sqlite3 differ diff --git a/e2e/settings.spec.ts b/e2e/settings.spec.ts new file mode 100644 index 000000000..1ab168d43 --- /dev/null +++ b/e2e/settings.spec.ts @@ -0,0 +1,41 @@ +import test, { expect } from "@playwright/test"; +import { impersonate, navigate, seed } from "~/utils/playwright"; +import { SETTINGS_PAGE } from "~/utils/urls"; + +test.describe("Settings", () => { + test("updates 'disableBuildAbilitySorting'", async ({ page }) => { + await seed(page); + await impersonate(page); + + await navigate({ + page, + url: "/builds/luna-blaster", + }); + + const oldContents = await page + .getByTestId("build-card") + .first() + .innerHTML(); + + await navigate({ + page, + url: SETTINGS_PAGE, + }); + + await page + .getByTestId("UPDATE_DISABLE_BUILD_ABILITY_SORTING-switch") + .click(); + + await navigate({ + page, + url: "/builds/luna-blaster", + }); + + const newContents = await page + .getByTestId("build-card") + .first() + .innerHTML(); + + expect(newContents).not.toBe(oldContents); + }); +}); diff --git a/locales/en/common.json b/locales/en/common.json index 73ae2ebd1..1caf8fc81 100644 --- a/locales/en/common.json +++ b/locales/en/common.json @@ -242,5 +242,8 @@ "chat.systemMsg.userLeft": "{{name}} left the group", "chat.newMessages": "New messages", - "fc.title": "Friend code" + "fc.title": "Friend code", + + "settings.UPDATE_DISABLE_BUILD_ABILITY_SORTING.label": "Builds: Disable automatic ability sorting", + "settings.UPDATE_DISABLE_BUILD_ABILITY_SORTING.bottomText": "Outside of your profile page, build abilities are sorted so that same abilities are next to each other. This setting allows you to see the abilities in the order they were authored everywhere." } diff --git a/migrations/081-user-preferences.js b/migrations/081-user-preferences.js new file mode 100644 index 000000000..93fe420e2 --- /dev/null +++ b/migrations/081-user-preferences.js @@ -0,0 +1,5 @@ +export function up(db) { + db.transaction(() => { + db.prepare(/* sql */ `alter table "User" add "preferences" text`).run(); + })(); +}