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 000000000..0c30af1ff Binary files /dev/null and b/public/static-assets/img/controllers/grip.avif differ diff --git a/public/static-assets/img/controllers/handheld.avif b/public/static-assets/img/controllers/handheld.avif new file mode 100644 index 000000000..911907a61 Binary files /dev/null and b/public/static-assets/img/controllers/handheld.avif differ diff --git a/public/static-assets/img/controllers/s1-pro-con.avif b/public/static-assets/img/controllers/s1-pro-con.avif new file mode 100644 index 000000000..b108e8ba5 Binary files /dev/null and b/public/static-assets/img/controllers/s1-pro-con.avif differ 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 000000000..a540616ae Binary files /dev/null and b/public/static-assets/img/controllers/s2-pro-con.avif differ