From 77978c450f9e98bf0ceb2f6c4a081b7c04a4758b Mon Sep 17 00:00:00 2001 From: Kalle <38327916+Sendouc@users.noreply.github.com> Date: Mon, 16 Feb 2026 19:26:57 +0200 Subject: [PATCH] New user page (#2812) Co-authored-by: hfcRed --- app/components/Avatar.tsx | 3 +- app/components/elements/Dialog.tsx | 17 +- app/components/icons/MainSlot.tsx | 33 + app/components/icons/SideSlot.tsx | 20 + app/db/seed/index.ts | 25 + app/db/tables.ts | 9 + app/features/badges/BadgeRepository.server.ts | 25 + app/features/builds/BuildRepository.server.ts | 10 +- .../calendar/CalendarRepository.server.ts | 2 +- .../calendar/loaders/calendar.new.server.ts | 9 +- app/features/info/routes/support.tsx | 5 + app/features/lfg/LFGRepository.server.ts | 9 + app/features/lfg/routes/lfg.module.css | 3 + app/features/lfg/routes/lfg.tsx | 8 +- app/features/mmr/core/Seasons.ts | 9 + app/features/team/TeamRepository.server.ts | 1 + app/features/team/routes/t.$customUrl.test.ts | 21 +- .../XRankPlacementRepository.server.ts | 61 ++ .../components/Bracket/bracket.css | 1 - .../tournament-bracket/core/tests/mocks-li.ts | 2 +- .../core/tests/mocks-sos.ts | 2 +- ...TournamentOrganizationRepository.server.ts | 13 +- .../tournament-organization.css | 1 - .../tournament/TournamentRepository.server.ts | 2 +- .../tournament/routes/to.$id.register.tsx | 2 +- app/features/tournament/routes/to.$id.tsx | 4 +- .../user-page/UserRepository.server.ts | 189 ++++ .../u.$identifier.edit-widgets.server.ts | 23 + .../actions/u.$identifier.edit.server.ts | 27 +- .../components/SubPageHeader.module.css | 63 ++ .../user-page/components/SubPageHeader.tsx | 34 + .../components/UserPageIconNav.module.css | 51 ++ .../user-page/components/UserPageIconNav.tsx | 51 ++ .../user-page/components/Widget.module.css | 312 +++++++ app/features/user-page/components/Widget.tsx | 849 ++++++++++++++++++ .../components/WidgetSettingsForm.tsx | 268 ++++++ .../core/widgets/portfolio-loaders.server.ts | 313 +++++++ .../user-page/core/widgets/portfolio.ts | 173 ++++ app/features/user-page/core/widgets/types.ts | 40 + .../user-page/core/widgets/utils.server.ts | 53 ++ .../core/widgets/widget-form-schemas.ts | 149 +++ .../u.$identifier.edit-widgets.server.ts | 10 + .../loaders/u.$identifier.edit.server.ts | 3 + .../loaders/u.$identifier.index.server.ts | 14 + .../user-page/loaders/u.$identifier.server.ts | 5 + .../user-page/routes/u.$identifier.admin.tsx | 19 +- .../user-page/routes/u.$identifier.art.tsx | 7 +- .../user-page/routes/u.$identifier.builds.tsx | 33 +- .../u.$identifier.edit-widgets.module.css | 204 +++++ .../routes/u.$identifier.edit-widgets.tsx | 444 +++++++++ .../routes/u.$identifier.edit.test.ts | 3 +- .../user-page/routes/u.$identifier.edit.tsx | 27 + .../user-page/routes/u.$identifier.index.tsx | 193 +++- .../user-page/routes/u.$identifier.module.css | 133 +++ .../routes/u.$identifier.results.tsx | 7 +- .../routes/u.$identifier.seasons.tsx | 23 +- .../user-page/routes/u.$identifier.tsx | 176 ++-- .../user-page/routes/u.$identifier.vods.tsx | 9 +- app/features/user-page/user-page-constants.ts | 3 + app/features/user-page/user-page-schemas.ts | 39 + app/form/SendouForm.tsx | 51 +- app/form/fields.ts | 65 ++ app/routes.ts | 4 + app/styles/u.css | 3 +- app/utils/urls.ts | 4 + e2e/tournament-bracket.spec.ts | 2 + locales/da/common.json | 3 + locales/da/forms.json | 16 +- locales/da/user.json | 130 ++- locales/de/common.json | 3 + locales/de/forms.json | 16 +- locales/de/user.json | 130 ++- locales/en/common.json | 3 + locales/en/forms.json | 16 +- locales/en/user.json | 130 ++- locales/es-ES/common.json | 3 + locales/es-ES/forms.json | 16 +- locales/es-ES/user.json | 130 ++- locales/es-US/common.json | 3 + locales/es-US/forms.json | 16 +- locales/es-US/user.json | 130 ++- locales/fr-CA/common.json | 3 + locales/fr-CA/forms.json | 16 +- locales/fr-CA/user.json | 130 ++- locales/fr-EU/common.json | 3 + locales/fr-EU/forms.json | 16 +- locales/fr-EU/user.json | 130 ++- locales/he/common.json | 3 + locales/he/forms.json | 16 +- locales/he/user.json | 130 ++- locales/it/common.json | 3 + locales/it/forms.json | 16 +- locales/it/user.json | 130 ++- locales/ja/common.json | 3 + locales/ja/forms.json | 16 +- locales/ja/user.json | 130 ++- locales/ko/common.json | 3 + locales/ko/forms.json | 16 +- locales/ko/user.json | 130 ++- locales/nl/common.json | 3 + locales/nl/forms.json | 16 +- locales/nl/user.json | 130 ++- locales/pl/common.json | 3 + locales/pl/forms.json | 16 +- locales/pl/user.json | 130 ++- locales/pt-BR/common.json | 3 + locales/pt-BR/forms.json | 16 +- locales/pt-BR/user.json | 130 ++- locales/ru/common.json | 3 + locales/ru/forms.json | 16 +- locales/ru/user.json | 130 ++- locales/zh/common.json | 3 + locales/zh/forms.json | 16 +- locales/zh/user.json | 130 ++- migrations/118-user-widget.js | 19 + .../static-assets/img/controllers/grip.avif | Bin 0 -> 8936 bytes .../img/controllers/handheld.avif | Bin 0 -> 6004 bytes .../img/controllers/s1-pro-con.avif | Bin 0 -> 11166 bytes .../img/controllers/s2-pro-con.avif | Bin 0 -> 10359 bytes 119 files changed, 6585 insertions(+), 191 deletions(-) create mode 100644 app/components/icons/MainSlot.tsx create mode 100644 app/components/icons/SideSlot.tsx create mode 100644 app/features/lfg/routes/lfg.module.css create mode 100644 app/features/user-page/actions/u.$identifier.edit-widgets.server.ts create mode 100644 app/features/user-page/components/SubPageHeader.module.css create mode 100644 app/features/user-page/components/SubPageHeader.tsx create mode 100644 app/features/user-page/components/UserPageIconNav.module.css create mode 100644 app/features/user-page/components/UserPageIconNav.tsx create mode 100644 app/features/user-page/components/Widget.module.css create mode 100644 app/features/user-page/components/Widget.tsx create mode 100644 app/features/user-page/components/WidgetSettingsForm.tsx create mode 100644 app/features/user-page/core/widgets/portfolio-loaders.server.ts create mode 100644 app/features/user-page/core/widgets/portfolio.ts create mode 100644 app/features/user-page/core/widgets/types.ts create mode 100644 app/features/user-page/core/widgets/utils.server.ts create mode 100644 app/features/user-page/core/widgets/widget-form-schemas.ts create mode 100644 app/features/user-page/loaders/u.$identifier.edit-widgets.server.ts create mode 100644 app/features/user-page/routes/u.$identifier.edit-widgets.module.css create mode 100644 app/features/user-page/routes/u.$identifier.edit-widgets.tsx create mode 100644 app/features/user-page/routes/u.$identifier.module.css create mode 100644 migrations/118-user-widget.js create mode 100644 public/static-assets/img/controllers/grip.avif create mode 100644 public/static-assets/img/controllers/handheld.avif create mode 100644 public/static-assets/img/controllers/s1-pro-con.avif create mode 100644 public/static-assets/img/controllers/s2-pro-con.avif diff --git a/app/components/Avatar.tsx b/app/components/Avatar.tsx index 1be4c682e..13e0e28b1 100644 --- a/app/components/Avatar.tsx +++ b/app/components/Avatar.tsx @@ -11,6 +11,7 @@ const dimensions = { sm: 44, xsm: 62, md: 81, + xmd: 94, lg: 125, } as const; @@ -43,7 +44,7 @@ export function Avatar({ ? discordAvatarUrl({ discordAvatar: user.discordAvatar, discordId: user.discordId, - size: size === "lg" ? "lg" : "sm", + size: size === "lg" || size === "xmd" ? "lg" : "sm", }) : BLANK_IMAGE_URL); // avoid broken image placeholder diff --git a/app/components/elements/Dialog.tsx b/app/components/elements/Dialog.tsx index 662b1391c..750c920e7 100644 --- a/app/components/elements/Dialog.tsx +++ b/app/components/elements/Dialog.tsx @@ -8,6 +8,7 @@ import { ModalOverlay, } from "react-aria-components"; import { useNavigate } from "react-router"; +import * as R from "remeda"; import { SendouButton } from "~/components/elements/Button"; import { CrossIcon } from "~/components/icons/Cross"; import styles from "./Dialog.module.css"; @@ -68,7 +69,9 @@ export function SendouDialog({ return ( {trigger} - {children} + + {children} + ); } @@ -79,8 +82,9 @@ function DialogModal({ showHeading = true, className, showCloseButton: showCloseButtonProp, + isControlledByTrigger, ...rest -}: Omit) { +}: Omit & { isControlledByTrigger?: boolean }) { const navigate = useNavigate(); const showCloseButton = showCloseButtonProp || rest.onClose || rest.onCloseTo; @@ -92,7 +96,7 @@ function DialogModal({ } }; - const onOpenChange = (isOpen: boolean) => { + const defaultOnOpenChange = (isOpen: boolean) => { if (!isOpen) { if (rest.onCloseTo) { navigate(rest.onCloseTo); @@ -102,13 +106,16 @@ function DialogModal({ } }; + const overlayProps = isControlledByTrigger + ? R.omit(rest, ["onOpenChange"]) + : { ...rest, onOpenChange: rest.onOpenChange ?? defaultOnOpenChange }; + return ( + {/* Left column - filled */} + + {/* Right column - outlined */} + + + ); +} diff --git a/app/components/icons/SideSlot.tsx b/app/components/icons/SideSlot.tsx new file mode 100644 index 000000000..67b904ceb --- /dev/null +++ b/app/components/icons/SideSlot.tsx @@ -0,0 +1,20 @@ +export function SideSlotIcon({ + className, + size, +}: { + className?: string; + size?: number; +}) { + return ( + + + + ); +} diff --git a/app/db/seed/index.ts b/app/db/seed/index.ts index 3d89ea8dd..a43cc2e32 100644 --- a/app/db/seed/index.ts +++ b/app/db/seed/index.ts @@ -179,6 +179,7 @@ const basicSeeds = (variation?: SeedVariation | null) => [ fixAdminId, makeArtists, adminUserWeaponPool, + adminUserWidgets, userProfiles, userMapModePreferences, userQWeaponPool, @@ -375,6 +376,30 @@ function adminUserWeaponPool() { } } +async function adminUserWidgets() { + await UserRepository.upsertWidgets(ADMIN_ID, [ + { + id: "bio", + settings: { bio: "" }, + }, + { + id: "badges-owned", + }, + { + id: "teams", + }, + { + id: "organizations", + }, + { + id: "peak-sp", + }, + { + id: "peak-xp", + }, + ]); +} + function nzapUser() { return UserRepository.upsert({ discordId: NZAP_TEST_DISCORD_ID, diff --git a/app/db/tables.ts b/app/db/tables.ts index 88d42ca67..d1d219844 100644 --- a/app/db/tables.ts +++ b/app/db/tables.ts @@ -16,6 +16,7 @@ import type { TEAM_MEMBER_ROLES } from "~/features/team/team-constants"; import type { TournamentTierNumber } from "~/features/tournament/core/tiering"; import type * as PickBan from "~/features/tournament-bracket/core/PickBan"; import type * as Progression from "~/features/tournament-bracket/core/Progression"; +import type { StoredWidget } from "~/features/user-page/core/widgets/types"; import type { ParticipantResult } from "~/modules/brackets-model"; import type { Ability, @@ -861,6 +862,8 @@ export interface UserPreferences { * "12h" = 12 hour format (e.g. 2:00 PM) * */ clockFormat?: "24h" | "12h" | "auto"; + /** Is the new widget based user page enabled? (Supporter early preview) */ + newProfileEnabled?: boolean; } export const SUBJECT_PRONOUNS = ["he", "she", "they", "it", "any"] as const; @@ -961,6 +964,11 @@ export interface UserFriendCode { createdAt: GeneratedAlways; } +export interface UserWidget { + userId: number; + index: number; + widget: JSONColumnType; +} export type ApiTokenType = "read" | "write"; export interface ApiToken { @@ -1223,6 +1231,7 @@ export interface DB { UserSubmittedImage: UserSubmittedImage; UserWeapon: UserWeapon; UserFriendCode: UserFriendCode; + UserWidget: UserWidget; Video: Video; VideoMatch: VideoMatch; VideoMatchPlayer: VideoMatchPlayer; diff --git a/app/features/badges/BadgeRepository.server.ts b/app/features/badges/BadgeRepository.server.ts index e66028b47..6458065ea 100644 --- a/app/features/badges/BadgeRepository.server.ts +++ b/app/features/badges/BadgeRepository.server.ts @@ -110,6 +110,31 @@ export function findManagedByUserId(userId: number) { .execute(); } +export function findByOwnerUserId(userId: number) { + return db + .selectFrom("BadgeOwner") + .innerJoin("Badge", "Badge.id", "BadgeOwner.badgeId") + .select(({ fn }) => [ + fn.count("BadgeOwner.badgeId").as("count"), + "Badge.id", + "Badge.displayName", + "Badge.code", + "Badge.hue", + ]) + .where("BadgeOwner.userId", "=", userId) + .groupBy(["BadgeOwner.badgeId", "BadgeOwner.userId"]) + .execute(); +} + +export function findByAuthorUserId(userId: number) { + return db + .selectFrom("Badge") + .select(["Badge.id", "Badge.displayName", "Badge.code", "Badge.hue"]) + .where("Badge.authorId", "=", userId) + .groupBy("Badge.id") + .execute(); +} + export function replaceManagers({ badgeId, managerIds, diff --git a/app/features/builds/BuildRepository.server.ts b/app/features/builds/BuildRepository.server.ts index 13ef3b97f..ff3839911 100644 --- a/app/features/builds/BuildRepository.server.ts +++ b/app/features/builds/BuildRepository.server.ts @@ -21,10 +21,14 @@ export async function allByUserId( options: { showPrivate?: boolean; sortAbilities?: boolean; + limit?: number; } = {}, ) { - const { showPrivate = false, sortAbilities: shouldSortAbilities = false } = - options; + const { + showPrivate = false, + sortAbilities: shouldSortAbilities = false, + limit, + } = options; const rows = await db .selectFrom("Build") .select(({ eb }) => [ @@ -48,6 +52,8 @@ export async function allByUserId( ]) .where("Build.ownerId", "=", userId) .$if(!showPrivate, (qb) => qb.where("Build.private", "=", 0)) + .$if(typeof limit === "number", (qb) => qb.limit(limit!)) + .orderBy("Build.updatedAt", "desc") .execute(); return rows.map((row) => { diff --git a/app/features/calendar/CalendarRepository.server.ts b/app/features/calendar/CalendarRepository.server.ts index 916709048..91e7255b9 100644 --- a/app/features/calendar/CalendarRepository.server.ts +++ b/app/features/calendar/CalendarRepository.server.ts @@ -101,7 +101,7 @@ function tournamentOrganization(organizationId: Expression) { "TournamentOrganization.slug", "TournamentOrganization.isEstablished", concatUserSubmittedImagePrefix(eb.ref("UserSubmittedImage.url")).as( - "avatarUrl", + "logoUrl", ), ]) .whereRef("TournamentOrganization.id", "=", organizationId), diff --git a/app/features/calendar/loaders/calendar.new.server.ts b/app/features/calendar/loaders/calendar.new.server.ts index a5b05c917..7cf31beb7 100644 --- a/app/features/calendar/loaders/calendar.new.server.ts +++ b/app/features/calendar/loaders/calendar.new.server.ts @@ -118,10 +118,15 @@ export async function findValidOrganizations( }); if (isTournamentAdder) { - return ["NO_ORG", ...orgs.map((org) => R.omit(org, ["isEstablished"]))]; + return [ + "NO_ORG", + ...orgs.map((org) => + R.omit(org, ["isEstablished", "role", "roleDisplayName"]), + ), + ]; } return orgs .filter((org) => org.isEstablished) - .map((org) => R.omit(org, ["isEstablished"])); + .map((org) => R.omit(org, ["isEstablished", "role", "roleDisplayName"])); } diff --git a/app/features/info/routes/support.tsx b/app/features/info/routes/support.tsx index 788ea83a5..ed6309ce6 100644 --- a/app/features/info/routes/support.tsx +++ b/app/features/info/routes/support.tsx @@ -57,6 +57,11 @@ const PERKS = [ name: "tournamentsBeta", extraInfo: false, }, + { + tier: 2, + name: "earlyAccess", + extraInfo: false, + }, { tier: 2, name: "previewQ", diff --git a/app/features/lfg/LFGRepository.server.ts b/app/features/lfg/LFGRepository.server.ts index 0fb38f1e1..047da9aff 100644 --- a/app/features/lfg/LFGRepository.server.ts +++ b/app/features/lfg/LFGRepository.server.ts @@ -157,3 +157,12 @@ export function deletePostsByTeamId(teamId: number, trx?: Transaction) { .where("teamId", "=", teamId) .execute(); } + +export async function findByAuthorUserId(authorId: number) { + return db + .selectFrom("LFGPost") + .select(["id", "type"]) + .where("authorId", "=", authorId) + .orderBy("updatedAt", "desc") + .execute(); +} diff --git a/app/features/lfg/routes/lfg.module.css b/app/features/lfg/routes/lfg.module.css new file mode 100644 index 000000000..0ef45816d --- /dev/null +++ b/app/features/lfg/routes/lfg.module.css @@ -0,0 +1,3 @@ +.post { + scroll-margin-top: 6rem; +} diff --git a/app/features/lfg/routes/lfg.tsx b/app/features/lfg/routes/lfg.tsx index ece1deb5e..bf10aef52 100644 --- a/app/features/lfg/routes/lfg.tsx +++ b/app/features/lfg/routes/lfg.tsx @@ -1,3 +1,4 @@ +import clsx from "clsx"; import { add, sub } from "date-fns"; import React from "react"; import { useTranslation } from "react-i18next"; @@ -26,6 +27,7 @@ import { smallStrToFilter, } from "../lfg-types"; import { loader } from "../loaders/lfg.server"; +import styles from "./lfg.module.css"; export { loader, action }; export const handle: SendouRouteHandle = { @@ -124,7 +126,11 @@ export default function LFGPage() { } /> {filteredPosts.map((post) => ( -
+
{showExpiryAlert(post) ? : null}
diff --git a/app/features/mmr/core/Seasons.ts b/app/features/mmr/core/Seasons.ts index 485e3f4f7..7cc54b3ba 100644 --- a/app/features/mmr/core/Seasons.ts +++ b/app/features/mmr/core/Seasons.ts @@ -181,3 +181,12 @@ export function allStarted(date = new Date()) { return [0]; } +/** + * Retrieves a list of season numbers that have finished based on the provided date (defaults to now). + * + * @returns An array of season numbers in descending order. If no seasons have finished, returns an empty array. + */ +export function allFinished(date = new Date()) { + const finishedSeasons = list.filter((s) => date > s.ends); + return finishedSeasons.map((s) => s.nth).reverse(); +} diff --git a/app/features/team/TeamRepository.server.ts b/app/features/team/TeamRepository.server.ts index c738d6817..ff77a0130 100644 --- a/app/features/team/TeamRepository.server.ts +++ b/app/features/team/TeamRepository.server.ts @@ -46,6 +46,7 @@ export function findAllMemberOfByUserId(userId: number) { "Team.id", "Team.customUrl", "Team.name", + "TeamMemberWithSecondary.role", concatUserSubmittedImagePrefix(eb.ref("UserSubmittedImage.url")).as( "logoUrl", ), diff --git a/app/features/team/routes/t.$customUrl.test.ts b/app/features/team/routes/t.$customUrl.test.ts index f63611dbd..730f12aa1 100644 --- a/app/features/team/routes/t.$customUrl.test.ts +++ b/app/features/team/routes/t.$customUrl.test.ts @@ -1,15 +1,12 @@ import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { REGULAR_USER_TEST_ID } from "~/db/seed/constants"; import { db } from "~/db/sql"; -import type { SerializeFrom } from "~/utils/remix"; import { assertResponseErrored, dbInsertUsers, dbReset, wrappedAction, - wrappedLoader, } from "~/utils/Test"; -import { loader as userProfileLoader } from "../../user-page/loaders/u.$identifier.index.server"; import { action as _teamPageAction } from "../actions/t.$customUrl.index.server"; import { action as teamIndexPageAction } from "../actions/t.server"; import { action as _editTeamAction } from "../routes/t.$customUrl.edit"; @@ -20,12 +17,6 @@ import type { teamProfilePageActionSchema, } from "../team-schemas.server"; -const loadUserTeamLoader = wrappedLoader< - SerializeFrom ->({ - loader: userProfileLoader, -}); - const createTeamAction = wrappedAction({ action: teamIndexPageAction, isJsonSubmission: true, @@ -40,14 +31,12 @@ const editTeamAction = wrappedAction({ }); async function loadTeams() { - const data = await loadUserTeamLoader({ - user: "regular", - params: { - identifier: String(REGULAR_USER_TEST_ID), - }, - }); + const teams = await TeamRepository.teamsByMemberUserId(REGULAR_USER_TEST_ID); - return { team: data.user.team, secondaryTeams: data.user.secondaryTeams }; + const mainTeam = teams.find((t) => t.isMainTeam); + const secondaryTeams = teams.filter((t) => !t.isMainTeam); + + return { team: mainTeam, secondaryTeams }; } describe("Secondary teams", () => { diff --git a/app/features/top-search/XRankPlacementRepository.server.ts b/app/features/top-search/XRankPlacementRepository.server.ts index 07f4173e9..08d920452 100644 --- a/app/features/top-search/XRankPlacementRepository.server.ts +++ b/app/features/top-search/XRankPlacementRepository.server.ts @@ -1,6 +1,9 @@ import type { InferResult } from "kysely"; +import { sql } from "kysely"; import { db } from "~/db/sql"; import type { Tables } from "~/db/tables"; +import { modesShort } from "~/modules/in-game-lists/modes"; +import type { MainWeaponId } from "~/modules/in-game-lists/types"; export function unlinkPlayerByUserId(userId: number) { return db @@ -54,6 +57,26 @@ export async function findPlacementsByPlayerId( return result.length ? result : null; } +export async function findPlacementsByUserId( + userId: Tables["User"]["id"], + options?: { limit?: number; weaponId?: MainWeaponId }, +) { + let query = xRankPlacementsQueryBase() + .where("SplatoonPlayer.userId", "=", userId) + .orderBy("XRankPlacement.power", "desc"); + + if (options?.weaponId) { + query = query.where("XRankPlacement.weaponSplId", "=", options.weaponId); + } + + if (options?.limit) { + query = query.limit(options.limit); + } + + const result = await query.execute(); + return result.length ? result : null; +} + export async function monthYears() { return await db .selectFrom("XRankPlacement") @@ -64,6 +87,44 @@ export async function monthYears() { .execute(); } +export async function findPeaksByUserId( + userId: Tables["User"]["id"], + division?: "both" | "tentatek" | "takoroka", +) { + let innerQuery = db + .selectFrom("XRankPlacement") + .innerJoin("SplatoonPlayer", "XRankPlacement.playerId", "SplatoonPlayer.id") + .where("SplatoonPlayer.userId", "=", userId) + .select([ + "XRankPlacement.mode", + "XRankPlacement.rank", + "XRankPlacement.power", + "XRankPlacement.region", + "XRankPlacement.playerId", + sql`ROW_NUMBER() OVER (PARTITION BY "XRankPlacement"."mode" ORDER BY "XRankPlacement"."power" DESC)`.as( + "rn", + ), + ]); + + if (division === "tentatek") { + innerQuery = innerQuery.where("XRankPlacement.region", "=", "WEST"); + } else if (division === "takoroka") { + innerQuery = innerQuery.where("XRankPlacement.region", "=", "JPN"); + } + + const rows = await db + .selectFrom(innerQuery.as("ranked")) + .selectAll() + .where("rn", "=", 1) + .execute(); + + const peaksByMode = new Map(rows.map((row) => [row.mode, row])); + + return modesShort + .map((mode) => peaksByMode.get(mode)) + .filter((p): p is NonNullable => p !== undefined); +} + export type FindPlacement = InferResult< ReturnType >[number]; diff --git a/app/features/tournament-bracket/components/Bracket/bracket.css b/app/features/tournament-bracket/components/Bracket/bracket.css index fecbace0c..863a02582 100644 --- a/app/features/tournament-bracket/components/Bracket/bracket.css +++ b/app/features/tournament-bracket/components/Bracket/bracket.css @@ -61,7 +61,6 @@ display: flex; flex-direction: column; gap: var(--s-1); - color: var(--text-main); justify-content: center; transition: background-color 0.2s; } diff --git a/app/features/tournament-bracket/core/tests/mocks-li.ts b/app/features/tournament-bracket/core/tests/mocks-li.ts index 2f4f80fd7..36f9d5997 100644 --- a/app/features/tournament-bracket/core/tests/mocks-li.ts +++ b/app/features/tournament-bracket/core/tests/mocks-li.ts @@ -6925,7 +6925,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ id: 3, name: "Inkling Performance Labs", slug: "inkling-performance-labs", - avatarUrl: "fZrToLQrkqV3UZkdgwp0Q-1722263644749.webp", + logoUrl: "fZrToLQrkqV3UZkdgwp0Q-1722263644749.webp", members: [ { userId: 405, diff --git a/app/features/tournament-bracket/core/tests/mocks-sos.ts b/app/features/tournament-bracket/core/tests/mocks-sos.ts index 23cf3ae3e..7397010e9 100644 --- a/app/features/tournament-bracket/core/tests/mocks-sos.ts +++ b/app/features/tournament-bracket/core/tests/mocks-sos.ts @@ -2028,7 +2028,7 @@ export const SWIM_OR_SINK_167 = ( id: 3, name: "Inkling Performance Labs", slug: "inkling-performance-labs", - avatarUrl: "fZrToLQrkqV3UZkdgwp0Q-1722263644749.webp", + logoUrl: "fZrToLQrkqV3UZkdgwp0Q-1722263644749.webp", members: [ { userId: 405, diff --git a/app/features/tournament-organization/TournamentOrganizationRepository.server.ts b/app/features/tournament-organization/TournamentOrganizationRepository.server.ts index c5095fe58..38b1fc4d9 100644 --- a/app/features/tournament-organization/TournamentOrganizationRepository.server.ts +++ b/app/features/tournament-organization/TournamentOrganizationRepository.server.ts @@ -151,10 +151,21 @@ export function findByUserId( "TournamentOrganization.id", "TournamentOrganizationMember.organizationId", ) - .select([ + .leftJoin( + "UserSubmittedImage", + "UserSubmittedImage.id", + "TournamentOrganization.avatarImgId", + ) + .select(({ eb }) => [ "TournamentOrganization.id", "TournamentOrganization.name", + "TournamentOrganization.slug", "TournamentOrganization.isEstablished", + "TournamentOrganizationMember.role", + "TournamentOrganizationMember.roleDisplayName", + concatUserSubmittedImagePrefix(eb.ref("UserSubmittedImage.url")).as( + "logoUrl", + ), ]) .where("TournamentOrganizationMember.userId", "=", userId) .$if(roles.length > 0, (qb) => diff --git a/app/features/tournament-organization/tournament-organization.css b/app/features/tournament-organization/tournament-organization.css index 9348b90c1..aaa018ec6 100644 --- a/app/features/tournament-organization/tournament-organization.css +++ b/app/features/tournament-organization/tournament-organization.css @@ -164,7 +164,6 @@ .org__social-link { font-size: var(--fonts-sm); - color: var(--text-main); display: flex; gap: var(--s-2); align-items: center; diff --git a/app/features/tournament/TournamentRepository.server.ts b/app/features/tournament/TournamentRepository.server.ts index 62801724d..567efb7ef 100644 --- a/app/features/tournament/TournamentRepository.server.ts +++ b/app/features/tournament/TournamentRepository.server.ts @@ -72,7 +72,7 @@ export async function findById(id: number) { "TournamentOrganization.slug", concatUserSubmittedImagePrefix( innerEb.ref("UserSubmittedImage.url"), - ).as("avatarUrl"), + ).as("logoUrl"), jsonArrayFrom( innerEb .selectFrom("TournamentOrganizationMember") diff --git a/app/features/tournament/routes/to.$id.register.tsx b/app/features/tournament/routes/to.$id.register.tsx index 6bb7cc4e6..a844ab6e2 100644 --- a/app/features/tournament/routes/to.$id.register.tsx +++ b/app/features/tournament/routes/to.$id.register.tsx @@ -97,7 +97,7 @@ export default function TournamentRegisterPage() { className="stack horizontal sm items-center text-xs text-main-forced" > {tournament.ctx.organization.name} diff --git a/app/features/tournament/routes/to.$id.tsx b/app/features/tournament/routes/to.$id.tsx index 06d714cb5..9312e519d 100644 --- a/app/features/tournament/routes/to.$id.tsx +++ b/app/features/tournament/routes/to.$id.tsx @@ -73,9 +73,9 @@ export const handle: SendouRouteHandle = { const data = JSON.parse(rawData) as TournamentLoaderData; return [ - data.tournament.ctx.organization?.avatarUrl + data.tournament.ctx.organization?.logoUrl ? { - imgPath: data.tournament.ctx.organization.avatarUrl, + imgPath: data.tournament.ctx.organization.logoUrl, href: tournamentOrganizationPage({ organizationSlug: data.tournament.ctx.organization.slug, }), diff --git a/app/features/user-page/UserRepository.server.ts b/app/features/user-page/UserRepository.server.ts index ca404708f..d06cb17fe 100644 --- a/app/features/user-page/UserRepository.server.ts +++ b/app/features/user-page/UserRepository.server.ts @@ -21,8 +21,13 @@ import { tournamentLogoOrNull, userChatNameColor, } from "~/utils/kysely.server"; +import { logger } from "~/utils/logger"; import { safeNumberParse } from "~/utils/number"; +import { bskyUrl, twitchUrl, youtubeUrl } from "~/utils/urls"; import type { ChatUser } from "../chat/chat-types"; +import { findWidgetById } from "./core/widgets/portfolio"; +import { WIDGET_LOADERS } from "./core/widgets/portfolio-loaders.server"; +import type { LoadedWidget } from "./core/widgets/types"; export const identifierToUserIdQuery = (identifier: string) => db @@ -78,6 +83,9 @@ export function findLayoutDataByIdentifier( return identifierToUserIdQuery(identifier) .select((eb) => [ ...COMMON_USER_FIELDS, + "User.pronouns", + "User.country", + "User.inGameName", "User.commissionText", "User.commissionsOpen", sql, +) { + return db.transaction().execute(async (trx) => { + await trx.deleteFrom("UserWidget").where("userId", "=", userId).execute(); + + await trx + .insertInto("UserWidget") + .values( + widgets.map((widget, index) => ({ + userId, + index, + widget: JSON.stringify(widget), + })), + ) + .execute(); + }); +} + +export async function storedWidgetsByUserId( + userId: number, +): Promise> { + const rows = await db + .selectFrom("UserWidget") + .select(["widget"]) + .where("userId", "=", userId) + .orderBy("index", "asc") + .execute(); + + return rows.map((row) => row.widget); +} + +export async function widgetsByUserId( + identifier: string, +): Promise { + const user = await identifierToUserId(identifier); + + if (!user) return null; + + const widgets = await db + .selectFrom("UserWidget") + .select(["widget"]) + .where("userId", "=", user.id) + .orderBy("index", "asc") + .execute(); + + const loadedWidgets = await Promise.all( + widgets.map(async ({ widget }) => { + const definition = findWidgetById(widget.id); + + if (!definition) { + logger.warn( + `Unknown widget id found for user ${user.id}: ${widget.id}`, + ); + return null; + } + + const loader = WIDGET_LOADERS[widget.id as keyof typeof WIDGET_LOADERS]; + const data = loader + ? await loader(user.id, widget.settings as any) + : widget.settings; + + return { + id: widget.id, + data, + settings: widget.settings, + slot: definition.slot, + } as LoadedWidget; + }), + ); + + return loadedWidgets.filter((w) => w !== null); +} + function favoriteBadgesOwnedAndSupporterStatusAdjusted(row: { favoriteBadgeIds: number[] | null; badges: Array<{ @@ -675,6 +779,30 @@ export async function hasHighlightedResultsByUserId(userId: number) { return !!highlightedCalendarEventResult; } +export async function findResultPlacementsByUserId(userId: number) { + const tournamentResults = await db + .selectFrom("TournamentResult") + .select(["TournamentResult.placement"]) + .where("userId", "=", userId) + .execute(); + + const calendarEventResults = await db + .selectFrom("CalendarEventResultPlayer") + .innerJoin( + "CalendarEventResultTeam", + "CalendarEventResultTeam.id", + "CalendarEventResultPlayer.teamId", + ) + .select(["CalendarEventResultTeam.placement"]) + .where("CalendarEventResultPlayer.userId", "=", userId) + .execute(); + + return [ + ...tournamentResults.map((r) => ({ placement: r.placement })), + ...calendarEventResults.map((r) => ({ placement: r.placement })), + ]; +} + const searchSelectedFields = ({ fn }: { fn: FunctionModule }) => [ ...COMMON_USER_FIELDS, @@ -862,6 +990,28 @@ export async function inGameNameByUserId(userId: number) { )?.inGameName; } +export async function patronSinceByUserId(userId: number) { + return ( + await db + .selectFrom("User") + .select("User.patronSince") + .where("id", "=", userId) + .executeTakeFirst() + )?.patronSince; +} + +export async function commissionsByUserId(userId: number) { + return await db + .selectFrom("User") + .select([ + "User.commissionsOpen", + "User.commissionsOpenedAt", + "User.commissionText", + ]) + .where("id", "=", userId) + .executeTakeFirst(); +} + export function insertFriendCode(args: TablesInsertable["UserFriendCode"]) { cachedFriendCodes?.add(args.friendCode); @@ -1128,6 +1278,45 @@ export async function anyUserPrefersNoScreen( return Boolean(result); } +export async function socialLinksByUserId(userId: number) { + const user = await db + .selectFrom("User") + .select([ + "User.twitch", + "User.youtubeId", + "User.bsky", + "User.discordUniqueName", + ]) + .where("User.id", "=", userId) + .executeTakeFirst(); + + if (!user) return []; + + const links: Array< + | { type: "url"; value: string } + | { type: "popover"; platform: "discord"; value: string } + > = []; + + if (user.twitch) { + links.push({ type: "url", value: twitchUrl(user.twitch) }); + } + if (user.youtubeId) { + links.push({ type: "url", value: youtubeUrl(user.youtubeId) }); + } + if (user.bsky) { + links.push({ type: "url", value: bskyUrl(user.bsky) }); + } + if (user.discordUniqueName) { + links.push({ + type: "popover", + platform: "discord", + value: user.discordUniqueName, + }); + } + + return links; +} + export function findIdsByTwitchUsernames(twitchUsernames: string[]) { if (twitchUsernames.length === 0) return []; diff --git a/app/features/user-page/actions/u.$identifier.edit-widgets.server.ts b/app/features/user-page/actions/u.$identifier.edit-widgets.server.ts new file mode 100644 index 000000000..5d8adf57d --- /dev/null +++ b/app/features/user-page/actions/u.$identifier.edit-widgets.server.ts @@ -0,0 +1,23 @@ +import { redirect } from "react-router"; +import { requireUser } from "~/features/auth/core/user.server"; +import type { StoredWidget } from "~/features/user-page/core/widgets/types"; +import * as UserRepository from "~/features/user-page/UserRepository.server"; +import { widgetsEditSchema } from "~/features/user-page/user-page-schemas"; +import { parseRequestPayload } from "~/utils/remix.server"; +import { userPage } from "~/utils/urls"; + +export const action = async ({ request }: { request: Request }) => { + const user = requireUser(); + + const payload = await parseRequestPayload({ + request, + schema: widgetsEditSchema, + }); + + await UserRepository.upsertWidgets( + user.id, + payload.widgets as StoredWidget[], + ); + + return redirect(userPage(user)); +}; diff --git a/app/features/user-page/actions/u.$identifier.edit.server.ts b/app/features/user-page/actions/u.$identifier.edit.server.ts index 9c136e171..04a955e05 100644 --- a/app/features/user-page/actions/u.$identifier.edit.server.ts +++ b/app/features/user-page/actions/u.$identifier.edit.server.ts @@ -20,7 +20,12 @@ export const action: ActionFunction = async ({ request }) => { }; } - const { inGameNameText, inGameNameDiscriminator, ...data } = parsedInput.data; + const { + inGameNameText, + inGameNameDiscriminator, + newProfileEnabled, + ...data + } = parsedInput.data; const user = requireUser(); const inGameName = @@ -28,15 +33,15 @@ export const action: ActionFunction = async ({ request }) => { ? `${inGameNameText}#${inGameNameDiscriminator}` : null; - const pronouns = - data.subjectPronoun && data.objectPronoun - ? JSON.stringify({ - subject: data.subjectPronoun, - object: data.objectPronoun, - }) - : null; - try { + const pronouns = + data.subjectPronoun && data.objectPronoun + ? JSON.stringify({ + subject: data.subjectPronoun, + object: data.objectPronoun, + }) + : null; + const editedUser = await UserRepository.updateProfile({ ...data, pronouns, @@ -44,6 +49,10 @@ export const action: ActionFunction = async ({ request }) => { userId: user.id, }); + await UserRepository.updatePreferences(user.id, { + newProfileEnabled: Boolean(newProfileEnabled), + }); + // TODO: to transaction if (inGameName) { const tournamentIdsAffected = diff --git a/app/features/user-page/components/SubPageHeader.module.css b/app/features/user-page/components/SubPageHeader.module.css new file mode 100644 index 000000000..9176e2b86 --- /dev/null +++ b/app/features/user-page/components/SubPageHeader.module.css @@ -0,0 +1,63 @@ +.subPageHeader { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--s-4); + padding-block: var(--s-4); + flex-wrap: wrap; +} + +.leftSection { + display: flex; + align-items: center; + gap: var(--s-2); +} + +.actions { + display: flex; + align-items: center; + gap: var(--s-2); + flex-wrap: wrap; +} + +.backButton { + display: flex; + align-items: center; + justify-content: center; + width: 36px; + height: 36px; + border-radius: 100%; + background-color: var(--bg); + border: 2px solid var(--bg-lightest); + color: var(--text); + transition: all 0.2s; +} + +.backButton svg { + min-width: 30px; +} + +.backButton:hover { + background-color: var(--bg-lightest); +} + +.backIcon { + width: 24px; + height: 24px; +} + +.avatar { + border-radius: 50%; +} + +.userInfo { + display: flex; + align-items: center; + gap: var(--s-2); + color: inherit; +} + +.username { + font-weight: var(--semi-bold); + font-size: var(--fonts-sm); +} diff --git a/app/features/user-page/components/SubPageHeader.tsx b/app/features/user-page/components/SubPageHeader.tsx new file mode 100644 index 000000000..9e8ea1ce0 --- /dev/null +++ b/app/features/user-page/components/SubPageHeader.tsx @@ -0,0 +1,34 @@ +import { Link } from "react-router"; +import { Avatar } from "~/components/Avatar"; +import { ArrowLeftIcon } from "~/components/icons/ArrowLeft"; +import type { Tables } from "~/db/tables"; +import styles from "./SubPageHeader.module.css"; + +export function SubPageHeader({ + user, + backTo, + children, +}: { + user: Pick; + backTo: string; + children?: React.ReactNode; +}) { + return ( +
+
+ + + + + + {user.username} + +
+ {children ?
{children}
: null} +
+ ); +} diff --git a/app/features/user-page/components/UserPageIconNav.module.css b/app/features/user-page/components/UserPageIconNav.module.css new file mode 100644 index 000000000..c6bb7240a --- /dev/null +++ b/app/features/user-page/components/UserPageIconNav.module.css @@ -0,0 +1,51 @@ +.iconNav { + display: flex; + justify-content: center; + gap: var(--s-2); + overflow-x: auto; + scroll-snap-type: x mandatory; + padding-block: var(--s-2); + scrollbar-width: thin; +} + +.iconNavItem { + flex: 0 0 auto; + scroll-snap-align: start; + display: flex; + align-items: center; + justify-content: center; + width: 42px; + height: 42px; + background-color: var(--bg); + border: 2px solid var(--bg-lightest); + border-radius: var(--rounded); + color: var(--text); + transition: all 0.2s; + cursor: pointer; +} + +.iconNavItem:hover { + background-color: var(--bg-lightest); +} + +.iconNavItem:focus-visible { + outline: 2px solid var(--theme-secondary); + outline-offset: 2px; +} + +.iconNavItem.active { + border-color: var(--theme-secondary); + background-color: var(--bg-lightest); +} + +@media screen and (min-width: 768px) { + .iconNav { + display: grid; + grid-template-columns: repeat(4, 42px); + grid-auto-rows: 42px; + gap: var(--s-2); + overflow-x: visible; + scroll-snap-type: none; + padding-block: 0; + } +} diff --git a/app/features/user-page/components/UserPageIconNav.tsx b/app/features/user-page/components/UserPageIconNav.tsx new file mode 100644 index 000000000..081afa910 --- /dev/null +++ b/app/features/user-page/components/UserPageIconNav.tsx @@ -0,0 +1,51 @@ +import clsx from "clsx"; +import { type LinkProps, NavLink } from "react-router"; +import { Image } from "~/components/Image"; +import { navIconUrl } from "~/utils/urls"; +import styles from "./UserPageIconNav.module.css"; + +export interface UserPageNavItem { + to: string; + iconName: string; + label: string; + count?: number; + isVisible: boolean; + testId?: string; + end?: boolean; + prefetch?: LinkProps["prefetch"]; +} + +export function UserPageIconNav({ items }: { items: UserPageNavItem[] }) { + const visibleItems = items.filter((item) => item.isVisible); + + return ( + + ); +} diff --git a/app/features/user-page/components/Widget.module.css b/app/features/user-page/components/Widget.module.css new file mode 100644 index 000000000..b78aaf359 --- /dev/null +++ b/app/features/user-page/components/Widget.module.css @@ -0,0 +1,312 @@ +.widget { + background-color: var(--bg); + border-radius: var(--rounded); + padding: 0; + display: flex; + flex-direction: column; + overflow: hidden; +} + +.header { + display: flex; + align-items: center; + justify-content: space-between; + padding-block: var(--s-2); + border-bottom: 2px solid var(--bg-lightest); +} + +.headerText { + font-size: var(--fonts-xs); + font-weight: var(--semi-bold); + text-transform: uppercase; + letter-spacing: 0.05em; + margin: 0; + color: var(--text-lighter); +} + +.headerLink { + font-size: var(--fonts-xs); + font-weight: var(--semi-bold); + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--theme); +} + +.content { + display: flex; + flex-direction: column; + gap: var(--s-2); + padding-block: var(--s-4); +} + +.memberships { + display: flex; + flex-direction: column; + gap: var(--s-3); +} + +.membership { + display: flex; + align-items: center; + gap: var(--s-2-5); + color: var(--text); + margin-block-end: var(--s-1); + border-radius: var(--rounded-sm); + transition: background-color 0.2s; +} + +.membershipInfo { + display: flex; + flex-direction: column; + gap: var(--s-0-5); +} + +.membershipName { + font-weight: var(--semi-bold); +} + +.membershipRole { + font-size: var(--fonts-xxs); + color: var(--text-lighter); +} + +.peakValue { + display: flex; + flex-direction: column; + align-items: center; + gap: var(--s-1); +} + +.widgetValueMain { + font-size: var(--fonts-xl); + font-weight: var(--semi-bold); + color: var(--text); +} + +.widgetValueFooter { + font-size: var(--fonts-xxs); + font-weight: var(--semi-bold); + color: var(--text-lighter); + text-transform: uppercase; + letter-spacing: 0.05em; +} + +.highlightedResults { + display: flex; + flex-direction: column; + gap: var(--s-3); +} + +.result { + display: flex; + align-items: center; + gap: var(--s-2-5); +} + +.resultPlacement { + flex-shrink: 0; +} + +.resultInfo { + display: flex; + flex-direction: column; + gap: var(--s-0-5); + flex: 1; + line-height: 1.25; +} + +.resultName { + font-weight: var(--semi-bold); +} + +.tournamentName { + display: flex; + align-items: center; + gap: var(--s-1); + max-width: 100%; + overflow: hidden; +} + +.tournamentName a { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + min-width: 0; + max-width: 175px; +} + +.resultDate { + font-size: var(--fonts-xxs); + color: var(--text-lighter); +} + +.videos { + margin: 0 auto; + display: flex; + flex-direction: column; + gap: var(--s-3); +} + +.weaponGrid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(40px, 1fr)); + gap: var(--s-2); + justify-items: center; +} + +.weaponCount { + margin-top: var(--s-3); + text-align: center; + font-size: var(--fonts-xs); + font-weight: var(--semi-bold); + color: var(--text-lighter); +} + +.weaponCountComplete { + color: var(--theme-success); +} + +.lfgPosts { + display: flex; + flex-direction: column; + gap: var(--s-2); +} + +.lfgPost { + color: var(--text); + font-size: var(--fonts-sm); + border-radius: var(--rounded-sm); + padding: var(--s-2); + transition: background-color 0.2s; + background-color: var(--bg-lighter); +} + +.lfgPost:hover { + background-color: var(--bg-lightest); +} + +.xRankPeaks { + display: flex; + flex-wrap: wrap; + gap: var(--s-3); + justify-content: center; + font-size: var(--fonts-sm); + font-weight: var(--semi-bold); +} + +.xRankPeakMode { + display: flex; + flex-direction: column; + align-items: center; + gap: var(--s-1); +} + +.xRankPeakModeIconWrapper { + position: relative; + width: 24px; + height: 24px; +} + +.xRankPeakDivision { + position: absolute; + bottom: -2px; + right: -2px; + display: flex; + align-items: center; + justify-content: center; + background-color: var(--bg-lightest); + border-radius: 50%; + padding: 1px; +} + +.placementResults { + display: flex; + gap: var(--s-4); + justify-content: center; + align-items: center; +} + +.placementResult { + display: flex; + align-items: center; + gap: var(--s-1); + font-size: var(--fonts-sm); + font-weight: var(--semi-bold); +} + +.builds { + display: flex; + gap: var(--s-3); + overflow-x: auto; + padding-block: var(--s-2); +} + +.artGrid { + display: flex; + gap: var(--s-3); + overflow-x: auto; + padding-block: var(--s-2); +} + +.artThumbnail { + height: 300px; + object-fit: contain; + flex-shrink: 0; +} + +.socialLinks { + display: flex; + flex-direction: column; + gap: var(--s-2); +} + +.socialLink { + font-size: var(--fonts-sm); + display: flex; + gap: var(--s-2); + align-items: center; + word-break: break-all; + overflow-wrap: break-word; +} + +.socialLink svg { + width: 18px; +} + +.socialLinkIconContainer { + background-color: var(--bg-lightest); + display: grid; + place-items: center; + border-radius: var(--rounded); + padding: var(--s-2); + flex-shrink: 0; +} + +.socialLinkIconContainer svg { + width: 18px; + height: 18px; + fill: var(--text); +} + +.socialLinkIconContainer.twitch svg { + fill: #9146ff; +} + +.socialLinkIconContainer.youtube svg { + fill: #f00; +} + +.socialLinkIconContainer.bsky path { + fill: #1285fe; +} + +.socialLinkIconContainer.discord svg { + fill: #5865f2; +} + +.socialLinksIcons { + display: flex; + gap: var(--s-2); + justify-content: center; + flex-wrap: wrap; +} diff --git a/app/features/user-page/components/Widget.tsx b/app/features/user-page/components/Widget.tsx new file mode 100644 index 000000000..4f7067444 --- /dev/null +++ b/app/features/user-page/components/Widget.tsx @@ -0,0 +1,849 @@ +import clsx from "clsx"; +import Markdown from "markdown-to-jsx"; +import * as React from "react"; +import { useTranslation } from "react-i18next"; +import { Link } from "react-router"; +import { BuildCard } from "~/components/BuildCard"; +import { SendouButton } from "~/components/elements/Button"; +import { SendouPopover } from "~/components/elements/Popover"; +import { Image, StageImage, WeaponImage } from "~/components/Image"; +import { BskyIcon } from "~/components/icons/Bsky"; +import { DiscordIcon } from "~/components/icons/Discord"; +import { LinkIcon } from "~/components/icons/Link"; +import { TwitchIcon } from "~/components/icons/Twitch"; +import { YouTubeIcon } from "~/components/icons/YouTube"; +import { Placement } from "~/components/Placement"; +import type { Tables } from "~/db/tables"; +import { previewUrl } from "~/features/art/art-utils"; +import { BadgeDisplay } from "~/features/badges/components/BadgeDisplay"; +import { VodListing } from "~/features/vods/components/VodListing"; +import { useTimeFormat } from "~/hooks/useTimeFormat"; +import type { + MainWeaponId, + ModeShort, + StageId, +} from "~/modules/in-game-lists/types"; +import { databaseTimestampToDate } from "~/utils/dates"; +import type { SerializeFrom } from "~/utils/remix"; +import { assertUnreachable } from "~/utils/types"; +import { + brandImageUrl, + calendarEventPage, + controllerImageUrl, + LEADERBOARDS_PAGE, + LFG_PAGE, + modeImageUrl, + navIconUrl, + teamPage, + topSearchPlayerPage, + tournamentBracketsPage, + tournamentOrganizationPage, + userArtPage, + userBuildsPage, + userResultsPage, + userVodsPage, +} from "~/utils/urls"; +import type { LoadedWidget } from "../core/widgets/types"; +import styles from "./Widget.module.css"; + +export function Widget({ + widget, + user, +}: { + widget: SerializeFrom; + user: Pick; +}) { + const { t } = useTranslation(["user", "badges", "team", "org", "lfg"]); + const { formatDate } = useTimeFormat(); + + const content = () => { + switch (widget.id) { + case "bio": + return
{widget.data.bio}
; + case "bio-md": + return ( +
+ + {widget.data.bio} + +
+ ); + case "badges-owned": + return ; + case "badges-authored": + return ; + case "teams": + return ( + ({ + id: team.id, + url: teamPage(team.customUrl), + name: team.name, + logoUrl: team.logoUrl, + roleDisplayName: team.role ? t(`team:roles.${team.role}`) : null, + }))} + /> + ); + case "organizations": + return ( + ({ + id: org.id, + url: tournamentOrganizationPage({ + organizationSlug: org.slug, + }), + name: org.name, + logoUrl: org.logoUrl, + roleDisplayName: + org.roleDisplayName ?? t(`org:roles.${org.role}`), + }))} + /> + ); + case "peak-sp": + if (!widget.data) return null; + return ( + + ); + case "top-10-seasons": + case "top-100-seasons": + if (!widget.data) return null; + return ( + a - b) + .map((s) => `S${s}`) + .join(" ")} + /> + ); + case "peak-xp": + if (!widget.data) return null; + return ( + + ); + case "peak-xp-unverified": + return ( + + ); + case "peak-xp-weapon": + if (!widget.data) return null; + return ( + + ); + case "highlighted-results": + return widget.data.length === 0 ? null : ( + + ); + case "placement-results": + if (!widget.data) return null; + return ; + case "patron-since": + if (!widget.data) return null; + return ( + + ); + case "timezone": + return ; + case "favorite-stage": + return ; + case "videos": + return widget.data.length === 0 ? null : ( + + ); + case "lfg-posts": + return widget.data.length === 0 ? null : ( + + ); + case "top-500-weapons": + if (!widget.data) return null; + return ; + case "top-500-weapons-shooters": + case "top-500-weapons-blasters": + case "top-500-weapons-rollers": + case "top-500-weapons-brushes": + case "top-500-weapons-chargers": + case "top-500-weapons-sloshers": + case "top-500-weapons-splatlings": + case "top-500-weapons-dualies": + case "top-500-weapons-brellas": + case "top-500-weapons-stringers": + case "top-500-weapons-splatanas": { + if (!widget.data) return null; + + return ( + + ); + } + case "x-rank-peaks": + return widget.data.length === 0 ? null : ( + + ); + case "builds": + return widget.data.length === 0 ? null : ( + + ); + case "weapon-pool": + return widget.data.weapons.length === 0 ? null : ( + + } + /> + ); + case "sens": + return ; + case "art": + return widget.data.length === 0 ? null : ( + + ); + case "commissions": + return ; + case "social-links": + return ; + case "links": + return widget.data.length === 0 ? null : ( + + ); + case "tier-list": + return ; + default: + assertUnreachable(widget); + } + }; + + const widgetLink = (() => { + switch (widget.id) { + case "videos": + return userVodsPage(user); + case "x-rank-peaks": + return widget.data.length > 0 + ? topSearchPlayerPage(widget.data[0].playerId) + : null; + case "highlighted-results": + return userResultsPage(user); + case "placement-results": + return userResultsPage(user); + case "builds": + return userBuildsPage(user); + case "peak-xp-weapon": + return widget.data + ? `${LEADERBOARDS_PAGE}?type=XP-WEAPON-${widget.data.weaponSplId}` + : null; + case "art": + return widget.data.length > 0 ? userArtPage(user) : null; + default: + return null; + } + })(); + + return ( +
+
+

{t(`user:widget.${widget.id}`)}

+ {widgetLink ? ( + + {t("user:widget.link.all")} + + ) : null} +
+
{content()}
+
+ ); +} + +function BigValue({ + value, + unit, + footer, +}: { + value: number | string; + unit?: string; + footer?: string; +}) { + return ( +
+
+ {value} {unit ? unit : null} +
+ {footer ?
{footer}
: null} +
+ ); +} + +function Memberships({ + memberships, +}: { + memberships: Array<{ + id: number; + url: string; + name: string; + logoUrl: string | null; + roleDisplayName: string | null; + }>; +}) { + return ( +
+ {memberships.map((membership) => ( + + {membership.logoUrl ? ( + + ) : null} +
+
{membership.name}
+ {membership.roleDisplayName ? ( +
+ {membership.roleDisplayName} +
+ ) : null} +
+ + ))} +
+ ); +} + +function HighlightedResults({ + results, +}: { + results: Extract["data"]; +}) { + const { formatDate } = useTimeFormat(); + + return ( +
+ {results.map((result, i) => ( +
+
+ +
+
+
+ {result.eventId ? ( + + {result.eventName} + + ) : null} + {result.tournamentId ? ( +
+ {result.logoUrl ? ( + + ) : null} + + {result.eventName} + +
+ ) : null} +
+
+ {formatDate(databaseTimestampToDate(result.startTime), { + day: "numeric", + month: "short", + year: "numeric", + })} +
+
+
+ ))} +
+ ); +} + +function Videos({ + videos, +}: { + videos: Extract["data"]; +}) { + return ( +
+ {videos.map((video) => ( + + ))} +
+ ); +} + +function Top500Weapons({ + weaponIds, + count, + total, +}: { + weaponIds: MainWeaponId[]; + count?: number; + total?: number; +}) { + const isComplete = + typeof count === "number" && typeof total === "number" && count === total; + + return ( +
+
+ {weaponIds.map((weaponId) => ( + + ))} +
+ {typeof count === "number" && typeof total === "number" ? ( +
+ {count} / {total} +
+ ) : null} +
+ ); +} + +function LFGPosts({ + posts, +}: { + posts: Extract["data"]; +}) { + const { t } = useTranslation(["lfg"]); + + return ( +
+ {posts.map((post) => ( + + {t(`lfg:types.${post.type}`)} + + ))} +
+ ); +} + +const TENTATEK_BRAND_ID = "B10"; +const TAKOROKA_BRAND_ID = "B11"; + +function XRankPeaks({ + peaks, +}: { + peaks: Extract["data"]; +}) { + return ( +
+ {peaks.map((peak) => ( +
+
+ +
+ {peak.region +
+
+
+ {peak.rank} / {peak.power.toFixed(1)} +
+
+ ))} +
+ ); +} + +function TimezoneWidget({ timezone }: { timezone: string }) { + const [currentTime, setCurrentTime] = React.useState(() => new Date()); + + React.useEffect(() => { + const interval = setInterval(() => { + setCurrentTime(new Date()); + }, 1000); + + return () => clearInterval(interval); + }, []); + + const formatter = new Intl.DateTimeFormat("en-US", { + timeZone: timezone, + hour: "numeric", + minute: "2-digit", + second: "2-digit", + hour12: true, + }); + + const dateFormatter = new Intl.DateTimeFormat("en-US", { + timeZone: timezone, + weekday: "short", + day: "numeric", + month: "short", + }); + + return ( +
+
+ {formatter.format(currentTime)} +
+
+ {dateFormatter.format(currentTime)} +
+
+ ); +} + +function FavoriteStageWidget({ stageId }: { stageId: StageId }) { + const { t } = useTranslation(["game-misc"]); + + return ( +
+ +
+ {t(`game-misc:STAGE_${stageId}`)} +
+
+ ); +} + +function PlacementResults({ + data, +}: { + data: NonNullable["data"]>; +}) { + return ( +
+ {data.placements.map(({ placement, count }) => { + return ( +
+ + ×{count} +
+ ); + })} +
+ ); +} + +function Builds({ + builds, +}: { + builds: Extract["data"]; +}) { + return ( +
+ {builds.map((build) => ( + + ))} +
+ ); +} + +function WeaponPool({ + weapons, +}: { + weapons: Array<{ id: MainWeaponId; isFavorite: boolean }>; +}) { + return ( +
+ {weapons.map((weapon) => { + return ( +
+ +
+ ); + })} +
+ ); +} + +function PeakXpWeapon({ + weaponSplId, + peakXp, + leaderboardPosition, +}: { + weaponSplId: MainWeaponId; + peakXp: number; + leaderboardPosition: number | null; +}) { + return ( +
+
+ +
+
{peakXp} XP
+ {leaderboardPosition ? ( +
#{leaderboardPosition}
+ ) : null} +
+ ); +} + +function SensWidget({ + data, +}: { + data: Extract["data"]; +}) { + const { t } = useTranslation(["user"]); + + const rawSensToString = (sens: number) => + `${sens > 0 ? "+" : ""}${sens / 10}`; + + return ( +
+ {t(`user:controllers.${data.controller}`)} +
+
+
+
{t("user:motionSens")}
+
+ {data.motionSens ? rawSensToString(data.motionSens) : "-"} +
+
+
+
{t("user:stickSens")}
+
+ {data.stickSens ? rawSensToString(data.stickSens) : "-"} +
+
+
+
+
+ ); +} + +function ArtWidget({ + arts, +}: { + arts: Extract["data"]; +}) { + return ( +
+ {arts.map((art) => ( + + ))} +
+ ); +} + +function CommissionsWidget({ + data, +}: { + data: Extract["data"]; +}) { + const { t } = useTranslation(["user"]); + + if (!data) return null; + + const isOpen = data.commissionsOpen === 1; + + return ( +
+
+ {isOpen ? t("user:commissions.open") : t("user:commissions.closed")} +
+ {data.commissionText ? ( +
{data.commissionText}
+ ) : null} +
+ ); +} + +const urlToLinkType = (url: string) => { + if (url.includes("twitch.tv")) { + return "twitch"; + } + if (url.includes("youtube.com")) { + return "youtube"; + } + if (url.includes("bsky.app")) { + return "bsky"; + } + return null; +}; + +const urlToIcon = (url: string) => { + const type = urlToLinkType(url); + if (type === "twitch") { + return ; + } + if (type === "youtube") { + return ; + } + if (type === "bsky") { + return ; + } + return ; +}; + +function SocialLinksWidget({ + data, +}: { + data: Extract["data"]; +}) { + if (data.length === 0) return null; + + return ( +
+ {data.map((link, i) => { + if (link.type === "popover") { + return ( + + {link.platform === "discord" ? : null} + + } + > + {link.value} + + ); + } + + const type = urlToLinkType(link.value); + return ( + + {urlToIcon(link.value)} + + ); + })} +
+ ); +} + +function LinksWidget({ links }: { links: string[] }) { + return ( +
+ {links.map((url, i) => { + const type = urlToLinkType(url); + return ( + +
+ {urlToIcon(url)} +
+ {url} +
+ ); + })} +
+ ); +} + +function TierListWidget({ searchParams }: { searchParams: string }) { + const fullUrl = `/tier-list-maker?${searchParams}`; + const parsedUrl = new URL(fullUrl, "https://sendou.ink"); + const title = parsedUrl.searchParams.get("title"); + const { t } = useTranslation(["user"]); + + return ( +
+ +
+ +
+ {title ? title : t("user:widget.tier-list.untitled")} + +
+ ); +} diff --git a/app/features/user-page/components/WidgetSettingsForm.tsx b/app/features/user-page/components/WidgetSettingsForm.tsx new file mode 100644 index 000000000..903d2882c --- /dev/null +++ b/app/features/user-page/components/WidgetSettingsForm.tsx @@ -0,0 +1,268 @@ +import clsx from "clsx"; +import { useTranslation } from "react-i18next"; +import type { Tables } from "~/db/tables"; +import { type CustomFieldRenderProps, FormField } from "~/form/FormField"; +import { SendouForm, useFormFieldContext } from "~/form/SendouForm"; +import { + CONTROLLERS, + getWidgetFormSchema, + TIMEZONE_OPTIONS, +} from "../core/widgets/widget-form-schemas"; +import styles from "../routes/u.$identifier.module.css"; + +export function WidgetSettingsForm({ + widget, + onSettingsChange, +}: { + widget: Tables["UserWidget"]["widget"]; + onSettingsChange: (widgetId: string, settings: unknown) => void; +}) { + const schema = getWidgetFormSchema(widget.id); + + if (!schema) { + return null; + } + + return ( + + ); +} + +function WidgetSettingsFormInner({ + widget, + schema, + onSettingsChange, +}: { + widget: Tables["UserWidget"]["widget"]; + schema: ReturnType; + onSettingsChange: (widgetId: string, settings: unknown) => void; +}) { + if (!schema) return null; + + const handleApply = (values: unknown) => { + onSettingsChange(widget.id, values); + }; + + const defaultValues = transformSettingsForForm( + widget.id, + widget.settings ?? {}, + ); + + return ( + + + + ); +} + +function WidgetFormFields({ widgetId }: { widgetId: string }) { + switch (widgetId) { + case "bio": + case "bio-md": + return ; + case "x-rank-peaks": + return ; + case "timezone": + return ; + case "favorite-stage": + return ; + case "peak-xp-unverified": + return ( +
+ + +
+ ); + case "peak-xp-weapon": + return ; + case "weapon-pool": + return ; + case "sens": + return ; + case "art": + return ; + case "links": + return ; + case "tier-list": + return ( + + {(props: CustomFieldRenderProps) => ( + )} /> + )} + + ); + default: + return null; + } +} + +function transformSettingsForForm( + widgetId: string, + settings: Record, +): Record { + if (widgetId === "weapon-pool" && settings.weapons) { + const weapons = settings.weapons as Array<{ + weaponSplId?: number; + id?: number; + isFavorite: number | boolean; + }>; + return { + ...settings, + weapons: weapons.map((w) => ({ + id: w.id ?? w.weaponSplId, + isFavorite: w.isFavorite === 1 || w.isFavorite === true, + })), + }; + } + return settings; +} + +const SENS_OPTIONS = [ + -50, -45, -40, -35, -30, -25, -20, -15, -10, -5, 0, 5, 10, 15, 20, 25, 30, 35, + 40, 45, 50, +]; + +function SensFields() { + const { t } = useTranslation(["user"]); + const { values, setValue, onFieldChange } = useFormFieldContext(); + + const controller = + (values.controller as (typeof CONTROLLERS)[number]) ?? "s2-pro-con"; + const motionSens = (values.motionSens as number | null) ?? null; + const stickSens = (values.stickSens as number | null) ?? null; + + const rawSensToString = (sens: number) => + `${sens > 0 ? "+" : ""}${sens / 10}`; + + const handleControllerChange = ( + newController: (typeof CONTROLLERS)[number], + ) => { + setValue("controller", newController); + onFieldChange?.("controller", newController); + }; + + const handleMotionSensChange = (sens: number | null) => { + setValue("motionSens", sens); + onFieldChange?.("motionSens", sens); + }; + + const handleStickSensChange = (sens: number | null) => { + setValue("stickSens", sens); + onFieldChange?.("stickSens", sens); + }; + + return ( +
+
+ + +
+ +
+
+ + +
+ +
+ + +
+
+
+ ); +} + +function TierListField({ value, onChange }: CustomFieldRenderProps) { + const { t } = useTranslation(["user"]); + + const handleChange = (e: React.ChangeEvent) => { + const inputValue = e.target.value; + + if (inputValue.includes("/tier-list-maker")) { + try { + const url = new URL(inputValue, "https://sendou.ink"); + const extractedSearchParams = url.search.substring(1); + onChange(extractedSearchParams); + return; + } catch { + // not a valid URL, just use the value as-is + } + } + + onChange(inputValue); + }; + + return ( +
+ +
+
/tier-list-maker?
+ +
+
+ ); +} diff --git a/app/features/user-page/core/widgets/portfolio-loaders.server.ts b/app/features/user-page/core/widgets/portfolio-loaders.server.ts new file mode 100644 index 000000000..578aa3902 --- /dev/null +++ b/app/features/user-page/core/widgets/portfolio-loaders.server.ts @@ -0,0 +1,313 @@ +import * as ArtRepository from "~/features/art/ArtRepository.server"; +import * as BadgeRepository from "~/features/badges/BadgeRepository.server"; +import * as BuildRepository from "~/features/builds/BuildRepository.server"; +import * as LeaderboardRepository from "~/features/leaderboards/LeaderboardRepository.server"; +import * as LFGRepository from "~/features/lfg/LFGRepository.server"; +import { ordinalToSp } from "~/features/mmr/mmr-utils"; +import { userSkills as _userSkills } from "~/features/mmr/tiered.server"; +import * as TeamRepository from "~/features/team/TeamRepository.server"; +import * as XRankPlacementRepository from "~/features/top-search/XRankPlacementRepository.server"; +import * as TournamentOrganizationRepository from "~/features/tournament-organization/TournamentOrganizationRepository.server"; +import * as UserRepository from "~/features/user-page/UserRepository.server"; +import * as VodRepository from "~/features/vods/VodRepository.server"; +import { weaponCategories } from "~/modules/in-game-lists/weapon-ids"; +import type { ExtractWidgetSettings } from "./types"; +import { cachedUserSQLeaderboardTopData } from "./utils.server"; + +export const WIDGET_LOADERS = { + "badges-owned": async (userId: number) => { + return BadgeRepository.findByOwnerUserId(userId); + }, + "badges-authored": async (userId: number) => { + return BadgeRepository.findByAuthorUserId(userId); + }, + teams: async (userId: number) => { + return TeamRepository.findAllMemberOfByUserId(userId); + }, + organizations: async (userId: number) => { + return TournamentOrganizationRepository.findByUserId(userId); + }, + "peak-sp": async (userId: number) => { + const seasonsParticipatedIn = + await LeaderboardRepository.seasonsParticipatedInByUserId(userId); + + if (seasonsParticipatedIn.length === 0) { + return null; + } + + let peakData = null; + let maxOrdinal = Number.NEGATIVE_INFINITY; + + for (const season of seasonsParticipatedIn) { + const { userSkills } = _userSkills(season); + const skillData = userSkills[userId]; + + if (!skillData || skillData.approximate) { + continue; + } + + if (skillData.ordinal > maxOrdinal) { + maxOrdinal = skillData.ordinal; + peakData = { + peakSp: ordinalToSp(skillData.ordinal), + tierName: skillData.tier.name, + isPlus: skillData.tier.isPlus, + season, + }; + } + } + + return peakData; + }, + "top-10-seasons": async (userId: number) => { + const cache = await cachedUserSQLeaderboardTopData(); + const userData = cache.get(userId); + + if (!userData || userData.TOP_10.times === 0) { + return null; + } + + return userData.TOP_10; + }, + "top-100-seasons": async (userId: number) => { + const cache = await cachedUserSQLeaderboardTopData(); + const userData = cache.get(userId); + + if (!userData || userData.TOP_100.times === 0) { + return null; + } + + return userData.TOP_100; + }, + "peak-xp": async (userId: number) => { + const placements = await XRankPlacementRepository.findPlacementsByUserId( + userId, + { + limit: 1, + }, + ); + + if (!placements || placements.length === 0) { + return null; + } + + const peakPlacement = placements[0]; + const leaderboardEntry = + // optimize, only check leaderboard if peak placement is high enough + peakPlacement.power >= 3318.9 + ? (await LeaderboardRepository.allXPLeaderboard()).find( + (entry) => entry.id === userId, + ) + : null; + + return { + peakXp: peakPlacement.power, + division: peakPlacement.region === "WEST" ? "Tentatek" : "Takoroka", + topRating: leaderboardEntry?.placementRank ?? null, + }; + }, + "peak-xp-weapon": async ( + userId: number, + settings: ExtractWidgetSettings<"peak-xp-weapon">, + ) => { + const placements = await XRankPlacementRepository.findPlacementsByUserId( + userId, + { + weaponId: settings.weaponSplId, + limit: 1, + }, + ); + + if (!placements || placements.length === 0) { + return null; + } + + const peakPlacement = placements[0]; + + const leaderboard = await LeaderboardRepository.weaponXPLeaderboard( + settings.weaponSplId, + ); + const leaderboardPosition = leaderboard.findIndex( + (entry) => entry.id === userId, + ); + + return { + peakXp: peakPlacement.power, + weaponSplId: settings.weaponSplId, + leaderboardPosition: + leaderboardPosition === -1 ? null : leaderboardPosition + 1, + }; + }, + "highlighted-results": async (userId: number) => { + const hasHighlightedResults = + await UserRepository.hasHighlightedResultsByUserId(userId); + + const results = await UserRepository.findResultsByUserId(userId, { + showHighlightsOnly: hasHighlightedResults, + limit: 3, + }); + + return results; + }, + "placement-results": async (userId: number) => { + const results = await UserRepository.findResultPlacementsByUserId(userId); + + if (results.length === 0) { + return null; + } + + const firstPlaceResults = results.filter( + (result) => result.placement === 1, + ); + const secondPlaceResults = results.filter( + (result) => result.placement === 2, + ); + const thirdPlaceResults = results.filter( + (result) => result.placement === 3, + ); + + return { + count: results.length, + placements: [ + { + placement: 1, + count: firstPlaceResults.length, + }, + { + placement: 2, + count: secondPlaceResults.length, + }, + { + placement: 3, + count: thirdPlaceResults.length, + }, + ], + }; + }, + "patron-since": async (userId: number) => { + return UserRepository.patronSinceByUserId(userId); + }, + videos: async (userId: number) => { + return VodRepository.findByUserId(userId, 3); + }, + "lfg-posts": async (userId: number) => { + return LFGRepository.findByAuthorUserId(userId); + }, + "top-500-weapons": async (userId: number) => { + const placements = + await XRankPlacementRepository.findPlacementsByUserId(userId); + + if (!placements || placements.length === 0) { + return null; + } + + const uniqueWeaponIds = [...new Set(placements.map((p) => p.weaponSplId))]; + + return uniqueWeaponIds.sort((a, b) => a - b); + }, + "top-500-weapons-shooters": async (userId: number) => { + return getTop500WeaponsByCategory(userId, "SHOOTERS"); + }, + "top-500-weapons-blasters": async (userId: number) => { + return getTop500WeaponsByCategory(userId, "BLASTERS"); + }, + "top-500-weapons-rollers": async (userId: number) => { + return getTop500WeaponsByCategory(userId, "ROLLERS"); + }, + "top-500-weapons-brushes": async (userId: number) => { + return getTop500WeaponsByCategory(userId, "BRUSHES"); + }, + "top-500-weapons-chargers": async (userId: number) => { + return getTop500WeaponsByCategory(userId, "CHARGERS"); + }, + "top-500-weapons-sloshers": async (userId: number) => { + return getTop500WeaponsByCategory(userId, "SLOSHERS"); + }, + "top-500-weapons-splatlings": async (userId: number) => { + return getTop500WeaponsByCategory(userId, "SPLATLINGS"); + }, + "top-500-weapons-dualies": async (userId: number) => { + return getTop500WeaponsByCategory(userId, "DUALIES"); + }, + "top-500-weapons-brellas": async (userId: number) => { + return getTop500WeaponsByCategory(userId, "BRELLAS"); + }, + "top-500-weapons-stringers": async (userId: number) => { + return getTop500WeaponsByCategory(userId, "STRINGERS"); + }, + "top-500-weapons-splatanas": async (userId: number) => { + return getTop500WeaponsByCategory(userId, "SPLATANAS"); + }, + "x-rank-peaks": async ( + userId: number, + settings: ExtractWidgetSettings<"x-rank-peaks">, + ) => { + return XRankPlacementRepository.findPeaksByUserId( + userId, + settings.division, + ); + }, + builds: async (userId: number) => { + const builds = await BuildRepository.allByUserId(userId, { + showPrivate: false, + limit: 3, + }); + + return builds; + }, + art: async (userId: number, settings: ExtractWidgetSettings<"art">) => { + const includeAuthored = + settings.source === "ALL" || settings.source === "MADE-BY"; + const includeTagged = + settings.source === "ALL" || settings.source === "MADE-OF"; + + const arts = await ArtRepository.findArtsByUserId(userId, { + includeAuthored, + includeTagged, + }); + + return arts.slice(0, 3); + }, + commissions: async (userId: number) => { + return UserRepository.commissionsByUserId(userId); + }, + "social-links": async (userId: number) => { + return UserRepository.socialLinksByUserId(userId); + }, + links: async (_userId: number, settings: ExtractWidgetSettings<"links">) => { + return settings.links; + }, +}; + +async function getTop500WeaponsByCategory( + userId: number, + categoryName?: string, +) { + const placements = + await XRankPlacementRepository.findPlacementsByUserId(userId); + + if (!placements || placements.length === 0) { + return null; + } + + const allWeaponIds = placements.map((p) => p.weaponSplId); + const uniqueWeaponIds = [...new Set(allWeaponIds)]; + + const category = weaponCategories.find((c) => c.name === categoryName); + if (!category) { + return null; + } + + const categoryWeaponIds = uniqueWeaponIds.filter((id) => + (category.weaponIds as readonly number[]).includes(id), + ); + + if (categoryWeaponIds.length === 0) { + return null; + } + + return { + weaponIds: categoryWeaponIds.sort((a, b) => a - b), + total: category.weaponIds.length, + }; +} diff --git a/app/features/user-page/core/widgets/portfolio.ts b/app/features/user-page/core/widgets/portfolio.ts new file mode 100644 index 000000000..9fbba4c91 --- /dev/null +++ b/app/features/user-page/core/widgets/portfolio.ts @@ -0,0 +1,173 @@ +import type { z } from "zod"; +import type { StoredWidget } from "./types"; +import { + artSchema, + bioMdSchema, + bioSchema, + favoriteStageSchema, + linksSchema, + peakXpUnverifiedSchema, + peakXpWeaponSchema, + sensSchema, + tierListSchema, + timezoneSchema, + weaponPoolSchema, + xRankPeaksSchema, +} from "./widget-form-schemas"; + +export const ALL_WIDGETS = { + misc: [ + defineWidget({ + id: "bio", + slot: "main", + schema: bioSchema, + defaultSettings: { bio: "" }, + }), + defineWidget({ + id: "bio-md", + slot: "main", + schema: bioMdSchema, + defaultSettings: { bio: "" }, + }), + defineWidget({ id: "organizations", slot: "side" }), + defineWidget({ id: "patron-since", slot: "side" }), + defineWidget({ + id: "timezone", + slot: "side", + schema: timezoneSchema, + defaultSettings: { timezone: "" }, + }), + defineWidget({ + id: "favorite-stage", + slot: "side", + schema: favoriteStageSchema, + defaultSettings: { stageId: 1 }, + }), + defineWidget({ + id: "weapon-pool", + slot: "main", + schema: weaponPoolSchema, + defaultSettings: { weapons: [] }, + }), + defineWidget({ id: "lfg-posts", slot: "main" }), + defineWidget({ + id: "sens", + slot: "side", + schema: sensSchema, + defaultSettings: { + controller: "s1-pro-con", + motionSens: null, + stickSens: null, + }, + }), + defineWidget({ id: "commissions", slot: "side" }), + defineWidget({ id: "social-links", slot: "side" }), + defineWidget({ + id: "links", + slot: "side", + schema: linksSchema, + defaultSettings: { links: [] }, + }), + defineWidget({ + id: "tier-list", + slot: "side", + schema: tierListSchema, + defaultSettings: { searchParams: "" }, + }), + ], + badges: [ + defineWidget({ id: "badges-owned", slot: "main" }), + defineWidget({ id: "badges-authored", slot: "main" }), + ], + teams: [defineWidget({ id: "teams", slot: "side" })], + sendouq: [ + defineWidget({ id: "peak-sp", slot: "side" }), + defineWidget({ id: "top-10-seasons", slot: "side" }), + defineWidget({ id: "top-100-seasons", slot: "side" }), + ], + xrank: [ + defineWidget({ id: "peak-xp", slot: "side" }), + defineWidget({ + id: "peak-xp-unverified", + slot: "side", + schema: peakXpUnverifiedSchema, + defaultSettings: { peakXp: 2000, division: "tentatek" }, + }), + defineWidget({ + id: "peak-xp-weapon", + slot: "side", + schema: peakXpWeaponSchema, + defaultSettings: { weaponSplId: 0 }, + }), + defineWidget({ + id: "x-rank-peaks", + slot: "main", + schema: xRankPeaksSchema, + defaultSettings: { division: "both" }, + }), + defineWidget({ id: "top-500-weapons", slot: "side" }), + defineWidget({ id: "top-500-weapons-shooters", slot: "side" }), + defineWidget({ id: "top-500-weapons-blasters", slot: "side" }), + defineWidget({ id: "top-500-weapons-rollers", slot: "side" }), + defineWidget({ id: "top-500-weapons-brushes", slot: "side" }), + defineWidget({ id: "top-500-weapons-chargers", slot: "side" }), + defineWidget({ id: "top-500-weapons-sloshers", slot: "side" }), + defineWidget({ id: "top-500-weapons-splatlings", slot: "side" }), + defineWidget({ id: "top-500-weapons-dualies", slot: "side" }), + defineWidget({ id: "top-500-weapons-brellas", slot: "side" }), + defineWidget({ id: "top-500-weapons-stringers", slot: "side" }), + defineWidget({ id: "top-500-weapons-splatanas", slot: "side" }), + ], + tournaments: [ + defineWidget({ id: "highlighted-results", slot: "side" }), + defineWidget({ id: "placement-results", slot: "side" }), + ], + vods: [defineWidget({ id: "videos", slot: "main" })], + builds: [defineWidget({ id: "builds", slot: "main" })], + art: [ + defineWidget({ + id: "art", + slot: "main", + schema: artSchema, + defaultSettings: { source: "ALL" }, + }), + ], +} as const; + +export function allWidgetsFlat() { + return Object.values(ALL_WIDGETS).flat(); +} + +export function findWidgetById(widgetId: string) { + return allWidgetsFlat().find((w) => w.id === widgetId); +} + +function defineWidget< + const Id extends string, + const Slot extends "main" | "side", + S extends z.ZodObject, +>(def: { + id: Id; + slot: Slot; + schema: S; + defaultSettings: z.infer; +}): typeof def; + +function defineWidget< + const Id extends string, + const Slot extends "main" | "side", +>(def: { id: Id; slot: Slot; schema?: never }): typeof def; +function defineWidget(def: Record) { + return def; +} + +export function defaultStoredWidget(widgetId: string): StoredWidget { + const widget = findWidgetById(widgetId); + if (!widget) throw new Error(`Unknown widget: ${widgetId}`); + + if ("defaultSettings" in widget) { + return { id: widget.id, settings: widget.defaultSettings } as StoredWidget; + } + + return { id: widget.id } as StoredWidget; +} diff --git a/app/features/user-page/core/widgets/types.ts b/app/features/user-page/core/widgets/types.ts new file mode 100644 index 000000000..d940576f4 --- /dev/null +++ b/app/features/user-page/core/widgets/types.ts @@ -0,0 +1,40 @@ +import type { z } from "zod"; +import type { allWidgetsFlat } from "./portfolio"; +import type { WIDGET_LOADERS } from "./portfolio-loaders.server"; + +type WidgetUnion = ReturnType[number]; + +export type WidgetId = WidgetUnion["id"]; + +type ExtractSchema = W extends { schema: infer S } + ? S extends z.ZodTypeAny + ? S + : never + : never; + +export type StoredWidget = { + [K in WidgetUnion as K["id"]]: { + id: K["id"]; + } & (ExtractSchema extends never + ? { settings?: never } + : { settings: z.infer> }); +}[WidgetUnion["id"]]; + +type InferLoaderReturn = T extends (...args: any[]) => Promise + ? R + : never; + +export type LoadedWidget = { + [K in WidgetId]: { + id: K; + data: K extends keyof typeof WIDGET_LOADERS + ? InferLoaderReturn> + : ExtractWidgetSettings; + slot: Extract["slot"]; + }; +}[WidgetId]; + +export type ExtractWidgetSettings = Extract< + StoredWidget, + { id: T } +>["settings"]; diff --git a/app/features/user-page/core/widgets/utils.server.ts b/app/features/user-page/core/widgets/utils.server.ts new file mode 100644 index 000000000..8cc01bff7 --- /dev/null +++ b/app/features/user-page/core/widgets/utils.server.ts @@ -0,0 +1,53 @@ +import * as LeaderboardRepository from "~/features/leaderboards/LeaderboardRepository.server"; +import * as Seasons from "~/features/mmr/core/Seasons"; + +type LeaderboardTopData = { + times: number; + seasons: number[]; +}; + +const sqLeaderboardTopCache = new Map< + number, + { + TOP_10: LeaderboardTopData; + TOP_100: LeaderboardTopData; + } +>(); + +export async function cachedUserSQLeaderboardTopData() { + if (sqLeaderboardTopCache.size > 0) { + return sqLeaderboardTopCache; + } + + const allSeasons = Seasons.allFinished(); + + for (const season of allSeasons) { + const leaderboard = await LeaderboardRepository.userSPLeaderboard(season); + + for (const entry of leaderboard) { + const userId = entry.id; + const placementRank = entry.placementRank; + + if (!sqLeaderboardTopCache.has(userId)) { + sqLeaderboardTopCache.set(userId, { + TOP_10: { times: 0, seasons: [] }, + TOP_100: { times: 0, seasons: [] }, + }); + } + + const userData = sqLeaderboardTopCache.get(userId)!; + + if (placementRank <= 10) { + userData.TOP_10.times += 1; + userData.TOP_10.seasons.push(season); + } + + if (placementRank <= 100) { + userData.TOP_100.times += 1; + userData.TOP_100.seasons.push(season); + } + } + } + + return sqLeaderboardTopCache; +} diff --git a/app/features/user-page/core/widgets/widget-form-schemas.ts b/app/features/user-page/core/widgets/widget-form-schemas.ts new file mode 100644 index 000000000..c47369082 --- /dev/null +++ b/app/features/user-page/core/widgets/widget-form-schemas.ts @@ -0,0 +1,149 @@ +import { z } from "zod"; +import { ART_SOURCES } from "~/features/art/art-types"; +import { TIMEZONES } from "~/features/lfg/lfg-constants"; +import { + array, + customField, + numberField, + select, + selectDynamic, + stageSelect, + textAreaRequired, + textFieldRequired, + weaponPool, + weaponSelect, +} from "~/form/fields"; +import type { SelectOption } from "~/form/types"; +import { USER } from "../../user-page-constants"; + +export const bioSchema = z.object({ + bio: textAreaRequired({ + label: "labels.bio", + maxLength: USER.BIO_MAX_LENGTH, + }), +}); + +export const bioMdSchema = z.object({ + bio: textAreaRequired({ + label: "labels.bio", + bottomText: "bottomTexts.bioMarkdown" as never, + maxLength: USER.BIO_MD_MAX_LENGTH, + }), +}); + +export const xRankPeaksSchema = z.object({ + division: select({ + label: "labels.division" as never, + items: [ + { value: "both", label: "options.division.both" as never }, + { value: "tentatek", label: "options.division.tentatek" as never }, + { value: "takoroka", label: "options.division.takoroka" as never }, + ], + }), +}); + +export const timezoneSchema = z.object({ + timezone: selectDynamic({ + label: "labels.timezone" as never, + }), +}); + +export const TIMEZONE_OPTIONS: SelectOption[] = TIMEZONES.map((tz) => ({ + value: tz, + label: tz, +})); + +export const favoriteStageSchema = z.object({ + stageId: stageSelect({ + label: "labels.favoriteStage" as never, + }), +}); + +export const peakXpUnverifiedSchema = z.object({ + peakXp: numberField({ + label: "labels.peakXp" as never, + minLength: 4, + maxLength: 4, + }), + division: select({ + label: "labels.division" as never, + items: [ + { value: "tentatek", label: "options.division.tentatek" as never }, + { value: "takoroka", label: "options.division.takoroka" as never }, + ], + }), +}); + +export const peakXpWeaponSchema = z.object({ + weaponSplId: weaponSelect({ + label: "labels.weapon" as never, + }), +}); + +export const weaponPoolSchema = z.object({ + weapons: weaponPool({ + label: "labels.weaponPool", + maxCount: USER.WEAPON_POOL_MAX_SIZE, + }), +}); + +export const CONTROLLERS = [ + "s1-pro-con", + "s2-pro-con", + "grip", + "handheld", +] as const; + +export const sensSchema = z.object({ + controller: customField({ initialValue: "s2-pro-con" }, z.enum(CONTROLLERS)), + motionSens: customField({ initialValue: null }, z.number().nullable()), + stickSens: customField({ initialValue: null }, z.number().nullable()), +}); + +export const artSchema = z.object({ + source: select({ + label: "labels.artSource" as never, + items: ART_SOURCES.map((source) => ({ + value: source, + label: `options.artSource.${source}` as never, + })), + }), +}); + +export const linksSchema = z.object({ + links: array({ + label: "labels.urls", + max: 10, + field: textFieldRequired({ + maxLength: 150, + validate: "url", + }), + }), +}); + +export const tierListSchema = z.object({ + searchParams: textFieldRequired({ + label: "labels.tierListUrl" as never, + leftAddon: "/tier-list-maker?", + maxLength: 500, + }), +}); + +const WIDGET_FORM_SCHEMAS: Record> = { + bio: bioSchema, + "bio-md": bioMdSchema, + "x-rank-peaks": xRankPeaksSchema, + timezone: timezoneSchema, + "favorite-stage": favoriteStageSchema, + "peak-xp-unverified": peakXpUnverifiedSchema, + "peak-xp-weapon": peakXpWeaponSchema, + "weapon-pool": weaponPoolSchema, + sens: sensSchema, + art: artSchema, + links: linksSchema, + "tier-list": tierListSchema, +}; + +export function getWidgetFormSchema(widgetId: string) { + return WIDGET_FORM_SCHEMAS[widgetId]; +} diff --git a/app/features/user-page/loaders/u.$identifier.edit-widgets.server.ts b/app/features/user-page/loaders/u.$identifier.edit-widgets.server.ts new file mode 100644 index 000000000..eb3f2d19e --- /dev/null +++ b/app/features/user-page/loaders/u.$identifier.edit-widgets.server.ts @@ -0,0 +1,10 @@ +import { requireUser } from "~/features/auth/core/user.server"; +import * as UserRepository from "~/features/user-page/UserRepository.server"; + +export const loader = async () => { + const user = requireUser(); + + const currentWidgets = await UserRepository.storedWidgetsByUserId(user.id); + + return { currentWidgets }; +}; diff --git a/app/features/user-page/loaders/u.$identifier.edit.server.ts b/app/features/user-page/loaders/u.$identifier.edit.server.ts index 13e4d2e3c..5eab26665 100644 --- a/app/features/user-page/loaders/u.$identifier.edit.server.ts +++ b/app/features/user-page/loaders/u.$identifier.edit.server.ts @@ -20,9 +20,12 @@ export const loader = async ({ params }: LoaderFunctionArgs) => { true, ))!; + const preferences = await UserRepository.preferencesByUserId(user.id); + return { user: userProfile, favoriteBadgeIds: userProfile.favoriteBadgeIds, discordUniqueName: userProfile.discordUniqueName, + newProfileEnabled: preferences?.newProfileEnabled ?? false, }; }; diff --git a/app/features/user-page/loaders/u.$identifier.index.server.ts b/app/features/user-page/loaders/u.$identifier.index.server.ts index c76d463b8..a9c7a2e62 100644 --- a/app/features/user-page/loaders/u.$identifier.index.server.ts +++ b/app/features/user-page/loaders/u.$identifier.index.server.ts @@ -3,11 +3,25 @@ import * as UserRepository from "~/features/user-page/UserRepository.server"; import { notFoundIfFalsy } from "~/utils/remix.server"; export const loader = async ({ params }: LoaderFunctionArgs) => { + const widgetsEnabled = await UserRepository.widgetsEnabledByIdentifier( + params.identifier!, + ); + + if (widgetsEnabled) { + return { + type: "new" as const, + widgets: notFoundIfFalsy( + await UserRepository.widgetsByUserId(params.identifier!), + ), + }; + } + const user = notFoundIfFalsy( await UserRepository.findProfileByIdentifier(params.identifier!), ); return { + type: "old" as const, user, }; }; diff --git a/app/features/user-page/loaders/u.$identifier.server.ts b/app/features/user-page/loaders/u.$identifier.server.ts index d04b87253..7d46a73a1 100644 --- a/app/features/user-page/loaders/u.$identifier.server.ts +++ b/app/features/user-page/loaders/u.$identifier.server.ts @@ -16,11 +16,16 @@ export const loader = async ({ params }: LoaderFunctionArgs) => { ), ); + const widgetsEnabled = await UserRepository.widgetsEnabledByIdentifier( + params.identifier!, + ); + return { user: { ...user, css: undefined, }, css: user.css, + type: widgetsEnabled ? ("new" as const) : ("old" as const), }; }; diff --git a/app/features/user-page/routes/u.$identifier.admin.tsx b/app/features/user-page/routes/u.$identifier.admin.tsx index 03c0a390e..d0ea715c1 100644 --- a/app/features/user-page/routes/u.$identifier.admin.tsx +++ b/app/features/user-page/routes/u.$identifier.admin.tsx @@ -1,23 +1,34 @@ -import { useLoaderData } from "react-router"; +import { useLoaderData, useMatches } from "react-router"; import { Divider } from "~/components/Divider"; import { SendouButton } from "~/components/elements/Button"; import { SendouDialog } from "~/components/elements/Dialog"; import { FormWithConfirm } from "~/components/FormWithConfirm"; import { PlusIcon } from "~/components/icons/Plus"; -import { Main } from "~/components/Main"; import { useUser } from "~/features/auth/core/user"; import { addModNoteSchema } from "~/features/user-page/user-page-schemas"; import { SendouForm } from "~/form"; import { useTimeFormat } from "~/hooks/useTimeFormat"; import { databaseTimestampToDate } from "~/utils/dates"; +import invariant from "~/utils/invariant"; +import { userPage } from "~/utils/urls"; import { action } from "../actions/u.$identifier.admin.server"; +import { SubPageHeader } from "../components/SubPageHeader"; import { loader } from "../loaders/u.$identifier.admin.server"; +import type { UserPageLoaderData } from "../loaders/u.$identifier.server"; import styles from "./u.$identifier.admin.module.css"; export { loader, action }; export default function UserAdminPage() { + const [, parentRoute] = useMatches(); + invariant(parentRoute); + const layoutData = parentRoute.data as UserPageLoaderData; + return ( -
+
+
@@ -40,7 +51,7 @@ export default function UserAdminPage() {
-
+
); } diff --git a/app/features/user-page/routes/u.$identifier.art.tsx b/app/features/user-page/routes/u.$identifier.art.tsx index a02a8f3fb..afd4e8ba9 100644 --- a/app/features/user-page/routes/u.$identifier.art.tsx +++ b/app/features/user-page/routes/u.$identifier.art.tsx @@ -7,8 +7,9 @@ import { useUser } from "~/features/auth/core/user"; import { useSearchParamState } from "~/hooks/useSearchParamState"; import invariant from "~/utils/invariant"; import type { SendouRouteHandle } from "~/utils/remix.server"; -import { newArtPage } from "~/utils/urls"; +import { newArtPage, userPage } from "~/utils/urls"; import { action } from "../actions/u.$identifier.art.server"; +import { SubPageHeader } from "../components/SubPageHeader"; import { loader } from "../loaders/u.$identifier.art.server"; import type { UserPageLoaderData } from "../loaders/u.$identifier.server"; export { action, loader }; @@ -52,9 +53,9 @@ export default function UserArtPage() { return (
-
+ -
+
{data.unvalidatedArtCount > 0 diff --git a/app/features/user-page/routes/u.$identifier.builds.tsx b/app/features/user-page/routes/u.$identifier.builds.tsx index 7f53c2f00..e0b36491b 100644 --- a/app/features/user-page/routes/u.$identifier.builds.tsx +++ b/app/features/user-page/routes/u.$identifier.builds.tsx @@ -19,8 +19,9 @@ import { useSearchParamState } from "~/hooks/useSearchParamState"; import type { MainWeaponId } from "~/modules/in-game-lists/types"; import { mainWeaponIds } from "~/modules/in-game-lists/weapon-ids"; import type { SendouRouteHandle } from "~/utils/remix.server"; -import { userNewBuildPage, weaponCategoryUrl } from "~/utils/urls"; +import { userNewBuildPage, userPage, weaponCategoryUrl } from "~/utils/urls"; import { action } from "../actions/u.$identifier.builds.server"; +import { SubPageHeader } from "../components/SubPageHeader"; import { loader, type UserBuildsPageData, @@ -81,20 +82,22 @@ export default function UserBuildsPage() { {changingSorting ? ( ) : null} - {isOwnPage && ( -
- setChangingSorting(true)} - size="small" - variant="outlined" - icon={} - data-testid="change-sorting-button" - > - {t("user:builds.sorting.changeButton")} - - -
- )} + + {isOwnPage ? ( + <> + setChangingSorting(true)} + size="small" + variant="outlined" + icon={} + data-testid="change-sorting-button" + > + {t("user:builds.sorting.changeButton")} + + + + ) : null} + (); + const isMounted = useIsMounted(); + + const [selectedWidgets, setSelectedWidgets] = useState< + Array + >(data.currentWidgets); + const [expandedWidgetId, setExpandedWidgetId] = useState(null); + + const mainWidgets = selectedWidgets.filter((w) => { + const def = findWidgetById(w.id); + return def?.slot === "main"; + }); + + const sideWidgets = selectedWidgets.filter((w) => { + const def = findWidgetById(w.id); + return def?.slot === "side"; + }); + + const sensors = useSensors( + useSensor(PointerSensor), + useSensor(KeyboardSensor, { + coordinateGetter: sortableKeyboardCoordinates, + }), + ); + + const handleDragStart = () => { + setExpandedWidgetId(null); + }; + + const handleDragEnd = (event: DragEndEvent) => { + const { active, over } = event; + + if (!over || active.id === over.id) { + return; + } + + const oldIndex = selectedWidgets.findIndex((w) => w.id === active.id); + const newIndex = selectedWidgets.findIndex((w) => w.id === over.id); + + setSelectedWidgets(arrayMove(selectedWidgets, oldIndex, newIndex)); + }; + + const addWidget = (widgetId: string) => { + const widget = findWidgetById(widgetId); + if (!widget) return; + + const currentCount = + widget.slot === "main" ? mainWidgets.length : sideWidgets.length; + const maxCount = + widget.slot === "main" ? USER.MAX_MAIN_WIDGETS : USER.MAX_SIDE_WIDGETS; + + if (currentCount >= maxCount) return; + + const newWidget = defaultStoredWidget(widgetId); + + setSelectedWidgets([...selectedWidgets, newWidget]); + + const widgetDef = findWidgetById(widgetId); + if (widgetDef && "schema" in widgetDef) { + setExpandedWidgetId(widgetId); + } + }; + + const removeWidget = (widgetId: string) => { + setSelectedWidgets(selectedWidgets.filter((w) => w.id !== widgetId)); + if (expandedWidgetId === widgetId) { + setExpandedWidgetId(null); + } + }; + + const handleSettingsChange = (widgetId: string, settings: any) => { + setSelectedWidgets( + selectedWidgets.map((w) => (w.id === widgetId ? { ...w, settings } : w)), + ); + }; + + const toggleExpanded = (widgetId: string) => { + setExpandedWidgetId(expandedWidgetId === widgetId ? null : widgetId); + }; + + if (!isMounted) { + return ; + } + + return ( +
+
+

{t("user:widgets.editTitle")}

+
+ + {t("common:actions.save")} + +
+
+ +
+ + +
+
+ + + +
+ +
+

{t("user:widgets.available")}

+ +
+
+
+
+ ); +} + +interface AvailableWidgetsListProps { + selectedWidgets: Array; + mainWidgets: Array; + sideWidgets: Array; + onAddWidget: (widgetId: string) => void; +} + +function AvailableWidgetsList({ + selectedWidgets, + mainWidgets, + sideWidgets, + onAddWidget, +}: AvailableWidgetsListProps) { + const { t } = useTranslation(["user"]); + const [searchValue, setSearchValue] = useState(""); + + const widgetsByCategory = ALL_WIDGETS; + const categoryKeys = ( + Object.keys(widgetsByCategory) as Array + ).sort((a, b) => a.localeCompare(b)); + + const searchLower = searchValue.toLowerCase(); + + return ( +
+ } + value={searchValue} + onChange={(e) => setSearchValue(e.target.value)} + placeholder={t("user:widgets.search")} + /> + {categoryKeys.map((category) => { + const filteredWidgets = widgetsByCategory[category]!.filter((widget) => + (t(`user:widget.${widget.id}` as const) as string) + .toLowerCase() + .includes(searchLower), + ); + + if (filteredWidgets.length === 0) return null; + + return ( +
+
+ {t(`user:widgets.category.${category}`)} +
+ {filteredWidgets.map((widget) => { + const isSelected = selectedWidgets.some( + (w) => w.id === widget.id, + ); + const currentCount = + widget.slot === "main" + ? mainWidgets.length + : sideWidgets.length; + const maxCount = + widget.slot === "main" + ? USER.MAX_MAIN_WIDGETS + : USER.MAX_SIDE_WIDGETS; + const isMaxReached = currentCount >= maxCount; + + return ( +
+
+ + {t(`user:widget.${widget.id}` as const)} + + onAddWidget(widget.id)} + isDisabled={isSelected || isMaxReached} + > + {t("user:widgets.add")} + +
+
+
+ {widget.slot === "main" ? ( + <> + + {t("user:widgets.main")} + + ) : ( + <> + + {t("user:widgets.side")} + + )} +
+
{"//"}
+
+ {t(`user:widgets.description.${widget.id}` as const)} +
+
+
+ ); + })} +
+ ); + })} +
+ ); +} + +interface SelectedWidgetsListProps { + mainWidgets: Array; + sideWidgets: Array; + onRemoveWidget: (widgetId: string) => void; + onSettingsChange: (widgetId: string, settings: any) => void; + expandedWidgetId: string | null; + onToggleExpanded: (widgetId: string) => void; +} + +function SelectedWidgetsList({ + mainWidgets, + sideWidgets, + onRemoveWidget, + onSettingsChange, + expandedWidgetId, + onToggleExpanded, +}: SelectedWidgetsListProps) { + const { t } = useTranslation(["user"]); + + return ( +
+
+
+ + {t("user:widgets.mainSlot")} + + + {mainWidgets.length}/{USER.MAX_MAIN_WIDGETS} + +
+ w.id)}> +
+ {mainWidgets.length === 0 ? ( +
+ {t("user:widgets.add")} {t("user:widgets.mainSlot")} +
+ ) : ( + mainWidgets.map((widget) => ( + + )) + )} +
+
+
+ +
+
+ + {t("user:widgets.sideSlot")} + + + {sideWidgets.length}/{USER.MAX_SIDE_WIDGETS} + +
+ w.id)}> +
+ {sideWidgets.length === 0 ? ( +
+ {t("user:widgets.add")} {t("user:widgets.sideSlot")} +
+ ) : ( + sideWidgets.map((widget) => ( + + )) + )} +
+
+
+
+ ); +} + +interface DraggableWidgetItemProps { + widget: Tables["UserWidget"]["widget"]; + onRemove: (widgetId: string) => void; + onSettingsChange: (widgetId: string, settings: any) => void; + isExpanded: boolean; + onToggleExpanded: (widgetId: string) => void; +} + +function DraggableWidgetItem({ + widget, + onRemove, + onSettingsChange, + isExpanded, + onToggleExpanded, +}: DraggableWidgetItemProps) { + const { t } = useTranslation(["user", "common"]); + + const { + attributes, + listeners, + setNodeRef, + transform, + transition, + isDragging, + } = useSortable({ id: widget.id }); + + const style = { + transform: CSS.Transform.toString(transform), + transition, + }; + + const widgetDef = findWidgetById(widget.id); + const hasSettings = widgetDef && "schema" in widgetDef; + + return ( +
+
+ + ☰ {t(`user:widget.${widget.id}` as const)} + +
+ {hasSettings ? ( + onToggleExpanded(widget.id)} + > + {isExpanded + ? t("common:actions.hide") + : t("common:actions.settings")} + + ) : null} + onRemove(widget.id)} + > + {t("user:widgets.remove")} + +
+
+ + {isExpanded && hasSettings ? ( +
+ +
+ ) : null} +
+ ); +} diff --git a/app/features/user-page/routes/u.$identifier.edit.test.ts b/app/features/user-page/routes/u.$identifier.edit.test.ts index 8754cf70c..109367508 100644 --- a/app/features/user-page/routes/u.$identifier.edit.test.ts +++ b/app/features/user-page/routes/u.$identifier.edit.test.ts @@ -21,9 +21,10 @@ const DEFAULT_FIELDS = { inGameNameText: null, motionSens: null, showDiscordUniqueName: 1, + newProfileEnabled: 0, stickSens: null, - objectPronoun: null, subjectPronoun: null, + objectPronoun: null, weapons: JSON.stringify([ { weaponSplId: 1 as MainWeaponId, isFavorite: 0 }, ]) as any, diff --git a/app/features/user-page/routes/u.$identifier.edit.tsx b/app/features/user-page/routes/u.$identifier.edit.tsx index 2e7701eb6..7dd80fd31 100644 --- a/app/features/user-page/routes/u.$identifier.edit.tsx +++ b/app/features/user-page/routes/u.$identifier.edit.tsx @@ -75,6 +75,7 @@ export default function UserEditPage() { )} + Username, profile picture, YouTube, Bluesky and Twitch accounts come @@ -475,6 +476,32 @@ function FavBadgeSelect() { ); } +function NewProfileToggle() { + const { t } = useTranslation(["user"]); + const data = useLoaderData(); + const isSupporter = useHasRole("SUPPORTER"); + const [checked, setChecked] = React.useState( + isSupporter && data.newProfileEnabled, + ); + + return ( +
+ + + + {t("user:forms.newProfileEnabled.info")} + +
+ ); +} + function ShowUniqueDiscordNameToggle() { const { t } = useTranslation(["user"]); const data = useLoaderData(); diff --git a/app/features/user-page/routes/u.$identifier.index.tsx b/app/features/user-page/routes/u.$identifier.index.tsx index c2df664f0..aed7f732e 100644 --- a/app/features/user-page/routes/u.$identifier.index.tsx +++ b/app/features/user-page/routes/u.$identifier.index.tsx @@ -1,18 +1,28 @@ import clsx from "clsx"; import { useTranslation } from "react-i18next"; -import { Link, useLoaderData, useMatches } from "react-router"; +import { + href, + Link, + useLoaderData, + useMatches, + useOutletContext, +} from "react-router"; import { Avatar } from "~/components/Avatar"; -import { SendouButton } from "~/components/elements/Button"; +import { LinkButton, SendouButton } from "~/components/elements/Button"; import { SendouPopover } from "~/components/elements/Popover"; import { Flag } from "~/components/Flag"; import { Image, WeaponImage } from "~/components/Image"; import { BattlefyIcon } from "~/components/icons/Battlefy"; import { BskyIcon } from "~/components/icons/Bsky"; import { DiscordIcon } from "~/components/icons/Discord"; +import { EditIcon } from "~/components/icons/Edit"; +import { PuzzleIcon } from "~/components/icons/Puzzle"; import { TwitchIcon } from "~/components/icons/Twitch"; import { YouTubeIcon } from "~/components/icons/YouTube"; +import { useUser } from "~/features/auth/core/user"; import { BadgeDisplay } from "~/features/badges/components/BadgeDisplay"; import { modesShort } from "~/modules/in-game-lists/modes"; +import { countryCodeToTranslatedName } from "~/utils/i18n"; import invariant from "~/utils/invariant"; import type { SendouRouteHandle } from "~/utils/remix.server"; import { rawSensToString } from "~/utils/strings"; @@ -24,19 +34,132 @@ import { teamPage, topSearchPlayerPage, } from "~/utils/urls"; +import type { UserPageNavItem } from "../components/UserPageIconNav"; +import { UserPageIconNav } from "../components/UserPageIconNav"; +import { Widget } from "../components/Widget"; import { loader } from "../loaders/u.$identifier.index.server"; import type { UserPageLoaderData } from "../loaders/u.$identifier.server"; +import styles from "./u.$identifier.module.css"; export { loader }; export const handle: SendouRouteHandle = { - i18n: ["badges", "team"], + i18n: ["badges", "team", "org", "vods", "lfg", "builds", "weapons", "gear"], }; export default function UserInfoPage() { const data = useLoaderData(); + + if (data.type === "new") { + return ; + } + return ; +} + +function NewUserInfoPage() { + const { t, i18n } = useTranslation(["user"]); + const data = useLoaderData(); + const user = useUser(); const [, parentRoute] = useMatches(); invariant(parentRoute); const layoutData = parentRoute.data as UserPageLoaderData; + const { navItems } = useOutletContext<{ navItems: UserPageNavItem[] }>(); + + if (data.type !== "new") { + throw new Error("Expected new user data"); + } + + const mainWidgets = data.widgets.filter((w) => w.slot === "main"); + const sideWidgets = data.widgets.filter((w) => w.slot === "side"); + + const isOwnPage = layoutData.user.id === user?.id; + + return ( +
+
+ +
+
+

{layoutData.user.username}

+ +
+
+
+ +
+ {isOwnPage ? ( +
+ } + > + {t("user:widgets.edit")} + + } + > + {t("user:widgets.editProfile")} + +
+ ) : null} +
+ +
+ +
+ +
+ {sideWidgets.map((widget) => ( + + ))} +
+ +
+ {mainWidgets.map((widget) => ( + + ))} +
+ +
+
+ {mainWidgets.map((widget) => ( + + ))} +
+
+ {sideWidgets.map((widget) => ( + + ))} +
+
+
+ ); +} + +export function OldUserInfoPage() { + const data = useLoaderData(); + const [, parentRoute] = useMatches(); + invariant(parentRoute); + const layoutData = parentRoute.data as UserPageLoaderData; + + if (data.type !== "old") { + throw new Error("Expected old user data"); + } return (
@@ -81,6 +204,10 @@ function TeamInfo() { const { t } = useTranslation(["team"]); const data = useLoaderData(); + if (data.type !== "old") { + throw new Error("Expected old user data"); + } + if (!data.user.team) return null; return ( @@ -118,6 +245,10 @@ function SecondaryTeamsPopover() { const data = useLoaderData(); + if (data.type !== "old") { + throw new Error("Expected old user data"); + } + if (data.user.secondaryTeams.length === 0) return null; return ( @@ -231,6 +362,10 @@ function ExtraInfos() { const { t } = useTranslation(["user"]); const data = useLoaderData(); + if (data.type !== "old") { + throw new Error("Expected old user data"); + } + const motionSensText = typeof data.user.motionSens === "number" ? `${t("user:motion")} ${rawSensToString(data.user.motionSens)}` @@ -294,6 +429,10 @@ function ExtraInfos() { function WeaponPool() { const data = useLoaderData(); + if (data.type !== "old") { + throw new Error("Expected old user data"); + } + if (data.user.weapons.length === 0) return null; return ( @@ -315,9 +454,57 @@ function WeaponPool() { ); } +function ProfileSubtitle({ + inGameName, + pronouns, + country, + language, +}: { + inGameName: string | null; + pronouns: { subject: string; object: string } | null; + country: string | null; + language: string; +}) { + const parts: React.ReactNode[] = []; + + if (inGameName) { + parts.push(inGameName); + } + + if (pronouns) { + parts.push(`${pronouns.subject}/${pronouns.object}`); + } + + if (country) { + parts.push( + + + {countryCodeToTranslatedName({ countryCode: country, language })} + , + ); + } + + if (parts.length === 0) return null; + + return ( +
+ {parts.map((part, i) => ( + + {i > 0 ? · : null} + {part} + + ))} +
+ ); +} + function TopPlacements() { const data = useLoaderData(); + if (data.type !== "old") { + throw new Error("Expected old user data"); + } + if (data.user.topPlacements.length === 0) return null; return ( diff --git a/app/features/user-page/routes/u.$identifier.module.css b/app/features/user-page/routes/u.$identifier.module.css new file mode 100644 index 000000000..6cdd9636d --- /dev/null +++ b/app/features/user-page/routes/u.$identifier.module.css @@ -0,0 +1,133 @@ +.container { + display: flex; + flex-direction: column; + gap: var(--s-6); + width: 100%; + max-width: 1200px; + margin: 0 auto; +} + +.header { + display: flex; + flex-direction: column; + align-items: center; + gap: var(--s-4); +} + +.userInfo { + display: flex; + flex-direction: column; + align-items: center; + gap: var(--s-2); +} + +.username { + font-size: var(--fonts-xl); + font-weight: var(--bold); + margin: 0; +} + +.subtitle { + font-size: var(--fonts-xxs); + color: var(--text-lighter); + display: flex; + align-items: center; + gap: var(--s-1); +} + +.nameGroup { + display: flex; + flex-direction: column; + line-height: 1.25; + align-items: center; +} + +.editButtons { + display: flex; + gap: var(--s-2); +} + +.mobileIconNav { + display: block; +} + +.desktopIconNav { + display: none; +} + +.sideCarousel { + display: flex; + overflow-x: auto; + gap: var(--s-4); + padding-block: var(--s-2); + scrollbar-width: thin; + scroll-snap-type: x mandatory; +} + +.sideCarousel > * { + flex: 0 0 280px; + scroll-snap-align: start; +} + +.mainStack { + display: flex; + flex-direction: column; + gap: var(--s-6); +} + +.grid { + display: none; +} + +@media screen and (min-width: 768px) { + .header { + flex-direction: row; + flex-wrap: wrap; + justify-content: flex-start; + align-items: center; + padding-block: var(--s-6); + } + + .nameGroup { + align-items: flex-start; + } + + .desktopIconNav { + display: block; + margin-left: auto; + } + + .mobileIconNav { + display: none; + } + + .mainStack { + display: none; + } + + .sideCarousel { + display: none; + } + + .grid { + display: grid; + grid-template-columns: minmax(0, 2fr) minmax(0, 1fr); + gap: var(--s-8); + align-items: start; + } + + .main { + display: flex; + flex-direction: column; + gap: var(--s-6); + } + + .side { + position: sticky; + top: 50px; + display: flex; + flex-direction: column; + gap: var(--s-6); + align-self: start; + } +} diff --git a/app/features/user-page/routes/u.$identifier.results.tsx b/app/features/user-page/routes/u.$identifier.results.tsx index 61da00ae7..938035d28 100644 --- a/app/features/user-page/routes/u.$identifier.results.tsx +++ b/app/features/user-page/routes/u.$identifier.results.tsx @@ -5,8 +5,9 @@ import { Pagination } from "~/components/Pagination"; import { useUser } from "~/features/auth/core/user"; import { UserResultsTable } from "~/features/user-page/components/UserResultsTable"; import invariant from "~/utils/invariant"; -import { userResultsEditHighlightsPage } from "~/utils/urls"; +import { userPage, userResultsEditHighlightsPage } from "~/utils/urls"; import { SendouButton } from "../../../components/elements/Button"; +import { SubPageHeader } from "../components/SubPageHeader"; import { loader } from "../loaders/u.$identifier.results.server"; import type { UserPageLoaderData } from "../loaders/u.$identifier.server"; export { loader }; @@ -32,6 +33,10 @@ export default function UserResultsPage() { return (
+

{showAll || !data.hasHighlightedResults diff --git a/app/features/user-page/routes/u.$identifier.seasons.tsx b/app/features/user-page/routes/u.$identifier.seasons.tsx index 41194b2f8..953ba2fea 100644 --- a/app/features/user-page/routes/u.$identifier.seasons.tsx +++ b/app/features/user-page/routes/u.$identifier.seasons.tsx @@ -54,8 +54,10 @@ import { sendouQMatchPage, TIERS_PAGE, tournamentTeamPage, + userPage, userSeasonsPage, } from "~/utils/urls"; +import { SubPageHeader } from "../components/SubPageHeader"; import { loader, type UserSeasonsPageLoaderData, @@ -71,11 +73,20 @@ const DAYS_WITH_SKILL_NEEDED_TO_SHOW_POWER_CHART = 2; export default function UserSeasonsPage() { const { t } = useTranslation(["user"]); const data = useLoaderData(); + const [, parentRoute] = useMatches(); + invariant(parentRoute); + const layoutData = parentRoute.data as UserPageLoaderData; if (!data) { return ( -
- {t("user:seasons.noSeasons")} +
+ +
+ {t("user:seasons.noSeasons")} +
); } @@ -83,6 +94,10 @@ export default function UserSeasonsPage() { if (data.results.value.length === 0) { return (
+ + (m.data as any)?.type === "new"); + + const navItems: UserPageNavItem[] = [ + { + to: userSeasonsPage({ user: data.user }), + iconName: "sendouq", + label: t("user:seasons"), + isVisible: true, + testId: "user-seasons-tab", + }, + { + to: userResultsPage(data.user), + iconName: "medal", + label: t("common:results"), + count: allResultsCount, + isVisible: allResultsCount > 0, + testId: "user-results-tab", + }, + { + to: userBuildsPage(data.user), + iconName: "builds", + label: t("common:pages.builds"), + count: data.user.buildsCount, + isVisible: data.user.buildsCount > 0 || isOwnPage, + testId: "user-builds-tab", + prefetch: "intent", + }, + { + to: userVodsPage(data.user), + iconName: "vods", + label: t("common:pages.vods"), + count: data.user.vodsCount, + isVisible: data.user.vodsCount > 0 || isOwnPage, + testId: "user-vods-tab", + }, + { + to: userArtPage(data.user), + iconName: "art", + label: t("common:pages.art"), + count: data.user.artCount, + isVisible: data.user.artCount > 0 || isOwnPage, + testId: "user-art-tab", + end: false, + }, + { + to: userAdminPage(data.user), + iconName: "admin", + label: "Admin", + isVisible: isStaff, + testId: "user-admin-tab", + }, + ]; + return (
- - - {t("common:header.profile")} - - - {t("user:seasons")} - - {isOwnPage ? ( + {isNewUserPage ? null : ( + + + {t("common:header.profile")} + - {t("common:actions.edit")} + {t("user:seasons")} - ) : null} - {allResultsCount > 0 ? ( - - {t("common:results")} ({allResultsCount}) - - ) : null} - {data.user.buildsCount > 0 || isOwnPage ? ( - - {t("common:pages.builds")} ({data.user.buildsCount}) - - ) : null} - {data.user.vodsCount > 0 || isOwnPage ? ( - - {t("common:pages.vods")} ({data.user.vodsCount}) - - ) : null} - {data.user.artCount > 0 || isOwnPage ? ( - - {t("common:pages.art")} ({data.user.artCount}) - - ) : null} - {isStaff ? ( - - Admin - - ) : null} - - + {isOwnPage ? ( + + {t("common:actions.edit")} + + ) : null} + {allResultsCount > 0 ? ( + + {t("common:results")} ({allResultsCount}) + + ) : null} + {data.user.buildsCount > 0 || isOwnPage ? ( + + {t("common:pages.builds")} ({data.user.buildsCount}) + + ) : null} + {data.user.vodsCount > 0 || isOwnPage ? ( + + {t("common:pages.vods")} ({data.user.vodsCount}) + + ) : null} + {data.user.artCount > 0 || isOwnPage ? ( + + {t("common:pages.art")} ({data.user.artCount}) + + ) : null} + {isStaff ? ( + + Admin + + ) : null} + + )} +
); } diff --git a/app/features/user-page/routes/u.$identifier.vods.tsx b/app/features/user-page/routes/u.$identifier.vods.tsx index 6e698488e..ac52d6553 100644 --- a/app/features/user-page/routes/u.$identifier.vods.tsx +++ b/app/features/user-page/routes/u.$identifier.vods.tsx @@ -4,7 +4,9 @@ import { VodListing } from "~/features/vods/components/VodListing"; import styles from "~/features/vods/routes/vods.module.css"; import invariant from "~/utils/invariant"; import type { SendouRouteHandle } from "~/utils/remix.server"; -import { newVodPage } from "~/utils/urls"; +import { newVodPage, userPage } from "~/utils/urls"; +import { SubPageHeader } from "../components/SubPageHeader"; +import type { UserPageLoaderData } from "../loaders/u.$identifier.server"; import { loader } from "../loaders/u.$identifier.vods.server"; export { loader }; @@ -16,12 +18,13 @@ export default function UserVodsPage() { const [, parentRoute] = useMatches(); invariant(parentRoute); const data = useLoaderData(); + const layoutData = parentRoute.data as UserPageLoaderData; return (
-
+ -
+
{data.vods.map((vod) => ( diff --git a/app/features/user-page/user-page-constants.ts b/app/features/user-page/user-page-constants.ts index 9450f9bf8..0c213aef9 100644 --- a/app/features/user-page/user-page-constants.ts +++ b/app/features/user-page/user-page-constants.ts @@ -3,6 +3,7 @@ export const HIGHLIGHT_TOURNAMENT_CHECKBOX_NAME = "highlightTournamentTeamIds"; export const USER = { BIO_MAX_LENGTH: 2000, + BIO_MD_MAX_LENGTH: 4000, CUSTOM_URL_MAX_LENGTH: 32, CUSTOM_NAME_MAX_LENGTH: 32, BATTLEFY_MAX_LENGTH: 32, @@ -11,6 +12,8 @@ export const USER = { WEAPON_POOL_MAX_SIZE: 5, COMMISSION_TEXT_MAX_LENGTH: 1000, MOD_NOTE_MAX_LENGTH: 2000, + MAX_MAIN_WIDGETS: 5, + MAX_SIDE_WIDGETS: 7, }; export const MATCHES_PER_SEASONS_PAGE = 8; diff --git a/app/features/user-page/user-page-schemas.ts b/app/features/user-page/user-page-schemas.ts index d2f1a32b1..31ff07d78 100644 --- a/app/features/user-page/user-page-schemas.ts +++ b/app/features/user-page/user-page-schemas.ts @@ -39,6 +39,7 @@ import { undefinedToNull, weaponSplId, } from "~/utils/zod"; +import { allWidgetsFlat, findWidgetById } from "./core/widgets/portfolio"; import { COUNTRY_CODES, HIGHLIGHT_CHECKBOX_NAME, @@ -147,6 +148,7 @@ export const userEditActionSchema = z .nullish(), ), showDiscordUniqueName: z.preprocess(checkboxValueToDbBoolean, dbBoolean), + newProfileEnabled: z.preprocess(checkboxValueToDbBoolean, dbBoolean), commissionsOpen: z.preprocess(checkboxValueToDbBoolean, dbBoolean), commissionText: z.preprocess( falsyToNull, @@ -199,6 +201,43 @@ export const userResultsPageSearchParamsSchema = z.object({ page: z.coerce.number().min(1).max(1_000).catch(1), }); +const widgetSettingsSchemas = allWidgetsFlat().map((widget) => { + if ("schema" in widget) { + return z.object({ + id: z.literal(widget.id), + settings: widget.schema, + }); + } + return z.object({ + id: z.literal(widget.id), + }); +}); + +const widgetSettingsSchema = z.union(widgetSettingsSchemas); + +export const widgetsEditSchema = z.object({ + widgets: z.preprocess( + safeJSONParse, + z + .array(widgetSettingsSchema) + .max(USER.MAX_MAIN_WIDGETS + USER.MAX_SIDE_WIDGETS) + .refine((widgets) => { + let mainCount = 0; + let sideCount = 0; + for (const w of widgets) { + const def = findWidgetById(w.id); + if (!def) return false; + if (def.slot === "main") mainCount++; + else sideCount++; + } + return ( + mainCount <= USER.MAX_MAIN_WIDGETS && + sideCount <= USER.MAX_SIDE_WIDGETS + ); + }), + ), +}); + const headGearIdSchema = z .number() .nullable() diff --git a/app/form/SendouForm.tsx b/app/form/SendouForm.tsx index 577b3239b..cef185625 100644 --- a/app/form/SendouForm.tsx +++ b/app/form/SendouForm.tsx @@ -60,6 +60,7 @@ type BaseFormProps = { _action?: string; submitButtonTestId?: string; autoSubmit?: boolean; + autoApply?: boolean; className?: string; onApply?: (values: z.infer>) => void; secondarySubmit?: React.ReactNode; @@ -84,6 +85,7 @@ export function SendouForm({ _action, submitButtonTestId, autoSubmit, + autoApply, className, onApply, secondarySubmit, @@ -248,30 +250,35 @@ export function SendouForm({ setClientErrors(newErrors); }; - const onFieldChange = autoSubmit - ? (changedName: string, changedValue: unknown) => { - const updatedValues = { ...values, [changedName]: changedValue }; + const onFieldChange = + autoSubmit || autoApply + ? (changedName: string, changedValue: unknown) => { + const updatedValues = { ...values, [changedName]: changedValue }; - const newErrors: Record = {}; - for (const key of Object.keys(schema.shape)) { - const error = validateField(schema, key, updatedValues[key]); - if (error) { - newErrors[key] = error; + const newErrors: Record = {}; + for (const key of Object.keys(schema.shape)) { + const error = validateField(schema, key, updatedValues[key]); + if (error) { + newErrors[key] = error; + } + } + + if (Object.keys(newErrors).length > 0) { + setClientErrors(newErrors); + return; + } + + if (autoApply && onApply) { + onApply(updatedValues as z.infer>); + } else if (autoSubmit) { + fetcher.submit(updatedValues as Record, { + method, + action, + encType: "application/json", + }); } } - - if (Object.keys(newErrors).length > 0) { - setClientErrors(newErrors); - return; - } - - fetcher.submit(updatedValues as Record, { - method, - action, - encType: "application/json", - }); - } - : undefined; + : undefined; const submitToServer = (valuesToSubmit: Record) => { if (!validateAndPrepare()) return; @@ -343,7 +350,7 @@ export function SendouForm({ > {title ?

{title}

: null} {resolvedChildren} - {autoSubmit ? null : ( + {autoSubmit || autoApply ? null : (
>( return result as T; } +export function numberField( + args: WithTypedTranslationKeys< + Omit< + Extract, + | "type" + | "initialValue" + | "required" + | "validate" + | "inputType" + | "maxLength" + > + > & { maxLength?: number }, +) { + return z.coerce + .number() + .int() + .nonnegative() + .register(formRegistry, { + ...args, + label: prefixKey(args.label), + bottomText: prefixKey(args.bottomText), + required: true, + type: "text-field", + inputType: "number", + initialValue: "", + maxLength: args.maxLength ?? 10, + }); +} + export function numberFieldOptional( args: WithTypedTranslationKeys< Omit< @@ -298,6 +327,24 @@ export function select( }); } +export function selectDynamic( + args: WithTypedTranslationKeys< + Omit< + Extract, + "type" | "initialValue" | "clearable" + > + >, +) { + return z.string().register(formRegistry, { + ...args, + label: prefixKey(args.label), + bottomText: prefixKey(args.bottomText), + type: "select-dynamic", + initialValue: null, + clearable: false, + }) as unknown as z.ZodType & FieldWithOptions; +} + export function selectDynamicOptional( args: WithTypedTranslationKeys< Omit< @@ -685,6 +732,24 @@ export function stageSelect( }); } +export function weaponSelect( + args: WithTypedTranslationKeys< + Omit< + Extract, + "type" | "initialValue" | "required" + > + >, +) { + return weaponSplId.register(formRegistry, { + ...args, + label: prefixKey(args.label), + bottomText: prefixKey(args.bottomText), + type: "weapon-select", + initialValue: null, + required: true, + }); +} + export function weaponSelectOptional( args: WithTypedTranslationKeys< Omit< diff --git a/app/routes.ts b/app/routes.ts index d046c0b43..88468af24 100644 --- a/app/routes.ts +++ b/app/routes.ts @@ -44,6 +44,10 @@ export default [ index("features/user-page/routes/u.$identifier.index.tsx"), route("art", "features/user-page/routes/u.$identifier.art.tsx"), route("edit", "features/user-page/routes/u.$identifier.edit.tsx"), + route( + "edit-widgets", + "features/user-page/routes/u.$identifier.edit-widgets.tsx", + ), route("seasons", "features/user-page/routes/u.$identifier.seasons.tsx"), route("vods", "features/user-page/routes/u.$identifier.vods.tsx"), route("builds", "features/user-page/routes/u.$identifier.builds.tsx"), diff --git a/app/styles/u.css b/app/styles/u.css index b7091dc68..05059ac25 100644 --- a/app/styles/u.css +++ b/app/styles/u.css @@ -200,7 +200,8 @@ .u__weapon { padding: var(--s-2); border-radius: 100%; - background-color: var(--bg-lighter); + background-color: var(--bg-lightest); + border: 2px solid var(--border); } .u__build-form { diff --git a/app/utils/urls.ts b/app/utils/urls.ts index 6f9118cdd..63a7c4db9 100644 --- a/app/utils/urls.ts +++ b/app/utils/urls.ts @@ -77,6 +77,8 @@ export const bskyUrl = (accountName: string) => `https://bsky.app/profile/${accountName}`; export const twitchUrl = (accountName: string) => `https://twitch.tv/${accountName}`; +export const youtubeUrl = (channelId: string) => + `https://youtube.com/channel/${channelId}`; export const LOG_IN_URL = "/auth"; export const LOG_OUT_URL = "/auth/logout"; @@ -492,6 +494,8 @@ export const stageImageUrl = (stageId: StageId) => `/static-assets/img/stages/${stageId}`; export const tierImageUrl = (tier: TierName | "CALCULATING") => `/static-assets/img/tiers/${tier.toLowerCase()}`; +export const controllerImageUrl = (controller: string) => + `/static-assets/img/controllers/${controller}.avif`; export const preferenceEmojiUrl = (preference?: Preference) => { const emoji = preference === "PREFER" diff --git a/e2e/tournament-bracket.spec.ts b/e2e/tournament-bracket.spec.ts index 796e64fd1..89c93509d 100644 --- a/e2e/tournament-bracket.spec.ts +++ b/e2e/tournament-bracket.spec.ts @@ -807,6 +807,8 @@ test.describe("Tournament bracket", () => { }); test("locks/unlocks matches & sets match as casted", async ({ page }) => { + test.slow(); + const tournamentId = 2; await seed(page); diff --git a/locales/da/common.json b/locales/da/common.json index ec589f2a6..06143702a 100644 --- a/locales/da/common.json +++ b/locales/da/common.json @@ -132,6 +132,8 @@ "actions.next": "", "actions.previous": "", "actions.back": "", + "actions.hide": "", + "actions.settings": "", "noResults": "", "maps.createMapList": "Lav bane-liste", "maps.halfSz": "50% DD", @@ -255,6 +257,7 @@ "support.perk.joinMoreAssociations": "", "support.perk.useBotToLogIn": "", "support.perk.useBotToLogIn.extra": "", + "support.perk.earlyAccess": "", "custom.colors.title": "Tilpas farver", "custom.colors.bg": "Baggrund", "custom.colors.bg-darker": "Mørkere baggrund", diff --git a/locales/da/forms.json b/locales/da/forms.json index 9cc0a818a..844a727ab 100644 --- a/locales/da/forms.json +++ b/locales/da/forms.json @@ -168,5 +168,19 @@ "labels.banUserExpiresAt": "", "bottomTexts.banUserExpiresAtHelp": "", "labels.scrimCancelReason": "", - "bottomTexts.scrimCancelReasonHelp": "" + "bottomTexts.scrimCancelReasonHelp": "", + "bottomTexts.bioMarkdown": "", + "labels.division": "", + "options.division.both": "", + "options.division.tentatek": "", + "options.division.takoroka": "", + "labels.timezone": "", + "labels.favoriteStage": "", + "labels.peakXp": "", + "labels.weapon": "", + "labels.artSource": "", + "options.artSource.ALL": "", + "options.artSource.MADE-BY": "", + "options.artSource.MADE-OF": "", + "labels.tierListUrl": "" } diff --git a/locales/da/user.json b/locales/da/user.json index e24e69ee7..4dbd44c03 100644 --- a/locales/da/user.json +++ b/locales/da/user.json @@ -5,6 +5,130 @@ "ign.short": "Splatnavn", "country": "Land", "bio": "Biografi", + "widget.bio": "", + "widget.bio-md": "", + "widget.badges-owned": "", + "widget.badges-authored": "", + "widget.teams": "", + "widget.organizations": "", + "widget.peak-sp": "", + "widget.top-10-seasons": "", + "widget.top-100-seasons": "", + "widget.peak-xp": "", + "widget.peak-xp-unverified": "", + "widget.peak-xp-weapon": "", + "widget.highlighted-results": "", + "widget.placement-results": "", + "widget.patron-since": "", + "widget.timezone": "", + "widget.favorite-stage": "", + "widget.videos": "", + "widget.lfg-posts": "", + "widget.top-500-weapons": "", + "widget.top-500-weapons-shooters": "", + "widget.top-500-weapons-blasters": "", + "widget.top-500-weapons-rollers": "", + "widget.top-500-weapons-brushes": "", + "widget.top-500-weapons-chargers": "", + "widget.top-500-weapons-sloshers": "", + "widget.top-500-weapons-splatlings": "", + "widget.top-500-weapons-dualies": "", + "widget.top-500-weapons-brellas": "", + "widget.top-500-weapons-stringers": "", + "widget.top-500-weapons-splatanas": "", + "widget.x-rank-peaks": "", + "widget.builds": "", + "widget.weapon-pool": "", + "widget.sens": "", + "widget.art": "", + "widget.commissions": "", + "widget.social-links": "", + "widget.links": "", + "widget.tier-list": "", + "widget.link.all": "", + "widgets.edit": "", + "widgets.editProfile": "", + "widgets.editTitle": "", + "widgets.available": "", + "widgets.mainSlot": "", + "widgets.sideSlot": "", + "widgets.main": "", + "widgets.side": "", + "widgets.add": "", + "widgets.remove": "", + "widgets.maxReached": "", + "widgets.search": "", + "widgets.category.misc": "", + "widgets.category.badges": "", + "widgets.category.teams": "", + "widgets.category.sendouq": "", + "widgets.category.xrank": "", + "widgets.category.tournaments": "", + "widgets.category.vods": "", + "widgets.category.builds": "", + "widgets.category.art": "", + "widgets.description.bio": "", + "widgets.description.bio-md": "", + "widgets.description.badges-owned": "", + "widgets.description.badges-authored": "", + "widgets.description.teams": "", + "widgets.description.organizations": "", + "widgets.description.peak-sp": "", + "widgets.description.top-10-seasons": "", + "widgets.description.top-100-seasons": "", + "widgets.description.peak-xp": "", + "widgets.description.peak-xp-unverified": "", + "widgets.description.peak-xp-weapon": "", + "widgets.description.highlighted-results": "", + "widgets.description.placement-results": "", + "widgets.description.patron-since": "", + "widgets.description.timezone": "", + "widgets.description.favorite-stage": "", + "widgets.description.videos": "", + "widgets.description.lfg-posts": "", + "widgets.description.top-500-weapons": "", + "widgets.description.top-500-weapons-shooters": "", + "widgets.description.top-500-weapons-blasters": "", + "widgets.description.top-500-weapons-rollers": "", + "widgets.description.top-500-weapons-brushes": "", + "widgets.description.top-500-weapons-chargers": "", + "widgets.description.top-500-weapons-sloshers": "", + "widgets.description.top-500-weapons-splatlings": "", + "widgets.description.top-500-weapons-dualies": "", + "widgets.description.top-500-weapons-brellas": "", + "widgets.description.top-500-weapons-stringers": "", + "widgets.description.top-500-weapons-splatanas": "", + "widgets.description.x-rank-peaks": "", + "widgets.description.builds": "", + "widgets.description.weapon-pool": "", + "widgets.description.sens": "", + "widgets.description.art": "", + "widgets.description.commissions": "", + "widgets.description.social-links": "", + "widgets.description.links": "", + "widgets.description.tier-list": "", + "widgets.forms.bio": "", + "widgets.forms.bio.markdownSupport": "", + "widgets.forms.division": "", + "widgets.forms.division.both": "", + "widgets.forms.division.tentatek": "", + "widgets.forms.division.takoroka": "", + "widgets.forms.timezone": "", + "widgets.forms.favoriteStage": "", + "widgets.forms.weapon": "", + "widgets.forms.peakXp": "", + "widgets.forms.controller": "", + "widgets.forms.source": "", + "widgets.forms.source.ALL": "", + "widgets.forms.source.MADE-BY": "", + "widgets.forms.source.MADE-OF": "", + "widgets.forms.links": "", + "widgets.forms.tierListUrl": "", + "widget.tier-list.untitled": "", + "controllers.s1-pro-con": "", + "controllers.s2-pro-con": "", + "controllers.grip": "", + "controllers.handheld": "", "stickSens": "Styrepindsfølsomhed", "motionSens": "Bevægelsesfølsomhed", "motion": "Bevægelse", @@ -17,6 +141,8 @@ "discordExplanation": "Brugernavn, Profilbillede, Youtube-, Bluesky- og Twitch-konter er hentet via din Discord-konto. Se <1>FAQ for yderligere information.", "favoriteBadges": "", "battlefy": "Battlefy brugernavn", + "forms.newProfileEnabled": "", + "forms.newProfileEnabled.info": "", "forms.showDiscordUniqueName": "Vis Discord-brugernavn", "forms.showDiscordUniqueName.info": "Vil du gøre dit unikke Discord-brugernavn ({{discordUniqueName}}) synligt for offentligheden?", "forms.commissionsOpen": "Åben for bestillinger", @@ -82,5 +208,7 @@ "builds.sorting.CLOTHES_ID": "Kropsbeklædning (in-game sortering)", "builds.sorting.SHOES_ID": "Sko in-game (in-game sortering)", "builds.sorting.PUBLIC_BUILD": "offentlige sæt", - "builds.sorting.PRIVATE_BUILD": "Private sæt" + "builds.sorting.PRIVATE_BUILD": "Private sæt", + "commissions.open": "", + "commissions.closed": "" } diff --git a/locales/de/common.json b/locales/de/common.json index 1b1ca7932..0e25a794c 100644 --- a/locales/de/common.json +++ b/locales/de/common.json @@ -132,6 +132,8 @@ "actions.next": "", "actions.previous": "", "actions.back": "", + "actions.hide": "", + "actions.settings": "", "noResults": "", "maps.createMapList": "Arenen-Liste erstellen", "maps.halfSz": "50% Herrschaft", @@ -255,6 +257,7 @@ "support.perk.joinMoreAssociations": "", "support.perk.useBotToLogIn": "", "support.perk.useBotToLogIn.extra": "", + "support.perk.earlyAccess": "", "custom.colors.title": "Eigene Farben", "custom.colors.bg": "Hintergrund", "custom.colors.bg-darker": "Hintergrund dunkler", diff --git a/locales/de/forms.json b/locales/de/forms.json index 37667c008..8e55ec48b 100644 --- a/locales/de/forms.json +++ b/locales/de/forms.json @@ -168,5 +168,19 @@ "labels.banUserExpiresAt": "", "bottomTexts.banUserExpiresAtHelp": "", "labels.scrimCancelReason": "", - "bottomTexts.scrimCancelReasonHelp": "" + "bottomTexts.scrimCancelReasonHelp": "", + "bottomTexts.bioMarkdown": "", + "labels.division": "", + "options.division.both": "", + "options.division.tentatek": "", + "options.division.takoroka": "", + "labels.timezone": "", + "labels.favoriteStage": "", + "labels.peakXp": "", + "labels.weapon": "", + "labels.artSource": "", + "options.artSource.ALL": "", + "options.artSource.MADE-BY": "", + "options.artSource.MADE-OF": "", + "labels.tierListUrl": "" } diff --git a/locales/de/user.json b/locales/de/user.json index e95e1a93d..bc7fc8f74 100644 --- a/locales/de/user.json +++ b/locales/de/user.json @@ -5,6 +5,130 @@ "ign.short": "IGN", "country": "Land", "bio": "Über mich", + "widget.bio": "", + "widget.bio-md": "", + "widget.badges-owned": "", + "widget.badges-authored": "", + "widget.teams": "", + "widget.organizations": "", + "widget.peak-sp": "", + "widget.top-10-seasons": "", + "widget.top-100-seasons": "", + "widget.peak-xp": "", + "widget.peak-xp-unverified": "", + "widget.peak-xp-weapon": "", + "widget.highlighted-results": "", + "widget.placement-results": "", + "widget.patron-since": "", + "widget.timezone": "", + "widget.favorite-stage": "", + "widget.videos": "", + "widget.lfg-posts": "", + "widget.top-500-weapons": "", + "widget.top-500-weapons-shooters": "", + "widget.top-500-weapons-blasters": "", + "widget.top-500-weapons-rollers": "", + "widget.top-500-weapons-brushes": "", + "widget.top-500-weapons-chargers": "", + "widget.top-500-weapons-sloshers": "", + "widget.top-500-weapons-splatlings": "", + "widget.top-500-weapons-dualies": "", + "widget.top-500-weapons-brellas": "", + "widget.top-500-weapons-stringers": "", + "widget.top-500-weapons-splatanas": "", + "widget.x-rank-peaks": "", + "widget.builds": "", + "widget.weapon-pool": "", + "widget.sens": "", + "widget.art": "", + "widget.commissions": "", + "widget.social-links": "", + "widget.links": "", + "widget.tier-list": "", + "widget.link.all": "", + "widgets.edit": "", + "widgets.editProfile": "", + "widgets.editTitle": "", + "widgets.available": "", + "widgets.mainSlot": "", + "widgets.sideSlot": "", + "widgets.main": "", + "widgets.side": "", + "widgets.add": "", + "widgets.remove": "", + "widgets.maxReached": "", + "widgets.search": "", + "widgets.category.misc": "", + "widgets.category.badges": "", + "widgets.category.teams": "", + "widgets.category.sendouq": "", + "widgets.category.xrank": "", + "widgets.category.tournaments": "", + "widgets.category.vods": "", + "widgets.category.builds": "", + "widgets.category.art": "", + "widgets.description.bio": "", + "widgets.description.bio-md": "", + "widgets.description.badges-owned": "", + "widgets.description.badges-authored": "", + "widgets.description.teams": "", + "widgets.description.organizations": "", + "widgets.description.peak-sp": "", + "widgets.description.top-10-seasons": "", + "widgets.description.top-100-seasons": "", + "widgets.description.peak-xp": "", + "widgets.description.peak-xp-unverified": "", + "widgets.description.peak-xp-weapon": "", + "widgets.description.highlighted-results": "", + "widgets.description.placement-results": "", + "widgets.description.patron-since": "", + "widgets.description.timezone": "", + "widgets.description.favorite-stage": "", + "widgets.description.videos": "", + "widgets.description.lfg-posts": "", + "widgets.description.top-500-weapons": "", + "widgets.description.top-500-weapons-shooters": "", + "widgets.description.top-500-weapons-blasters": "", + "widgets.description.top-500-weapons-rollers": "", + "widgets.description.top-500-weapons-brushes": "", + "widgets.description.top-500-weapons-chargers": "", + "widgets.description.top-500-weapons-sloshers": "", + "widgets.description.top-500-weapons-splatlings": "", + "widgets.description.top-500-weapons-dualies": "", + "widgets.description.top-500-weapons-brellas": "", + "widgets.description.top-500-weapons-stringers": "", + "widgets.description.top-500-weapons-splatanas": "", + "widgets.description.x-rank-peaks": "", + "widgets.description.builds": "", + "widgets.description.weapon-pool": "", + "widgets.description.sens": "", + "widgets.description.art": "", + "widgets.description.commissions": "", + "widgets.description.social-links": "", + "widgets.description.links": "", + "widgets.description.tier-list": "", + "widgets.forms.bio": "", + "widgets.forms.bio.markdownSupport": "", + "widgets.forms.division": "", + "widgets.forms.division.both": "", + "widgets.forms.division.tentatek": "", + "widgets.forms.division.takoroka": "", + "widgets.forms.timezone": "", + "widgets.forms.favoriteStage": "", + "widgets.forms.weapon": "", + "widgets.forms.peakXp": "", + "widgets.forms.controller": "", + "widgets.forms.source": "", + "widgets.forms.source.ALL": "", + "widgets.forms.source.MADE-BY": "", + "widgets.forms.source.MADE-OF": "", + "widgets.forms.links": "", + "widgets.forms.tierListUrl": "", + "widget.tier-list.untitled": "", + "controllers.s1-pro-con": "", + "controllers.s2-pro-con": "", + "controllers.grip": "", + "controllers.handheld": "", "stickSens": "Empfindlichkeit R-Stick", "motionSens": "Empfindlichkeit Bewegungssteuerung", "motion": "Bewegungssteuerung", @@ -17,6 +141,8 @@ "discordExplanation": "Der Username, Profilbild, YouTube-, Bluesky- und Twitch-Konten stammen von deinem Discord-Konto. Mehr Infos in den <1>FAQ.", "favoriteBadges": "", "battlefy": "", + "forms.newProfileEnabled": "", + "forms.newProfileEnabled.info": "", "forms.showDiscordUniqueName": "", "forms.showDiscordUniqueName.info": "", "forms.commissionsOpen": "", @@ -82,5 +208,7 @@ "builds.sorting.CLOTHES_ID": "", "builds.sorting.SHOES_ID": "", "builds.sorting.PUBLIC_BUILD": "", - "builds.sorting.PRIVATE_BUILD": "" + "builds.sorting.PRIVATE_BUILD": "", + "commissions.open": "", + "commissions.closed": "" } diff --git a/locales/en/common.json b/locales/en/common.json index 882aeff32..febf09603 100644 --- a/locales/en/common.json +++ b/locales/en/common.json @@ -132,6 +132,8 @@ "actions.next": "Next", "actions.previous": "Previous", "actions.back": "Back", + "actions.hide": "Hide", + "actions.settings": "Settings", "noResults": "No results", "maps.createMapList": "Create map list", "maps.halfSz": "50% SZ", @@ -255,6 +257,7 @@ "support.perk.joinMoreAssociations": "Join up to 6 associations", "support.perk.useBotToLogIn": "Log-in via Discord bot", "support.perk.useBotToLogIn.extra": "Request a log-in link from the Lohi bot as an alternative to the normal website log-in", + "support.perk.earlyAccess": "Occasional early access to features", "custom.colors.title": "Custom colors", "custom.colors.bg": "Background", "custom.colors.bg-darker": "Background darker", diff --git a/locales/en/forms.json b/locales/en/forms.json index b8faa05da..e59105b61 100644 --- a/locales/en/forms.json +++ b/locales/en/forms.json @@ -168,5 +168,19 @@ "labels.banUserExpiresAt": "Ban expiration date", "bottomTexts.banUserExpiresAtHelp": "Leave empty for a permanent ban", "labels.scrimCancelReason": "Reason for cancellation", - "bottomTexts.scrimCancelReasonHelp": "Explain why you are cancelling the scrim. This will be visible to the other team." + "bottomTexts.scrimCancelReasonHelp": "Explain why you are cancelling the scrim. This will be visible to the other team.", + "bottomTexts.bioMarkdown": "Supports Markdown", + "labels.division": "Division", + "options.division.both": "Both divisions", + "options.division.tentatek": "Tentatek only", + "options.division.takoroka": "Takoroka only", + "labels.timezone": "Timezone", + "labels.favoriteStage": "Favorite Stage", + "labels.peakXp": "Peak XP", + "labels.weapon": "Weapon", + "labels.artSource": "Art source", + "options.artSource.ALL": "All", + "options.artSource.MADE-BY": "Made by me", + "options.artSource.MADE-OF": "Made of me", + "labels.tierListUrl": "Tier List URL" } diff --git a/locales/en/user.json b/locales/en/user.json index c7cdc88d9..1d64eff0e 100644 --- a/locales/en/user.json +++ b/locales/en/user.json @@ -5,6 +5,130 @@ "ign.short": "IGN", "country": "Country", "bio": "Bio", + "widget.bio": "Bio", + "widget.bio-md": "Bio", + "widget.badges-owned": "Badges", + "widget.badges-authored": "Badges Authored", + "widget.teams": "Teams", + "widget.organizations": "Organizations", + "widget.peak-sp": "Peak SP", + "widget.top-10-seasons": "SendouQ Top 10 Finishes", + "widget.top-100-seasons": "SendouQ Top 100 Finishes", + "widget.peak-xp": "Peak XP", + "widget.peak-xp-unverified": "Peak XP (Unverified)", + "widget.peak-xp-weapon": "Peak XP (Weapon)", + "widget.highlighted-results": "Tournament Results", + "widget.placement-results": "Tournament Placements", + "widget.patron-since": "Supporter Since", + "widget.timezone": "User's Current Time", + "widget.favorite-stage": "Favorite Stage", + "widget.videos": "Videos", + "widget.lfg-posts": "LFG Posts", + "widget.top-500-weapons": "Top 500 Weapons", + "widget.top-500-weapons-shooters": "Top 500 Shooters", + "widget.top-500-weapons-blasters": "Top 500 Blasters", + "widget.top-500-weapons-rollers": "Top 500 Rollers", + "widget.top-500-weapons-brushes": "Top 500 Brushes", + "widget.top-500-weapons-chargers": "Top 500 Chargers", + "widget.top-500-weapons-sloshers": "Top 500 Sloshers", + "widget.top-500-weapons-splatlings": "Top 500 Splatlings", + "widget.top-500-weapons-dualies": "Top 500 Dualies", + "widget.top-500-weapons-brellas": "Top 500 Brellas", + "widget.top-500-weapons-stringers": "Top 500 Stringers", + "widget.top-500-weapons-splatanas": "Top 500 Splatanas", + "widget.x-rank-peaks": "X Rank Peaks Per Mode", + "widget.builds": "Builds", + "widget.weapon-pool": "Weapon Pool", + "widget.sens": "Sensitivity", + "widget.art": "Art", + "widget.commissions": "Commissions", + "widget.social-links": "Verified Social Links", + "widget.links": "Links", + "widget.tier-list": "Featured Tier List", + "widget.link.all": "All", + "widgets.edit": "Edit Widgets", + "widgets.editProfile": "Edit Profile", + "widgets.editTitle": "Edit Profile Widgets", + "widgets.available": "Gallery", + "widgets.mainSlot": "Main Widgets", + "widgets.sideSlot": "Side Widgets", + "widgets.main": "Main", + "widgets.side": "Side", + "widgets.add": "Add", + "widgets.remove": "Remove", + "widgets.maxReached": "Maximum widgets reached", + "widgets.search": "Search widgets...", + "widgets.category.misc": "Miscellaneous", + "widgets.category.badges": "Badges", + "widgets.category.teams": "Teams", + "widgets.category.sendouq": "SendouQ", + "widgets.category.xrank": "X Rank", + "widgets.category.tournaments": "Tournaments", + "widgets.category.vods": "Videos", + "widgets.category.builds": "Builds", + "widgets.category.art": "Art", + "widgets.description.bio": "Share freeform information about yourself", + "widgets.description.bio-md": "Share freeform information about yourself with markdown formatting", + "widgets.description.badges-owned": "Display your earned badges", + "widgets.description.badges-authored": "Display badges you've created", + "widgets.description.teams": "Show teams you're a member of", + "widgets.description.organizations": "Display tournament organizations you're part of", + "widgets.description.peak-sp": "Show your highest SendouQ rating across all seasons", + "widgets.description.top-10-seasons": "Show how many times and which seasons you've finished top 10 of SendouQ leaderboards", + "widgets.description.top-100-seasons": "Show how many times and which seasons you've finished top 100 of SendouQ leaderboards", + "widgets.description.peak-xp": "Show your peak X Rank power", + "widgets.description.peak-xp-unverified": "Show your self-reported peak X Rank power", + "widgets.description.peak-xp-weapon": "Show your peak X Rank power for a specific weapon with leaderboard position", + "widgets.description.highlighted-results": "Display your (highlighted) tournament results", + "widgets.description.placement-results": "Show your tournament placements by 1st, 2nd, and 3rd place", + "widgets.description.patron-since": "Show the date when you became a supporter", + "widgets.description.timezone": "Display your current local time", + "widgets.description.favorite-stage": "Show your favorite stage", + "widgets.description.videos": "Display your 3 most recent videos", + "widgets.description.lfg-posts": "Show your current LFG posts", + "widgets.description.top-500-weapons": "Show weapons you've reached top 500 with", + "widgets.description.top-500-weapons-shooters": "Show shooters you've reached top 500 with", + "widgets.description.top-500-weapons-blasters": "Show blasters you've reached top 500 with", + "widgets.description.top-500-weapons-rollers": "Show rollers you've reached top 500 with", + "widgets.description.top-500-weapons-brushes": "Show brushes you've reached top 500 with", + "widgets.description.top-500-weapons-chargers": "Show chargers you've reached top 500 with", + "widgets.description.top-500-weapons-sloshers": "Show sloshers you've reached top 500 with", + "widgets.description.top-500-weapons-splatlings": "Show splatlings you've reached top 500 with", + "widgets.description.top-500-weapons-dualies": "Show dualies you've reached top 500 with", + "widgets.description.top-500-weapons-brellas": "Show brellas you've reached top 500 with", + "widgets.description.top-500-weapons-stringers": "Show stringers you've reached top 500 with", + "widgets.description.top-500-weapons-splatanas": "Show splatanas you've reached top 500 with", + "widgets.description.x-rank-peaks": "Show your peak X Rank placement for each mode, optionally select division", + "widgets.description.builds": "Display your 3 most recent builds", + "widgets.description.weapon-pool": "Display your weapon pool with favorite selection", + "widgets.description.sens": "Show your sensitivity settings and controller of choice", + "widgets.description.art": "Display your 3 most recent art pieces", + "widgets.description.commissions": "Show your commission status and details", + "widgets.description.social-links": "Display your social media links from Discord", + "widgets.description.links": "Share your custom social media and website links", + "widgets.description.tier-list": "Display a tier list you have made", + "widgets.forms.bio": "Bio", + "widgets.forms.bio.markdownSupport": "Supports Markdown", + "widgets.forms.division": "Division", + "widgets.forms.division.both": "Both divisions", + "widgets.forms.division.tentatek": "Tentatek only", + "widgets.forms.division.takoroka": "Takoroka only", + "widgets.forms.timezone": "Timezone", + "widgets.forms.favoriteStage": "Favorite Stage", + "widgets.forms.weapon": "Weapon", + "widgets.forms.peakXp": "Peak XP", + "widgets.forms.controller": "Controller", + "widgets.forms.source": "Art source", + "widgets.forms.source.ALL": "All", + "widgets.forms.source.MADE-BY": "Made by me", + "widgets.forms.source.MADE-OF": "Made of me", + "widgets.forms.links": "Links", + "widgets.forms.tierListUrl": "Tier List URL", + "widget.tier-list.untitled": "Untitled Tier List", + "controllers.s1-pro-con": "Switch 1 Pro Controller", + "controllers.s2-pro-con": "Switch 2 Pro Controller", + "controllers.grip": "Joy-Con Grip", + "controllers.handheld": "Handheld", "stickSens": "R-stick sens", "motionSens": "Motion sens", "motion": "Motion", @@ -17,6 +141,8 @@ "discordExplanation": "Username, profile picture, YouTube, Bluesky and Twitch accounts come from your Discord account. See <1>FAQ for more information.", "favoriteBadges": "Favorite badges", "battlefy": "Battlefy account name", + "forms.newProfileEnabled": "New profile page", + "forms.newProfileEnabled.info": "Enable the new widget-based profile page. Currently available as an early preview for supporters.", "forms.showDiscordUniqueName": "Show Discord username", "forms.showDiscordUniqueName.info": "Show your unique Discord name ({{discordUniqueName}}) publicly?", "forms.commissionsOpen": "Commissions open", @@ -82,5 +208,7 @@ "builds.sorting.CLOTHES_ID": "Clothing in-game order", "builds.sorting.SHOES_ID": "Shoes in-game order", "builds.sorting.PUBLIC_BUILD": "Public build", - "builds.sorting.PRIVATE_BUILD": "Private build" + "builds.sorting.PRIVATE_BUILD": "Private build", + "commissions.open": "Open", + "commissions.closed": "Closed" } diff --git a/locales/es-ES/common.json b/locales/es-ES/common.json index 311b3a91a..0f222b74e 100644 --- a/locales/es-ES/common.json +++ b/locales/es-ES/common.json @@ -132,6 +132,8 @@ "actions.next": "", "actions.previous": "", "actions.back": "", + "actions.hide": "", + "actions.settings": "", "noResults": "", "maps.createMapList": "Crear lista de mapas", "maps.halfSz": "50% Pintazonas", @@ -257,6 +259,7 @@ "support.perk.joinMoreAssociations": "", "support.perk.useBotToLogIn": "", "support.perk.useBotToLogIn.extra": "", + "support.perk.earlyAccess": "", "custom.colors.title": "Colores personalizados", "custom.colors.bg": "Fondo", "custom.colors.bg-darker": "Fondo más oscuro", diff --git a/locales/es-ES/forms.json b/locales/es-ES/forms.json index 78cd75cd5..d2bae4c66 100644 --- a/locales/es-ES/forms.json +++ b/locales/es-ES/forms.json @@ -168,5 +168,19 @@ "labels.banUserExpiresAt": "", "bottomTexts.banUserExpiresAtHelp": "", "labels.scrimCancelReason": "", - "bottomTexts.scrimCancelReasonHelp": "" + "bottomTexts.scrimCancelReasonHelp": "", + "bottomTexts.bioMarkdown": "", + "labels.division": "", + "options.division.both": "", + "options.division.tentatek": "", + "options.division.takoroka": "", + "labels.timezone": "", + "labels.favoriteStage": "", + "labels.peakXp": "", + "labels.weapon": "", + "labels.artSource": "", + "options.artSource.ALL": "", + "options.artSource.MADE-BY": "", + "options.artSource.MADE-OF": "", + "labels.tierListUrl": "" } diff --git a/locales/es-ES/user.json b/locales/es-ES/user.json index 38f3cc5f7..7d602c673 100644 --- a/locales/es-ES/user.json +++ b/locales/es-ES/user.json @@ -5,6 +5,130 @@ "ign.short": "IGN", "country": "País", "bio": "Biografía", + "widget.bio": "", + "widget.bio-md": "", + "widget.badges-owned": "", + "widget.badges-authored": "", + "widget.teams": "", + "widget.organizations": "", + "widget.peak-sp": "", + "widget.top-10-seasons": "", + "widget.top-100-seasons": "", + "widget.peak-xp": "", + "widget.peak-xp-unverified": "", + "widget.peak-xp-weapon": "", + "widget.highlighted-results": "", + "widget.placement-results": "", + "widget.patron-since": "", + "widget.timezone": "", + "widget.favorite-stage": "", + "widget.videos": "", + "widget.lfg-posts": "", + "widget.top-500-weapons": "", + "widget.top-500-weapons-shooters": "", + "widget.top-500-weapons-blasters": "", + "widget.top-500-weapons-rollers": "", + "widget.top-500-weapons-brushes": "", + "widget.top-500-weapons-chargers": "", + "widget.top-500-weapons-sloshers": "", + "widget.top-500-weapons-splatlings": "", + "widget.top-500-weapons-dualies": "", + "widget.top-500-weapons-brellas": "", + "widget.top-500-weapons-stringers": "", + "widget.top-500-weapons-splatanas": "", + "widget.x-rank-peaks": "", + "widget.builds": "", + "widget.weapon-pool": "", + "widget.sens": "", + "widget.art": "", + "widget.commissions": "", + "widget.social-links": "", + "widget.links": "", + "widget.tier-list": "", + "widget.link.all": "", + "widgets.edit": "", + "widgets.editProfile": "", + "widgets.editTitle": "", + "widgets.available": "", + "widgets.mainSlot": "", + "widgets.sideSlot": "", + "widgets.main": "", + "widgets.side": "", + "widgets.add": "", + "widgets.remove": "", + "widgets.maxReached": "", + "widgets.search": "", + "widgets.category.misc": "", + "widgets.category.badges": "", + "widgets.category.teams": "", + "widgets.category.sendouq": "", + "widgets.category.xrank": "", + "widgets.category.tournaments": "", + "widgets.category.vods": "", + "widgets.category.builds": "", + "widgets.category.art": "", + "widgets.description.bio": "", + "widgets.description.bio-md": "", + "widgets.description.badges-owned": "", + "widgets.description.badges-authored": "", + "widgets.description.teams": "", + "widgets.description.organizations": "", + "widgets.description.peak-sp": "", + "widgets.description.top-10-seasons": "", + "widgets.description.top-100-seasons": "", + "widgets.description.peak-xp": "", + "widgets.description.peak-xp-unverified": "", + "widgets.description.peak-xp-weapon": "", + "widgets.description.highlighted-results": "", + "widgets.description.placement-results": "", + "widgets.description.patron-since": "", + "widgets.description.timezone": "", + "widgets.description.favorite-stage": "", + "widgets.description.videos": "", + "widgets.description.lfg-posts": "", + "widgets.description.top-500-weapons": "", + "widgets.description.top-500-weapons-shooters": "", + "widgets.description.top-500-weapons-blasters": "", + "widgets.description.top-500-weapons-rollers": "", + "widgets.description.top-500-weapons-brushes": "", + "widgets.description.top-500-weapons-chargers": "", + "widgets.description.top-500-weapons-sloshers": "", + "widgets.description.top-500-weapons-splatlings": "", + "widgets.description.top-500-weapons-dualies": "", + "widgets.description.top-500-weapons-brellas": "", + "widgets.description.top-500-weapons-stringers": "", + "widgets.description.top-500-weapons-splatanas": "", + "widgets.description.x-rank-peaks": "", + "widgets.description.builds": "", + "widgets.description.weapon-pool": "", + "widgets.description.sens": "", + "widgets.description.art": "", + "widgets.description.commissions": "", + "widgets.description.social-links": "", + "widgets.description.links": "", + "widgets.description.tier-list": "", + "widgets.forms.bio": "", + "widgets.forms.bio.markdownSupport": "", + "widgets.forms.division": "", + "widgets.forms.division.both": "", + "widgets.forms.division.tentatek": "", + "widgets.forms.division.takoroka": "", + "widgets.forms.timezone": "", + "widgets.forms.favoriteStage": "", + "widgets.forms.weapon": "", + "widgets.forms.peakXp": "", + "widgets.forms.controller": "", + "widgets.forms.source": "", + "widgets.forms.source.ALL": "", + "widgets.forms.source.MADE-BY": "", + "widgets.forms.source.MADE-OF": "", + "widgets.forms.links": "", + "widgets.forms.tierListUrl": "", + "widget.tier-list.untitled": "", + "controllers.s1-pro-con": "", + "controllers.s2-pro-con": "", + "controllers.grip": "", + "controllers.handheld": "", "stickSens": "Sens de palanca", "motionSens": "Sens del giroscopio", "motion": "Giroscopio", @@ -17,6 +141,8 @@ "discordExplanation": "Tu nombre, foto, y cuentas de YouTube, Bluesky y Twitch se obtienen por tu cuenta en Discord. Ver <1>FAQ para más información.", "favoriteBadges": "", "battlefy": "", + "forms.newProfileEnabled": "", + "forms.newProfileEnabled.info": "", "forms.showDiscordUniqueName": "Mostrar usuario de Discord", "forms.showDiscordUniqueName.info": "¿Mostrar tu nombre de Discord ({{discordUniqueName}}) publicamente?", "forms.commissionsOpen": "Comisiones abiertas", @@ -82,5 +208,7 @@ "builds.sorting.CLOTHES_ID": "", "builds.sorting.SHOES_ID": "", "builds.sorting.PUBLIC_BUILD": "", - "builds.sorting.PRIVATE_BUILD": "" + "builds.sorting.PRIVATE_BUILD": "", + "commissions.open": "", + "commissions.closed": "" } diff --git a/locales/es-US/common.json b/locales/es-US/common.json index 3079f897b..4c273f62f 100644 --- a/locales/es-US/common.json +++ b/locales/es-US/common.json @@ -132,6 +132,8 @@ "actions.next": "", "actions.previous": "", "actions.back": "", + "actions.hide": "", + "actions.settings": "", "noResults": "", "maps.createMapList": "Crear lista de escenarios", "maps.halfSz": "50% Pintazonas", @@ -257,6 +259,7 @@ "support.perk.joinMoreAssociations": "", "support.perk.useBotToLogIn": "", "support.perk.useBotToLogIn.extra": "", + "support.perk.earlyAccess": "", "custom.colors.title": "Colores personalizados", "custom.colors.bg": "Fondo", "custom.colors.bg-darker": "Fondo más oscuro", diff --git a/locales/es-US/forms.json b/locales/es-US/forms.json index 122ca4633..5aa23fd75 100644 --- a/locales/es-US/forms.json +++ b/locales/es-US/forms.json @@ -168,5 +168,19 @@ "labels.banUserExpiresAt": "", "bottomTexts.banUserExpiresAtHelp": "", "labels.scrimCancelReason": "", - "bottomTexts.scrimCancelReasonHelp": "" + "bottomTexts.scrimCancelReasonHelp": "", + "bottomTexts.bioMarkdown": "", + "labels.division": "", + "options.division.both": "", + "options.division.tentatek": "", + "options.division.takoroka": "", + "labels.timezone": "", + "labels.favoriteStage": "", + "labels.peakXp": "", + "labels.weapon": "", + "labels.artSource": "", + "options.artSource.ALL": "", + "options.artSource.MADE-BY": "", + "options.artSource.MADE-OF": "", + "labels.tierListUrl": "" } diff --git a/locales/es-US/user.json b/locales/es-US/user.json index 1eebacb36..abdffe54e 100644 --- a/locales/es-US/user.json +++ b/locales/es-US/user.json @@ -5,6 +5,130 @@ "ign.short": "IGN", "country": "País", "bio": "Biografía", + "widget.bio": "", + "widget.bio-md": "", + "widget.badges-owned": "", + "widget.badges-authored": "", + "widget.teams": "", + "widget.organizations": "", + "widget.peak-sp": "", + "widget.top-10-seasons": "", + "widget.top-100-seasons": "", + "widget.peak-xp": "", + "widget.peak-xp-unverified": "", + "widget.peak-xp-weapon": "", + "widget.highlighted-results": "", + "widget.placement-results": "", + "widget.patron-since": "", + "widget.timezone": "", + "widget.favorite-stage": "", + "widget.videos": "", + "widget.lfg-posts": "", + "widget.top-500-weapons": "", + "widget.top-500-weapons-shooters": "", + "widget.top-500-weapons-blasters": "", + "widget.top-500-weapons-rollers": "", + "widget.top-500-weapons-brushes": "", + "widget.top-500-weapons-chargers": "", + "widget.top-500-weapons-sloshers": "", + "widget.top-500-weapons-splatlings": "", + "widget.top-500-weapons-dualies": "", + "widget.top-500-weapons-brellas": "", + "widget.top-500-weapons-stringers": "", + "widget.top-500-weapons-splatanas": "", + "widget.x-rank-peaks": "", + "widget.builds": "", + "widget.weapon-pool": "", + "widget.sens": "", + "widget.art": "", + "widget.commissions": "", + "widget.social-links": "", + "widget.links": "", + "widget.tier-list": "", + "widget.link.all": "", + "widgets.edit": "", + "widgets.editProfile": "", + "widgets.editTitle": "", + "widgets.available": "", + "widgets.mainSlot": "", + "widgets.sideSlot": "", + "widgets.main": "", + "widgets.side": "", + "widgets.add": "", + "widgets.remove": "", + "widgets.maxReached": "", + "widgets.search": "", + "widgets.category.misc": "", + "widgets.category.badges": "", + "widgets.category.teams": "", + "widgets.category.sendouq": "", + "widgets.category.xrank": "", + "widgets.category.tournaments": "", + "widgets.category.vods": "", + "widgets.category.builds": "", + "widgets.category.art": "", + "widgets.description.bio": "", + "widgets.description.bio-md": "", + "widgets.description.badges-owned": "", + "widgets.description.badges-authored": "", + "widgets.description.teams": "", + "widgets.description.organizations": "", + "widgets.description.peak-sp": "", + "widgets.description.top-10-seasons": "", + "widgets.description.top-100-seasons": "", + "widgets.description.peak-xp": "", + "widgets.description.peak-xp-unverified": "", + "widgets.description.peak-xp-weapon": "", + "widgets.description.highlighted-results": "", + "widgets.description.placement-results": "", + "widgets.description.patron-since": "", + "widgets.description.timezone": "", + "widgets.description.favorite-stage": "", + "widgets.description.videos": "", + "widgets.description.lfg-posts": "", + "widgets.description.top-500-weapons": "", + "widgets.description.top-500-weapons-shooters": "", + "widgets.description.top-500-weapons-blasters": "", + "widgets.description.top-500-weapons-rollers": "", + "widgets.description.top-500-weapons-brushes": "", + "widgets.description.top-500-weapons-chargers": "", + "widgets.description.top-500-weapons-sloshers": "", + "widgets.description.top-500-weapons-splatlings": "", + "widgets.description.top-500-weapons-dualies": "", + "widgets.description.top-500-weapons-brellas": "", + "widgets.description.top-500-weapons-stringers": "", + "widgets.description.top-500-weapons-splatanas": "", + "widgets.description.x-rank-peaks": "", + "widgets.description.builds": "", + "widgets.description.weapon-pool": "", + "widgets.description.sens": "", + "widgets.description.art": "", + "widgets.description.commissions": "", + "widgets.description.social-links": "", + "widgets.description.links": "", + "widgets.description.tier-list": "", + "widgets.forms.bio": "", + "widgets.forms.bio.markdownSupport": "", + "widgets.forms.division": "", + "widgets.forms.division.both": "", + "widgets.forms.division.tentatek": "", + "widgets.forms.division.takoroka": "", + "widgets.forms.timezone": "", + "widgets.forms.favoriteStage": "", + "widgets.forms.weapon": "", + "widgets.forms.peakXp": "", + "widgets.forms.controller": "", + "widgets.forms.source": "", + "widgets.forms.source.ALL": "", + "widgets.forms.source.MADE-BY": "", + "widgets.forms.source.MADE-OF": "", + "widgets.forms.links": "", + "widgets.forms.tierListUrl": "", + "widget.tier-list.untitled": "", + "controllers.s1-pro-con": "", + "controllers.s2-pro-con": "", + "controllers.grip": "", + "controllers.handheld": "", "stickSens": "Sens de palanca", "motionSens": "Sens del giroscopio", "motion": "Giroscopio", @@ -17,6 +141,8 @@ "discordExplanation": "Tu nombre, foto, y cuentas de YouTube, Bluesky y Twitch se obtienen por tu cuenta en Discord. Ver <1>FAQ para más información.", "favoriteBadges": "", "battlefy": "Nombre de cuenta de Battlefy", + "forms.newProfileEnabled": "", + "forms.newProfileEnabled.info": "", "forms.showDiscordUniqueName": "Mostrar usuario de Discord", "forms.showDiscordUniqueName.info": "¿Mostrar tu nombre de Discord ({{discordUniqueName}}) publicamente?", "forms.commissionsOpen": "Comisiones abiertas", @@ -82,5 +208,7 @@ "builds.sorting.CLOTHES_ID": "Ordenación de ropa en juego", "builds.sorting.SHOES_ID": "Ordenación de calzados en juego", "builds.sorting.PUBLIC_BUILD": "Build público", - "builds.sorting.PRIVATE_BUILD": "Build privado" + "builds.sorting.PRIVATE_BUILD": "Build privado", + "commissions.open": "", + "commissions.closed": "" } diff --git a/locales/fr-CA/common.json b/locales/fr-CA/common.json index 37095a0f5..a6e8d7cee 100644 --- a/locales/fr-CA/common.json +++ b/locales/fr-CA/common.json @@ -132,6 +132,8 @@ "actions.next": "", "actions.previous": "", "actions.back": "", + "actions.hide": "", + "actions.settings": "", "noResults": "", "maps.createMapList": "Créer une liste de stages", "maps.halfSz": "50% DdZ", @@ -257,6 +259,7 @@ "support.perk.joinMoreAssociations": "", "support.perk.useBotToLogIn": "", "support.perk.useBotToLogIn.extra": "", + "support.perk.earlyAccess": "", "custom.colors.title": "Couleurs personalisées", "custom.colors.bg": "Arrière-plan", "custom.colors.bg-darker": "Arrière-plan sombre", diff --git a/locales/fr-CA/forms.json b/locales/fr-CA/forms.json index ad46a5169..c41790a7e 100644 --- a/locales/fr-CA/forms.json +++ b/locales/fr-CA/forms.json @@ -168,5 +168,19 @@ "labels.banUserExpiresAt": "", "bottomTexts.banUserExpiresAtHelp": "", "labels.scrimCancelReason": "", - "bottomTexts.scrimCancelReasonHelp": "" + "bottomTexts.scrimCancelReasonHelp": "", + "bottomTexts.bioMarkdown": "", + "labels.division": "", + "options.division.both": "", + "options.division.tentatek": "", + "options.division.takoroka": "", + "labels.timezone": "", + "labels.favoriteStage": "", + "labels.peakXp": "", + "labels.weapon": "", + "labels.artSource": "", + "options.artSource.ALL": "", + "options.artSource.MADE-BY": "", + "options.artSource.MADE-OF": "", + "labels.tierListUrl": "" } diff --git a/locales/fr-CA/user.json b/locales/fr-CA/user.json index c71f5a53c..6b5ab1deb 100644 --- a/locales/fr-CA/user.json +++ b/locales/fr-CA/user.json @@ -5,6 +5,130 @@ "ign.short": "PEJ", "country": "Pays", "bio": "Bio", + "widget.bio": "", + "widget.bio-md": "", + "widget.badges-owned": "", + "widget.badges-authored": "", + "widget.teams": "", + "widget.organizations": "", + "widget.peak-sp": "", + "widget.top-10-seasons": "", + "widget.top-100-seasons": "", + "widget.peak-xp": "", + "widget.peak-xp-unverified": "", + "widget.peak-xp-weapon": "", + "widget.highlighted-results": "", + "widget.placement-results": "", + "widget.patron-since": "", + "widget.timezone": "", + "widget.favorite-stage": "", + "widget.videos": "", + "widget.lfg-posts": "", + "widget.top-500-weapons": "", + "widget.top-500-weapons-shooters": "", + "widget.top-500-weapons-blasters": "", + "widget.top-500-weapons-rollers": "", + "widget.top-500-weapons-brushes": "", + "widget.top-500-weapons-chargers": "", + "widget.top-500-weapons-sloshers": "", + "widget.top-500-weapons-splatlings": "", + "widget.top-500-weapons-dualies": "", + "widget.top-500-weapons-brellas": "", + "widget.top-500-weapons-stringers": "", + "widget.top-500-weapons-splatanas": "", + "widget.x-rank-peaks": "", + "widget.builds": "", + "widget.weapon-pool": "", + "widget.sens": "", + "widget.art": "", + "widget.commissions": "", + "widget.social-links": "", + "widget.links": "", + "widget.tier-list": "", + "widget.link.all": "", + "widgets.edit": "", + "widgets.editProfile": "", + "widgets.editTitle": "", + "widgets.available": "", + "widgets.mainSlot": "", + "widgets.sideSlot": "", + "widgets.main": "", + "widgets.side": "", + "widgets.add": "", + "widgets.remove": "", + "widgets.maxReached": "", + "widgets.search": "", + "widgets.category.misc": "", + "widgets.category.badges": "", + "widgets.category.teams": "", + "widgets.category.sendouq": "", + "widgets.category.xrank": "", + "widgets.category.tournaments": "", + "widgets.category.vods": "", + "widgets.category.builds": "", + "widgets.category.art": "", + "widgets.description.bio": "", + "widgets.description.bio-md": "", + "widgets.description.badges-owned": "", + "widgets.description.badges-authored": "", + "widgets.description.teams": "", + "widgets.description.organizations": "", + "widgets.description.peak-sp": "", + "widgets.description.top-10-seasons": "", + "widgets.description.top-100-seasons": "", + "widgets.description.peak-xp": "", + "widgets.description.peak-xp-unverified": "", + "widgets.description.peak-xp-weapon": "", + "widgets.description.highlighted-results": "", + "widgets.description.placement-results": "", + "widgets.description.patron-since": "", + "widgets.description.timezone": "", + "widgets.description.favorite-stage": "", + "widgets.description.videos": "", + "widgets.description.lfg-posts": "", + "widgets.description.top-500-weapons": "", + "widgets.description.top-500-weapons-shooters": "", + "widgets.description.top-500-weapons-blasters": "", + "widgets.description.top-500-weapons-rollers": "", + "widgets.description.top-500-weapons-brushes": "", + "widgets.description.top-500-weapons-chargers": "", + "widgets.description.top-500-weapons-sloshers": "", + "widgets.description.top-500-weapons-splatlings": "", + "widgets.description.top-500-weapons-dualies": "", + "widgets.description.top-500-weapons-brellas": "", + "widgets.description.top-500-weapons-stringers": "", + "widgets.description.top-500-weapons-splatanas": "", + "widgets.description.x-rank-peaks": "", + "widgets.description.builds": "", + "widgets.description.weapon-pool": "", + "widgets.description.sens": "", + "widgets.description.art": "", + "widgets.description.commissions": "", + "widgets.description.social-links": "", + "widgets.description.links": "", + "widgets.description.tier-list": "", + "widgets.forms.bio": "", + "widgets.forms.bio.markdownSupport": "", + "widgets.forms.division": "", + "widgets.forms.division.both": "", + "widgets.forms.division.tentatek": "", + "widgets.forms.division.takoroka": "", + "widgets.forms.timezone": "", + "widgets.forms.favoriteStage": "", + "widgets.forms.weapon": "", + "widgets.forms.peakXp": "", + "widgets.forms.controller": "", + "widgets.forms.source": "", + "widgets.forms.source.ALL": "", + "widgets.forms.source.MADE-BY": "", + "widgets.forms.source.MADE-OF": "", + "widgets.forms.links": "", + "widgets.forms.tierListUrl": "", + "widget.tier-list.untitled": "", + "controllers.s1-pro-con": "", + "controllers.s2-pro-con": "", + "controllers.grip": "", + "controllers.handheld": "", "stickSens": "Sensibilité du stick droit", "motionSens": "Sensibilité du gyroscope", "motion": "Gyro", @@ -17,6 +141,8 @@ "discordExplanation": "Votre pseudo, votre photo de profil et vos comptes Youtube, Bluesky et Twitch viennent de votre compte Discord. Voir la <1>FAQ pour plus d'informations.", "favoriteBadges": "", "battlefy": "", + "forms.newProfileEnabled": "", + "forms.newProfileEnabled.info": "", "forms.showDiscordUniqueName": "Montrer le pseudo Discord", "forms.showDiscordUniqueName.info": "Show your unique Discord name ({{discordUniqueName}}) publicly?", "forms.commissionsOpen": "Commissions acceptées", @@ -82,5 +208,7 @@ "builds.sorting.CLOTHES_ID": "", "builds.sorting.SHOES_ID": "", "builds.sorting.PUBLIC_BUILD": "", - "builds.sorting.PRIVATE_BUILD": "" + "builds.sorting.PRIVATE_BUILD": "", + "commissions.open": "", + "commissions.closed": "" } diff --git a/locales/fr-EU/common.json b/locales/fr-EU/common.json index bca983487..a1513606e 100644 --- a/locales/fr-EU/common.json +++ b/locales/fr-EU/common.json @@ -132,6 +132,8 @@ "actions.next": "", "actions.previous": "", "actions.back": "", + "actions.hide": "", + "actions.settings": "", "noResults": "Aucun résultats", "maps.createMapList": "Créer une liste de stages", "maps.halfSz": "50% DdZ", @@ -257,6 +259,7 @@ "support.perk.joinMoreAssociations": "Rejoignez jusqu'à un maximum 6 associations", "support.perk.useBotToLogIn": "Se connecter via le bot Discord", "support.perk.useBotToLogIn.extra": "Demander un lien de connexion au bot Lohi comme alternative à la connexion normale au site Web", + "support.perk.earlyAccess": "", "custom.colors.title": "Couleurs personalisées", "custom.colors.bg": "Arrière-plan", "custom.colors.bg-darker": "Arrière-plan sombre", diff --git a/locales/fr-EU/forms.json b/locales/fr-EU/forms.json index 376bc6ebb..99aa434a9 100644 --- a/locales/fr-EU/forms.json +++ b/locales/fr-EU/forms.json @@ -168,5 +168,19 @@ "labels.banUserExpiresAt": "", "bottomTexts.banUserExpiresAtHelp": "", "labels.scrimCancelReason": "", - "bottomTexts.scrimCancelReasonHelp": "" + "bottomTexts.scrimCancelReasonHelp": "", + "bottomTexts.bioMarkdown": "", + "labels.division": "", + "options.division.both": "", + "options.division.tentatek": "", + "options.division.takoroka": "", + "labels.timezone": "", + "labels.favoriteStage": "", + "labels.peakXp": "", + "labels.weapon": "", + "labels.artSource": "", + "options.artSource.ALL": "", + "options.artSource.MADE-BY": "", + "options.artSource.MADE-OF": "", + "labels.tierListUrl": "" } diff --git a/locales/fr-EU/user.json b/locales/fr-EU/user.json index 5c9deec1c..00c8482fb 100644 --- a/locales/fr-EU/user.json +++ b/locales/fr-EU/user.json @@ -5,6 +5,130 @@ "ign.short": "PEJ", "country": "Pays", "bio": "Bio", + "widget.bio": "", + "widget.bio-md": "", + "widget.badges-owned": "", + "widget.badges-authored": "", + "widget.teams": "", + "widget.organizations": "", + "widget.peak-sp": "", + "widget.top-10-seasons": "", + "widget.top-100-seasons": "", + "widget.peak-xp": "", + "widget.peak-xp-unverified": "", + "widget.peak-xp-weapon": "", + "widget.highlighted-results": "", + "widget.placement-results": "", + "widget.patron-since": "", + "widget.timezone": "", + "widget.favorite-stage": "", + "widget.videos": "", + "widget.lfg-posts": "", + "widget.top-500-weapons": "", + "widget.top-500-weapons-shooters": "", + "widget.top-500-weapons-blasters": "", + "widget.top-500-weapons-rollers": "", + "widget.top-500-weapons-brushes": "", + "widget.top-500-weapons-chargers": "", + "widget.top-500-weapons-sloshers": "", + "widget.top-500-weapons-splatlings": "", + "widget.top-500-weapons-dualies": "", + "widget.top-500-weapons-brellas": "", + "widget.top-500-weapons-stringers": "", + "widget.top-500-weapons-splatanas": "", + "widget.x-rank-peaks": "", + "widget.builds": "", + "widget.weapon-pool": "", + "widget.sens": "", + "widget.art": "", + "widget.commissions": "", + "widget.social-links": "", + "widget.links": "", + "widget.tier-list": "", + "widget.link.all": "", + "widgets.edit": "", + "widgets.editProfile": "", + "widgets.editTitle": "", + "widgets.available": "", + "widgets.mainSlot": "", + "widgets.sideSlot": "", + "widgets.main": "", + "widgets.side": "", + "widgets.add": "", + "widgets.remove": "", + "widgets.maxReached": "", + "widgets.search": "", + "widgets.category.misc": "", + "widgets.category.badges": "", + "widgets.category.teams": "", + "widgets.category.sendouq": "", + "widgets.category.xrank": "", + "widgets.category.tournaments": "", + "widgets.category.vods": "", + "widgets.category.builds": "", + "widgets.category.art": "", + "widgets.description.bio": "", + "widgets.description.bio-md": "", + "widgets.description.badges-owned": "", + "widgets.description.badges-authored": "", + "widgets.description.teams": "", + "widgets.description.organizations": "", + "widgets.description.peak-sp": "", + "widgets.description.top-10-seasons": "", + "widgets.description.top-100-seasons": "", + "widgets.description.peak-xp": "", + "widgets.description.peak-xp-unverified": "", + "widgets.description.peak-xp-weapon": "", + "widgets.description.highlighted-results": "", + "widgets.description.placement-results": "", + "widgets.description.patron-since": "", + "widgets.description.timezone": "", + "widgets.description.favorite-stage": "", + "widgets.description.videos": "", + "widgets.description.lfg-posts": "", + "widgets.description.top-500-weapons": "", + "widgets.description.top-500-weapons-shooters": "", + "widgets.description.top-500-weapons-blasters": "", + "widgets.description.top-500-weapons-rollers": "", + "widgets.description.top-500-weapons-brushes": "", + "widgets.description.top-500-weapons-chargers": "", + "widgets.description.top-500-weapons-sloshers": "", + "widgets.description.top-500-weapons-splatlings": "", + "widgets.description.top-500-weapons-dualies": "", + "widgets.description.top-500-weapons-brellas": "", + "widgets.description.top-500-weapons-stringers": "", + "widgets.description.top-500-weapons-splatanas": "", + "widgets.description.x-rank-peaks": "", + "widgets.description.builds": "", + "widgets.description.weapon-pool": "", + "widgets.description.sens": "", + "widgets.description.art": "", + "widgets.description.commissions": "", + "widgets.description.social-links": "", + "widgets.description.links": "", + "widgets.description.tier-list": "", + "widgets.forms.bio": "", + "widgets.forms.bio.markdownSupport": "", + "widgets.forms.division": "", + "widgets.forms.division.both": "", + "widgets.forms.division.tentatek": "", + "widgets.forms.division.takoroka": "", + "widgets.forms.timezone": "", + "widgets.forms.favoriteStage": "", + "widgets.forms.weapon": "", + "widgets.forms.peakXp": "", + "widgets.forms.controller": "", + "widgets.forms.source": "", + "widgets.forms.source.ALL": "", + "widgets.forms.source.MADE-BY": "", + "widgets.forms.source.MADE-OF": "", + "widgets.forms.links": "", + "widgets.forms.tierListUrl": "", + "widget.tier-list.untitled": "", + "controllers.s1-pro-con": "", + "controllers.s2-pro-con": "", + "controllers.grip": "", + "controllers.handheld": "", "stickSens": "Sensibilité du stick droit", "motionSens": "Sensibilité du gyroscope", "motion": "Gyro", @@ -17,6 +141,8 @@ "discordExplanation": "Votre pseudo, votre photo de profil et vos comptes Youtube, Bluesky et Twitch viennent de votre compte Discord. Voir la <1>FAQ pour plus d'informations.", "favoriteBadges": "Badge favori", "battlefy": "Nom du compte Battlefy", + "forms.newProfileEnabled": "", + "forms.newProfileEnabled.info": "", "forms.showDiscordUniqueName": "Montrer le pseudo Discord", "forms.showDiscordUniqueName.info": "Show your unique Discord name ({{discordUniqueName}}) publicly?", "forms.commissionsOpen": "Commissions acceptées", @@ -82,5 +208,7 @@ "builds.sorting.CLOTHES_ID": "Ordre des Vêtements dans le jeu", "builds.sorting.SHOES_ID": "Ordre des Chaussures dans le jeu", "builds.sorting.PUBLIC_BUILD": "Build public", - "builds.sorting.PRIVATE_BUILD": "Build privée" + "builds.sorting.PRIVATE_BUILD": "Build privée", + "commissions.open": "", + "commissions.closed": "" } diff --git a/locales/he/common.json b/locales/he/common.json index 2704a2be4..9214681f2 100644 --- a/locales/he/common.json +++ b/locales/he/common.json @@ -132,6 +132,8 @@ "actions.next": "", "actions.previous": "", "actions.back": "", + "actions.hide": "", + "actions.settings": "", "noResults": "", "maps.createMapList": "יצירת רשימת מפות", "maps.halfSz": "50% SZ", @@ -256,6 +258,7 @@ "support.perk.joinMoreAssociations": "", "support.perk.useBotToLogIn": "", "support.perk.useBotToLogIn.extra": "", + "support.perk.earlyAccess": "", "custom.colors.title": "צבעים מותאמים אישית", "custom.colors.bg": "רקע", "custom.colors.bg-darker": "רקע חשוך", diff --git a/locales/he/forms.json b/locales/he/forms.json index 8d6b33159..eee78d70f 100644 --- a/locales/he/forms.json +++ b/locales/he/forms.json @@ -168,5 +168,19 @@ "labels.banUserExpiresAt": "", "bottomTexts.banUserExpiresAtHelp": "", "labels.scrimCancelReason": "", - "bottomTexts.scrimCancelReasonHelp": "" + "bottomTexts.scrimCancelReasonHelp": "", + "bottomTexts.bioMarkdown": "", + "labels.division": "", + "options.division.both": "", + "options.division.tentatek": "", + "options.division.takoroka": "", + "labels.timezone": "", + "labels.favoriteStage": "", + "labels.peakXp": "", + "labels.weapon": "", + "labels.artSource": "", + "options.artSource.ALL": "", + "options.artSource.MADE-BY": "", + "options.artSource.MADE-OF": "", + "labels.tierListUrl": "" } diff --git a/locales/he/user.json b/locales/he/user.json index b20995e79..d8562a7cd 100644 --- a/locales/he/user.json +++ b/locales/he/user.json @@ -5,6 +5,130 @@ "ign.short": "IGN", "country": "מדינה", "bio": "ביו", + "widget.bio": "", + "widget.bio-md": "", + "widget.badges-owned": "", + "widget.badges-authored": "", + "widget.teams": "", + "widget.organizations": "", + "widget.peak-sp": "", + "widget.top-10-seasons": "", + "widget.top-100-seasons": "", + "widget.peak-xp": "", + "widget.peak-xp-unverified": "", + "widget.peak-xp-weapon": "", + "widget.highlighted-results": "", + "widget.placement-results": "", + "widget.patron-since": "", + "widget.timezone": "", + "widget.favorite-stage": "", + "widget.videos": "", + "widget.lfg-posts": "", + "widget.top-500-weapons": "", + "widget.top-500-weapons-shooters": "", + "widget.top-500-weapons-blasters": "", + "widget.top-500-weapons-rollers": "", + "widget.top-500-weapons-brushes": "", + "widget.top-500-weapons-chargers": "", + "widget.top-500-weapons-sloshers": "", + "widget.top-500-weapons-splatlings": "", + "widget.top-500-weapons-dualies": "", + "widget.top-500-weapons-brellas": "", + "widget.top-500-weapons-stringers": "", + "widget.top-500-weapons-splatanas": "", + "widget.x-rank-peaks": "", + "widget.builds": "", + "widget.weapon-pool": "", + "widget.sens": "", + "widget.art": "", + "widget.commissions": "", + "widget.social-links": "", + "widget.links": "", + "widget.tier-list": "", + "widget.link.all": "", + "widgets.edit": "", + "widgets.editProfile": "", + "widgets.editTitle": "", + "widgets.available": "", + "widgets.mainSlot": "", + "widgets.sideSlot": "", + "widgets.main": "", + "widgets.side": "", + "widgets.add": "", + "widgets.remove": "", + "widgets.maxReached": "", + "widgets.search": "", + "widgets.category.misc": "", + "widgets.category.badges": "", + "widgets.category.teams": "", + "widgets.category.sendouq": "", + "widgets.category.xrank": "", + "widgets.category.tournaments": "", + "widgets.category.vods": "", + "widgets.category.builds": "", + "widgets.category.art": "", + "widgets.description.bio": "", + "widgets.description.bio-md": "", + "widgets.description.badges-owned": "", + "widgets.description.badges-authored": "", + "widgets.description.teams": "", + "widgets.description.organizations": "", + "widgets.description.peak-sp": "", + "widgets.description.top-10-seasons": "", + "widgets.description.top-100-seasons": "", + "widgets.description.peak-xp": "", + "widgets.description.peak-xp-unverified": "", + "widgets.description.peak-xp-weapon": "", + "widgets.description.highlighted-results": "", + "widgets.description.placement-results": "", + "widgets.description.patron-since": "", + "widgets.description.timezone": "", + "widgets.description.favorite-stage": "", + "widgets.description.videos": "", + "widgets.description.lfg-posts": "", + "widgets.description.top-500-weapons": "", + "widgets.description.top-500-weapons-shooters": "", + "widgets.description.top-500-weapons-blasters": "", + "widgets.description.top-500-weapons-rollers": "", + "widgets.description.top-500-weapons-brushes": "", + "widgets.description.top-500-weapons-chargers": "", + "widgets.description.top-500-weapons-sloshers": "", + "widgets.description.top-500-weapons-splatlings": "", + "widgets.description.top-500-weapons-dualies": "", + "widgets.description.top-500-weapons-brellas": "", + "widgets.description.top-500-weapons-stringers": "", + "widgets.description.top-500-weapons-splatanas": "", + "widgets.description.x-rank-peaks": "", + "widgets.description.builds": "", + "widgets.description.weapon-pool": "", + "widgets.description.sens": "", + "widgets.description.art": "", + "widgets.description.commissions": "", + "widgets.description.social-links": "", + "widgets.description.links": "", + "widgets.description.tier-list": "", + "widgets.forms.bio": "", + "widgets.forms.bio.markdownSupport": "", + "widgets.forms.division": "", + "widgets.forms.division.both": "", + "widgets.forms.division.tentatek": "", + "widgets.forms.division.takoroka": "", + "widgets.forms.timezone": "", + "widgets.forms.favoriteStage": "", + "widgets.forms.weapon": "", + "widgets.forms.peakXp": "", + "widgets.forms.controller": "", + "widgets.forms.source": "", + "widgets.forms.source.ALL": "", + "widgets.forms.source.MADE-BY": "", + "widgets.forms.source.MADE-OF": "", + "widgets.forms.links": "", + "widgets.forms.tierListUrl": "", + "widget.tier-list.untitled": "", + "controllers.s1-pro-con": "", + "controllers.s2-pro-con": "", + "controllers.grip": "", + "controllers.handheld": "", "stickSens": "רגישות סטיק ימני", "motionSens": "רגישות תנועה", "motion": "תנועה", @@ -17,6 +141,8 @@ "discordExplanation": "שם משתמש, תמונת פרופיל, חשבונות YouTube, Bluesky ו-Twitch מגיעים מחשבון Discord שלך. ראו <1>שאלות נפוצות למידע נוסף.", "favoriteBadges": "", "battlefy": "", + "forms.newProfileEnabled": "", + "forms.newProfileEnabled.info": "", "forms.showDiscordUniqueName": "הראה שם משתמש Discord", "forms.showDiscordUniqueName.info": "להראות את שם ה-Discord היחודי שלכם ({{discordUniqueName}}) בפומבי?", "forms.commissionsOpen": "בקשות פתוחות", @@ -82,5 +208,7 @@ "builds.sorting.CLOTHES_ID": "", "builds.sorting.SHOES_ID": "", "builds.sorting.PUBLIC_BUILD": "", - "builds.sorting.PRIVATE_BUILD": "" + "builds.sorting.PRIVATE_BUILD": "", + "commissions.open": "", + "commissions.closed": "" } diff --git a/locales/it/common.json b/locales/it/common.json index 13d481872..d9696188b 100644 --- a/locales/it/common.json +++ b/locales/it/common.json @@ -132,6 +132,8 @@ "actions.next": "", "actions.previous": "", "actions.back": "", + "actions.hide": "", + "actions.settings": "", "noResults": "", "maps.createMapList": "Crea lista scenari", "maps.halfSz": "50% ZS", @@ -257,6 +259,7 @@ "support.perk.joinMoreAssociations": "", "support.perk.useBotToLogIn": "", "support.perk.useBotToLogIn.extra": "", + "support.perk.earlyAccess": "", "custom.colors.title": "Colori personalizzati", "custom.colors.bg": "Sfondo", "custom.colors.bg-darker": "Sfondo più scuro", diff --git a/locales/it/forms.json b/locales/it/forms.json index da3e3c379..e2e492b43 100644 --- a/locales/it/forms.json +++ b/locales/it/forms.json @@ -168,5 +168,19 @@ "labels.banUserExpiresAt": "", "bottomTexts.banUserExpiresAtHelp": "", "labels.scrimCancelReason": "", - "bottomTexts.scrimCancelReasonHelp": "" + "bottomTexts.scrimCancelReasonHelp": "", + "bottomTexts.bioMarkdown": "", + "labels.division": "", + "options.division.both": "", + "options.division.tentatek": "", + "options.division.takoroka": "", + "labels.timezone": "", + "labels.favoriteStage": "", + "labels.peakXp": "", + "labels.weapon": "", + "labels.artSource": "", + "options.artSource.ALL": "", + "options.artSource.MADE-BY": "", + "options.artSource.MADE-OF": "", + "labels.tierListUrl": "" } diff --git a/locales/it/user.json b/locales/it/user.json index a9e665d2f..23e821e68 100644 --- a/locales/it/user.json +++ b/locales/it/user.json @@ -5,6 +5,130 @@ "ign.short": "IGN", "country": "Paese", "bio": "Biografia", + "widget.bio": "", + "widget.bio-md": "", + "widget.badges-owned": "", + "widget.badges-authored": "", + "widget.teams": "", + "widget.organizations": "", + "widget.peak-sp": "", + "widget.top-10-seasons": "", + "widget.top-100-seasons": "", + "widget.peak-xp": "", + "widget.peak-xp-unverified": "", + "widget.peak-xp-weapon": "", + "widget.highlighted-results": "", + "widget.placement-results": "", + "widget.patron-since": "", + "widget.timezone": "", + "widget.favorite-stage": "", + "widget.videos": "", + "widget.lfg-posts": "", + "widget.top-500-weapons": "", + "widget.top-500-weapons-shooters": "", + "widget.top-500-weapons-blasters": "", + "widget.top-500-weapons-rollers": "", + "widget.top-500-weapons-brushes": "", + "widget.top-500-weapons-chargers": "", + "widget.top-500-weapons-sloshers": "", + "widget.top-500-weapons-splatlings": "", + "widget.top-500-weapons-dualies": "", + "widget.top-500-weapons-brellas": "", + "widget.top-500-weapons-stringers": "", + "widget.top-500-weapons-splatanas": "", + "widget.x-rank-peaks": "", + "widget.builds": "", + "widget.weapon-pool": "", + "widget.sens": "", + "widget.art": "", + "widget.commissions": "", + "widget.social-links": "", + "widget.links": "", + "widget.tier-list": "", + "widget.link.all": "", + "widgets.edit": "", + "widgets.editProfile": "", + "widgets.editTitle": "", + "widgets.available": "", + "widgets.mainSlot": "", + "widgets.sideSlot": "", + "widgets.main": "", + "widgets.side": "", + "widgets.add": "", + "widgets.remove": "", + "widgets.maxReached": "", + "widgets.search": "", + "widgets.category.misc": "", + "widgets.category.badges": "", + "widgets.category.teams": "", + "widgets.category.sendouq": "", + "widgets.category.xrank": "", + "widgets.category.tournaments": "", + "widgets.category.vods": "", + "widgets.category.builds": "", + "widgets.category.art": "", + "widgets.description.bio": "", + "widgets.description.bio-md": "", + "widgets.description.badges-owned": "", + "widgets.description.badges-authored": "", + "widgets.description.teams": "", + "widgets.description.organizations": "", + "widgets.description.peak-sp": "", + "widgets.description.top-10-seasons": "", + "widgets.description.top-100-seasons": "", + "widgets.description.peak-xp": "", + "widgets.description.peak-xp-unverified": "", + "widgets.description.peak-xp-weapon": "", + "widgets.description.highlighted-results": "", + "widgets.description.placement-results": "", + "widgets.description.patron-since": "", + "widgets.description.timezone": "", + "widgets.description.favorite-stage": "", + "widgets.description.videos": "", + "widgets.description.lfg-posts": "", + "widgets.description.top-500-weapons": "", + "widgets.description.top-500-weapons-shooters": "", + "widgets.description.top-500-weapons-blasters": "", + "widgets.description.top-500-weapons-rollers": "", + "widgets.description.top-500-weapons-brushes": "", + "widgets.description.top-500-weapons-chargers": "", + "widgets.description.top-500-weapons-sloshers": "", + "widgets.description.top-500-weapons-splatlings": "", + "widgets.description.top-500-weapons-dualies": "", + "widgets.description.top-500-weapons-brellas": "", + "widgets.description.top-500-weapons-stringers": "", + "widgets.description.top-500-weapons-splatanas": "", + "widgets.description.x-rank-peaks": "", + "widgets.description.builds": "", + "widgets.description.weapon-pool": "", + "widgets.description.sens": "", + "widgets.description.art": "", + "widgets.description.commissions": "", + "widgets.description.social-links": "", + "widgets.description.links": "", + "widgets.description.tier-list": "", + "widgets.forms.bio": "", + "widgets.forms.bio.markdownSupport": "", + "widgets.forms.division": "", + "widgets.forms.division.both": "", + "widgets.forms.division.tentatek": "", + "widgets.forms.division.takoroka": "", + "widgets.forms.timezone": "", + "widgets.forms.favoriteStage": "", + "widgets.forms.weapon": "", + "widgets.forms.peakXp": "", + "widgets.forms.controller": "", + "widgets.forms.source": "", + "widgets.forms.source.ALL": "", + "widgets.forms.source.MADE-BY": "", + "widgets.forms.source.MADE-OF": "", + "widgets.forms.links": "", + "widgets.forms.tierListUrl": "", + "widget.tier-list.untitled": "", + "controllers.s1-pro-con": "", + "controllers.s2-pro-con": "", + "controllers.grip": "", + "controllers.handheld": "", "stickSens": "Sensitività Joystick Dx", "motionSens": "Sensitività Giroscopio", "motion": "Giroscopio", @@ -17,6 +141,8 @@ "discordExplanation": "Username, foto profilo, account YouTube, Bluesky e Twitch vengono dal tuo account Discord. Visita <1>FAQ per ulteriori informazioni.", "favoriteBadges": "", "battlefy": "Nome account Battlefy", + "forms.newProfileEnabled": "", + "forms.newProfileEnabled.info": "", "forms.showDiscordUniqueName": "Mostra username Discord", "forms.showDiscordUniqueName.info": "Mostrare il proprio nome unico Discord ({{discordUniqueName}}) pubblicamente?", "forms.commissionsOpen": "Commissioni aperte", @@ -82,5 +208,7 @@ "builds.sorting.CLOTHES_ID": "Vestiti in ordine del gioco", "builds.sorting.SHOES_ID": "Scarpe in ordine del gioco", "builds.sorting.PUBLIC_BUILD": "Build pubblica", - "builds.sorting.PRIVATE_BUILD": "Build privata" + "builds.sorting.PRIVATE_BUILD": "Build privata", + "commissions.open": "", + "commissions.closed": "" } diff --git a/locales/ja/common.json b/locales/ja/common.json index 19db31e73..625ecfe82 100644 --- a/locales/ja/common.json +++ b/locales/ja/common.json @@ -132,6 +132,8 @@ "actions.next": "", "actions.previous": "", "actions.back": "", + "actions.hide": "", + "actions.settings": "", "noResults": "", "maps.createMapList": "ステージ一覧を作る", "maps.halfSz": "ガチエリア (2ヶ所)", @@ -251,6 +253,7 @@ "support.perk.joinMoreAssociations": "", "support.perk.useBotToLogIn": "", "support.perk.useBotToLogIn.extra": "", + "support.perk.earlyAccess": "", "custom.colors.title": "カスタムカラー", "custom.colors.bg": "バックグラウンド", "custom.colors.bg-darker": "バックグランド 暗め", diff --git a/locales/ja/forms.json b/locales/ja/forms.json index f338db093..f7f2b8829 100644 --- a/locales/ja/forms.json +++ b/locales/ja/forms.json @@ -168,5 +168,19 @@ "labels.banUserExpiresAt": "", "bottomTexts.banUserExpiresAtHelp": "", "labels.scrimCancelReason": "", - "bottomTexts.scrimCancelReasonHelp": "" + "bottomTexts.scrimCancelReasonHelp": "", + "bottomTexts.bioMarkdown": "", + "labels.division": "", + "options.division.both": "", + "options.division.tentatek": "", + "options.division.takoroka": "", + "labels.timezone": "", + "labels.favoriteStage": "", + "labels.peakXp": "", + "labels.weapon": "", + "labels.artSource": "", + "options.artSource.ALL": "", + "options.artSource.MADE-BY": "", + "options.artSource.MADE-OF": "", + "labels.tierListUrl": "" } diff --git a/locales/ja/user.json b/locales/ja/user.json index 53036d465..d48afebe6 100644 --- a/locales/ja/user.json +++ b/locales/ja/user.json @@ -5,6 +5,130 @@ "ign.short": "ゲーム中の名前", "country": "国", "bio": "自己紹介", + "widget.bio": "", + "widget.bio-md": "", + "widget.badges-owned": "", + "widget.badges-authored": "", + "widget.teams": "", + "widget.organizations": "", + "widget.peak-sp": "", + "widget.top-10-seasons": "", + "widget.top-100-seasons": "", + "widget.peak-xp": "", + "widget.peak-xp-unverified": "", + "widget.peak-xp-weapon": "", + "widget.highlighted-results": "", + "widget.placement-results": "", + "widget.patron-since": "", + "widget.timezone": "", + "widget.favorite-stage": "", + "widget.videos": "", + "widget.lfg-posts": "", + "widget.top-500-weapons": "", + "widget.top-500-weapons-shooters": "", + "widget.top-500-weapons-blasters": "", + "widget.top-500-weapons-rollers": "", + "widget.top-500-weapons-brushes": "", + "widget.top-500-weapons-chargers": "", + "widget.top-500-weapons-sloshers": "", + "widget.top-500-weapons-splatlings": "", + "widget.top-500-weapons-dualies": "", + "widget.top-500-weapons-brellas": "", + "widget.top-500-weapons-stringers": "", + "widget.top-500-weapons-splatanas": "", + "widget.x-rank-peaks": "", + "widget.builds": "", + "widget.weapon-pool": "", + "widget.sens": "", + "widget.art": "", + "widget.commissions": "", + "widget.social-links": "", + "widget.links": "", + "widget.tier-list": "", + "widget.link.all": "", + "widgets.edit": "", + "widgets.editProfile": "", + "widgets.editTitle": "", + "widgets.available": "", + "widgets.mainSlot": "", + "widgets.sideSlot": "", + "widgets.main": "", + "widgets.side": "", + "widgets.add": "", + "widgets.remove": "", + "widgets.maxReached": "", + "widgets.search": "", + "widgets.category.misc": "", + "widgets.category.badges": "", + "widgets.category.teams": "", + "widgets.category.sendouq": "", + "widgets.category.xrank": "", + "widgets.category.tournaments": "", + "widgets.category.vods": "", + "widgets.category.builds": "", + "widgets.category.art": "", + "widgets.description.bio": "", + "widgets.description.bio-md": "", + "widgets.description.badges-owned": "", + "widgets.description.badges-authored": "", + "widgets.description.teams": "", + "widgets.description.organizations": "", + "widgets.description.peak-sp": "", + "widgets.description.top-10-seasons": "", + "widgets.description.top-100-seasons": "", + "widgets.description.peak-xp": "", + "widgets.description.peak-xp-unverified": "", + "widgets.description.peak-xp-weapon": "", + "widgets.description.highlighted-results": "", + "widgets.description.placement-results": "", + "widgets.description.patron-since": "", + "widgets.description.timezone": "", + "widgets.description.favorite-stage": "", + "widgets.description.videos": "", + "widgets.description.lfg-posts": "", + "widgets.description.top-500-weapons": "", + "widgets.description.top-500-weapons-shooters": "", + "widgets.description.top-500-weapons-blasters": "", + "widgets.description.top-500-weapons-rollers": "", + "widgets.description.top-500-weapons-brushes": "", + "widgets.description.top-500-weapons-chargers": "", + "widgets.description.top-500-weapons-sloshers": "", + "widgets.description.top-500-weapons-splatlings": "", + "widgets.description.top-500-weapons-dualies": "", + "widgets.description.top-500-weapons-brellas": "", + "widgets.description.top-500-weapons-stringers": "", + "widgets.description.top-500-weapons-splatanas": "", + "widgets.description.x-rank-peaks": "", + "widgets.description.builds": "", + "widgets.description.weapon-pool": "", + "widgets.description.sens": "", + "widgets.description.art": "", + "widgets.description.commissions": "", + "widgets.description.social-links": "", + "widgets.description.links": "", + "widgets.description.tier-list": "", + "widgets.forms.bio": "", + "widgets.forms.bio.markdownSupport": "", + "widgets.forms.division": "", + "widgets.forms.division.both": "", + "widgets.forms.division.tentatek": "", + "widgets.forms.division.takoroka": "", + "widgets.forms.timezone": "", + "widgets.forms.favoriteStage": "", + "widgets.forms.weapon": "", + "widgets.forms.peakXp": "", + "widgets.forms.controller": "", + "widgets.forms.source": "", + "widgets.forms.source.ALL": "", + "widgets.forms.source.MADE-BY": "", + "widgets.forms.source.MADE-OF": "", + "widgets.forms.links": "", + "widgets.forms.tierListUrl": "", + "widget.tier-list.untitled": "", + "controllers.s1-pro-con": "", + "controllers.s2-pro-con": "", + "controllers.grip": "", + "controllers.handheld": "", "stickSens": "右スティック感度", "motionSens": "モーション感度", "motion": "モーション", @@ -17,6 +141,8 @@ "discordExplanation": "ユーザー名、プロファイル画像、YouTube、Bluesky と Twitch アカウントは Discord のアカウントに設定されているものが使用されます。詳しくは <1>FAQ をご覧ください。", "favoriteBadges": "", "battlefy": "Battlefyアカウント名", + "forms.newProfileEnabled": "", + "forms.newProfileEnabled.info": "", "forms.showDiscordUniqueName": "Discord のユーザー名を表示する", "forms.showDiscordUniqueName.info": "Discord のユニーク名 ({{discordUniqueName}}) 公表しますか?", "forms.commissionsOpen": "依頼を受付中", @@ -82,5 +208,7 @@ "builds.sorting.CLOTHES_ID": "フク(ギア)ゲーム内の順番", "builds.sorting.SHOES_ID": "クツ(ギア)ゲーム内の順番", "builds.sorting.PUBLIC_BUILD": "公開ビルド", - "builds.sorting.PRIVATE_BUILD": "非公開ビルド" + "builds.sorting.PRIVATE_BUILD": "非公開ビルド", + "commissions.open": "", + "commissions.closed": "" } diff --git a/locales/ko/common.json b/locales/ko/common.json index e334d74cd..1702b4bb1 100644 --- a/locales/ko/common.json +++ b/locales/ko/common.json @@ -132,6 +132,8 @@ "actions.next": "", "actions.previous": "", "actions.back": "", + "actions.hide": "", + "actions.settings": "", "noResults": "", "maps.createMapList": "맵 목록 생성", "maps.halfSz": "에어리어 50%", @@ -251,6 +253,7 @@ "support.perk.joinMoreAssociations": "", "support.perk.useBotToLogIn": "", "support.perk.useBotToLogIn.extra": "", + "support.perk.earlyAccess": "", "custom.colors.title": "", "custom.colors.bg": "", "custom.colors.bg-darker": "", diff --git a/locales/ko/forms.json b/locales/ko/forms.json index 226cfcb04..6ba178dff 100644 --- a/locales/ko/forms.json +++ b/locales/ko/forms.json @@ -168,5 +168,19 @@ "labels.banUserExpiresAt": "", "bottomTexts.banUserExpiresAtHelp": "", "labels.scrimCancelReason": "", - "bottomTexts.scrimCancelReasonHelp": "" + "bottomTexts.scrimCancelReasonHelp": "", + "bottomTexts.bioMarkdown": "", + "labels.division": "", + "options.division.both": "", + "options.division.tentatek": "", + "options.division.takoroka": "", + "labels.timezone": "", + "labels.favoriteStage": "", + "labels.peakXp": "", + "labels.weapon": "", + "labels.artSource": "", + "options.artSource.ALL": "", + "options.artSource.MADE-BY": "", + "options.artSource.MADE-OF": "", + "labels.tierListUrl": "" } diff --git a/locales/ko/user.json b/locales/ko/user.json index 65134ad40..e56ef3539 100644 --- a/locales/ko/user.json +++ b/locales/ko/user.json @@ -5,6 +5,130 @@ "ign.short": "", "country": "국가", "bio": "소개", + "widget.bio": "", + "widget.bio-md": "", + "widget.badges-owned": "", + "widget.badges-authored": "", + "widget.teams": "", + "widget.organizations": "", + "widget.peak-sp": "", + "widget.top-10-seasons": "", + "widget.top-100-seasons": "", + "widget.peak-xp": "", + "widget.peak-xp-unverified": "", + "widget.peak-xp-weapon": "", + "widget.highlighted-results": "", + "widget.placement-results": "", + "widget.patron-since": "", + "widget.timezone": "", + "widget.favorite-stage": "", + "widget.videos": "", + "widget.lfg-posts": "", + "widget.top-500-weapons": "", + "widget.top-500-weapons-shooters": "", + "widget.top-500-weapons-blasters": "", + "widget.top-500-weapons-rollers": "", + "widget.top-500-weapons-brushes": "", + "widget.top-500-weapons-chargers": "", + "widget.top-500-weapons-sloshers": "", + "widget.top-500-weapons-splatlings": "", + "widget.top-500-weapons-dualies": "", + "widget.top-500-weapons-brellas": "", + "widget.top-500-weapons-stringers": "", + "widget.top-500-weapons-splatanas": "", + "widget.x-rank-peaks": "", + "widget.builds": "", + "widget.weapon-pool": "", + "widget.sens": "", + "widget.art": "", + "widget.commissions": "", + "widget.social-links": "", + "widget.links": "", + "widget.tier-list": "", + "widget.link.all": "", + "widgets.edit": "", + "widgets.editProfile": "", + "widgets.editTitle": "", + "widgets.available": "", + "widgets.mainSlot": "", + "widgets.sideSlot": "", + "widgets.main": "", + "widgets.side": "", + "widgets.add": "", + "widgets.remove": "", + "widgets.maxReached": "", + "widgets.search": "", + "widgets.category.misc": "", + "widgets.category.badges": "", + "widgets.category.teams": "", + "widgets.category.sendouq": "", + "widgets.category.xrank": "", + "widgets.category.tournaments": "", + "widgets.category.vods": "", + "widgets.category.builds": "", + "widgets.category.art": "", + "widgets.description.bio": "", + "widgets.description.bio-md": "", + "widgets.description.badges-owned": "", + "widgets.description.badges-authored": "", + "widgets.description.teams": "", + "widgets.description.organizations": "", + "widgets.description.peak-sp": "", + "widgets.description.top-10-seasons": "", + "widgets.description.top-100-seasons": "", + "widgets.description.peak-xp": "", + "widgets.description.peak-xp-unverified": "", + "widgets.description.peak-xp-weapon": "", + "widgets.description.highlighted-results": "", + "widgets.description.placement-results": "", + "widgets.description.patron-since": "", + "widgets.description.timezone": "", + "widgets.description.favorite-stage": "", + "widgets.description.videos": "", + "widgets.description.lfg-posts": "", + "widgets.description.top-500-weapons": "", + "widgets.description.top-500-weapons-shooters": "", + "widgets.description.top-500-weapons-blasters": "", + "widgets.description.top-500-weapons-rollers": "", + "widgets.description.top-500-weapons-brushes": "", + "widgets.description.top-500-weapons-chargers": "", + "widgets.description.top-500-weapons-sloshers": "", + "widgets.description.top-500-weapons-splatlings": "", + "widgets.description.top-500-weapons-dualies": "", + "widgets.description.top-500-weapons-brellas": "", + "widgets.description.top-500-weapons-stringers": "", + "widgets.description.top-500-weapons-splatanas": "", + "widgets.description.x-rank-peaks": "", + "widgets.description.builds": "", + "widgets.description.weapon-pool": "", + "widgets.description.sens": "", + "widgets.description.art": "", + "widgets.description.commissions": "", + "widgets.description.social-links": "", + "widgets.description.links": "", + "widgets.description.tier-list": "", + "widgets.forms.bio": "", + "widgets.forms.bio.markdownSupport": "", + "widgets.forms.division": "", + "widgets.forms.division.both": "", + "widgets.forms.division.tentatek": "", + "widgets.forms.division.takoroka": "", + "widgets.forms.timezone": "", + "widgets.forms.favoriteStage": "", + "widgets.forms.weapon": "", + "widgets.forms.peakXp": "", + "widgets.forms.controller": "", + "widgets.forms.source": "", + "widgets.forms.source.ALL": "", + "widgets.forms.source.MADE-BY": "", + "widgets.forms.source.MADE-OF": "", + "widgets.forms.links": "", + "widgets.forms.tierListUrl": "", + "widget.tier-list.untitled": "", + "controllers.s1-pro-con": "", + "controllers.s2-pro-con": "", + "controllers.grip": "", + "controllers.handheld": "", "stickSens": "", "motionSens": "", "motion": "", @@ -17,6 +141,8 @@ "discordExplanation": "", "favoriteBadges": "", "battlefy": "", + "forms.newProfileEnabled": "", + "forms.newProfileEnabled.info": "", "forms.showDiscordUniqueName": "", "forms.showDiscordUniqueName.info": "", "forms.commissionsOpen": "", @@ -82,5 +208,7 @@ "builds.sorting.CLOTHES_ID": "", "builds.sorting.SHOES_ID": "", "builds.sorting.PUBLIC_BUILD": "", - "builds.sorting.PRIVATE_BUILD": "" + "builds.sorting.PRIVATE_BUILD": "", + "commissions.open": "", + "commissions.closed": "" } diff --git a/locales/nl/common.json b/locales/nl/common.json index 7e9f238ea..77acf7dc1 100644 --- a/locales/nl/common.json +++ b/locales/nl/common.json @@ -132,6 +132,8 @@ "actions.next": "", "actions.previous": "", "actions.back": "", + "actions.hide": "", + "actions.settings": "", "noResults": "", "maps.createMapList": "Maak levellijst", "maps.halfSz": "50% SZ", @@ -255,6 +257,7 @@ "support.perk.joinMoreAssociations": "", "support.perk.useBotToLogIn": "", "support.perk.useBotToLogIn.extra": "", + "support.perk.earlyAccess": "", "custom.colors.title": "", "custom.colors.bg": "", "custom.colors.bg-darker": "", diff --git a/locales/nl/forms.json b/locales/nl/forms.json index b0b8a100a..e54894569 100644 --- a/locales/nl/forms.json +++ b/locales/nl/forms.json @@ -168,5 +168,19 @@ "labels.banUserExpiresAt": "", "bottomTexts.banUserExpiresAtHelp": "", "labels.scrimCancelReason": "", - "bottomTexts.scrimCancelReasonHelp": "" + "bottomTexts.scrimCancelReasonHelp": "", + "bottomTexts.bioMarkdown": "", + "labels.division": "", + "options.division.both": "", + "options.division.tentatek": "", + "options.division.takoroka": "", + "labels.timezone": "", + "labels.favoriteStage": "", + "labels.peakXp": "", + "labels.weapon": "", + "labels.artSource": "", + "options.artSource.ALL": "", + "options.artSource.MADE-BY": "", + "options.artSource.MADE-OF": "", + "labels.tierListUrl": "" } diff --git a/locales/nl/user.json b/locales/nl/user.json index 95d3a2d08..5051ddb42 100644 --- a/locales/nl/user.json +++ b/locales/nl/user.json @@ -5,6 +5,130 @@ "ign.short": "IGN", "country": "Land", "bio": "Bio", + "widget.bio": "", + "widget.bio-md": "", + "widget.badges-owned": "", + "widget.badges-authored": "", + "widget.teams": "", + "widget.organizations": "", + "widget.peak-sp": "", + "widget.top-10-seasons": "", + "widget.top-100-seasons": "", + "widget.peak-xp": "", + "widget.peak-xp-unverified": "", + "widget.peak-xp-weapon": "", + "widget.highlighted-results": "", + "widget.placement-results": "", + "widget.patron-since": "", + "widget.timezone": "", + "widget.favorite-stage": "", + "widget.videos": "", + "widget.lfg-posts": "", + "widget.top-500-weapons": "", + "widget.top-500-weapons-shooters": "", + "widget.top-500-weapons-blasters": "", + "widget.top-500-weapons-rollers": "", + "widget.top-500-weapons-brushes": "", + "widget.top-500-weapons-chargers": "", + "widget.top-500-weapons-sloshers": "", + "widget.top-500-weapons-splatlings": "", + "widget.top-500-weapons-dualies": "", + "widget.top-500-weapons-brellas": "", + "widget.top-500-weapons-stringers": "", + "widget.top-500-weapons-splatanas": "", + "widget.x-rank-peaks": "", + "widget.builds": "", + "widget.weapon-pool": "", + "widget.sens": "", + "widget.art": "", + "widget.commissions": "", + "widget.social-links": "", + "widget.links": "", + "widget.tier-list": "", + "widget.link.all": "", + "widgets.edit": "", + "widgets.editProfile": "", + "widgets.editTitle": "", + "widgets.available": "", + "widgets.mainSlot": "", + "widgets.sideSlot": "", + "widgets.main": "", + "widgets.side": "", + "widgets.add": "", + "widgets.remove": "", + "widgets.maxReached": "", + "widgets.search": "", + "widgets.category.misc": "", + "widgets.category.badges": "", + "widgets.category.teams": "", + "widgets.category.sendouq": "", + "widgets.category.xrank": "", + "widgets.category.tournaments": "", + "widgets.category.vods": "", + "widgets.category.builds": "", + "widgets.category.art": "", + "widgets.description.bio": "", + "widgets.description.bio-md": "", + "widgets.description.badges-owned": "", + "widgets.description.badges-authored": "", + "widgets.description.teams": "", + "widgets.description.organizations": "", + "widgets.description.peak-sp": "", + "widgets.description.top-10-seasons": "", + "widgets.description.top-100-seasons": "", + "widgets.description.peak-xp": "", + "widgets.description.peak-xp-unverified": "", + "widgets.description.peak-xp-weapon": "", + "widgets.description.highlighted-results": "", + "widgets.description.placement-results": "", + "widgets.description.patron-since": "", + "widgets.description.timezone": "", + "widgets.description.favorite-stage": "", + "widgets.description.videos": "", + "widgets.description.lfg-posts": "", + "widgets.description.top-500-weapons": "", + "widgets.description.top-500-weapons-shooters": "", + "widgets.description.top-500-weapons-blasters": "", + "widgets.description.top-500-weapons-rollers": "", + "widgets.description.top-500-weapons-brushes": "", + "widgets.description.top-500-weapons-chargers": "", + "widgets.description.top-500-weapons-sloshers": "", + "widgets.description.top-500-weapons-splatlings": "", + "widgets.description.top-500-weapons-dualies": "", + "widgets.description.top-500-weapons-brellas": "", + "widgets.description.top-500-weapons-stringers": "", + "widgets.description.top-500-weapons-splatanas": "", + "widgets.description.x-rank-peaks": "", + "widgets.description.builds": "", + "widgets.description.weapon-pool": "", + "widgets.description.sens": "", + "widgets.description.art": "", + "widgets.description.commissions": "", + "widgets.description.social-links": "", + "widgets.description.links": "", + "widgets.description.tier-list": "", + "widgets.forms.bio": "", + "widgets.forms.bio.markdownSupport": "", + "widgets.forms.division": "", + "widgets.forms.division.both": "", + "widgets.forms.division.tentatek": "", + "widgets.forms.division.takoroka": "", + "widgets.forms.timezone": "", + "widgets.forms.favoriteStage": "", + "widgets.forms.weapon": "", + "widgets.forms.peakXp": "", + "widgets.forms.controller": "", + "widgets.forms.source": "", + "widgets.forms.source.ALL": "", + "widgets.forms.source.MADE-BY": "", + "widgets.forms.source.MADE-OF": "", + "widgets.forms.links": "", + "widgets.forms.tierListUrl": "", + "widget.tier-list.untitled": "", + "controllers.s1-pro-con": "", + "controllers.s2-pro-con": "", + "controllers.grip": "", + "controllers.handheld": "", "stickSens": "R-stick gevoeligheid", "motionSens": "Bewegingsgevoeligheid", "motion": "Beweging", @@ -17,6 +141,8 @@ "discordExplanation": "", "favoriteBadges": "", "battlefy": "", + "forms.newProfileEnabled": "", + "forms.newProfileEnabled.info": "", "forms.showDiscordUniqueName": "", "forms.showDiscordUniqueName.info": "", "forms.commissionsOpen": "", @@ -82,5 +208,7 @@ "builds.sorting.CLOTHES_ID": "", "builds.sorting.SHOES_ID": "", "builds.sorting.PUBLIC_BUILD": "", - "builds.sorting.PRIVATE_BUILD": "" + "builds.sorting.PRIVATE_BUILD": "", + "commissions.open": "", + "commissions.closed": "" } diff --git a/locales/pl/common.json b/locales/pl/common.json index 38a3270ce..83dbb3fb1 100644 --- a/locales/pl/common.json +++ b/locales/pl/common.json @@ -132,6 +132,8 @@ "actions.next": "", "actions.previous": "", "actions.back": "", + "actions.hide": "", + "actions.settings": "", "noResults": "", "maps.createMapList": "Stwórz liste map", "maps.halfSz": "50% SZ", @@ -258,6 +260,7 @@ "support.perk.joinMoreAssociations": "", "support.perk.useBotToLogIn": "", "support.perk.useBotToLogIn.extra": "", + "support.perk.earlyAccess": "", "custom.colors.title": "Niestandardowe kolory", "custom.colors.bg": "Tło", "custom.colors.bg-darker": "Ciemniejsze tło", diff --git a/locales/pl/forms.json b/locales/pl/forms.json index 73fd053fb..e34cc00ca 100644 --- a/locales/pl/forms.json +++ b/locales/pl/forms.json @@ -168,5 +168,19 @@ "labels.banUserExpiresAt": "", "bottomTexts.banUserExpiresAtHelp": "", "labels.scrimCancelReason": "", - "bottomTexts.scrimCancelReasonHelp": "" + "bottomTexts.scrimCancelReasonHelp": "", + "bottomTexts.bioMarkdown": "", + "labels.division": "", + "options.division.both": "", + "options.division.tentatek": "", + "options.division.takoroka": "", + "labels.timezone": "", + "labels.favoriteStage": "", + "labels.peakXp": "", + "labels.weapon": "", + "labels.artSource": "", + "options.artSource.ALL": "", + "options.artSource.MADE-BY": "", + "options.artSource.MADE-OF": "", + "labels.tierListUrl": "" } diff --git a/locales/pl/user.json b/locales/pl/user.json index d31d62ce3..6f83ba46d 100644 --- a/locales/pl/user.json +++ b/locales/pl/user.json @@ -5,6 +5,130 @@ "ign.short": "IGN", "country": "Kraj", "bio": "Opis", + "widget.bio": "", + "widget.bio-md": "", + "widget.badges-owned": "", + "widget.badges-authored": "", + "widget.teams": "", + "widget.organizations": "", + "widget.peak-sp": "", + "widget.top-10-seasons": "", + "widget.top-100-seasons": "", + "widget.peak-xp": "", + "widget.peak-xp-unverified": "", + "widget.peak-xp-weapon": "", + "widget.highlighted-results": "", + "widget.placement-results": "", + "widget.patron-since": "", + "widget.timezone": "", + "widget.favorite-stage": "", + "widget.videos": "", + "widget.lfg-posts": "", + "widget.top-500-weapons": "", + "widget.top-500-weapons-shooters": "", + "widget.top-500-weapons-blasters": "", + "widget.top-500-weapons-rollers": "", + "widget.top-500-weapons-brushes": "", + "widget.top-500-weapons-chargers": "", + "widget.top-500-weapons-sloshers": "", + "widget.top-500-weapons-splatlings": "", + "widget.top-500-weapons-dualies": "", + "widget.top-500-weapons-brellas": "", + "widget.top-500-weapons-stringers": "", + "widget.top-500-weapons-splatanas": "", + "widget.x-rank-peaks": "", + "widget.builds": "", + "widget.weapon-pool": "", + "widget.sens": "", + "widget.art": "", + "widget.commissions": "", + "widget.social-links": "", + "widget.links": "", + "widget.tier-list": "", + "widget.link.all": "", + "widgets.edit": "", + "widgets.editProfile": "", + "widgets.editTitle": "", + "widgets.available": "", + "widgets.mainSlot": "", + "widgets.sideSlot": "", + "widgets.main": "", + "widgets.side": "", + "widgets.add": "", + "widgets.remove": "", + "widgets.maxReached": "", + "widgets.search": "", + "widgets.category.misc": "", + "widgets.category.badges": "", + "widgets.category.teams": "", + "widgets.category.sendouq": "", + "widgets.category.xrank": "", + "widgets.category.tournaments": "", + "widgets.category.vods": "", + "widgets.category.builds": "", + "widgets.category.art": "", + "widgets.description.bio": "", + "widgets.description.bio-md": "", + "widgets.description.badges-owned": "", + "widgets.description.badges-authored": "", + "widgets.description.teams": "", + "widgets.description.organizations": "", + "widgets.description.peak-sp": "", + "widgets.description.top-10-seasons": "", + "widgets.description.top-100-seasons": "", + "widgets.description.peak-xp": "", + "widgets.description.peak-xp-unverified": "", + "widgets.description.peak-xp-weapon": "", + "widgets.description.highlighted-results": "", + "widgets.description.placement-results": "", + "widgets.description.patron-since": "", + "widgets.description.timezone": "", + "widgets.description.favorite-stage": "", + "widgets.description.videos": "", + "widgets.description.lfg-posts": "", + "widgets.description.top-500-weapons": "", + "widgets.description.top-500-weapons-shooters": "", + "widgets.description.top-500-weapons-blasters": "", + "widgets.description.top-500-weapons-rollers": "", + "widgets.description.top-500-weapons-brushes": "", + "widgets.description.top-500-weapons-chargers": "", + "widgets.description.top-500-weapons-sloshers": "", + "widgets.description.top-500-weapons-splatlings": "", + "widgets.description.top-500-weapons-dualies": "", + "widgets.description.top-500-weapons-brellas": "", + "widgets.description.top-500-weapons-stringers": "", + "widgets.description.top-500-weapons-splatanas": "", + "widgets.description.x-rank-peaks": "", + "widgets.description.builds": "", + "widgets.description.weapon-pool": "", + "widgets.description.sens": "", + "widgets.description.art": "", + "widgets.description.commissions": "", + "widgets.description.social-links": "", + "widgets.description.links": "", + "widgets.description.tier-list": "", + "widgets.forms.bio": "", + "widgets.forms.bio.markdownSupport": "", + "widgets.forms.division": "", + "widgets.forms.division.both": "", + "widgets.forms.division.tentatek": "", + "widgets.forms.division.takoroka": "", + "widgets.forms.timezone": "", + "widgets.forms.favoriteStage": "", + "widgets.forms.weapon": "", + "widgets.forms.peakXp": "", + "widgets.forms.controller": "", + "widgets.forms.source": "", + "widgets.forms.source.ALL": "", + "widgets.forms.source.MADE-BY": "", + "widgets.forms.source.MADE-OF": "", + "widgets.forms.links": "", + "widgets.forms.tierListUrl": "", + "widget.tier-list.untitled": "", + "controllers.s1-pro-con": "", + "controllers.s2-pro-con": "", + "controllers.grip": "", + "controllers.handheld": "", "stickSens": "R-stick sens", "motionSens": "Motion sens", "motion": "Motion", @@ -17,6 +141,8 @@ "discordExplanation": "Nazwa, profilowe oraz połączone konta brane są z konta Discord. Zobacz <1>FAQ by dowiedzieć się więcej.", "favoriteBadges": "", "battlefy": "", + "forms.newProfileEnabled": "", + "forms.newProfileEnabled.info": "", "forms.showDiscordUniqueName": "", "forms.showDiscordUniqueName.info": "", "forms.commissionsOpen": "", @@ -82,5 +208,7 @@ "builds.sorting.CLOTHES_ID": "", "builds.sorting.SHOES_ID": "", "builds.sorting.PUBLIC_BUILD": "", - "builds.sorting.PRIVATE_BUILD": "" + "builds.sorting.PRIVATE_BUILD": "", + "commissions.open": "", + "commissions.closed": "" } diff --git a/locales/pt-BR/common.json b/locales/pt-BR/common.json index 63790cc4f..721da89ec 100644 --- a/locales/pt-BR/common.json +++ b/locales/pt-BR/common.json @@ -132,6 +132,8 @@ "actions.next": "", "actions.previous": "", "actions.back": "", + "actions.hide": "", + "actions.settings": "", "noResults": "", "maps.createMapList": "Criar lista de mapas", "maps.halfSz": "50% Zones", @@ -257,6 +259,7 @@ "support.perk.joinMoreAssociations": "", "support.perk.useBotToLogIn": "", "support.perk.useBotToLogIn.extra": "", + "support.perk.earlyAccess": "", "custom.colors.title": "Cores personalizadas", "custom.colors.bg": "Plano de fundo", "custom.colors.bg-darker": "Plano de fundo mais escuro", diff --git a/locales/pt-BR/forms.json b/locales/pt-BR/forms.json index a9b9cc564..e2e14c0a1 100644 --- a/locales/pt-BR/forms.json +++ b/locales/pt-BR/forms.json @@ -168,5 +168,19 @@ "labels.banUserExpiresAt": "", "bottomTexts.banUserExpiresAtHelp": "", "labels.scrimCancelReason": "", - "bottomTexts.scrimCancelReasonHelp": "" + "bottomTexts.scrimCancelReasonHelp": "", + "bottomTexts.bioMarkdown": "", + "labels.division": "", + "options.division.both": "", + "options.division.tentatek": "", + "options.division.takoroka": "", + "labels.timezone": "", + "labels.favoriteStage": "", + "labels.peakXp": "", + "labels.weapon": "", + "labels.artSource": "", + "options.artSource.ALL": "", + "options.artSource.MADE-BY": "", + "options.artSource.MADE-OF": "", + "labels.tierListUrl": "" } diff --git a/locales/pt-BR/user.json b/locales/pt-BR/user.json index 7a29ea536..6d72ec214 100644 --- a/locales/pt-BR/user.json +++ b/locales/pt-BR/user.json @@ -5,6 +5,130 @@ "ign.short": "NNJ (IGN)", "country": "País", "bio": "Bio", + "widget.bio": "", + "widget.bio-md": "", + "widget.badges-owned": "", + "widget.badges-authored": "", + "widget.teams": "", + "widget.organizations": "", + "widget.peak-sp": "", + "widget.top-10-seasons": "", + "widget.top-100-seasons": "", + "widget.peak-xp": "", + "widget.peak-xp-unverified": "", + "widget.peak-xp-weapon": "", + "widget.highlighted-results": "", + "widget.placement-results": "", + "widget.patron-since": "", + "widget.timezone": "", + "widget.favorite-stage": "", + "widget.videos": "", + "widget.lfg-posts": "", + "widget.top-500-weapons": "", + "widget.top-500-weapons-shooters": "", + "widget.top-500-weapons-blasters": "", + "widget.top-500-weapons-rollers": "", + "widget.top-500-weapons-brushes": "", + "widget.top-500-weapons-chargers": "", + "widget.top-500-weapons-sloshers": "", + "widget.top-500-weapons-splatlings": "", + "widget.top-500-weapons-dualies": "", + "widget.top-500-weapons-brellas": "", + "widget.top-500-weapons-stringers": "", + "widget.top-500-weapons-splatanas": "", + "widget.x-rank-peaks": "", + "widget.builds": "", + "widget.weapon-pool": "", + "widget.sens": "", + "widget.art": "", + "widget.commissions": "", + "widget.social-links": "", + "widget.links": "", + "widget.tier-list": "", + "widget.link.all": "", + "widgets.edit": "", + "widgets.editProfile": "", + "widgets.editTitle": "", + "widgets.available": "", + "widgets.mainSlot": "", + "widgets.sideSlot": "", + "widgets.main": "", + "widgets.side": "", + "widgets.add": "", + "widgets.remove": "", + "widgets.maxReached": "", + "widgets.search": "", + "widgets.category.misc": "", + "widgets.category.badges": "", + "widgets.category.teams": "", + "widgets.category.sendouq": "", + "widgets.category.xrank": "", + "widgets.category.tournaments": "", + "widgets.category.vods": "", + "widgets.category.builds": "", + "widgets.category.art": "", + "widgets.description.bio": "", + "widgets.description.bio-md": "", + "widgets.description.badges-owned": "", + "widgets.description.badges-authored": "", + "widgets.description.teams": "", + "widgets.description.organizations": "", + "widgets.description.peak-sp": "", + "widgets.description.top-10-seasons": "", + "widgets.description.top-100-seasons": "", + "widgets.description.peak-xp": "", + "widgets.description.peak-xp-unverified": "", + "widgets.description.peak-xp-weapon": "", + "widgets.description.highlighted-results": "", + "widgets.description.placement-results": "", + "widgets.description.patron-since": "", + "widgets.description.timezone": "", + "widgets.description.favorite-stage": "", + "widgets.description.videos": "", + "widgets.description.lfg-posts": "", + "widgets.description.top-500-weapons": "", + "widgets.description.top-500-weapons-shooters": "", + "widgets.description.top-500-weapons-blasters": "", + "widgets.description.top-500-weapons-rollers": "", + "widgets.description.top-500-weapons-brushes": "", + "widgets.description.top-500-weapons-chargers": "", + "widgets.description.top-500-weapons-sloshers": "", + "widgets.description.top-500-weapons-splatlings": "", + "widgets.description.top-500-weapons-dualies": "", + "widgets.description.top-500-weapons-brellas": "", + "widgets.description.top-500-weapons-stringers": "", + "widgets.description.top-500-weapons-splatanas": "", + "widgets.description.x-rank-peaks": "", + "widgets.description.builds": "", + "widgets.description.weapon-pool": "", + "widgets.description.sens": "", + "widgets.description.art": "", + "widgets.description.commissions": "", + "widgets.description.social-links": "", + "widgets.description.links": "", + "widgets.description.tier-list": "", + "widgets.forms.bio": "", + "widgets.forms.bio.markdownSupport": "", + "widgets.forms.division": "", + "widgets.forms.division.both": "", + "widgets.forms.division.tentatek": "", + "widgets.forms.division.takoroka": "", + "widgets.forms.timezone": "", + "widgets.forms.favoriteStage": "", + "widgets.forms.weapon": "", + "widgets.forms.peakXp": "", + "widgets.forms.controller": "", + "widgets.forms.source": "", + "widgets.forms.source.ALL": "", + "widgets.forms.source.MADE-BY": "", + "widgets.forms.source.MADE-OF": "", + "widgets.forms.links": "", + "widgets.forms.tierListUrl": "", + "widget.tier-list.untitled": "", + "controllers.s1-pro-con": "", + "controllers.s2-pro-con": "", + "controllers.grip": "", + "controllers.handheld": "", "stickSens": "Sensibilidade do Analógico Direito", "motionSens": "Sensibilidade do Controle de Movimento (Giroscópio)", "motion": "Movimento (Giroscópio)", @@ -17,6 +141,8 @@ "discordExplanation": "Nome de usuário, foto de perfil, conta do YouTube, Bluesky e Twitch vêm da sua conta do Discord. Veja o <1>Perguntas Frequentes para mais informações.", "favoriteBadges": "", "battlefy": "", + "forms.newProfileEnabled": "", + "forms.newProfileEnabled.info": "", "forms.showDiscordUniqueName": "Mostrar nome de usuário Discord", "forms.showDiscordUniqueName.info": "Deixe ativado para mostrar seu nome de usuário único do Discord ({{discordUniqueName}}) publicamente.", "forms.commissionsOpen": "Comissões abertas", @@ -82,5 +208,7 @@ "builds.sorting.CLOTHES_ID": "", "builds.sorting.SHOES_ID": "", "builds.sorting.PUBLIC_BUILD": "", - "builds.sorting.PRIVATE_BUILD": "" + "builds.sorting.PRIVATE_BUILD": "", + "commissions.open": "", + "commissions.closed": "" } diff --git a/locales/ru/common.json b/locales/ru/common.json index e47d1294d..0b31d94a2 100644 --- a/locales/ru/common.json +++ b/locales/ru/common.json @@ -132,6 +132,8 @@ "actions.next": "", "actions.previous": "", "actions.back": "", + "actions.hide": "", + "actions.settings": "", "noResults": "Нет результатов", "maps.createMapList": "Создать список карт", "maps.halfSz": "50% Зон", @@ -258,6 +260,7 @@ "support.perk.joinMoreAssociations": "Возможность присоединиться к до 6 ассоциаций", "support.perk.useBotToLogIn": "Лог-ин через Discord бот", "support.perk.useBotToLogIn.extra": "Возможность запрость лог-ин ссылку от бота Lohi как альтернатива обычному лог-ину", + "support.perk.earlyAccess": "", "custom.colors.title": "Пользовательские цвета", "custom.colors.bg": "Фон", "custom.colors.bg-darker": "Фон темнее", diff --git a/locales/ru/forms.json b/locales/ru/forms.json index 851e524ff..ff76dbdb1 100644 --- a/locales/ru/forms.json +++ b/locales/ru/forms.json @@ -168,5 +168,19 @@ "labels.banUserExpiresAt": "", "bottomTexts.banUserExpiresAtHelp": "", "labels.scrimCancelReason": "", - "bottomTexts.scrimCancelReasonHelp": "" + "bottomTexts.scrimCancelReasonHelp": "", + "bottomTexts.bioMarkdown": "", + "labels.division": "", + "options.division.both": "", + "options.division.tentatek": "", + "options.division.takoroka": "", + "labels.timezone": "", + "labels.favoriteStage": "", + "labels.peakXp": "", + "labels.weapon": "", + "labels.artSource": "", + "options.artSource.ALL": "", + "options.artSource.MADE-BY": "", + "options.artSource.MADE-OF": "", + "labels.tierListUrl": "" } diff --git a/locales/ru/user.json b/locales/ru/user.json index 8a04e5ba8..e870510fb 100644 --- a/locales/ru/user.json +++ b/locales/ru/user.json @@ -5,6 +5,130 @@ "ign.short": "Ник", "country": "Страна", "bio": "О себе", + "widget.bio": "", + "widget.bio-md": "", + "widget.badges-owned": "", + "widget.badges-authored": "", + "widget.teams": "", + "widget.organizations": "", + "widget.peak-sp": "", + "widget.top-10-seasons": "", + "widget.top-100-seasons": "", + "widget.peak-xp": "", + "widget.peak-xp-unverified": "", + "widget.peak-xp-weapon": "", + "widget.highlighted-results": "", + "widget.placement-results": "", + "widget.patron-since": "", + "widget.timezone": "", + "widget.favorite-stage": "", + "widget.videos": "", + "widget.lfg-posts": "", + "widget.top-500-weapons": "", + "widget.top-500-weapons-shooters": "", + "widget.top-500-weapons-blasters": "", + "widget.top-500-weapons-rollers": "", + "widget.top-500-weapons-brushes": "", + "widget.top-500-weapons-chargers": "", + "widget.top-500-weapons-sloshers": "", + "widget.top-500-weapons-splatlings": "", + "widget.top-500-weapons-dualies": "", + "widget.top-500-weapons-brellas": "", + "widget.top-500-weapons-stringers": "", + "widget.top-500-weapons-splatanas": "", + "widget.x-rank-peaks": "", + "widget.builds": "", + "widget.weapon-pool": "", + "widget.sens": "", + "widget.art": "", + "widget.commissions": "", + "widget.social-links": "", + "widget.links": "", + "widget.tier-list": "", + "widget.link.all": "", + "widgets.edit": "", + "widgets.editProfile": "", + "widgets.editTitle": "", + "widgets.available": "", + "widgets.mainSlot": "", + "widgets.sideSlot": "", + "widgets.main": "", + "widgets.side": "", + "widgets.add": "", + "widgets.remove": "", + "widgets.maxReached": "", + "widgets.search": "", + "widgets.category.misc": "", + "widgets.category.badges": "", + "widgets.category.teams": "", + "widgets.category.sendouq": "", + "widgets.category.xrank": "", + "widgets.category.tournaments": "", + "widgets.category.vods": "", + "widgets.category.builds": "", + "widgets.category.art": "", + "widgets.description.bio": "", + "widgets.description.bio-md": "", + "widgets.description.badges-owned": "", + "widgets.description.badges-authored": "", + "widgets.description.teams": "", + "widgets.description.organizations": "", + "widgets.description.peak-sp": "", + "widgets.description.top-10-seasons": "", + "widgets.description.top-100-seasons": "", + "widgets.description.peak-xp": "", + "widgets.description.peak-xp-unverified": "", + "widgets.description.peak-xp-weapon": "", + "widgets.description.highlighted-results": "", + "widgets.description.placement-results": "", + "widgets.description.patron-since": "", + "widgets.description.timezone": "", + "widgets.description.favorite-stage": "", + "widgets.description.videos": "", + "widgets.description.lfg-posts": "", + "widgets.description.top-500-weapons": "", + "widgets.description.top-500-weapons-shooters": "", + "widgets.description.top-500-weapons-blasters": "", + "widgets.description.top-500-weapons-rollers": "", + "widgets.description.top-500-weapons-brushes": "", + "widgets.description.top-500-weapons-chargers": "", + "widgets.description.top-500-weapons-sloshers": "", + "widgets.description.top-500-weapons-splatlings": "", + "widgets.description.top-500-weapons-dualies": "", + "widgets.description.top-500-weapons-brellas": "", + "widgets.description.top-500-weapons-stringers": "", + "widgets.description.top-500-weapons-splatanas": "", + "widgets.description.x-rank-peaks": "", + "widgets.description.builds": "", + "widgets.description.weapon-pool": "", + "widgets.description.sens": "", + "widgets.description.art": "", + "widgets.description.commissions": "", + "widgets.description.social-links": "", + "widgets.description.links": "", + "widgets.description.tier-list": "", + "widgets.forms.bio": "", + "widgets.forms.bio.markdownSupport": "", + "widgets.forms.division": "", + "widgets.forms.division.both": "", + "widgets.forms.division.tentatek": "", + "widgets.forms.division.takoroka": "", + "widgets.forms.timezone": "", + "widgets.forms.favoriteStage": "", + "widgets.forms.weapon": "", + "widgets.forms.peakXp": "", + "widgets.forms.controller": "", + "widgets.forms.source": "", + "widgets.forms.source.ALL": "", + "widgets.forms.source.MADE-BY": "", + "widgets.forms.source.MADE-OF": "", + "widgets.forms.links": "", + "widgets.forms.tierListUrl": "", + "widget.tier-list.untitled": "", + "controllers.s1-pro-con": "", + "controllers.s2-pro-con": "", + "controllers.grip": "", + "controllers.handheld": "", "stickSens": "Чувствительность стика", "motionSens": "Чувствительность наклона", "motion": "Наклон", @@ -17,6 +141,8 @@ "discordExplanation": "Имя пользователя, аватар, ссылка на аккаунты YouTube, Bluesky и Twitch берутся из вашего аккаунта в Discord. Посмотрите <1>FAQ для дополнительной информации.", "favoriteBadges": "Любимые награды", "battlefy": "Аккаунт Battlefy", + "forms.newProfileEnabled": "", + "forms.newProfileEnabled.info": "", "forms.showDiscordUniqueName": "Показать пользовательское имя Discord", "forms.showDiscordUniqueName.info": "Показывать ваше уникальное Discord имя ({{discordUniqueName}})?", "forms.commissionsOpen": "Коммишены открыты", @@ -82,5 +208,7 @@ "builds.sorting.CLOTHES_ID": "Порядок одежды в игре", "builds.sorting.SHOES_ID": "Порядок обуви в игре", "builds.sorting.PUBLIC_BUILD": "Публичные сборки", - "builds.sorting.PRIVATE_BUILD": "Частные сборки" + "builds.sorting.PRIVATE_BUILD": "Частные сборки", + "commissions.open": "", + "commissions.closed": "" } diff --git a/locales/zh/common.json b/locales/zh/common.json index 52c2e7961..4beb8eb90 100644 --- a/locales/zh/common.json +++ b/locales/zh/common.json @@ -132,6 +132,8 @@ "actions.next": "", "actions.previous": "", "actions.back": "", + "actions.hide": "", + "actions.settings": "", "noResults": "", "maps.createMapList": "创建地图列表", "maps.halfSz": "50%为真格区域", @@ -251,6 +253,7 @@ "support.perk.joinMoreAssociations": "", "support.perk.useBotToLogIn": "", "support.perk.useBotToLogIn.extra": "", + "support.perk.earlyAccess": "", "custom.colors.title": "自定义颜色", "custom.colors.bg": "背景", "custom.colors.bg-darker": "深色背景", diff --git a/locales/zh/forms.json b/locales/zh/forms.json index 60ddbda21..d365f2cd0 100644 --- a/locales/zh/forms.json +++ b/locales/zh/forms.json @@ -168,5 +168,19 @@ "labels.banUserExpiresAt": "", "bottomTexts.banUserExpiresAtHelp": "", "labels.scrimCancelReason": "", - "bottomTexts.scrimCancelReasonHelp": "" + "bottomTexts.scrimCancelReasonHelp": "", + "bottomTexts.bioMarkdown": "", + "labels.division": "", + "options.division.both": "", + "options.division.tentatek": "", + "options.division.takoroka": "", + "labels.timezone": "", + "labels.favoriteStage": "", + "labels.peakXp": "", + "labels.weapon": "", + "labels.artSource": "", + "options.artSource.ALL": "", + "options.artSource.MADE-BY": "", + "options.artSource.MADE-OF": "", + "labels.tierListUrl": "" } diff --git a/locales/zh/user.json b/locales/zh/user.json index 81c923ab5..80631f8ae 100644 --- a/locales/zh/user.json +++ b/locales/zh/user.json @@ -5,6 +5,130 @@ "ign.short": "游戏ID", "country": "国家", "bio": "个人简介", + "widget.bio": "", + "widget.bio-md": "", + "widget.badges-owned": "", + "widget.badges-authored": "", + "widget.teams": "", + "widget.organizations": "", + "widget.peak-sp": "", + "widget.top-10-seasons": "", + "widget.top-100-seasons": "", + "widget.peak-xp": "", + "widget.peak-xp-unverified": "", + "widget.peak-xp-weapon": "", + "widget.highlighted-results": "", + "widget.placement-results": "", + "widget.patron-since": "", + "widget.timezone": "", + "widget.favorite-stage": "", + "widget.videos": "", + "widget.lfg-posts": "", + "widget.top-500-weapons": "", + "widget.top-500-weapons-shooters": "", + "widget.top-500-weapons-blasters": "", + "widget.top-500-weapons-rollers": "", + "widget.top-500-weapons-brushes": "", + "widget.top-500-weapons-chargers": "", + "widget.top-500-weapons-sloshers": "", + "widget.top-500-weapons-splatlings": "", + "widget.top-500-weapons-dualies": "", + "widget.top-500-weapons-brellas": "", + "widget.top-500-weapons-stringers": "", + "widget.top-500-weapons-splatanas": "", + "widget.x-rank-peaks": "", + "widget.builds": "", + "widget.weapon-pool": "", + "widget.sens": "", + "widget.art": "", + "widget.commissions": "", + "widget.social-links": "", + "widget.links": "", + "widget.tier-list": "", + "widget.link.all": "", + "widgets.edit": "", + "widgets.editProfile": "", + "widgets.editTitle": "", + "widgets.available": "", + "widgets.mainSlot": "", + "widgets.sideSlot": "", + "widgets.main": "", + "widgets.side": "", + "widgets.add": "", + "widgets.remove": "", + "widgets.maxReached": "", + "widgets.search": "", + "widgets.category.misc": "", + "widgets.category.badges": "", + "widgets.category.teams": "", + "widgets.category.sendouq": "", + "widgets.category.xrank": "", + "widgets.category.tournaments": "", + "widgets.category.vods": "", + "widgets.category.builds": "", + "widgets.category.art": "", + "widgets.description.bio": "", + "widgets.description.bio-md": "", + "widgets.description.badges-owned": "", + "widgets.description.badges-authored": "", + "widgets.description.teams": "", + "widgets.description.organizations": "", + "widgets.description.peak-sp": "", + "widgets.description.top-10-seasons": "", + "widgets.description.top-100-seasons": "", + "widgets.description.peak-xp": "", + "widgets.description.peak-xp-unverified": "", + "widgets.description.peak-xp-weapon": "", + "widgets.description.highlighted-results": "", + "widgets.description.placement-results": "", + "widgets.description.patron-since": "", + "widgets.description.timezone": "", + "widgets.description.favorite-stage": "", + "widgets.description.videos": "", + "widgets.description.lfg-posts": "", + "widgets.description.top-500-weapons": "", + "widgets.description.top-500-weapons-shooters": "", + "widgets.description.top-500-weapons-blasters": "", + "widgets.description.top-500-weapons-rollers": "", + "widgets.description.top-500-weapons-brushes": "", + "widgets.description.top-500-weapons-chargers": "", + "widgets.description.top-500-weapons-sloshers": "", + "widgets.description.top-500-weapons-splatlings": "", + "widgets.description.top-500-weapons-dualies": "", + "widgets.description.top-500-weapons-brellas": "", + "widgets.description.top-500-weapons-stringers": "", + "widgets.description.top-500-weapons-splatanas": "", + "widgets.description.x-rank-peaks": "", + "widgets.description.builds": "", + "widgets.description.weapon-pool": "", + "widgets.description.sens": "", + "widgets.description.art": "", + "widgets.description.commissions": "", + "widgets.description.social-links": "", + "widgets.description.links": "", + "widgets.description.tier-list": "", + "widgets.forms.bio": "", + "widgets.forms.bio.markdownSupport": "", + "widgets.forms.division": "", + "widgets.forms.division.both": "", + "widgets.forms.division.tentatek": "", + "widgets.forms.division.takoroka": "", + "widgets.forms.timezone": "", + "widgets.forms.favoriteStage": "", + "widgets.forms.weapon": "", + "widgets.forms.peakXp": "", + "widgets.forms.controller": "", + "widgets.forms.source": "", + "widgets.forms.source.ALL": "", + "widgets.forms.source.MADE-BY": "", + "widgets.forms.source.MADE-OF": "", + "widgets.forms.links": "", + "widgets.forms.tierListUrl": "", + "widget.tier-list.untitled": "", + "controllers.s1-pro-con": "", + "controllers.s2-pro-con": "", + "controllers.grip": "", + "controllers.handheld": "", "stickSens": "摇杆感度", "motionSens": "体感感度", "motion": "体感", @@ -17,6 +141,8 @@ "discordExplanation": "用户名、头像、Youtube、Bluesky和Twitch账号皆来自您的Discord账号。查看 <1>FAQ 以获得更多相关信息。", "favoriteBadges": "", "battlefy": "Battlefy用户名", + "forms.newProfileEnabled": "", + "forms.newProfileEnabled.info": "", "forms.showDiscordUniqueName": "显示Discord用户名", "forms.showDiscordUniqueName.info": "是否公开显示您的Discord用户名 ({{discordUniqueName}}) ?", "forms.commissionsOpen": "开放委托", @@ -82,5 +208,7 @@ "builds.sorting.CLOTHES_ID": "游戏内衣服顺序", "builds.sorting.SHOES_ID": "游戏内鞋子顺序", "builds.sorting.PUBLIC_BUILD": "公开配装", - "builds.sorting.PRIVATE_BUILD": "私人配装" + "builds.sorting.PRIVATE_BUILD": "私人配装", + "commissions.open": "", + "commissions.closed": "" } diff --git a/migrations/118-user-widget.js b/migrations/118-user-widget.js new file mode 100644 index 000000000..41b98847c --- /dev/null +++ b/migrations/118-user-widget.js @@ -0,0 +1,19 @@ +export function up(db) { + db.transaction(() => { + db.prepare( + /* sql */ ` + create table "UserWidget" ( + "userId" integer not null, + "index" integer not null, + "widget" text not null, + primary key ("userId", "index"), + foreign key ("userId") references "User"("id") on delete cascade + ) strict + `, + ).run(); + + db.prepare( + /* sql */ `create index user_widget_user_id on "UserWidget"("userId")`, + ).run(); + })(); +} diff --git a/public/static-assets/img/controllers/grip.avif b/public/static-assets/img/controllers/grip.avif new file mode 100644 index 0000000000000000000000000000000000000000..0c30af1ff8c8558b410c29693f62abba8dfb8dc7 GIT binary patch literal 8936 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

2T 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 bA-`-7KCAEX10Z06wlFi;_}u#T*I51!m5&IQ literal 0 HcmV?d00001 diff --git a/public/static-assets/img/controllers/s2-pro-con.avif b/public/static-assets/img/controllers/s2-pro-con.avif new file mode 100644 index 0000000000000000000000000000000000000000..a540616aed00b25e85a8a6e291710813ecf83ee6 GIT binary patch literal 10359 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 literal 0 HcmV?d00001