New user page (#2812)
Some checks are pending
E2E Tests / e2e (push) Waiting to run
Tests and checks on push / run-checks-and-tests (push) Waiting to run
Updates translation progress / update-translation-progress-issue (push) Waiting to run

Co-authored-by: hfcRed <hfcred@gmx.net>
This commit is contained in:
Kalle 2026-02-16 19:26:57 +02:00 committed by GitHub
parent 9ea752f72f
commit 77978c450f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
119 changed files with 6585 additions and 191 deletions

View File

@ -11,6 +11,7 @@ const dimensions = {
sm: 44, sm: 44,
xsm: 62, xsm: 62,
md: 81, md: 81,
xmd: 94,
lg: 125, lg: 125,
} as const; } as const;
@ -43,7 +44,7 @@ export function Avatar({
? discordAvatarUrl({ ? discordAvatarUrl({
discordAvatar: user.discordAvatar, discordAvatar: user.discordAvatar,
discordId: user.discordId, discordId: user.discordId,
size: size === "lg" ? "lg" : "sm", size: size === "lg" || size === "xmd" ? "lg" : "sm",
}) })
: BLANK_IMAGE_URL); // avoid broken image placeholder : BLANK_IMAGE_URL); // avoid broken image placeholder

View File

@ -8,6 +8,7 @@ import {
ModalOverlay, ModalOverlay,
} from "react-aria-components"; } from "react-aria-components";
import { useNavigate } from "react-router"; import { useNavigate } from "react-router";
import * as R from "remeda";
import { SendouButton } from "~/components/elements/Button"; import { SendouButton } from "~/components/elements/Button";
import { CrossIcon } from "~/components/icons/Cross"; import { CrossIcon } from "~/components/icons/Cross";
import styles from "./Dialog.module.css"; import styles from "./Dialog.module.css";
@ -68,7 +69,9 @@ export function SendouDialog({
return ( return (
<DialogTrigger> <DialogTrigger>
{trigger} {trigger}
<DialogModal {...rest}>{children}</DialogModal> <DialogModal {...rest} isControlledByTrigger>
{children}
</DialogModal>
</DialogTrigger> </DialogTrigger>
); );
} }
@ -79,8 +82,9 @@ function DialogModal({
showHeading = true, showHeading = true,
className, className,
showCloseButton: showCloseButtonProp, showCloseButton: showCloseButtonProp,
isControlledByTrigger,
...rest ...rest
}: Omit<SendouDialogProps, "trigger">) { }: Omit<SendouDialogProps, "trigger"> & { isControlledByTrigger?: boolean }) {
const navigate = useNavigate(); const navigate = useNavigate();
const showCloseButton = showCloseButtonProp || rest.onClose || rest.onCloseTo; const showCloseButton = showCloseButtonProp || rest.onClose || rest.onCloseTo;
@ -92,7 +96,7 @@ function DialogModal({
} }
}; };
const onOpenChange = (isOpen: boolean) => { const defaultOnOpenChange = (isOpen: boolean) => {
if (!isOpen) { if (!isOpen) {
if (rest.onCloseTo) { if (rest.onCloseTo) {
navigate(rest.onCloseTo); navigate(rest.onCloseTo);
@ -102,13 +106,16 @@ function DialogModal({
} }
}; };
const overlayProps = isControlledByTrigger
? R.omit(rest, ["onOpenChange"])
: { ...rest, onOpenChange: rest.onOpenChange ?? defaultOnOpenChange };
return ( return (
<ModalOverlay <ModalOverlay
className={clsx(rest.overlayClassName, styles.overlay, { className={clsx(rest.overlayClassName, styles.overlay, {
[styles.fullScreenOverlay]: rest.isFullScreen, [styles.fullScreenOverlay]: rest.isFullScreen,
})} })}
onOpenChange={rest.onOpenChange ?? onOpenChange} {...overlayProps}
{...rest}
> >
<Modal <Modal
className={clsx(className, styles.modal, { className={clsx(className, styles.modal, {

View File

@ -0,0 +1,33 @@
export function MainSlotIcon({
className,
size,
}: {
className?: string;
size?: number;
}) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
className={className}
width={size}
height={size}
>
{/* Left column - filled */}
<path
d="M3 6a3 3 0 0 1 3 -3h7v18h-7a3 3 0 0 1 -3 -3z"
fill="currentColor"
stroke="none"
/>
{/* Right column - outlined */}
<path
d="M13 4h5a2 2 0 0 1 2 2v12a2 2 0 0 1 -2 2h-5"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
}

View File

@ -0,0 +1,20 @@
export function SideSlotIcon({
className,
size,
}: {
className?: string;
size?: number;
}) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentColor"
className={className}
width={size}
height={size}
>
<path d="M6 21a3 3 0 0 1 -3 -3v-12a3 3 0 0 1 3 -3h12a3 3 0 0 1 3 3v12a3 3 0 0 1 -3 3zm8 -16h-8a1 1 0 0 0 -1 1v12a1 1 0 0 0 1 1h8z" />
</svg>
);
}

View File

@ -179,6 +179,7 @@ const basicSeeds = (variation?: SeedVariation | null) => [
fixAdminId, fixAdminId,
makeArtists, makeArtists,
adminUserWeaponPool, adminUserWeaponPool,
adminUserWidgets,
userProfiles, userProfiles,
userMapModePreferences, userMapModePreferences,
userQWeaponPool, 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() { function nzapUser() {
return UserRepository.upsert({ return UserRepository.upsert({
discordId: NZAP_TEST_DISCORD_ID, discordId: NZAP_TEST_DISCORD_ID,

View File

@ -16,6 +16,7 @@ import type { TEAM_MEMBER_ROLES } from "~/features/team/team-constants";
import type { TournamentTierNumber } from "~/features/tournament/core/tiering"; import type { TournamentTierNumber } from "~/features/tournament/core/tiering";
import type * as PickBan from "~/features/tournament-bracket/core/PickBan"; import type * as PickBan from "~/features/tournament-bracket/core/PickBan";
import type * as Progression from "~/features/tournament-bracket/core/Progression"; 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 { ParticipantResult } from "~/modules/brackets-model";
import type { import type {
Ability, Ability,
@ -861,6 +862,8 @@ export interface UserPreferences {
* "12h" = 12 hour format (e.g. 2:00 PM) * "12h" = 12 hour format (e.g. 2:00 PM)
* */ * */
clockFormat?: "24h" | "12h" | "auto"; 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; export const SUBJECT_PRONOUNS = ["he", "she", "they", "it", "any"] as const;
@ -961,6 +964,11 @@ export interface UserFriendCode {
createdAt: GeneratedAlways<number>; createdAt: GeneratedAlways<number>;
} }
export interface UserWidget {
userId: number;
index: number;
widget: JSONColumnType<StoredWidget>;
}
export type ApiTokenType = "read" | "write"; export type ApiTokenType = "read" | "write";
export interface ApiToken { export interface ApiToken {
@ -1223,6 +1231,7 @@ export interface DB {
UserSubmittedImage: UserSubmittedImage; UserSubmittedImage: UserSubmittedImage;
UserWeapon: UserWeapon; UserWeapon: UserWeapon;
UserFriendCode: UserFriendCode; UserFriendCode: UserFriendCode;
UserWidget: UserWidget;
Video: Video; Video: Video;
VideoMatch: VideoMatch; VideoMatch: VideoMatch;
VideoMatchPlayer: VideoMatchPlayer; VideoMatchPlayer: VideoMatchPlayer;

View File

@ -110,6 +110,31 @@ export function findManagedByUserId(userId: number) {
.execute(); .execute();
} }
export function findByOwnerUserId(userId: number) {
return db
.selectFrom("BadgeOwner")
.innerJoin("Badge", "Badge.id", "BadgeOwner.badgeId")
.select(({ fn }) => [
fn.count<number>("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({ export function replaceManagers({
badgeId, badgeId,
managerIds, managerIds,

View File

@ -21,10 +21,14 @@ export async function allByUserId(
options: { options: {
showPrivate?: boolean; showPrivate?: boolean;
sortAbilities?: boolean; sortAbilities?: boolean;
limit?: number;
} = {}, } = {},
) { ) {
const { showPrivate = false, sortAbilities: shouldSortAbilities = false } = const {
options; showPrivate = false,
sortAbilities: shouldSortAbilities = false,
limit,
} = options;
const rows = await db const rows = await db
.selectFrom("Build") .selectFrom("Build")
.select(({ eb }) => [ .select(({ eb }) => [
@ -48,6 +52,8 @@ export async function allByUserId(
]) ])
.where("Build.ownerId", "=", userId) .where("Build.ownerId", "=", userId)
.$if(!showPrivate, (qb) => qb.where("Build.private", "=", 0)) .$if(!showPrivate, (qb) => qb.where("Build.private", "=", 0))
.$if(typeof limit === "number", (qb) => qb.limit(limit!))
.orderBy("Build.updatedAt", "desc")
.execute(); .execute();
return rows.map((row) => { return rows.map((row) => {

View File

@ -101,7 +101,7 @@ function tournamentOrganization(organizationId: Expression<number | null>) {
"TournamentOrganization.slug", "TournamentOrganization.slug",
"TournamentOrganization.isEstablished", "TournamentOrganization.isEstablished",
concatUserSubmittedImagePrefix(eb.ref("UserSubmittedImage.url")).as( concatUserSubmittedImagePrefix(eb.ref("UserSubmittedImage.url")).as(
"avatarUrl", "logoUrl",
), ),
]) ])
.whereRef("TournamentOrganization.id", "=", organizationId), .whereRef("TournamentOrganization.id", "=", organizationId),

View File

@ -118,10 +118,15 @@ export async function findValidOrganizations(
}); });
if (isTournamentAdder) { 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 return orgs
.filter((org) => org.isEstablished) .filter((org) => org.isEstablished)
.map((org) => R.omit(org, ["isEstablished"])); .map((org) => R.omit(org, ["isEstablished", "role", "roleDisplayName"]));
} }

View File

@ -57,6 +57,11 @@ const PERKS = [
name: "tournamentsBeta", name: "tournamentsBeta",
extraInfo: false, extraInfo: false,
}, },
{
tier: 2,
name: "earlyAccess",
extraInfo: false,
},
{ {
tier: 2, tier: 2,
name: "previewQ", name: "previewQ",

View File

@ -157,3 +157,12 @@ export function deletePostsByTeamId(teamId: number, trx?: Transaction<DB>) {
.where("teamId", "=", teamId) .where("teamId", "=", teamId)
.execute(); .execute();
} }
export async function findByAuthorUserId(authorId: number) {
return db
.selectFrom("LFGPost")
.select(["id", "type"])
.where("authorId", "=", authorId)
.orderBy("updatedAt", "desc")
.execute();
}

View File

@ -0,0 +1,3 @@
.post {
scroll-margin-top: 6rem;
}

View File

@ -1,3 +1,4 @@
import clsx from "clsx";
import { add, sub } from "date-fns"; import { add, sub } from "date-fns";
import React from "react"; import React from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
@ -26,6 +27,7 @@ import {
smallStrToFilter, smallStrToFilter,
} from "../lfg-types"; } from "../lfg-types";
import { loader } from "../loaders/lfg.server"; import { loader } from "../loaders/lfg.server";
import styles from "./lfg.module.css";
export { loader, action }; export { loader, action };
export const handle: SendouRouteHandle = { export const handle: SendouRouteHandle = {
@ -124,7 +126,11 @@ export default function LFGPage() {
} }
/> />
{filteredPosts.map((post) => ( {filteredPosts.map((post) => (
<div key={post.id} className="stack sm"> <div
key={post.id}
id={String(post.id)}
className={clsx("stack sm", styles.post)}
>
{showExpiryAlert(post) ? <PostExpiryAlert postId={post.id} /> : null} {showExpiryAlert(post) ? <PostExpiryAlert postId={post.id} /> : null}
<LFGPost post={post} tiersMap={tiersMap} /> <LFGPost post={post} tiersMap={tiersMap} />
</div> </div>

View File

@ -181,3 +181,12 @@ export function allStarted(date = new Date()) {
return [0]; 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();
}

View File

@ -46,6 +46,7 @@ export function findAllMemberOfByUserId(userId: number) {
"Team.id", "Team.id",
"Team.customUrl", "Team.customUrl",
"Team.name", "Team.name",
"TeamMemberWithSecondary.role",
concatUserSubmittedImagePrefix(eb.ref("UserSubmittedImage.url")).as( concatUserSubmittedImagePrefix(eb.ref("UserSubmittedImage.url")).as(
"logoUrl", "logoUrl",
), ),

View File

@ -1,15 +1,12 @@
import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { REGULAR_USER_TEST_ID } from "~/db/seed/constants"; import { REGULAR_USER_TEST_ID } from "~/db/seed/constants";
import { db } from "~/db/sql"; import { db } from "~/db/sql";
import type { SerializeFrom } from "~/utils/remix";
import { import {
assertResponseErrored, assertResponseErrored,
dbInsertUsers, dbInsertUsers,
dbReset, dbReset,
wrappedAction, wrappedAction,
wrappedLoader,
} from "~/utils/Test"; } 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 _teamPageAction } from "../actions/t.$customUrl.index.server";
import { action as teamIndexPageAction } from "../actions/t.server"; import { action as teamIndexPageAction } from "../actions/t.server";
import { action as _editTeamAction } from "../routes/t.$customUrl.edit"; import { action as _editTeamAction } from "../routes/t.$customUrl.edit";
@ -20,12 +17,6 @@ import type {
teamProfilePageActionSchema, teamProfilePageActionSchema,
} from "../team-schemas.server"; } from "../team-schemas.server";
const loadUserTeamLoader = wrappedLoader<
SerializeFrom<typeof userProfileLoader>
>({
loader: userProfileLoader,
});
const createTeamAction = wrappedAction<typeof createTeamSchema>({ const createTeamAction = wrappedAction<typeof createTeamSchema>({
action: teamIndexPageAction, action: teamIndexPageAction,
isJsonSubmission: true, isJsonSubmission: true,
@ -40,14 +31,12 @@ const editTeamAction = wrappedAction<typeof editTeamSchema>({
}); });
async function loadTeams() { async function loadTeams() {
const data = await loadUserTeamLoader({ const teams = await TeamRepository.teamsByMemberUserId(REGULAR_USER_TEST_ID);
user: "regular",
params: {
identifier: String(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", () => { describe("Secondary teams", () => {

View File

@ -1,6 +1,9 @@
import type { InferResult } from "kysely"; import type { InferResult } from "kysely";
import { sql } from "kysely";
import { db } from "~/db/sql"; import { db } from "~/db/sql";
import type { Tables } from "~/db/tables"; 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) { export function unlinkPlayerByUserId(userId: number) {
return db return db
@ -54,6 +57,26 @@ export async function findPlacementsByPlayerId(
return result.length ? result : null; 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() { export async function monthYears() {
return await db return await db
.selectFrom("XRankPlacement") .selectFrom("XRankPlacement")
@ -64,6 +87,44 @@ export async function monthYears() {
.execute(); .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<number>`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<typeof p> => p !== undefined);
}
export type FindPlacement = InferResult< export type FindPlacement = InferResult<
ReturnType<typeof xRankPlacementsQueryBase> ReturnType<typeof xRankPlacementsQueryBase>
>[number]; >[number];

View File

@ -61,7 +61,6 @@
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: var(--s-1); gap: var(--s-1);
color: var(--text-main);
justify-content: center; justify-content: center;
transition: background-color 0.2s; transition: background-color 0.2s;
} }

View File

@ -6925,7 +6925,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({
id: 3, id: 3,
name: "Inkling Performance Labs", name: "Inkling Performance Labs",
slug: "inkling-performance-labs", slug: "inkling-performance-labs",
avatarUrl: "fZrToLQrkqV3UZkdgwp0Q-1722263644749.webp", logoUrl: "fZrToLQrkqV3UZkdgwp0Q-1722263644749.webp",
members: [ members: [
{ {
userId: 405, userId: 405,

View File

@ -2028,7 +2028,7 @@ export const SWIM_OR_SINK_167 = (
id: 3, id: 3,
name: "Inkling Performance Labs", name: "Inkling Performance Labs",
slug: "inkling-performance-labs", slug: "inkling-performance-labs",
avatarUrl: "fZrToLQrkqV3UZkdgwp0Q-1722263644749.webp", logoUrl: "fZrToLQrkqV3UZkdgwp0Q-1722263644749.webp",
members: [ members: [
{ {
userId: 405, userId: 405,

View File

@ -151,10 +151,21 @@ export function findByUserId(
"TournamentOrganization.id", "TournamentOrganization.id",
"TournamentOrganizationMember.organizationId", "TournamentOrganizationMember.organizationId",
) )
.select([ .leftJoin(
"UserSubmittedImage",
"UserSubmittedImage.id",
"TournamentOrganization.avatarImgId",
)
.select(({ eb }) => [
"TournamentOrganization.id", "TournamentOrganization.id",
"TournamentOrganization.name", "TournamentOrganization.name",
"TournamentOrganization.slug",
"TournamentOrganization.isEstablished", "TournamentOrganization.isEstablished",
"TournamentOrganizationMember.role",
"TournamentOrganizationMember.roleDisplayName",
concatUserSubmittedImagePrefix(eb.ref("UserSubmittedImage.url")).as(
"logoUrl",
),
]) ])
.where("TournamentOrganizationMember.userId", "=", userId) .where("TournamentOrganizationMember.userId", "=", userId)
.$if(roles.length > 0, (qb) => .$if(roles.length > 0, (qb) =>

View File

@ -164,7 +164,6 @@
.org__social-link { .org__social-link {
font-size: var(--fonts-sm); font-size: var(--fonts-sm);
color: var(--text-main);
display: flex; display: flex;
gap: var(--s-2); gap: var(--s-2);
align-items: center; align-items: center;

View File

@ -72,7 +72,7 @@ export async function findById(id: number) {
"TournamentOrganization.slug", "TournamentOrganization.slug",
concatUserSubmittedImagePrefix( concatUserSubmittedImagePrefix(
innerEb.ref("UserSubmittedImage.url"), innerEb.ref("UserSubmittedImage.url"),
).as("avatarUrl"), ).as("logoUrl"),
jsonArrayFrom( jsonArrayFrom(
innerEb innerEb
.selectFrom("TournamentOrganizationMember") .selectFrom("TournamentOrganizationMember")

View File

@ -97,7 +97,7 @@ export default function TournamentRegisterPage() {
className="stack horizontal sm items-center text-xs text-main-forced" className="stack horizontal sm items-center text-xs text-main-forced"
> >
<Avatar <Avatar
url={tournament.ctx.organization.avatarUrl ?? undefined} url={tournament.ctx.organization.logoUrl ?? undefined}
size="xxs" size="xxs"
/> />
{tournament.ctx.organization.name} {tournament.ctx.organization.name}

View File

@ -73,9 +73,9 @@ export const handle: SendouRouteHandle = {
const data = JSON.parse(rawData) as TournamentLoaderData; const data = JSON.parse(rawData) as TournamentLoaderData;
return [ 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({ href: tournamentOrganizationPage({
organizationSlug: data.tournament.ctx.organization.slug, organizationSlug: data.tournament.ctx.organization.slug,
}), }),

View File

@ -21,8 +21,13 @@ import {
tournamentLogoOrNull, tournamentLogoOrNull,
userChatNameColor, userChatNameColor,
} from "~/utils/kysely.server"; } from "~/utils/kysely.server";
import { logger } from "~/utils/logger";
import { safeNumberParse } from "~/utils/number"; import { safeNumberParse } from "~/utils/number";
import { bskyUrl, twitchUrl, youtubeUrl } from "~/utils/urls";
import type { ChatUser } from "../chat/chat-types"; 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) => export const identifierToUserIdQuery = (identifier: string) =>
db db
@ -78,6 +83,9 @@ export function findLayoutDataByIdentifier(
return identifierToUserIdQuery(identifier) return identifierToUserIdQuery(identifier)
.select((eb) => [ .select((eb) => [
...COMMON_USER_FIELDS, ...COMMON_USER_FIELDS,
"User.pronouns",
"User.country",
"User.inGameName",
"User.commissionText", "User.commissionText",
"User.commissionsOpen", "User.commissionsOpen",
sql<Record< sql<Record<
@ -262,6 +270,102 @@ export async function findProfileByIdentifier(
}; };
} }
export async function widgetsEnabledByIdentifier(identifier: string) {
const row = await identifierToUserIdQuery(identifier)
.select(["User.preferences", "User.patronTier"])
.executeTakeFirst();
if (!row) return false;
if (!isSupporter(row)) return false;
return row?.preferences?.newProfileEnabled === true;
}
export async function preferencesByUserId(userId: number) {
const row = await db
.selectFrom("User")
.select("User.preferences")
.where("User.id", "=", userId)
.executeTakeFirst();
return row?.preferences ?? null;
}
export async function upsertWidgets(
userId: number,
widgets: Array<Tables["UserWidget"]["widget"]>,
) {
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<Array<Tables["UserWidget"]["widget"]>> {
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<LoadedWidget[] | null> {
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: { function favoriteBadgesOwnedAndSupporterStatusAdjusted(row: {
favoriteBadgeIds: number[] | null; favoriteBadgeIds: number[] | null;
badges: Array<{ badges: Array<{
@ -675,6 +779,30 @@ export async function hasHighlightedResultsByUserId(userId: number) {
return !!highlightedCalendarEventResult; 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<DB, "User"> }) => const searchSelectedFields = ({ fn }: { fn: FunctionModule<DB, "User"> }) =>
[ [
...COMMON_USER_FIELDS, ...COMMON_USER_FIELDS,
@ -862,6 +990,28 @@ export async function inGameNameByUserId(userId: number) {
)?.inGameName; )?.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"]) { export function insertFriendCode(args: TablesInsertable["UserFriendCode"]) {
cachedFriendCodes?.add(args.friendCode); cachedFriendCodes?.add(args.friendCode);
@ -1128,6 +1278,45 @@ export async function anyUserPrefersNoScreen(
return Boolean(result); 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[]) { export function findIdsByTwitchUsernames(twitchUsernames: string[]) {
if (twitchUsernames.length === 0) return []; if (twitchUsernames.length === 0) return [];

View File

@ -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));
};

View File

@ -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 user = requireUser();
const inGameName = const inGameName =
@ -28,15 +33,15 @@ export const action: ActionFunction = async ({ request }) => {
? `${inGameNameText}#${inGameNameDiscriminator}` ? `${inGameNameText}#${inGameNameDiscriminator}`
: null; : null;
const pronouns =
data.subjectPronoun && data.objectPronoun
? JSON.stringify({
subject: data.subjectPronoun,
object: data.objectPronoun,
})
: null;
try { try {
const pronouns =
data.subjectPronoun && data.objectPronoun
? JSON.stringify({
subject: data.subjectPronoun,
object: data.objectPronoun,
})
: null;
const editedUser = await UserRepository.updateProfile({ const editedUser = await UserRepository.updateProfile({
...data, ...data,
pronouns, pronouns,
@ -44,6 +49,10 @@ export const action: ActionFunction = async ({ request }) => {
userId: user.id, userId: user.id,
}); });
await UserRepository.updatePreferences(user.id, {
newProfileEnabled: Boolean(newProfileEnabled),
});
// TODO: to transaction // TODO: to transaction
if (inGameName) { if (inGameName) {
const tournamentIdsAffected = const tournamentIdsAffected =

View File

@ -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);
}

View File

@ -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<Tables["User"], "username" | "discordId" | "discordAvatar">;
backTo: string;
children?: React.ReactNode;
}) {
return (
<div className={styles.subPageHeader}>
<div className={styles.leftSection}>
<Link
to={backTo}
className={styles.backButton}
aria-label="Back to profile"
>
<ArrowLeftIcon className={styles.backIcon} />
</Link>
<Link to={backTo} className={styles.userInfo}>
<Avatar user={user} size="xs" className={styles.avatar} />
<span className={styles.username}>{user.username}</span>
</Link>
</div>
{children ? <div className={styles.actions}>{children}</div> : null}
</div>
);
}

View File

@ -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;
}
}

View File

@ -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 (
<nav className={styles.iconNav}>
{visibleItems.map((item) => (
<NavLink
key={item.to}
to={item.to}
end={item.end ?? true}
prefetch={item.prefetch}
data-testid={item.testId}
className={(state) =>
clsx(styles.iconNavItem, {
[styles.active]: state.isActive,
})
}
aria-label={
item.count !== undefined
? `${item.label} (${item.count})`
: item.label
}
>
<Image
path={navIconUrl(item.iconName)}
width={24}
height={24}
alt=""
/>
</NavLink>
))}
</nav>
);
}

View File

@ -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;
}

View File

@ -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<LoadedWidget>;
user: Pick<Tables["User"], "discordId" | "customUrl">;
}) {
const { t } = useTranslation(["user", "badges", "team", "org", "lfg"]);
const { formatDate } = useTimeFormat();
const content = () => {
switch (widget.id) {
case "bio":
return <article>{widget.data.bio}</article>;
case "bio-md":
return (
<article>
<Markdown options={{ wrapper: React.Fragment }}>
{widget.data.bio}
</Markdown>
</article>
);
case "badges-owned":
return <BadgeDisplay badges={widget.data} />;
case "badges-authored":
return <BadgeDisplay badges={widget.data} />;
case "teams":
return (
<Memberships
memberships={widget.data.map((team) => ({
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 (
<Memberships
memberships={widget.data.map((org) => ({
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 (
<BigValue
value={widget.data.peakSp}
unit="SP"
footer={`${widget.data.tierName}${widget.data.isPlus ? "+" : ""} / ${t("user:seasons.season.short")}${widget.data.season}`}
/>
);
case "top-10-seasons":
case "top-100-seasons":
if (!widget.data) return null;
return (
<BigValue
value={widget.data.times}
footer={widget.data.seasons
.sort((a, b) => a - b)
.map((s) => `S${s}`)
.join(" ")}
/>
);
case "peak-xp":
if (!widget.data) return null;
return (
<BigValue
value={widget.data.peakXp}
unit="XP"
footer={`${widget.data.division}${widget.data.topRating ? ` / #${widget.data.topRating}` : ""}`}
/>
);
case "peak-xp-unverified":
return (
<BigValue
value={widget.data.peakXp}
unit="XP"
footer={
widget.data.division === "tentatek" ? "Tentatek" : "Takoroka"
}
/>
);
case "peak-xp-weapon":
if (!widget.data) return null;
return (
<PeakXpWeapon
weaponSplId={widget.data.weaponSplId as MainWeaponId}
peakXp={widget.data.peakXp}
leaderboardPosition={widget.data.leaderboardPosition}
/>
);
case "highlighted-results":
return widget.data.length === 0 ? null : (
<HighlightedResults results={widget.data} />
);
case "placement-results":
if (!widget.data) return null;
return <PlacementResults data={widget.data} />;
case "patron-since":
if (!widget.data) return null;
return (
<BigValue
value={formatDate(databaseTimestampToDate(widget.data), {
day: "numeric",
month: "short",
year: "numeric",
})}
/>
);
case "timezone":
return <TimezoneWidget timezone={widget.data.timezone} />;
case "favorite-stage":
return <FavoriteStageWidget stageId={widget.data.stageId as StageId} />;
case "videos":
return widget.data.length === 0 ? null : (
<Videos videos={widget.data} />
);
case "lfg-posts":
return widget.data.length === 0 ? null : (
<LFGPosts posts={widget.data} />
);
case "top-500-weapons":
if (!widget.data) return null;
return <Top500Weapons weaponIds={widget.data} />;
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 (
<Top500Weapons
weaponIds={widget.data.weaponIds}
count={widget.data.weaponIds.length}
total={widget.data.total}
/>
);
}
case "x-rank-peaks":
return widget.data.length === 0 ? null : (
<XRankPeaks peaks={widget.data} />
);
case "builds":
return widget.data.length === 0 ? null : (
<Builds builds={widget.data} />
);
case "weapon-pool":
return widget.data.weapons.length === 0 ? null : (
<WeaponPool
weapons={
widget.data.weapons as Array<{
id: MainWeaponId;
isFavorite: boolean;
}>
}
/>
);
case "sens":
return <SensWidget data={widget.data} />;
case "art":
return widget.data.length === 0 ? null : (
<ArtWidget arts={widget.data} />
);
case "commissions":
return <CommissionsWidget data={widget.data} />;
case "social-links":
return <SocialLinksWidget data={widget.data} />;
case "links":
return widget.data.length === 0 ? null : (
<LinksWidget links={widget.data} />
);
case "tier-list":
return <TierListWidget searchParams={widget.data.searchParams} />;
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 (
<div className={styles.widget}>
<div className={styles.header}>
<h2 className={styles.headerText}>{t(`user:widget.${widget.id}`)}</h2>
{widgetLink ? (
<Link to={widgetLink} className={styles.headerLink}>
{t("user:widget.link.all")}
</Link>
) : null}
</div>
<div className={styles.content}>{content()}</div>
</div>
);
}
function BigValue({
value,
unit,
footer,
}: {
value: number | string;
unit?: string;
footer?: string;
}) {
return (
<div className={styles.peakValue}>
<div className={styles.widgetValueMain}>
{value} {unit ? unit : null}
</div>
{footer ? <div className={styles.widgetValueFooter}>{footer}</div> : null}
</div>
);
}
function Memberships({
memberships,
}: {
memberships: Array<{
id: number;
url: string;
name: string;
logoUrl: string | null;
roleDisplayName: string | null;
}>;
}) {
return (
<div className={styles.memberships}>
{memberships.map((membership) => (
<Link
key={membership.id}
to={membership.url}
className={styles.membership}
>
{membership.logoUrl ? (
<img
alt=""
src={membership.logoUrl}
width={42}
height={42}
className="rounded-full"
/>
) : null}
<div className={styles.membershipInfo}>
<div className={styles.membershipName}>{membership.name}</div>
{membership.roleDisplayName ? (
<div className={styles.membershipRole}>
{membership.roleDisplayName}
</div>
) : null}
</div>
</Link>
))}
</div>
);
}
function HighlightedResults({
results,
}: {
results: Extract<LoadedWidget, { id: "highlighted-results" }>["data"];
}) {
const { formatDate } = useTimeFormat();
return (
<div className={styles.highlightedResults}>
{results.map((result, i) => (
<div key={i} className={styles.result}>
<div className={styles.resultPlacement}>
<Placement placement={result.placement} size={28} />
</div>
<div className={styles.resultInfo}>
<div className={styles.resultName}>
{result.eventId ? (
<Link
to={calendarEventPage(result.eventId)}
className="text-main-forced"
>
{result.eventName}
</Link>
) : null}
{result.tournamentId ? (
<div className={styles.tournamentName}>
{result.logoUrl ? (
<img
src={result.logoUrl}
alt=""
width={18}
height={18}
className="rounded-full"
/>
) : null}
<Link
to={tournamentBracketsPage({
tournamentId: result.tournamentId,
})}
className="text-main-forced"
>
{result.eventName}
</Link>
</div>
) : null}
</div>
<div className={styles.resultDate}>
{formatDate(databaseTimestampToDate(result.startTime), {
day: "numeric",
month: "short",
year: "numeric",
})}
</div>
</div>
</div>
))}
</div>
);
}
function Videos({
videos,
}: {
videos: Extract<LoadedWidget, { id: "videos" }>["data"];
}) {
return (
<div className={styles.videos}>
{videos.map((video) => (
<VodListing key={video.id} vod={video} showUser={false} />
))}
</div>
);
}
function Top500Weapons({
weaponIds,
count,
total,
}: {
weaponIds: MainWeaponId[];
count?: number;
total?: number;
}) {
const isComplete =
typeof count === "number" && typeof total === "number" && count === total;
return (
<div>
<div className={styles.weaponGrid}>
{weaponIds.map((weaponId) => (
<WeaponImage key={weaponId} weaponSplId={weaponId} variant="badge" />
))}
</div>
{typeof count === "number" && typeof total === "number" ? (
<div
className={clsx(styles.weaponCount, {
[styles.weaponCountComplete]: isComplete,
})}
>
{count} / {total}
</div>
) : null}
</div>
);
}
function LFGPosts({
posts,
}: {
posts: Extract<LoadedWidget, { id: "lfg-posts" }>["data"];
}) {
const { t } = useTranslation(["lfg"]);
return (
<div className={styles.lfgPosts}>
{posts.map((post) => (
<Link
key={post.id}
to={`${LFG_PAGE}#${post.id}`}
className={styles.lfgPost}
>
{t(`lfg:types.${post.type}`)}
</Link>
))}
</div>
);
}
const TENTATEK_BRAND_ID = "B10";
const TAKOROKA_BRAND_ID = "B11";
function XRankPeaks({
peaks,
}: {
peaks: Extract<LoadedWidget, { id: "x-rank-peaks" }>["data"];
}) {
return (
<div className={styles.xRankPeaks}>
{peaks.map((peak) => (
<div key={peak.mode} className={styles.xRankPeakMode}>
<div className={styles.xRankPeakModeIconWrapper}>
<Image
path={modeImageUrl(peak.mode as ModeShort)}
alt=""
width={24}
height={24}
/>
<div className={styles.xRankPeakDivision}>
<Image
path={brandImageUrl(
peak.region === "WEST"
? TENTATEK_BRAND_ID
: TAKOROKA_BRAND_ID,
)}
alt={peak.region === "WEST" ? "Tentatek" : "Takoroka"}
width={12}
height={12}
/>
</div>
</div>
<div>
{peak.rank} / {peak.power.toFixed(1)}
</div>
</div>
))}
</div>
);
}
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 (
<div className="stack sm items-center">
<div className={styles.widgetValueMain}>
{formatter.format(currentTime)}
</div>
<div className={styles.widgetValueFooter}>
{dateFormatter.format(currentTime)}
</div>
</div>
);
}
function FavoriteStageWidget({ stageId }: { stageId: StageId }) {
const { t } = useTranslation(["game-misc"]);
return (
<div className="stack sm items-center">
<StageImage stageId={stageId} width={225} className="rounded" />
<div className={styles.widgetValueFooter}>
{t(`game-misc:STAGE_${stageId}`)}
</div>
</div>
);
}
function PlacementResults({
data,
}: {
data: NonNullable<Extract<LoadedWidget, { id: "placement-results" }>["data"]>;
}) {
return (
<div className={styles.placementResults}>
{data.placements.map(({ placement, count }) => {
return (
<div key={placement} className={styles.placementResult}>
<Placement placement={placement} />
<span>×{count}</span>
</div>
);
})}
</div>
);
}
function Builds({
builds,
}: {
builds: Extract<LoadedWidget, { id: "builds" }>["data"];
}) {
return (
<div className={styles.builds}>
{builds.map((build) => (
<BuildCard key={build.id} build={build} canEdit={false} />
))}
</div>
);
}
function WeaponPool({
weapons,
}: {
weapons: Array<{ id: MainWeaponId; isFavorite: boolean }>;
}) {
return (
<div className="stack horizontal sm justify-center flex-wrap">
{weapons.map((weapon) => {
return (
<div key={weapon.id} className="u__weapon">
<WeaponImage
weaponSplId={weapon.id}
variant={weapon.isFavorite ? "badge-5-star" : "badge"}
width={38}
height={38}
/>
</div>
);
})}
</div>
);
}
function PeakXpWeapon({
weaponSplId,
peakXp,
leaderboardPosition,
}: {
weaponSplId: MainWeaponId;
peakXp: number;
leaderboardPosition: number | null;
}) {
return (
<div className={styles.peakValue}>
<div className="stack horizontal sm items-center justify-center mb-2">
<WeaponImage weaponSplId={weaponSplId} variant="badge" size={48} />
</div>
<div className={styles.widgetValueMain}>{peakXp} XP</div>
{leaderboardPosition ? (
<div className={styles.widgetValueFooter}>#{leaderboardPosition}</div>
) : null}
</div>
);
}
function SensWidget({
data,
}: {
data: Extract<LoadedWidget, { id: "sens" }>["data"];
}) {
const { t } = useTranslation(["user"]);
const rawSensToString = (sens: number) =>
`${sens > 0 ? "+" : ""}${sens / 10}`;
return (
<div className="stack md items-center">
<img
src={controllerImageUrl(data.controller)}
alt={t(`user:controllers.${data.controller}`)}
height={50}
/>
<div className="stack xs items-center">
<div className="stack horizontal md">
<div className="stack xs items-center">
<div className="text-xs text-lighter">{t("user:motionSens")}</div>
<div className={styles.widgetValueMain}>
{data.motionSens ? rawSensToString(data.motionSens) : "-"}
</div>
</div>
<div className="stack xs items-center">
<div className="text-xs text-lighter">{t("user:stickSens")}</div>
<div className={styles.widgetValueMain}>
{data.stickSens ? rawSensToString(data.stickSens) : "-"}
</div>
</div>
</div>
</div>
</div>
);
}
function ArtWidget({
arts,
}: {
arts: Extract<LoadedWidget, { id: "art" }>["data"];
}) {
return (
<div className={styles.artGrid}>
{arts.map((art) => (
<img
key={art.id}
alt=""
src={previewUrl(art.url)}
loading="lazy"
className={styles.artThumbnail}
/>
))}
</div>
);
}
function CommissionsWidget({
data,
}: {
data: Extract<LoadedWidget, { id: "commissions" }>["data"];
}) {
const { t } = useTranslation(["user"]);
if (!data) return null;
const isOpen = data.commissionsOpen === 1;
return (
<div className="stack sm items-center">
<div className={styles.widgetValueMain}>
{isOpen ? t("user:commissions.open") : t("user:commissions.closed")}
</div>
{data.commissionText ? (
<div className={styles.widgetValueFooter}>{data.commissionText}</div>
) : null}
</div>
);
}
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 <TwitchIcon />;
}
if (type === "youtube") {
return <YouTubeIcon />;
}
if (type === "bsky") {
return <BskyIcon />;
}
return <LinkIcon />;
};
function SocialLinksWidget({
data,
}: {
data: Extract<LoadedWidget, { id: "social-links" }>["data"];
}) {
if (data.length === 0) return null;
return (
<div className={styles.socialLinksIcons}>
{data.map((link, i) => {
if (link.type === "popover") {
return (
<SendouPopover
key={i}
trigger={
<SendouButton
variant="minimal"
className={clsx(
styles.socialLinkIconContainer,
styles[link.platform],
)}
>
{link.platform === "discord" ? <DiscordIcon /> : null}
</SendouButton>
}
>
{link.value}
</SendouPopover>
);
}
const type = urlToLinkType(link.value);
return (
<a
key={i}
href={link.value}
target="_blank"
rel="noreferrer"
className={clsx(styles.socialLinkIconContainer, {
[styles.twitch]: type === "twitch",
[styles.youtube]: type === "youtube",
[styles.bsky]: type === "bsky",
})}
>
{urlToIcon(link.value)}
</a>
);
})}
</div>
);
}
function LinksWidget({ links }: { links: string[] }) {
return (
<div className={styles.socialLinks}>
{links.map((url, i) => {
const type = urlToLinkType(url);
return (
<a
key={i}
href={url}
target="_blank"
rel="noreferrer"
className={styles.socialLink}
>
<div
className={clsx(styles.socialLinkIconContainer, {
[styles.twitch]: type === "twitch",
[styles.youtube]: type === "youtube",
[styles.bsky]: type === "bsky",
})}
>
{urlToIcon(url)}
</div>
{url}
</a>
);
})}
</div>
);
}
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 (
<div className={styles.socialLinks}>
<Link to={fullUrl} className={styles.socialLink}>
<div className={styles.socialLinkIconContainer}>
<Image path={navIconUrl("tier-list-maker")} alt="" width={24} />
</div>
{title ? title : t("user:widget.tier-list.untitled")}
</Link>
</div>
);
}

View File

@ -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 (
<WidgetSettingsFormInner
widget={widget}
schema={schema}
onSettingsChange={onSettingsChange}
/>
);
}
function WidgetSettingsFormInner({
widget,
schema,
onSettingsChange,
}: {
widget: Tables["UserWidget"]["widget"];
schema: ReturnType<typeof getWidgetFormSchema>;
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 (
<SendouForm
schema={schema}
defaultValues={defaultValues}
autoApply
onApply={handleApply}
className="stack md"
>
<WidgetFormFields widgetId={widget.id} />
</SendouForm>
);
}
function WidgetFormFields({ widgetId }: { widgetId: string }) {
switch (widgetId) {
case "bio":
case "bio-md":
return <FormField name="bio" />;
case "x-rank-peaks":
return <FormField name="division" />;
case "timezone":
return <FormField name="timezone" options={TIMEZONE_OPTIONS} />;
case "favorite-stage":
return <FormField name="stageId" />;
case "peak-xp-unverified":
return (
<div className="stack md">
<FormField name="peakXp" />
<FormField name="division" />
</div>
);
case "peak-xp-weapon":
return <FormField name="weaponSplId" />;
case "weapon-pool":
return <FormField name="weapons" />;
case "sens":
return <SensFields />;
case "art":
return <FormField name="source" />;
case "links":
return <FormField name="links" />;
case "tier-list":
return (
<FormField name="searchParams">
{(props: CustomFieldRenderProps) => (
<TierListField {...(props as CustomFieldRenderProps<string>)} />
)}
</FormField>
);
default:
return null;
}
}
function transformSettingsForForm(
widgetId: string,
settings: Record<string, unknown>,
): Record<string, unknown> {
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 (
<div className="stack md">
<div>
<label htmlFor="controller">{t("widgets.forms.controller")}</label>
<select
id="controller"
value={controller}
onChange={(e) =>
handleControllerChange(
e.target.value as (typeof CONTROLLERS)[number],
)
}
className={clsx(styles.sensSelect)}
>
{CONTROLLERS.map((ctrl) => (
<option key={ctrl} value={ctrl}>
{t(`user:controllers.${ctrl}`)}
</option>
))}
</select>
</div>
<div className="stack horizontal md">
<div>
<label htmlFor="motionSens">{t("user:motionSens")}</label>
<select
id="motionSens"
value={motionSens ?? ""}
onChange={(e) =>
handleMotionSensChange(
e.target.value === "" ? null : Number(e.target.value),
)
}
className={clsx(styles.sensSelect)}
>
<option value="">{"-"}</option>
{SENS_OPTIONS.map((sens) => (
<option key={sens} value={sens}>
{rawSensToString(sens)}
</option>
))}
</select>
</div>
<div>
<label htmlFor="stickSens">{t("user:stickSens")}</label>
<select
id="stickSens"
value={stickSens ?? ""}
onChange={(e) =>
handleStickSensChange(
e.target.value === "" ? null : Number(e.target.value),
)
}
className={clsx(styles.sensSelect)}
>
<option value="">{"-"}</option>
{SENS_OPTIONS.map((sens) => (
<option key={sens} value={sens}>
{rawSensToString(sens)}
</option>
))}
</select>
</div>
</div>
</div>
);
}
function TierListField({ value, onChange }: CustomFieldRenderProps<string>) {
const { t } = useTranslation(["user"]);
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
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 (
<div>
<label htmlFor="tier-list-searchParams">
{t("widgets.forms.tierListUrl")}
</label>
<div className="input-container">
<div className="input-addon">/tier-list-maker?</div>
<input
id="tier-list-searchParams"
value={value ?? ""}
onChange={handleChange}
/>
</div>
</div>
);
}

View File

@ -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,
};
}

View File

@ -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<z.ZodRawShape>,
>(def: {
id: Id;
slot: Slot;
schema: S;
defaultSettings: z.infer<S>;
}): 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<string, unknown>) {
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;
}

View File

@ -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<typeof allWidgetsFlat>[number];
export type WidgetId = WidgetUnion["id"];
type ExtractSchema<W> = 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<K> extends never
? { settings?: never }
: { settings: z.infer<ExtractSchema<K>> });
}[WidgetUnion["id"]];
type InferLoaderReturn<T> = T extends (...args: any[]) => Promise<infer R>
? R
: never;
export type LoadedWidget = {
[K in WidgetId]: {
id: K;
data: K extends keyof typeof WIDGET_LOADERS
? InferLoaderReturn<NonNullable<(typeof WIDGET_LOADERS)[K]>>
: ExtractWidgetSettings<K>;
slot: Extract<WidgetUnion, { id: K }>["slot"];
};
}[WidgetId];
export type ExtractWidgetSettings<T extends StoredWidget["id"]> = Extract<
StoredWidget,
{ id: T }
>["settings"];

View File

@ -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;
}

View File

@ -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<string, z.ZodObject<z.ZodRawShape>> = {
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];
}

View File

@ -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 };
};

View File

@ -20,9 +20,12 @@ export const loader = async ({ params }: LoaderFunctionArgs) => {
true, true,
))!; ))!;
const preferences = await UserRepository.preferencesByUserId(user.id);
return { return {
user: userProfile, user: userProfile,
favoriteBadgeIds: userProfile.favoriteBadgeIds, favoriteBadgeIds: userProfile.favoriteBadgeIds,
discordUniqueName: userProfile.discordUniqueName, discordUniqueName: userProfile.discordUniqueName,
newProfileEnabled: preferences?.newProfileEnabled ?? false,
}; };
}; };

View File

@ -3,11 +3,25 @@ import * as UserRepository from "~/features/user-page/UserRepository.server";
import { notFoundIfFalsy } from "~/utils/remix.server"; import { notFoundIfFalsy } from "~/utils/remix.server";
export const loader = async ({ params }: LoaderFunctionArgs) => { 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( const user = notFoundIfFalsy(
await UserRepository.findProfileByIdentifier(params.identifier!), await UserRepository.findProfileByIdentifier(params.identifier!),
); );
return { return {
type: "old" as const,
user, user,
}; };
}; };

View File

@ -16,11 +16,16 @@ export const loader = async ({ params }: LoaderFunctionArgs) => {
), ),
); );
const widgetsEnabled = await UserRepository.widgetsEnabledByIdentifier(
params.identifier!,
);
return { return {
user: { user: {
...user, ...user,
css: undefined, css: undefined,
}, },
css: user.css, css: user.css,
type: widgetsEnabled ? ("new" as const) : ("old" as const),
}; };
}; };

View File

@ -1,23 +1,34 @@
import { useLoaderData } from "react-router"; import { useLoaderData, useMatches } from "react-router";
import { Divider } from "~/components/Divider"; import { Divider } from "~/components/Divider";
import { SendouButton } from "~/components/elements/Button"; import { SendouButton } from "~/components/elements/Button";
import { SendouDialog } from "~/components/elements/Dialog"; import { SendouDialog } from "~/components/elements/Dialog";
import { FormWithConfirm } from "~/components/FormWithConfirm"; import { FormWithConfirm } from "~/components/FormWithConfirm";
import { PlusIcon } from "~/components/icons/Plus"; import { PlusIcon } from "~/components/icons/Plus";
import { Main } from "~/components/Main";
import { useUser } from "~/features/auth/core/user"; import { useUser } from "~/features/auth/core/user";
import { addModNoteSchema } from "~/features/user-page/user-page-schemas"; import { addModNoteSchema } from "~/features/user-page/user-page-schemas";
import { SendouForm } from "~/form"; import { SendouForm } from "~/form";
import { useTimeFormat } from "~/hooks/useTimeFormat"; import { useTimeFormat } from "~/hooks/useTimeFormat";
import { databaseTimestampToDate } from "~/utils/dates"; import { databaseTimestampToDate } from "~/utils/dates";
import invariant from "~/utils/invariant";
import { userPage } from "~/utils/urls";
import { action } from "../actions/u.$identifier.admin.server"; import { action } from "../actions/u.$identifier.admin.server";
import { SubPageHeader } from "../components/SubPageHeader";
import { loader } from "../loaders/u.$identifier.admin.server"; import { loader } from "../loaders/u.$identifier.admin.server";
import type { UserPageLoaderData } from "../loaders/u.$identifier.server";
import styles from "./u.$identifier.admin.module.css"; import styles from "./u.$identifier.admin.module.css";
export { loader, action }; export { loader, action };
export default function UserAdminPage() { export default function UserAdminPage() {
const [, parentRoute] = useMatches();
invariant(parentRoute);
const layoutData = parentRoute.data as UserPageLoaderData;
return ( return (
<Main className="stack xl"> <div className="stack xl">
<SubPageHeader
user={layoutData.user}
backTo={userPage(layoutData.user)}
/>
<AccountInfos /> <AccountInfos />
<div className="stack sm"> <div className="stack sm">
@ -40,7 +51,7 @@ export default function UserAdminPage() {
</Divider> </Divider>
<BanLog /> <BanLog />
</div> </div>
</Main> </div>
); );
} }

View File

@ -7,8 +7,9 @@ import { useUser } from "~/features/auth/core/user";
import { useSearchParamState } from "~/hooks/useSearchParamState"; import { useSearchParamState } from "~/hooks/useSearchParamState";
import invariant from "~/utils/invariant"; import invariant from "~/utils/invariant";
import type { SendouRouteHandle } from "~/utils/remix.server"; 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 { action } from "../actions/u.$identifier.art.server";
import { SubPageHeader } from "../components/SubPageHeader";
import { loader } from "../loaders/u.$identifier.art.server"; import { loader } from "../loaders/u.$identifier.art.server";
import type { UserPageLoaderData } from "../loaders/u.$identifier.server"; import type { UserPageLoaderData } from "../loaders/u.$identifier.server";
export { action, loader }; export { action, loader };
@ -52,9 +53,9 @@ export default function UserArtPage() {
return ( return (
<div className="stack md"> <div className="stack md">
<div className="stack items-end"> <SubPageHeader user={layoutData.user} backTo={userPage(layoutData.user)}>
<AddNewButton navIcon="art" to={newArtPage()} /> <AddNewButton navIcon="art" to={newArtPage()} />
</div> </SubPageHeader>
<div className="stack horizontal justify-between items-start text-xs text-lighter"> <div className="stack horizontal justify-between items-start text-xs text-lighter">
<div> <div>
{data.unvalidatedArtCount > 0 {data.unvalidatedArtCount > 0

View File

@ -19,8 +19,9 @@ import { useSearchParamState } from "~/hooks/useSearchParamState";
import type { MainWeaponId } from "~/modules/in-game-lists/types"; import type { MainWeaponId } from "~/modules/in-game-lists/types";
import { mainWeaponIds } from "~/modules/in-game-lists/weapon-ids"; import { mainWeaponIds } from "~/modules/in-game-lists/weapon-ids";
import type { SendouRouteHandle } from "~/utils/remix.server"; 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 { action } from "../actions/u.$identifier.builds.server";
import { SubPageHeader } from "../components/SubPageHeader";
import { import {
loader, loader,
type UserBuildsPageData, type UserBuildsPageData,
@ -81,20 +82,22 @@ export default function UserBuildsPage() {
{changingSorting ? ( {changingSorting ? (
<ChangeSortingDialog close={closeSortingDialog} /> <ChangeSortingDialog close={closeSortingDialog} />
) : null} ) : null}
{isOwnPage && ( <SubPageHeader user={layoutData.user} backTo={userPage(layoutData.user)}>
<div className="stack sm horizontal items-center justify-end"> {isOwnPage ? (
<SendouButton <>
onPress={() => setChangingSorting(true)} <SendouButton
size="small" onPress={() => setChangingSorting(true)}
variant="outlined" size="small"
icon={<SortIcon />} variant="outlined"
data-testid="change-sorting-button" icon={<SortIcon />}
> data-testid="change-sorting-button"
{t("user:builds.sorting.changeButton")} >
</SendouButton> {t("user:builds.sorting.changeButton")}
<AddNewButton navIcon="builds" to={userNewBuildPage(user)} /> </SendouButton>
</div> <AddNewButton navIcon="builds" to={userNewBuildPage(user)} />
)} </>
) : null}
</SubPageHeader>
<BuildsFilters <BuildsFilters
weaponFilter={weaponFilter} weaponFilter={weaponFilter}
setWeaponFilter={setWeaponFilter} setWeaponFilter={setWeaponFilter}

View File

@ -0,0 +1,204 @@
.container {
container-type: inline-size;
width: 100%;
max-width: 80rem;
margin: 0 auto;
padding: var(--s-4);
}
.header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: var(--s-6);
flex-wrap: wrap;
gap: var(--s-4);
}
.header h1 {
font-size: var(--text-xl);
font-weight: 600;
}
.actions {
display: flex;
gap: var(--s-2);
}
.content {
display: flex;
flex-direction: column;
gap: var(--s-6);
}
.grid {
display: grid;
grid-template-columns: 1fr;
gap: var(--s-6);
}
@container (min-width: 768px) {
.grid {
grid-template-columns: 1fr 1fr;
}
}
.available,
.selected {
display: flex;
flex-direction: column;
gap: var(--s-4);
}
.available h2,
.selected h2 {
font-size: var(--text-lg);
font-weight: 600;
}
.searchInput {
height: 40px !important;
}
.searchIcon {
height: 20px;
margin: auto;
margin-right: 10px;
}
.categoryGroup {
display: flex;
flex-direction: column;
gap: var(--s-2);
}
.categoryTitle {
font-size: var(--fonts-sm);
font-weight: 600;
color: var(--text-lighter);
text-transform: uppercase;
letter-spacing: 0.05em;
margin-top: var(--s-4);
}
.widgetCard {
display: flex;
flex-direction: column;
gap: var(--s-1-5);
padding: var(--s-3);
border: 1px solid var(--border);
border-radius: var(--rounded);
background-color: var(--bg-lighter);
}
.widgetHeader {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--s-2);
width: 100%;
}
.widgetActions {
display: flex;
gap: var(--s-2);
align-items: center;
}
.widgetName {
font-weight: 500;
font-size: var(--text-sm);
}
.widgetFooter {
display: flex;
align-items: center;
gap: var(--s-2);
}
.widgetSlot {
display: flex;
align-items: center;
gap: var(--s-1);
font-size: var(--fonts-xs);
color: var(--text-lighter);
flex-shrink: 0;
}
.widgetDescription {
font-size: var(--fonts-xs);
color: var(--text-lighter);
}
.slotSection {
display: flex;
flex-direction: column;
gap: var(--s-2);
}
.slotHeader {
display: flex;
align-items: center;
justify-content: space-between;
font-size: var(--text-sm);
font-weight: 600;
}
.slotCount {
font-size: var(--fonts-xxs);
color: var(--text-lighter);
font-weight: var(--bold);
}
.widgetList {
display: flex;
flex-direction: column;
gap: var(--s-2);
min-height: 3rem;
}
.draggableWidget {
display: flex;
flex-direction: column;
gap: var(--s-2);
padding: var(--s-3);
border: 1px solid var(--border);
border-radius: var(--rounded);
background-color: var(--bg-lighter);
transition: opacity 0.2s;
}
.widgetSettings {
padding: var(--s-3);
margin-top: var(--s-1);
background-color: var(--bg-darker);
border-radius: var(--rounded);
}
.draggableWidget .widgetName {
cursor: grab;
user-select: none;
}
.draggableWidget .widgetName:active {
cursor: grabbing;
}
.draggableWidget.isDragging {
opacity: 0.5;
}
.selectedWidgetsList {
display: flex;
flex-direction: column;
gap: var(--s-4);
}
.empty {
padding: var(--s-4);
text-align: center;
color: var(--text-lighter);
font-size: var(--text-sm);
border: 1px dashed var(--border);
border-radius: var(--rounded);
}

View File

@ -0,0 +1,444 @@
import type { DragEndEvent } from "@dnd-kit/core";
import {
DndContext,
KeyboardSensor,
PointerSensor,
useSensor,
useSensors,
} from "@dnd-kit/core";
import {
arrayMove,
SortableContext,
sortableKeyboardCoordinates,
useSortable,
} from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { Form, useLoaderData } from "react-router";
import { SendouButton } from "~/components/elements/Button";
import { Input } from "~/components/Input";
import { MainSlotIcon } from "~/components/icons/MainSlot";
import { SearchIcon } from "~/components/icons/Search";
import { SideSlotIcon } from "~/components/icons/SideSlot";
import { Placeholder } from "~/components/Placeholder";
import type { Tables } from "~/db/tables";
import {
ALL_WIDGETS,
defaultStoredWidget,
findWidgetById,
} from "~/features/user-page/core/widgets/portfolio";
import { USER } from "~/features/user-page/user-page-constants";
import { useIsMounted } from "~/hooks/useIsMounted";
import { action } from "../actions/u.$identifier.edit-widgets.server";
import { WidgetSettingsForm } from "../components/WidgetSettingsForm";
import { loader } from "../loaders/u.$identifier.edit-widgets.server";
import styles from "./u.$identifier.edit-widgets.module.css";
export { loader, action };
export default function EditWidgetsPage() {
const { t } = useTranslation(["user", "common"]);
const data = useLoaderData<typeof loader>();
const isMounted = useIsMounted();
const [selectedWidgets, setSelectedWidgets] = useState<
Array<Tables["UserWidget"]["widget"]>
>(data.currentWidgets);
const [expandedWidgetId, setExpandedWidgetId] = useState<string | null>(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 <Placeholder />;
}
return (
<div className={styles.container}>
<header className={styles.header}>
<h1>{t("user:widgets.editTitle")}</h1>
<div className={styles.actions}>
<SendouButton type="submit" form="widget-form">
{t("common:actions.save")}
</SendouButton>
</div>
</header>
<Form method="post" id="widget-form" className={styles.content}>
<input
type="hidden"
name="widgets"
value={JSON.stringify(selectedWidgets)}
/>
<div className={styles.grid}>
<section className={styles.selected}>
<DndContext
sensors={sensors}
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
>
<SelectedWidgetsList
mainWidgets={mainWidgets}
sideWidgets={sideWidgets}
onRemoveWidget={removeWidget}
onSettingsChange={handleSettingsChange}
expandedWidgetId={expandedWidgetId}
onToggleExpanded={toggleExpanded}
/>
</DndContext>
</section>
<section className={styles.available}>
<h2>{t("user:widgets.available")}</h2>
<AvailableWidgetsList
selectedWidgets={selectedWidgets}
mainWidgets={mainWidgets}
sideWidgets={sideWidgets}
onAddWidget={addWidget}
/>
</section>
</div>
</Form>
</div>
);
}
interface AvailableWidgetsListProps {
selectedWidgets: Array<Tables["UserWidget"]["widget"]>;
mainWidgets: Array<Tables["UserWidget"]["widget"]>;
sideWidgets: Array<Tables["UserWidget"]["widget"]>;
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<keyof typeof widgetsByCategory>
).sort((a, b) => a.localeCompare(b));
const searchLower = searchValue.toLowerCase();
return (
<div>
<Input
className={styles.searchInput}
icon={<SearchIcon className={styles.searchIcon} />}
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 (
<div key={category} className={styles.categoryGroup}>
<div className={styles.categoryTitle}>
{t(`user:widgets.category.${category}`)}
</div>
{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 (
<div key={widget.id} className={styles.widgetCard}>
<div className={styles.widgetHeader}>
<span className={styles.widgetName}>
{t(`user:widget.${widget.id}` as const)}
</span>
<SendouButton
size="miniscule"
variant="outlined"
onPress={() => onAddWidget(widget.id)}
isDisabled={isSelected || isMaxReached}
>
{t("user:widgets.add")}
</SendouButton>
</div>
<div className={styles.widgetFooter}>
<div className={styles.widgetSlot}>
{widget.slot === "main" ? (
<>
<MainSlotIcon size={16} />
<span>{t("user:widgets.main")}</span>
</>
) : (
<>
<SideSlotIcon size={16} />
<span>{t("user:widgets.side")}</span>
</>
)}
</div>
<div className="text-xs font-bold">{"//"}</div>
<div className={styles.widgetDescription}>
{t(`user:widgets.description.${widget.id}` as const)}
</div>
</div>
</div>
);
})}
</div>
);
})}
</div>
);
}
interface SelectedWidgetsListProps {
mainWidgets: Array<Tables["UserWidget"]["widget"]>;
sideWidgets: Array<Tables["UserWidget"]["widget"]>;
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 (
<div className={styles.selectedWidgetsList}>
<div className={styles.slotSection}>
<div className={styles.slotHeader}>
<span className="stack horizontal xs">
<MainSlotIcon size={24} /> {t("user:widgets.mainSlot")}
</span>
<span className={styles.slotCount}>
{mainWidgets.length}/{USER.MAX_MAIN_WIDGETS}
</span>
</div>
<SortableContext items={mainWidgets.map((w) => w.id)}>
<div className={styles.widgetList}>
{mainWidgets.length === 0 ? (
<div className={styles.empty}>
{t("user:widgets.add")} {t("user:widgets.mainSlot")}
</div>
) : (
mainWidgets.map((widget) => (
<DraggableWidgetItem
key={widget.id}
widget={widget}
onRemove={onRemoveWidget}
onSettingsChange={onSettingsChange}
isExpanded={expandedWidgetId === widget.id}
onToggleExpanded={onToggleExpanded}
/>
))
)}
</div>
</SortableContext>
</div>
<div className={styles.slotSection}>
<div className={styles.slotHeader}>
<span className="stack horizontal xs">
<SideSlotIcon size={24} /> {t("user:widgets.sideSlot")}
</span>
<span className={styles.slotCount}>
{sideWidgets.length}/{USER.MAX_SIDE_WIDGETS}
</span>
</div>
<SortableContext items={sideWidgets.map((w) => w.id)}>
<div className={styles.widgetList}>
{sideWidgets.length === 0 ? (
<div className={styles.empty}>
{t("user:widgets.add")} {t("user:widgets.sideSlot")}
</div>
) : (
sideWidgets.map((widget) => (
<DraggableWidgetItem
key={widget.id}
widget={widget}
onRemove={onRemoveWidget}
onSettingsChange={onSettingsChange}
isExpanded={expandedWidgetId === widget.id}
onToggleExpanded={onToggleExpanded}
/>
))
)}
</div>
</SortableContext>
</div>
</div>
);
}
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 (
<div
ref={setNodeRef}
style={style}
className={`${styles.draggableWidget} ${isDragging ? styles.isDragging : ""}`}
{...attributes}
>
<div className={styles.widgetHeader}>
<span className={styles.widgetName} {...listeners}>
{t(`user:widget.${widget.id}` as const)}
</span>
<div className={styles.widgetActions}>
{hasSettings ? (
<SendouButton
size="miniscule"
variant="outlined"
onPress={() => onToggleExpanded(widget.id)}
>
{isExpanded
? t("common:actions.hide")
: t("common:actions.settings")}
</SendouButton>
) : null}
<SendouButton
size="miniscule"
variant="minimal-destructive"
onPress={() => onRemove(widget.id)}
>
{t("user:widgets.remove")}
</SendouButton>
</div>
</div>
{isExpanded && hasSettings ? (
<div className={styles.widgetSettings}>
<WidgetSettingsForm
widget={widget}
onSettingsChange={onSettingsChange}
/>
</div>
) : null}
</div>
);
}

View File

@ -21,9 +21,10 @@ const DEFAULT_FIELDS = {
inGameNameText: null, inGameNameText: null,
motionSens: null, motionSens: null,
showDiscordUniqueName: 1, showDiscordUniqueName: 1,
newProfileEnabled: 0,
stickSens: null, stickSens: null,
objectPronoun: null,
subjectPronoun: null, subjectPronoun: null,
objectPronoun: null,
weapons: JSON.stringify([ weapons: JSON.stringify([
{ weaponSplId: 1 as MainWeaponId, isFavorite: 0 }, { weaponSplId: 1 as MainWeaponId, isFavorite: 0 },
]) as any, ]) as any,

View File

@ -75,6 +75,7 @@ export default function UserEditPage() {
<input type="hidden" name="commissionText" value="" /> <input type="hidden" name="commissionText" value="" />
</> </>
)} )}
<NewProfileToggle />
<FormMessage type="info"> <FormMessage type="info">
<Trans i18nKey={"user:discordExplanation"} t={t}> <Trans i18nKey={"user:discordExplanation"} t={t}>
Username, profile picture, YouTube, Bluesky and Twitch accounts come Username, profile picture, YouTube, Bluesky and Twitch accounts come
@ -475,6 +476,32 @@ function FavBadgeSelect() {
); );
} }
function NewProfileToggle() {
const { t } = useTranslation(["user"]);
const data = useLoaderData<typeof loader>();
const isSupporter = useHasRole("SUPPORTER");
const [checked, setChecked] = React.useState(
isSupporter && data.newProfileEnabled,
);
return (
<div>
<label htmlFor="newProfileEnabled">
{t("user:forms.newProfileEnabled")}
</label>
<SendouSwitch
isSelected={checked}
onChange={setChecked}
name="newProfileEnabled"
isDisabled={!isSupporter}
/>
<FormMessage type="info">
{t("user:forms.newProfileEnabled.info")}
</FormMessage>
</div>
);
}
function ShowUniqueDiscordNameToggle() { function ShowUniqueDiscordNameToggle() {
const { t } = useTranslation(["user"]); const { t } = useTranslation(["user"]);
const data = useLoaderData<typeof loader>(); const data = useLoaderData<typeof loader>();

View File

@ -1,18 +1,28 @@
import clsx from "clsx"; import clsx from "clsx";
import { useTranslation } from "react-i18next"; 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 { Avatar } from "~/components/Avatar";
import { SendouButton } from "~/components/elements/Button"; import { LinkButton, SendouButton } from "~/components/elements/Button";
import { SendouPopover } from "~/components/elements/Popover"; import { SendouPopover } from "~/components/elements/Popover";
import { Flag } from "~/components/Flag"; import { Flag } from "~/components/Flag";
import { Image, WeaponImage } from "~/components/Image"; import { Image, WeaponImage } from "~/components/Image";
import { BattlefyIcon } from "~/components/icons/Battlefy"; import { BattlefyIcon } from "~/components/icons/Battlefy";
import { BskyIcon } from "~/components/icons/Bsky"; import { BskyIcon } from "~/components/icons/Bsky";
import { DiscordIcon } from "~/components/icons/Discord"; 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 { TwitchIcon } from "~/components/icons/Twitch";
import { YouTubeIcon } from "~/components/icons/YouTube"; import { YouTubeIcon } from "~/components/icons/YouTube";
import { useUser } from "~/features/auth/core/user";
import { BadgeDisplay } from "~/features/badges/components/BadgeDisplay"; import { BadgeDisplay } from "~/features/badges/components/BadgeDisplay";
import { modesShort } from "~/modules/in-game-lists/modes"; import { modesShort } from "~/modules/in-game-lists/modes";
import { countryCodeToTranslatedName } from "~/utils/i18n";
import invariant from "~/utils/invariant"; import invariant from "~/utils/invariant";
import type { SendouRouteHandle } from "~/utils/remix.server"; import type { SendouRouteHandle } from "~/utils/remix.server";
import { rawSensToString } from "~/utils/strings"; import { rawSensToString } from "~/utils/strings";
@ -24,19 +34,132 @@ import {
teamPage, teamPage,
topSearchPlayerPage, topSearchPlayerPage,
} from "~/utils/urls"; } 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 { loader } from "../loaders/u.$identifier.index.server";
import type { UserPageLoaderData } from "../loaders/u.$identifier.server"; import type { UserPageLoaderData } from "../loaders/u.$identifier.server";
import styles from "./u.$identifier.module.css";
export { loader }; export { loader };
export const handle: SendouRouteHandle = { export const handle: SendouRouteHandle = {
i18n: ["badges", "team"], i18n: ["badges", "team", "org", "vods", "lfg", "builds", "weapons", "gear"],
}; };
export default function UserInfoPage() { export default function UserInfoPage() {
const data = useLoaderData<typeof loader>(); const data = useLoaderData<typeof loader>();
if (data.type === "new") {
return <NewUserInfoPage />;
}
return <OldUserInfoPage />;
}
function NewUserInfoPage() {
const { t, i18n } = useTranslation(["user"]);
const data = useLoaderData<typeof loader>();
const user = useUser();
const [, parentRoute] = useMatches(); const [, parentRoute] = useMatches();
invariant(parentRoute); invariant(parentRoute);
const layoutData = parentRoute.data as UserPageLoaderData; 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 (
<div className={styles.container}>
<div className={styles.header}>
<Avatar user={layoutData.user} size="xmd" />
<div className={styles.userInfo}>
<div className={styles.nameGroup}>
<h1 className={styles.username}>{layoutData.user.username}</h1>
<ProfileSubtitle
inGameName={layoutData.user.inGameName}
pronouns={layoutData.user.pronouns}
country={layoutData.user.country}
language={i18n.language}
/>
</div>
</div>
<div className={styles.desktopIconNav}>
<UserPageIconNav items={navItems} />
</div>
{isOwnPage ? (
<div className={styles.editButtons}>
<LinkButton
to={href("/u/:identifier/edit-widgets", {
identifier:
layoutData.user.customUrl ?? layoutData.user.discordId,
})}
variant="outlined"
size="small"
icon={<PuzzleIcon />}
>
{t("user:widgets.edit")}
</LinkButton>
<LinkButton
to={href("/u/:identifier/edit", {
identifier:
layoutData.user.customUrl ?? layoutData.user.discordId,
})}
variant="outlined"
size="small"
icon={<EditIcon />}
>
{t("user:widgets.editProfile")}
</LinkButton>
</div>
) : null}
</div>
<div className={styles.mobileIconNav}>
<UserPageIconNav items={navItems} />
</div>
<div className={styles.sideCarousel}>
{sideWidgets.map((widget) => (
<Widget key={widget.id} widget={widget} user={layoutData.user} />
))}
</div>
<div className={styles.mainStack}>
{mainWidgets.map((widget) => (
<Widget key={widget.id} widget={widget} user={layoutData.user} />
))}
</div>
<div className={styles.grid}>
<div className={styles.main}>
{mainWidgets.map((widget) => (
<Widget key={widget.id} widget={widget} user={layoutData.user} />
))}
</div>
<div className={styles.side}>
{sideWidgets.map((widget) => (
<Widget key={widget.id} widget={widget} user={layoutData.user} />
))}
</div>
</div>
</div>
);
}
export function OldUserInfoPage() {
const data = useLoaderData<typeof loader>();
const [, parentRoute] = useMatches();
invariant(parentRoute);
const layoutData = parentRoute.data as UserPageLoaderData;
if (data.type !== "old") {
throw new Error("Expected old user data");
}
return ( return (
<div className="u__container"> <div className="u__container">
@ -81,6 +204,10 @@ function TeamInfo() {
const { t } = useTranslation(["team"]); const { t } = useTranslation(["team"]);
const data = useLoaderData<typeof loader>(); const data = useLoaderData<typeof loader>();
if (data.type !== "old") {
throw new Error("Expected old user data");
}
if (!data.user.team) return null; if (!data.user.team) return null;
return ( return (
@ -118,6 +245,10 @@ function SecondaryTeamsPopover() {
const data = useLoaderData<typeof loader>(); const data = useLoaderData<typeof loader>();
if (data.type !== "old") {
throw new Error("Expected old user data");
}
if (data.user.secondaryTeams.length === 0) return null; if (data.user.secondaryTeams.length === 0) return null;
return ( return (
@ -231,6 +362,10 @@ function ExtraInfos() {
const { t } = useTranslation(["user"]); const { t } = useTranslation(["user"]);
const data = useLoaderData<typeof loader>(); const data = useLoaderData<typeof loader>();
if (data.type !== "old") {
throw new Error("Expected old user data");
}
const motionSensText = const motionSensText =
typeof data.user.motionSens === "number" typeof data.user.motionSens === "number"
? `${t("user:motion")} ${rawSensToString(data.user.motionSens)}` ? `${t("user:motion")} ${rawSensToString(data.user.motionSens)}`
@ -294,6 +429,10 @@ function ExtraInfos() {
function WeaponPool() { function WeaponPool() {
const data = useLoaderData<typeof loader>(); const data = useLoaderData<typeof loader>();
if (data.type !== "old") {
throw new Error("Expected old user data");
}
if (data.user.weapons.length === 0) return null; if (data.user.weapons.length === 0) return null;
return ( 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(
<span key="country" className="stack horizontal xs items-center">
<Flag countryCode={country} tiny />
{countryCodeToTranslatedName({ countryCode: country, language })}
</span>,
);
}
if (parts.length === 0) return null;
return (
<div className={styles.subtitle}>
{parts.map((part, i) => (
<span key={i} className="stack horizontal xs items-center">
{i > 0 ? <span>·</span> : null}
{part}
</span>
))}
</div>
);
}
function TopPlacements() { function TopPlacements() {
const data = useLoaderData<typeof loader>(); const data = useLoaderData<typeof loader>();
if (data.type !== "old") {
throw new Error("Expected old user data");
}
if (data.user.topPlacements.length === 0) return null; if (data.user.topPlacements.length === 0) return null;
return ( return (

View File

@ -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;
}
}

View File

@ -5,8 +5,9 @@ import { Pagination } from "~/components/Pagination";
import { useUser } from "~/features/auth/core/user"; import { useUser } from "~/features/auth/core/user";
import { UserResultsTable } from "~/features/user-page/components/UserResultsTable"; import { UserResultsTable } from "~/features/user-page/components/UserResultsTable";
import invariant from "~/utils/invariant"; import invariant from "~/utils/invariant";
import { userResultsEditHighlightsPage } from "~/utils/urls"; import { userPage, userResultsEditHighlightsPage } from "~/utils/urls";
import { SendouButton } from "../../../components/elements/Button"; import { SendouButton } from "../../../components/elements/Button";
import { SubPageHeader } from "../components/SubPageHeader";
import { loader } from "../loaders/u.$identifier.results.server"; import { loader } from "../loaders/u.$identifier.results.server";
import type { UserPageLoaderData } from "../loaders/u.$identifier.server"; import type { UserPageLoaderData } from "../loaders/u.$identifier.server";
export { loader }; export { loader };
@ -32,6 +33,10 @@ export default function UserResultsPage() {
return ( return (
<div className="stack lg"> <div className="stack lg">
<SubPageHeader
user={layoutData.user}
backTo={userPage(layoutData.user)}
/>
<div className="stack horizontal justify-between items-center"> <div className="stack horizontal justify-between items-center">
<h2 className="text-lg"> <h2 className="text-lg">
{showAll || !data.hasHighlightedResults {showAll || !data.hasHighlightedResults

View File

@ -54,8 +54,10 @@ import {
sendouQMatchPage, sendouQMatchPage,
TIERS_PAGE, TIERS_PAGE,
tournamentTeamPage, tournamentTeamPage,
userPage,
userSeasonsPage, userSeasonsPage,
} from "~/utils/urls"; } from "~/utils/urls";
import { SubPageHeader } from "../components/SubPageHeader";
import { import {
loader, loader,
type UserSeasonsPageLoaderData, type UserSeasonsPageLoaderData,
@ -71,11 +73,20 @@ const DAYS_WITH_SKILL_NEEDED_TO_SHOW_POWER_CHART = 2;
export default function UserSeasonsPage() { export default function UserSeasonsPage() {
const { t } = useTranslation(["user"]); const { t } = useTranslation(["user"]);
const data = useLoaderData<typeof loader>(); const data = useLoaderData<typeof loader>();
const [, parentRoute] = useMatches();
invariant(parentRoute);
const layoutData = parentRoute.data as UserPageLoaderData;
if (!data) { if (!data) {
return ( return (
<div className="text-lg text-lighter font-semi-bold text-center mt-2"> <div>
{t("user:seasons.noSeasons")} <SubPageHeader
user={layoutData.user}
backTo={userPage(layoutData.user)}
/>
<div className="text-lg text-lighter font-semi-bold text-center mt-2">
{t("user:seasons.noSeasons")}
</div>
</div> </div>
); );
} }
@ -83,6 +94,10 @@ export default function UserSeasonsPage() {
if (data.results.value.length === 0) { if (data.results.value.length === 0) {
return ( return (
<div className="stack lg half-width"> <div className="stack lg half-width">
<SubPageHeader
user={layoutData.user}
backTo={userPage(layoutData.user)}
/>
<SeasonHeader <SeasonHeader
seasonViewed={data.season} seasonViewed={data.season}
seasonsParticipatedIn={data.seasonsParticipatedIn} seasonsParticipatedIn={data.seasonsParticipatedIn}
@ -99,6 +114,10 @@ export default function UserSeasonsPage() {
return ( return (
<div className="stack lg half-width"> <div className="stack lg half-width">
<SubPageHeader
user={layoutData.user}
backTo={userPage(layoutData.user)}
/>
<SeasonHeader <SeasonHeader
seasonViewed={data.season} seasonViewed={data.season}
seasonsParticipatedIn={data.seasonsParticipatedIn} seasonsParticipatedIn={data.seasonsParticipatedIn}

View File

@ -1,6 +1,6 @@
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import type { MetaFunction } from "react-router"; import type { MetaFunction } from "react-router";
import { Outlet, useLoaderData, useLocation } from "react-router"; import { Outlet, useLoaderData, useLocation, useMatches } from "react-router";
import { Main } from "~/components/Main"; import { Main } from "~/components/Main";
import { SubNav, SubNavLink } from "~/components/SubNav"; import { SubNav, SubNavLink } from "~/components/SubNav";
import { useUser } from "~/features/auth/core/user"; import { useUser } from "~/features/auth/core/user";
@ -19,6 +19,7 @@ import {
userSeasonsPage, userSeasonsPage,
userVodsPage, userVodsPage,
} from "~/utils/urls"; } from "~/utils/urls";
import type { UserPageNavItem } from "../components/UserPageIconNav";
import { import {
loader, loader,
@ -66,74 +67,133 @@ export default function UserPageLayout() {
const isStaff = useHasRole("STAFF"); const isStaff = useHasRole("STAFF");
const location = useLocation(); const location = useLocation();
const { t } = useTranslation(["common", "user"]); const { t } = useTranslation(["common", "user"]);
const matches = useMatches();
const isOwnPage = data.user.id === user?.id; const isOwnPage = data.user.id === user?.id;
const allResultsCount = const allResultsCount =
data.user.calendarEventResultsCount + data.user.tournamentResultsCount; data.user.calendarEventResultsCount + data.user.tournamentResultsCount;
const isNewUserPage = matches.some((m) => (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 ( return (
<Main bigger={location.pathname.includes("results")}> <Main bigger={location.pathname.includes("results")}>
<SubNav> {isNewUserPage ? null : (
<SubNavLink to={userPage(data.user)} data-testid="user-profile-tab"> <SubNav>
{t("common:header.profile")} <SubNavLink to={userPage(data.user)} data-testid="user-profile-tab">
</SubNavLink> {t("common:header.profile")}
<SubNavLink </SubNavLink>
to={userSeasonsPage({ user: data.user })}
data-testid="user-seasons-tab"
>
{t("user:seasons")}
</SubNavLink>
{isOwnPage ? (
<SubNavLink <SubNavLink
to={userEditProfilePage(data.user)} to={userSeasonsPage({ user: data.user })}
prefetch="intent" data-testid="user-seasons-tab"
data-testid="user-edit-tab"
> >
{t("common:actions.edit")} {t("user:seasons")}
</SubNavLink> </SubNavLink>
) : null} {isOwnPage ? (
{allResultsCount > 0 ? ( <SubNavLink
<SubNavLink to={userEditProfilePage(data.user)}
to={userResultsPage(data.user)} prefetch="intent"
data-testid="user-results-tab" data-testid="user-edit-tab"
> >
{t("common:results")} ({allResultsCount}) {t("common:actions.edit")}
</SubNavLink> </SubNavLink>
) : null} ) : null}
{data.user.buildsCount > 0 || isOwnPage ? ( {allResultsCount > 0 ? (
<SubNavLink <SubNavLink
to={userBuildsPage(data.user)} to={userResultsPage(data.user)}
prefetch="intent" data-testid="user-results-tab"
data-testid="user-builds-tab" >
> {t("common:results")} ({allResultsCount})
{t("common:pages.builds")} ({data.user.buildsCount}) </SubNavLink>
</SubNavLink> ) : null}
) : null} {data.user.buildsCount > 0 || isOwnPage ? (
{data.user.vodsCount > 0 || isOwnPage ? ( <SubNavLink
<SubNavLink to={userVodsPage(data.user)} data-testid="user-vods-tab"> to={userBuildsPage(data.user)}
{t("common:pages.vods")} ({data.user.vodsCount}) prefetch="intent"
</SubNavLink> data-testid="user-builds-tab"
) : null} >
{data.user.artCount > 0 || isOwnPage ? ( {t("common:pages.builds")} ({data.user.buildsCount})
<SubNavLink </SubNavLink>
to={userArtPage(data.user)} ) : null}
end={false} {data.user.vodsCount > 0 || isOwnPage ? (
data-testid="user-art-tab" <SubNavLink
> to={userVodsPage(data.user)}
{t("common:pages.art")} ({data.user.artCount}) data-testid="user-vods-tab"
</SubNavLink> >
) : null} {t("common:pages.vods")} ({data.user.vodsCount})
{isStaff ? ( </SubNavLink>
<SubNavLink ) : null}
to={userAdminPage(data.user)} {data.user.artCount > 0 || isOwnPage ? (
data-testid="user-admin-tab" <SubNavLink
> to={userArtPage(data.user)}
Admin end={false}
</SubNavLink> data-testid="user-art-tab"
) : null} >
</SubNav> {t("common:pages.art")} ({data.user.artCount})
<Outlet /> </SubNavLink>
) : null}
{isStaff ? (
<SubNavLink
to={userAdminPage(data.user)}
data-testid="user-admin-tab"
>
Admin
</SubNavLink>
) : null}
</SubNav>
)}
<Outlet context={{ navItems }} />
</Main> </Main>
); );
} }

View File

@ -4,7 +4,9 @@ import { VodListing } from "~/features/vods/components/VodListing";
import styles from "~/features/vods/routes/vods.module.css"; import styles from "~/features/vods/routes/vods.module.css";
import invariant from "~/utils/invariant"; import invariant from "~/utils/invariant";
import type { SendouRouteHandle } from "~/utils/remix.server"; 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"; import { loader } from "../loaders/u.$identifier.vods.server";
export { loader }; export { loader };
@ -16,12 +18,13 @@ export default function UserVodsPage() {
const [, parentRoute] = useMatches(); const [, parentRoute] = useMatches();
invariant(parentRoute); invariant(parentRoute);
const data = useLoaderData<typeof loader>(); const data = useLoaderData<typeof loader>();
const layoutData = parentRoute.data as UserPageLoaderData;
return ( return (
<div className="stack md"> <div className="stack md">
<div className="stack items-end"> <SubPageHeader user={layoutData.user} backTo={userPage(layoutData.user)}>
<AddNewButton navIcon="vods" to={newVodPage()} /> <AddNewButton navIcon="vods" to={newVodPage()} />
</div> </SubPageHeader>
<div className={styles.listingList}> <div className={styles.listingList}>
{data.vods.map((vod) => ( {data.vods.map((vod) => (
<VodListing key={vod.id} vod={vod} showUser={false} /> <VodListing key={vod.id} vod={vod} showUser={false} />

View File

@ -3,6 +3,7 @@ export const HIGHLIGHT_TOURNAMENT_CHECKBOX_NAME = "highlightTournamentTeamIds";
export const USER = { export const USER = {
BIO_MAX_LENGTH: 2000, BIO_MAX_LENGTH: 2000,
BIO_MD_MAX_LENGTH: 4000,
CUSTOM_URL_MAX_LENGTH: 32, CUSTOM_URL_MAX_LENGTH: 32,
CUSTOM_NAME_MAX_LENGTH: 32, CUSTOM_NAME_MAX_LENGTH: 32,
BATTLEFY_MAX_LENGTH: 32, BATTLEFY_MAX_LENGTH: 32,
@ -11,6 +12,8 @@ export const USER = {
WEAPON_POOL_MAX_SIZE: 5, WEAPON_POOL_MAX_SIZE: 5,
COMMISSION_TEXT_MAX_LENGTH: 1000, COMMISSION_TEXT_MAX_LENGTH: 1000,
MOD_NOTE_MAX_LENGTH: 2000, MOD_NOTE_MAX_LENGTH: 2000,
MAX_MAIN_WIDGETS: 5,
MAX_SIDE_WIDGETS: 7,
}; };
export const MATCHES_PER_SEASONS_PAGE = 8; export const MATCHES_PER_SEASONS_PAGE = 8;

View File

@ -39,6 +39,7 @@ import {
undefinedToNull, undefinedToNull,
weaponSplId, weaponSplId,
} from "~/utils/zod"; } from "~/utils/zod";
import { allWidgetsFlat, findWidgetById } from "./core/widgets/portfolio";
import { import {
COUNTRY_CODES, COUNTRY_CODES,
HIGHLIGHT_CHECKBOX_NAME, HIGHLIGHT_CHECKBOX_NAME,
@ -147,6 +148,7 @@ export const userEditActionSchema = z
.nullish(), .nullish(),
), ),
showDiscordUniqueName: z.preprocess(checkboxValueToDbBoolean, dbBoolean), showDiscordUniqueName: z.preprocess(checkboxValueToDbBoolean, dbBoolean),
newProfileEnabled: z.preprocess(checkboxValueToDbBoolean, dbBoolean),
commissionsOpen: z.preprocess(checkboxValueToDbBoolean, dbBoolean), commissionsOpen: z.preprocess(checkboxValueToDbBoolean, dbBoolean),
commissionText: z.preprocess( commissionText: z.preprocess(
falsyToNull, falsyToNull,
@ -199,6 +201,43 @@ export const userResultsPageSearchParamsSchema = z.object({
page: z.coerce.number().min(1).max(1_000).catch(1), 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 const headGearIdSchema = z
.number() .number()
.nullable() .nullable()

View File

@ -60,6 +60,7 @@ type BaseFormProps<T extends z.ZodRawShape> = {
_action?: string; _action?: string;
submitButtonTestId?: string; submitButtonTestId?: string;
autoSubmit?: boolean; autoSubmit?: boolean;
autoApply?: boolean;
className?: string; className?: string;
onApply?: (values: z.infer<z.ZodObject<T>>) => void; onApply?: (values: z.infer<z.ZodObject<T>>) => void;
secondarySubmit?: React.ReactNode; secondarySubmit?: React.ReactNode;
@ -84,6 +85,7 @@ export function SendouForm<T extends z.ZodRawShape>({
_action, _action,
submitButtonTestId, submitButtonTestId,
autoSubmit, autoSubmit,
autoApply,
className, className,
onApply, onApply,
secondarySubmit, secondarySubmit,
@ -248,30 +250,35 @@ export function SendouForm<T extends z.ZodRawShape>({
setClientErrors(newErrors); setClientErrors(newErrors);
}; };
const onFieldChange = autoSubmit const onFieldChange =
? (changedName: string, changedValue: unknown) => { autoSubmit || autoApply
const updatedValues = { ...values, [changedName]: changedValue }; ? (changedName: string, changedValue: unknown) => {
const updatedValues = { ...values, [changedName]: changedValue };
const newErrors: Record<string, string> = {}; const newErrors: Record<string, string> = {};
for (const key of Object.keys(schema.shape)) { for (const key of Object.keys(schema.shape)) {
const error = validateField(schema, key, updatedValues[key]); const error = validateField(schema, key, updatedValues[key]);
if (error) { if (error) {
newErrors[key] = error; newErrors[key] = error;
}
}
if (Object.keys(newErrors).length > 0) {
setClientErrors(newErrors);
return;
}
if (autoApply && onApply) {
onApply(updatedValues as z.infer<z.ZodObject<T>>);
} else if (autoSubmit) {
fetcher.submit(updatedValues as Record<string, string>, {
method,
action,
encType: "application/json",
});
} }
} }
: undefined;
if (Object.keys(newErrors).length > 0) {
setClientErrors(newErrors);
return;
}
fetcher.submit(updatedValues as Record<string, string>, {
method,
action,
encType: "application/json",
});
}
: undefined;
const submitToServer = (valuesToSubmit: Record<string, unknown>) => { const submitToServer = (valuesToSubmit: Record<string, unknown>) => {
if (!validateAndPrepare()) return; if (!validateAndPrepare()) return;
@ -343,7 +350,7 @@ export function SendouForm<T extends z.ZodRawShape>({
> >
{title ? <h2 className={styles.title}>{title}</h2> : null} {title ? <h2 className={styles.title}>{title}</h2> : null}
<React.Fragment key={locationKey}>{resolvedChildren}</React.Fragment> <React.Fragment key={locationKey}>{resolvedChildren}</React.Fragment>
{autoSubmit ? null : ( {autoSubmit || autoApply ? null : (
<div className="mt-4 stack horizontal md mx-auto justify-center"> <div className="mt-4 stack horizontal md mx-auto justify-center">
<SubmitButton <SubmitButton
_action={_action} _action={_action}

View File

@ -170,6 +170,35 @@ function textFieldRefined<T extends z.ZodType<string | null>>(
return result as T; return result as T;
} }
export function numberField(
args: WithTypedTranslationKeys<
Omit<
Extract<FormField, { type: "text-field" }>,
| "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( export function numberFieldOptional(
args: WithTypedTranslationKeys< args: WithTypedTranslationKeys<
Omit< Omit<
@ -298,6 +327,24 @@ export function select<V extends string>(
}); });
} }
export function selectDynamic(
args: WithTypedTranslationKeys<
Omit<
Extract<FormField, { type: "select-dynamic" }>,
"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<string> & FieldWithOptions<SelectOption[]>;
}
export function selectDynamicOptional( export function selectDynamicOptional(
args: WithTypedTranslationKeys< args: WithTypedTranslationKeys<
Omit< Omit<
@ -685,6 +732,24 @@ export function stageSelect(
}); });
} }
export function weaponSelect(
args: WithTypedTranslationKeys<
Omit<
Extract<FormField, { type: "weapon-select" }>,
"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( export function weaponSelectOptional(
args: WithTypedTranslationKeys< args: WithTypedTranslationKeys<
Omit< Omit<

View File

@ -44,6 +44,10 @@ export default [
index("features/user-page/routes/u.$identifier.index.tsx"), index("features/user-page/routes/u.$identifier.index.tsx"),
route("art", "features/user-page/routes/u.$identifier.art.tsx"), route("art", "features/user-page/routes/u.$identifier.art.tsx"),
route("edit", "features/user-page/routes/u.$identifier.edit.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("seasons", "features/user-page/routes/u.$identifier.seasons.tsx"),
route("vods", "features/user-page/routes/u.$identifier.vods.tsx"), route("vods", "features/user-page/routes/u.$identifier.vods.tsx"),
route("builds", "features/user-page/routes/u.$identifier.builds.tsx"), route("builds", "features/user-page/routes/u.$identifier.builds.tsx"),

View File

@ -200,7 +200,8 @@
.u__weapon { .u__weapon {
padding: var(--s-2); padding: var(--s-2);
border-radius: 100%; border-radius: 100%;
background-color: var(--bg-lighter); background-color: var(--bg-lightest);
border: 2px solid var(--border);
} }
.u__build-form { .u__build-form {

View File

@ -77,6 +77,8 @@ export const bskyUrl = (accountName: string) =>
`https://bsky.app/profile/${accountName}`; `https://bsky.app/profile/${accountName}`;
export const twitchUrl = (accountName: string) => export const twitchUrl = (accountName: string) =>
`https://twitch.tv/${accountName}`; `https://twitch.tv/${accountName}`;
export const youtubeUrl = (channelId: string) =>
`https://youtube.com/channel/${channelId}`;
export const LOG_IN_URL = "/auth"; export const LOG_IN_URL = "/auth";
export const LOG_OUT_URL = "/auth/logout"; export const LOG_OUT_URL = "/auth/logout";
@ -492,6 +494,8 @@ export const stageImageUrl = (stageId: StageId) =>
`/static-assets/img/stages/${stageId}`; `/static-assets/img/stages/${stageId}`;
export const tierImageUrl = (tier: TierName | "CALCULATING") => export const tierImageUrl = (tier: TierName | "CALCULATING") =>
`/static-assets/img/tiers/${tier.toLowerCase()}`; `/static-assets/img/tiers/${tier.toLowerCase()}`;
export const controllerImageUrl = (controller: string) =>
`/static-assets/img/controllers/${controller}.avif`;
export const preferenceEmojiUrl = (preference?: Preference) => { export const preferenceEmojiUrl = (preference?: Preference) => {
const emoji = const emoji =
preference === "PREFER" preference === "PREFER"

View File

@ -807,6 +807,8 @@ test.describe("Tournament bracket", () => {
}); });
test("locks/unlocks matches & sets match as casted", async ({ page }) => { test("locks/unlocks matches & sets match as casted", async ({ page }) => {
test.slow();
const tournamentId = 2; const tournamentId = 2;
await seed(page); await seed(page);

View File

@ -132,6 +132,8 @@
"actions.next": "", "actions.next": "",
"actions.previous": "", "actions.previous": "",
"actions.back": "", "actions.back": "",
"actions.hide": "",
"actions.settings": "",
"noResults": "", "noResults": "",
"maps.createMapList": "Lav bane-liste", "maps.createMapList": "Lav bane-liste",
"maps.halfSz": "50% DD", "maps.halfSz": "50% DD",
@ -255,6 +257,7 @@
"support.perk.joinMoreAssociations": "", "support.perk.joinMoreAssociations": "",
"support.perk.useBotToLogIn": "", "support.perk.useBotToLogIn": "",
"support.perk.useBotToLogIn.extra": "", "support.perk.useBotToLogIn.extra": "",
"support.perk.earlyAccess": "",
"custom.colors.title": "Tilpas farver", "custom.colors.title": "Tilpas farver",
"custom.colors.bg": "Baggrund", "custom.colors.bg": "Baggrund",
"custom.colors.bg-darker": "Mørkere baggrund", "custom.colors.bg-darker": "Mørkere baggrund",

View File

@ -168,5 +168,19 @@
"labels.banUserExpiresAt": "", "labels.banUserExpiresAt": "",
"bottomTexts.banUserExpiresAtHelp": "", "bottomTexts.banUserExpiresAtHelp": "",
"labels.scrimCancelReason": "", "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": ""
} }

View File

@ -5,6 +5,130 @@
"ign.short": "Splatnavn", "ign.short": "Splatnavn",
"country": "Land", "country": "Land",
"bio": "Biografi", "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", "stickSens": "Styrepindsfølsomhed",
"motionSens": "Bevægelsesfølsomhed", "motionSens": "Bevægelsesfølsomhed",
"motion": "Bevægelse", "motion": "Bevægelse",
@ -17,6 +141,8 @@
"discordExplanation": "Brugernavn, Profilbillede, Youtube-, Bluesky- og Twitch-konter er hentet via din Discord-konto. Se <1>FAQ</1> for yderligere information.", "discordExplanation": "Brugernavn, Profilbillede, Youtube-, Bluesky- og Twitch-konter er hentet via din Discord-konto. Se <1>FAQ</1> for yderligere information.",
"favoriteBadges": "", "favoriteBadges": "",
"battlefy": "Battlefy brugernavn", "battlefy": "Battlefy brugernavn",
"forms.newProfileEnabled": "",
"forms.newProfileEnabled.info": "",
"forms.showDiscordUniqueName": "Vis Discord-brugernavn", "forms.showDiscordUniqueName": "Vis Discord-brugernavn",
"forms.showDiscordUniqueName.info": "Vil du gøre dit unikke Discord-brugernavn ({{discordUniqueName}}) synligt for offentligheden?", "forms.showDiscordUniqueName.info": "Vil du gøre dit unikke Discord-brugernavn ({{discordUniqueName}}) synligt for offentligheden?",
"forms.commissionsOpen": "Åben for bestillinger", "forms.commissionsOpen": "Åben for bestillinger",
@ -82,5 +208,7 @@
"builds.sorting.CLOTHES_ID": "Kropsbeklædning (in-game sortering)", "builds.sorting.CLOTHES_ID": "Kropsbeklædning (in-game sortering)",
"builds.sorting.SHOES_ID": "Sko in-game (in-game sortering)", "builds.sorting.SHOES_ID": "Sko in-game (in-game sortering)",
"builds.sorting.PUBLIC_BUILD": "offentlige sæt", "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": ""
} }

View File

@ -132,6 +132,8 @@
"actions.next": "", "actions.next": "",
"actions.previous": "", "actions.previous": "",
"actions.back": "", "actions.back": "",
"actions.hide": "",
"actions.settings": "",
"noResults": "", "noResults": "",
"maps.createMapList": "Arenen-Liste erstellen", "maps.createMapList": "Arenen-Liste erstellen",
"maps.halfSz": "50% Herrschaft", "maps.halfSz": "50% Herrschaft",
@ -255,6 +257,7 @@
"support.perk.joinMoreAssociations": "", "support.perk.joinMoreAssociations": "",
"support.perk.useBotToLogIn": "", "support.perk.useBotToLogIn": "",
"support.perk.useBotToLogIn.extra": "", "support.perk.useBotToLogIn.extra": "",
"support.perk.earlyAccess": "",
"custom.colors.title": "Eigene Farben", "custom.colors.title": "Eigene Farben",
"custom.colors.bg": "Hintergrund", "custom.colors.bg": "Hintergrund",
"custom.colors.bg-darker": "Hintergrund dunkler", "custom.colors.bg-darker": "Hintergrund dunkler",

View File

@ -168,5 +168,19 @@
"labels.banUserExpiresAt": "", "labels.banUserExpiresAt": "",
"bottomTexts.banUserExpiresAtHelp": "", "bottomTexts.banUserExpiresAtHelp": "",
"labels.scrimCancelReason": "", "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": ""
} }

View File

@ -5,6 +5,130 @@
"ign.short": "IGN", "ign.short": "IGN",
"country": "Land", "country": "Land",
"bio": "Über mich", "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", "stickSens": "Empfindlichkeit R-Stick",
"motionSens": "Empfindlichkeit Bewegungssteuerung", "motionSens": "Empfindlichkeit Bewegungssteuerung",
"motion": "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</1>.", "discordExplanation": "Der Username, Profilbild, YouTube-, Bluesky- und Twitch-Konten stammen von deinem Discord-Konto. Mehr Infos in den <1>FAQ</1>.",
"favoriteBadges": "", "favoriteBadges": "",
"battlefy": "", "battlefy": "",
"forms.newProfileEnabled": "",
"forms.newProfileEnabled.info": "",
"forms.showDiscordUniqueName": "", "forms.showDiscordUniqueName": "",
"forms.showDiscordUniqueName.info": "", "forms.showDiscordUniqueName.info": "",
"forms.commissionsOpen": "", "forms.commissionsOpen": "",
@ -82,5 +208,7 @@
"builds.sorting.CLOTHES_ID": "", "builds.sorting.CLOTHES_ID": "",
"builds.sorting.SHOES_ID": "", "builds.sorting.SHOES_ID": "",
"builds.sorting.PUBLIC_BUILD": "", "builds.sorting.PUBLIC_BUILD": "",
"builds.sorting.PRIVATE_BUILD": "" "builds.sorting.PRIVATE_BUILD": "",
"commissions.open": "",
"commissions.closed": ""
} }

View File

@ -132,6 +132,8 @@
"actions.next": "Next", "actions.next": "Next",
"actions.previous": "Previous", "actions.previous": "Previous",
"actions.back": "Back", "actions.back": "Back",
"actions.hide": "Hide",
"actions.settings": "Settings",
"noResults": "No results", "noResults": "No results",
"maps.createMapList": "Create map list", "maps.createMapList": "Create map list",
"maps.halfSz": "50% SZ", "maps.halfSz": "50% SZ",
@ -255,6 +257,7 @@
"support.perk.joinMoreAssociations": "Join up to 6 associations", "support.perk.joinMoreAssociations": "Join up to 6 associations",
"support.perk.useBotToLogIn": "Log-in via Discord bot", "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.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.title": "Custom colors",
"custom.colors.bg": "Background", "custom.colors.bg": "Background",
"custom.colors.bg-darker": "Background darker", "custom.colors.bg-darker": "Background darker",

View File

@ -168,5 +168,19 @@
"labels.banUserExpiresAt": "Ban expiration date", "labels.banUserExpiresAt": "Ban expiration date",
"bottomTexts.banUserExpiresAtHelp": "Leave empty for a permanent ban", "bottomTexts.banUserExpiresAtHelp": "Leave empty for a permanent ban",
"labels.scrimCancelReason": "Reason for cancellation", "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"
} }

View File

@ -5,6 +5,130 @@
"ign.short": "IGN", "ign.short": "IGN",
"country": "Country", "country": "Country",
"bio": "Bio", "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", "stickSens": "R-stick sens",
"motionSens": "Motion sens", "motionSens": "Motion sens",
"motion": "Motion", "motion": "Motion",
@ -17,6 +141,8 @@
"discordExplanation": "Username, profile picture, YouTube, Bluesky and Twitch accounts come from your Discord account. See <1>FAQ</1> for more information.", "discordExplanation": "Username, profile picture, YouTube, Bluesky and Twitch accounts come from your Discord account. See <1>FAQ</1> for more information.",
"favoriteBadges": "Favorite badges", "favoriteBadges": "Favorite badges",
"battlefy": "Battlefy account name", "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": "Show Discord username",
"forms.showDiscordUniqueName.info": "Show your unique Discord name ({{discordUniqueName}}) publicly?", "forms.showDiscordUniqueName.info": "Show your unique Discord name ({{discordUniqueName}}) publicly?",
"forms.commissionsOpen": "Commissions open", "forms.commissionsOpen": "Commissions open",
@ -82,5 +208,7 @@
"builds.sorting.CLOTHES_ID": "Clothing in-game order", "builds.sorting.CLOTHES_ID": "Clothing in-game order",
"builds.sorting.SHOES_ID": "Shoes in-game order", "builds.sorting.SHOES_ID": "Shoes in-game order",
"builds.sorting.PUBLIC_BUILD": "Public build", "builds.sorting.PUBLIC_BUILD": "Public build",
"builds.sorting.PRIVATE_BUILD": "Private build" "builds.sorting.PRIVATE_BUILD": "Private build",
"commissions.open": "Open",
"commissions.closed": "Closed"
} }

View File

@ -132,6 +132,8 @@
"actions.next": "", "actions.next": "",
"actions.previous": "", "actions.previous": "",
"actions.back": "", "actions.back": "",
"actions.hide": "",
"actions.settings": "",
"noResults": "", "noResults": "",
"maps.createMapList": "Crear lista de mapas", "maps.createMapList": "Crear lista de mapas",
"maps.halfSz": "50% Pintazonas", "maps.halfSz": "50% Pintazonas",
@ -257,6 +259,7 @@
"support.perk.joinMoreAssociations": "", "support.perk.joinMoreAssociations": "",
"support.perk.useBotToLogIn": "", "support.perk.useBotToLogIn": "",
"support.perk.useBotToLogIn.extra": "", "support.perk.useBotToLogIn.extra": "",
"support.perk.earlyAccess": "",
"custom.colors.title": "Colores personalizados", "custom.colors.title": "Colores personalizados",
"custom.colors.bg": "Fondo", "custom.colors.bg": "Fondo",
"custom.colors.bg-darker": "Fondo más oscuro", "custom.colors.bg-darker": "Fondo más oscuro",

View File

@ -168,5 +168,19 @@
"labels.banUserExpiresAt": "", "labels.banUserExpiresAt": "",
"bottomTexts.banUserExpiresAtHelp": "", "bottomTexts.banUserExpiresAtHelp": "",
"labels.scrimCancelReason": "", "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": ""
} }

View File

@ -5,6 +5,130 @@
"ign.short": "IGN", "ign.short": "IGN",
"country": "País", "country": "País",
"bio": "Biografía", "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", "stickSens": "Sens de palanca",
"motionSens": "Sens del giroscopio", "motionSens": "Sens del giroscopio",
"motion": "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</1> para más información.", "discordExplanation": "Tu nombre, foto, y cuentas de YouTube, Bluesky y Twitch se obtienen por tu cuenta en Discord. Ver <1>FAQ</1> para más información.",
"favoriteBadges": "", "favoriteBadges": "",
"battlefy": "", "battlefy": "",
"forms.newProfileEnabled": "",
"forms.newProfileEnabled.info": "",
"forms.showDiscordUniqueName": "Mostrar usuario de Discord", "forms.showDiscordUniqueName": "Mostrar usuario de Discord",
"forms.showDiscordUniqueName.info": "¿Mostrar tu nombre de Discord ({{discordUniqueName}}) publicamente?", "forms.showDiscordUniqueName.info": "¿Mostrar tu nombre de Discord ({{discordUniqueName}}) publicamente?",
"forms.commissionsOpen": "Comisiones abiertas", "forms.commissionsOpen": "Comisiones abiertas",
@ -82,5 +208,7 @@
"builds.sorting.CLOTHES_ID": "", "builds.sorting.CLOTHES_ID": "",
"builds.sorting.SHOES_ID": "", "builds.sorting.SHOES_ID": "",
"builds.sorting.PUBLIC_BUILD": "", "builds.sorting.PUBLIC_BUILD": "",
"builds.sorting.PRIVATE_BUILD": "" "builds.sorting.PRIVATE_BUILD": "",
"commissions.open": "",
"commissions.closed": ""
} }

View File

@ -132,6 +132,8 @@
"actions.next": "", "actions.next": "",
"actions.previous": "", "actions.previous": "",
"actions.back": "", "actions.back": "",
"actions.hide": "",
"actions.settings": "",
"noResults": "", "noResults": "",
"maps.createMapList": "Crear lista de escenarios", "maps.createMapList": "Crear lista de escenarios",
"maps.halfSz": "50% Pintazonas", "maps.halfSz": "50% Pintazonas",
@ -257,6 +259,7 @@
"support.perk.joinMoreAssociations": "", "support.perk.joinMoreAssociations": "",
"support.perk.useBotToLogIn": "", "support.perk.useBotToLogIn": "",
"support.perk.useBotToLogIn.extra": "", "support.perk.useBotToLogIn.extra": "",
"support.perk.earlyAccess": "",
"custom.colors.title": "Colores personalizados", "custom.colors.title": "Colores personalizados",
"custom.colors.bg": "Fondo", "custom.colors.bg": "Fondo",
"custom.colors.bg-darker": "Fondo más oscuro", "custom.colors.bg-darker": "Fondo más oscuro",

View File

@ -168,5 +168,19 @@
"labels.banUserExpiresAt": "", "labels.banUserExpiresAt": "",
"bottomTexts.banUserExpiresAtHelp": "", "bottomTexts.banUserExpiresAtHelp": "",
"labels.scrimCancelReason": "", "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": ""
} }

View File

@ -5,6 +5,130 @@
"ign.short": "IGN", "ign.short": "IGN",
"country": "País", "country": "País",
"bio": "Biografía", "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", "stickSens": "Sens de palanca",
"motionSens": "Sens del giroscopio", "motionSens": "Sens del giroscopio",
"motion": "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</1> para más información.", "discordExplanation": "Tu nombre, foto, y cuentas de YouTube, Bluesky y Twitch se obtienen por tu cuenta en Discord. Ver <1>FAQ</1> para más información.",
"favoriteBadges": "", "favoriteBadges": "",
"battlefy": "Nombre de cuenta de Battlefy", "battlefy": "Nombre de cuenta de Battlefy",
"forms.newProfileEnabled": "",
"forms.newProfileEnabled.info": "",
"forms.showDiscordUniqueName": "Mostrar usuario de Discord", "forms.showDiscordUniqueName": "Mostrar usuario de Discord",
"forms.showDiscordUniqueName.info": "¿Mostrar tu nombre de Discord ({{discordUniqueName}}) publicamente?", "forms.showDiscordUniqueName.info": "¿Mostrar tu nombre de Discord ({{discordUniqueName}}) publicamente?",
"forms.commissionsOpen": "Comisiones abiertas", "forms.commissionsOpen": "Comisiones abiertas",
@ -82,5 +208,7 @@
"builds.sorting.CLOTHES_ID": "Ordenación de ropa en juego", "builds.sorting.CLOTHES_ID": "Ordenación de ropa en juego",
"builds.sorting.SHOES_ID": "Ordenación de calzados en juego", "builds.sorting.SHOES_ID": "Ordenación de calzados en juego",
"builds.sorting.PUBLIC_BUILD": "Build público", "builds.sorting.PUBLIC_BUILD": "Build público",
"builds.sorting.PRIVATE_BUILD": "Build privado" "builds.sorting.PRIVATE_BUILD": "Build privado",
"commissions.open": "",
"commissions.closed": ""
} }

View File

@ -132,6 +132,8 @@
"actions.next": "", "actions.next": "",
"actions.previous": "", "actions.previous": "",
"actions.back": "", "actions.back": "",
"actions.hide": "",
"actions.settings": "",
"noResults": "", "noResults": "",
"maps.createMapList": "Créer une liste de stages", "maps.createMapList": "Créer une liste de stages",
"maps.halfSz": "50% DdZ", "maps.halfSz": "50% DdZ",
@ -257,6 +259,7 @@
"support.perk.joinMoreAssociations": "", "support.perk.joinMoreAssociations": "",
"support.perk.useBotToLogIn": "", "support.perk.useBotToLogIn": "",
"support.perk.useBotToLogIn.extra": "", "support.perk.useBotToLogIn.extra": "",
"support.perk.earlyAccess": "",
"custom.colors.title": "Couleurs personalisées", "custom.colors.title": "Couleurs personalisées",
"custom.colors.bg": "Arrière-plan", "custom.colors.bg": "Arrière-plan",
"custom.colors.bg-darker": "Arrière-plan sombre", "custom.colors.bg-darker": "Arrière-plan sombre",

View File

@ -168,5 +168,19 @@
"labels.banUserExpiresAt": "", "labels.banUserExpiresAt": "",
"bottomTexts.banUserExpiresAtHelp": "", "bottomTexts.banUserExpiresAtHelp": "",
"labels.scrimCancelReason": "", "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": ""
} }

View File

@ -5,6 +5,130 @@
"ign.short": "PEJ", "ign.short": "PEJ",
"country": "Pays", "country": "Pays",
"bio": "Bio", "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", "stickSens": "Sensibilité du stick droit",
"motionSens": "Sensibilité du gyroscope", "motionSens": "Sensibilité du gyroscope",
"motion": "Gyro", "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</1> pour plus d'informations.", "discordExplanation": "Votre pseudo, votre photo de profil et vos comptes Youtube, Bluesky et Twitch viennent de votre compte Discord. Voir la <1>FAQ</1> pour plus d'informations.",
"favoriteBadges": "", "favoriteBadges": "",
"battlefy": "", "battlefy": "",
"forms.newProfileEnabled": "",
"forms.newProfileEnabled.info": "",
"forms.showDiscordUniqueName": "Montrer le pseudo Discord", "forms.showDiscordUniqueName": "Montrer le pseudo Discord",
"forms.showDiscordUniqueName.info": "Show your unique Discord name ({{discordUniqueName}}) publicly?", "forms.showDiscordUniqueName.info": "Show your unique Discord name ({{discordUniqueName}}) publicly?",
"forms.commissionsOpen": "Commissions acceptées", "forms.commissionsOpen": "Commissions acceptées",
@ -82,5 +208,7 @@
"builds.sorting.CLOTHES_ID": "", "builds.sorting.CLOTHES_ID": "",
"builds.sorting.SHOES_ID": "", "builds.sorting.SHOES_ID": "",
"builds.sorting.PUBLIC_BUILD": "", "builds.sorting.PUBLIC_BUILD": "",
"builds.sorting.PRIVATE_BUILD": "" "builds.sorting.PRIVATE_BUILD": "",
"commissions.open": "",
"commissions.closed": ""
} }

View File

@ -132,6 +132,8 @@
"actions.next": "", "actions.next": "",
"actions.previous": "", "actions.previous": "",
"actions.back": "", "actions.back": "",
"actions.hide": "",
"actions.settings": "",
"noResults": "Aucun résultats", "noResults": "Aucun résultats",
"maps.createMapList": "Créer une liste de stages", "maps.createMapList": "Créer une liste de stages",
"maps.halfSz": "50% DdZ", "maps.halfSz": "50% DdZ",
@ -257,6 +259,7 @@
"support.perk.joinMoreAssociations": "Rejoignez jusqu'à un maximum 6 associations", "support.perk.joinMoreAssociations": "Rejoignez jusqu'à un maximum 6 associations",
"support.perk.useBotToLogIn": "Se connecter via le bot Discord", "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.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.title": "Couleurs personalisées",
"custom.colors.bg": "Arrière-plan", "custom.colors.bg": "Arrière-plan",
"custom.colors.bg-darker": "Arrière-plan sombre", "custom.colors.bg-darker": "Arrière-plan sombre",

View File

@ -168,5 +168,19 @@
"labels.banUserExpiresAt": "", "labels.banUserExpiresAt": "",
"bottomTexts.banUserExpiresAtHelp": "", "bottomTexts.banUserExpiresAtHelp": "",
"labels.scrimCancelReason": "", "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": ""
} }

View File

@ -5,6 +5,130 @@
"ign.short": "PEJ", "ign.short": "PEJ",
"country": "Pays", "country": "Pays",
"bio": "Bio", "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", "stickSens": "Sensibilité du stick droit",
"motionSens": "Sensibilité du gyroscope", "motionSens": "Sensibilité du gyroscope",
"motion": "Gyro", "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</1> pour plus d'informations.", "discordExplanation": "Votre pseudo, votre photo de profil et vos comptes Youtube, Bluesky et Twitch viennent de votre compte Discord. Voir la <1>FAQ</1> pour plus d'informations.",
"favoriteBadges": "Badge favori", "favoriteBadges": "Badge favori",
"battlefy": "Nom du compte Battlefy", "battlefy": "Nom du compte Battlefy",
"forms.newProfileEnabled": "",
"forms.newProfileEnabled.info": "",
"forms.showDiscordUniqueName": "Montrer le pseudo Discord", "forms.showDiscordUniqueName": "Montrer le pseudo Discord",
"forms.showDiscordUniqueName.info": "Show your unique Discord name ({{discordUniqueName}}) publicly?", "forms.showDiscordUniqueName.info": "Show your unique Discord name ({{discordUniqueName}}) publicly?",
"forms.commissionsOpen": "Commissions acceptées", "forms.commissionsOpen": "Commissions acceptées",
@ -82,5 +208,7 @@
"builds.sorting.CLOTHES_ID": "Ordre des Vêtements dans le jeu", "builds.sorting.CLOTHES_ID": "Ordre des Vêtements dans le jeu",
"builds.sorting.SHOES_ID": "Ordre des Chaussures dans le jeu", "builds.sorting.SHOES_ID": "Ordre des Chaussures dans le jeu",
"builds.sorting.PUBLIC_BUILD": "Build public", "builds.sorting.PUBLIC_BUILD": "Build public",
"builds.sorting.PRIVATE_BUILD": "Build privée" "builds.sorting.PRIVATE_BUILD": "Build privée",
"commissions.open": "",
"commissions.closed": ""
} }

View File

@ -132,6 +132,8 @@
"actions.next": "", "actions.next": "",
"actions.previous": "", "actions.previous": "",
"actions.back": "", "actions.back": "",
"actions.hide": "",
"actions.settings": "",
"noResults": "", "noResults": "",
"maps.createMapList": "יצירת רשימת מפות", "maps.createMapList": "יצירת רשימת מפות",
"maps.halfSz": "50% SZ", "maps.halfSz": "50% SZ",
@ -256,6 +258,7 @@
"support.perk.joinMoreAssociations": "", "support.perk.joinMoreAssociations": "",
"support.perk.useBotToLogIn": "", "support.perk.useBotToLogIn": "",
"support.perk.useBotToLogIn.extra": "", "support.perk.useBotToLogIn.extra": "",
"support.perk.earlyAccess": "",
"custom.colors.title": "צבעים מותאמים אישית", "custom.colors.title": "צבעים מותאמים אישית",
"custom.colors.bg": "רקע", "custom.colors.bg": "רקע",
"custom.colors.bg-darker": "רקע חשוך", "custom.colors.bg-darker": "רקע חשוך",

View File

@ -168,5 +168,19 @@
"labels.banUserExpiresAt": "", "labels.banUserExpiresAt": "",
"bottomTexts.banUserExpiresAtHelp": "", "bottomTexts.banUserExpiresAtHelp": "",
"labels.scrimCancelReason": "", "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": ""
} }

View File

@ -5,6 +5,130 @@
"ign.short": "IGN", "ign.short": "IGN",
"country": "מדינה", "country": "מדינה",
"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": "רגישות סטיק ימני", "stickSens": "רגישות סטיק ימני",
"motionSens": "רגישות תנועה", "motionSens": "רגישות תנועה",
"motion": "תנועה", "motion": "תנועה",
@ -17,6 +141,8 @@
"discordExplanation": "שם משתמש, תמונת פרופיל, חשבונות YouTube, Bluesky ו-Twitch מגיעים מחשבון Discord שלך. ראו <1>שאלות נפוצות</1> למידע נוסף.", "discordExplanation": "שם משתמש, תמונת פרופיל, חשבונות YouTube, Bluesky ו-Twitch מגיעים מחשבון Discord שלך. ראו <1>שאלות נפוצות</1> למידע נוסף.",
"favoriteBadges": "", "favoriteBadges": "",
"battlefy": "", "battlefy": "",
"forms.newProfileEnabled": "",
"forms.newProfileEnabled.info": "",
"forms.showDiscordUniqueName": "הראה שם משתמש Discord", "forms.showDiscordUniqueName": "הראה שם משתמש Discord",
"forms.showDiscordUniqueName.info": "להראות את שם ה-Discord היחודי שלכם ({{discordUniqueName}}) בפומבי?", "forms.showDiscordUniqueName.info": "להראות את שם ה-Discord היחודי שלכם ({{discordUniqueName}}) בפומבי?",
"forms.commissionsOpen": "בקשות פתוחות", "forms.commissionsOpen": "בקשות פתוחות",
@ -82,5 +208,7 @@
"builds.sorting.CLOTHES_ID": "", "builds.sorting.CLOTHES_ID": "",
"builds.sorting.SHOES_ID": "", "builds.sorting.SHOES_ID": "",
"builds.sorting.PUBLIC_BUILD": "", "builds.sorting.PUBLIC_BUILD": "",
"builds.sorting.PRIVATE_BUILD": "" "builds.sorting.PRIVATE_BUILD": "",
"commissions.open": "",
"commissions.closed": ""
} }

View File

@ -132,6 +132,8 @@
"actions.next": "", "actions.next": "",
"actions.previous": "", "actions.previous": "",
"actions.back": "", "actions.back": "",
"actions.hide": "",
"actions.settings": "",
"noResults": "", "noResults": "",
"maps.createMapList": "Crea lista scenari", "maps.createMapList": "Crea lista scenari",
"maps.halfSz": "50% ZS", "maps.halfSz": "50% ZS",
@ -257,6 +259,7 @@
"support.perk.joinMoreAssociations": "", "support.perk.joinMoreAssociations": "",
"support.perk.useBotToLogIn": "", "support.perk.useBotToLogIn": "",
"support.perk.useBotToLogIn.extra": "", "support.perk.useBotToLogIn.extra": "",
"support.perk.earlyAccess": "",
"custom.colors.title": "Colori personalizzati", "custom.colors.title": "Colori personalizzati",
"custom.colors.bg": "Sfondo", "custom.colors.bg": "Sfondo",
"custom.colors.bg-darker": "Sfondo più scuro", "custom.colors.bg-darker": "Sfondo più scuro",

View File

@ -168,5 +168,19 @@
"labels.banUserExpiresAt": "", "labels.banUserExpiresAt": "",
"bottomTexts.banUserExpiresAtHelp": "", "bottomTexts.banUserExpiresAtHelp": "",
"labels.scrimCancelReason": "", "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": ""
} }

View File

@ -5,6 +5,130 @@
"ign.short": "IGN", "ign.short": "IGN",
"country": "Paese", "country": "Paese",
"bio": "Biografia", "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", "stickSens": "Sensitività Joystick Dx",
"motionSens": "Sensitività Giroscopio", "motionSens": "Sensitività Giroscopio",
"motion": "Giroscopio", "motion": "Giroscopio",
@ -17,6 +141,8 @@
"discordExplanation": "Username, foto profilo, account YouTube, Bluesky e Twitch vengono dal tuo account Discord. Visita <1>FAQ</1> per ulteriori informazioni.", "discordExplanation": "Username, foto profilo, account YouTube, Bluesky e Twitch vengono dal tuo account Discord. Visita <1>FAQ</1> per ulteriori informazioni.",
"favoriteBadges": "", "favoriteBadges": "",
"battlefy": "Nome account Battlefy", "battlefy": "Nome account Battlefy",
"forms.newProfileEnabled": "",
"forms.newProfileEnabled.info": "",
"forms.showDiscordUniqueName": "Mostra username Discord", "forms.showDiscordUniqueName": "Mostra username Discord",
"forms.showDiscordUniqueName.info": "Mostrare il proprio nome unico Discord ({{discordUniqueName}}) pubblicamente?", "forms.showDiscordUniqueName.info": "Mostrare il proprio nome unico Discord ({{discordUniqueName}}) pubblicamente?",
"forms.commissionsOpen": "Commissioni aperte", "forms.commissionsOpen": "Commissioni aperte",
@ -82,5 +208,7 @@
"builds.sorting.CLOTHES_ID": "Vestiti in ordine del gioco", "builds.sorting.CLOTHES_ID": "Vestiti in ordine del gioco",
"builds.sorting.SHOES_ID": "Scarpe in ordine del gioco", "builds.sorting.SHOES_ID": "Scarpe in ordine del gioco",
"builds.sorting.PUBLIC_BUILD": "Build pubblica", "builds.sorting.PUBLIC_BUILD": "Build pubblica",
"builds.sorting.PRIVATE_BUILD": "Build privata" "builds.sorting.PRIVATE_BUILD": "Build privata",
"commissions.open": "",
"commissions.closed": ""
} }

View File

@ -132,6 +132,8 @@
"actions.next": "", "actions.next": "",
"actions.previous": "", "actions.previous": "",
"actions.back": "", "actions.back": "",
"actions.hide": "",
"actions.settings": "",
"noResults": "", "noResults": "",
"maps.createMapList": "ステージ一覧を作る", "maps.createMapList": "ステージ一覧を作る",
"maps.halfSz": "ガチエリア (2ヶ所)", "maps.halfSz": "ガチエリア (2ヶ所)",
@ -251,6 +253,7 @@
"support.perk.joinMoreAssociations": "", "support.perk.joinMoreAssociations": "",
"support.perk.useBotToLogIn": "", "support.perk.useBotToLogIn": "",
"support.perk.useBotToLogIn.extra": "", "support.perk.useBotToLogIn.extra": "",
"support.perk.earlyAccess": "",
"custom.colors.title": "カスタムカラー", "custom.colors.title": "カスタムカラー",
"custom.colors.bg": "バックグラウンド", "custom.colors.bg": "バックグラウンド",
"custom.colors.bg-darker": "バックグランド 暗め", "custom.colors.bg-darker": "バックグランド 暗め",

View File

@ -168,5 +168,19 @@
"labels.banUserExpiresAt": "", "labels.banUserExpiresAt": "",
"bottomTexts.banUserExpiresAtHelp": "", "bottomTexts.banUserExpiresAtHelp": "",
"labels.scrimCancelReason": "", "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": ""
} }

View File

@ -5,6 +5,130 @@
"ign.short": "ゲーム中の名前", "ign.short": "ゲーム中の名前",
"country": "国", "country": "国",
"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": "右スティック感度", "stickSens": "右スティック感度",
"motionSens": "モーション感度", "motionSens": "モーション感度",
"motion": "モーション", "motion": "モーション",
@ -17,6 +141,8 @@
"discordExplanation": "ユーザー名、プロファイル画像、YouTube、Bluesky と Twitch アカウントは Discord のアカウントに設定されているものが使用されます。詳しくは <1>FAQ</1> をご覧ください。", "discordExplanation": "ユーザー名、プロファイル画像、YouTube、Bluesky と Twitch アカウントは Discord のアカウントに設定されているものが使用されます。詳しくは <1>FAQ</1> をご覧ください。",
"favoriteBadges": "", "favoriteBadges": "",
"battlefy": "Battlefyアカウント名", "battlefy": "Battlefyアカウント名",
"forms.newProfileEnabled": "",
"forms.newProfileEnabled.info": "",
"forms.showDiscordUniqueName": "Discord のユーザー名を表示する", "forms.showDiscordUniqueName": "Discord のユーザー名を表示する",
"forms.showDiscordUniqueName.info": "Discord のユニーク名 ({{discordUniqueName}}) 公表しますか?", "forms.showDiscordUniqueName.info": "Discord のユニーク名 ({{discordUniqueName}}) 公表しますか?",
"forms.commissionsOpen": "依頼を受付中", "forms.commissionsOpen": "依頼を受付中",
@ -82,5 +208,7 @@
"builds.sorting.CLOTHES_ID": "フク(ギア)ゲーム内の順番", "builds.sorting.CLOTHES_ID": "フク(ギア)ゲーム内の順番",
"builds.sorting.SHOES_ID": "クツ(ギア)ゲーム内の順番", "builds.sorting.SHOES_ID": "クツ(ギア)ゲーム内の順番",
"builds.sorting.PUBLIC_BUILD": "公開ビルド", "builds.sorting.PUBLIC_BUILD": "公開ビルド",
"builds.sorting.PRIVATE_BUILD": "非公開ビルド" "builds.sorting.PRIVATE_BUILD": "非公開ビルド",
"commissions.open": "",
"commissions.closed": ""
} }

View File

@ -132,6 +132,8 @@
"actions.next": "", "actions.next": "",
"actions.previous": "", "actions.previous": "",
"actions.back": "", "actions.back": "",
"actions.hide": "",
"actions.settings": "",
"noResults": "", "noResults": "",
"maps.createMapList": "맵 목록 생성", "maps.createMapList": "맵 목록 생성",
"maps.halfSz": "에어리어 50%", "maps.halfSz": "에어리어 50%",
@ -251,6 +253,7 @@
"support.perk.joinMoreAssociations": "", "support.perk.joinMoreAssociations": "",
"support.perk.useBotToLogIn": "", "support.perk.useBotToLogIn": "",
"support.perk.useBotToLogIn.extra": "", "support.perk.useBotToLogIn.extra": "",
"support.perk.earlyAccess": "",
"custom.colors.title": "", "custom.colors.title": "",
"custom.colors.bg": "", "custom.colors.bg": "",
"custom.colors.bg-darker": "", "custom.colors.bg-darker": "",

View File

@ -168,5 +168,19 @@
"labels.banUserExpiresAt": "", "labels.banUserExpiresAt": "",
"bottomTexts.banUserExpiresAtHelp": "", "bottomTexts.banUserExpiresAtHelp": "",
"labels.scrimCancelReason": "", "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": ""
} }

View File

@ -5,6 +5,130 @@
"ign.short": "", "ign.short": "",
"country": "국가", "country": "국가",
"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": "", "stickSens": "",
"motionSens": "", "motionSens": "",
"motion": "", "motion": "",
@ -17,6 +141,8 @@
"discordExplanation": "", "discordExplanation": "",
"favoriteBadges": "", "favoriteBadges": "",
"battlefy": "", "battlefy": "",
"forms.newProfileEnabled": "",
"forms.newProfileEnabled.info": "",
"forms.showDiscordUniqueName": "", "forms.showDiscordUniqueName": "",
"forms.showDiscordUniqueName.info": "", "forms.showDiscordUniqueName.info": "",
"forms.commissionsOpen": "", "forms.commissionsOpen": "",
@ -82,5 +208,7 @@
"builds.sorting.CLOTHES_ID": "", "builds.sorting.CLOTHES_ID": "",
"builds.sorting.SHOES_ID": "", "builds.sorting.SHOES_ID": "",
"builds.sorting.PUBLIC_BUILD": "", "builds.sorting.PUBLIC_BUILD": "",
"builds.sorting.PRIVATE_BUILD": "" "builds.sorting.PRIVATE_BUILD": "",
"commissions.open": "",
"commissions.closed": ""
} }

View File

@ -132,6 +132,8 @@
"actions.next": "", "actions.next": "",
"actions.previous": "", "actions.previous": "",
"actions.back": "", "actions.back": "",
"actions.hide": "",
"actions.settings": "",
"noResults": "", "noResults": "",
"maps.createMapList": "Maak levellijst", "maps.createMapList": "Maak levellijst",
"maps.halfSz": "50% SZ", "maps.halfSz": "50% SZ",
@ -255,6 +257,7 @@
"support.perk.joinMoreAssociations": "", "support.perk.joinMoreAssociations": "",
"support.perk.useBotToLogIn": "", "support.perk.useBotToLogIn": "",
"support.perk.useBotToLogIn.extra": "", "support.perk.useBotToLogIn.extra": "",
"support.perk.earlyAccess": "",
"custom.colors.title": "", "custom.colors.title": "",
"custom.colors.bg": "", "custom.colors.bg": "",
"custom.colors.bg-darker": "", "custom.colors.bg-darker": "",

Some files were not shown because too many files have changed in this diff Show More