mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-03-21 18:04:39 -05:00
parent
9ea752f72f
commit
77978c450f
|
|
@ -11,6 +11,7 @@ const dimensions = {
|
|||
sm: 44,
|
||||
xsm: 62,
|
||||
md: 81,
|
||||
xmd: 94,
|
||||
lg: 125,
|
||||
} as const;
|
||||
|
||||
|
|
@ -43,7 +44,7 @@ export function Avatar({
|
|||
? discordAvatarUrl({
|
||||
discordAvatar: user.discordAvatar,
|
||||
discordId: user.discordId,
|
||||
size: size === "lg" ? "lg" : "sm",
|
||||
size: size === "lg" || size === "xmd" ? "lg" : "sm",
|
||||
})
|
||||
: BLANK_IMAGE_URL); // avoid broken image placeholder
|
||||
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import {
|
|||
ModalOverlay,
|
||||
} from "react-aria-components";
|
||||
import { useNavigate } from "react-router";
|
||||
import * as R from "remeda";
|
||||
import { SendouButton } from "~/components/elements/Button";
|
||||
import { CrossIcon } from "~/components/icons/Cross";
|
||||
import styles from "./Dialog.module.css";
|
||||
|
|
@ -68,7 +69,9 @@ export function SendouDialog({
|
|||
return (
|
||||
<DialogTrigger>
|
||||
{trigger}
|
||||
<DialogModal {...rest}>{children}</DialogModal>
|
||||
<DialogModal {...rest} isControlledByTrigger>
|
||||
{children}
|
||||
</DialogModal>
|
||||
</DialogTrigger>
|
||||
);
|
||||
}
|
||||
|
|
@ -79,8 +82,9 @@ function DialogModal({
|
|||
showHeading = true,
|
||||
className,
|
||||
showCloseButton: showCloseButtonProp,
|
||||
isControlledByTrigger,
|
||||
...rest
|
||||
}: Omit<SendouDialogProps, "trigger">) {
|
||||
}: Omit<SendouDialogProps, "trigger"> & { isControlledByTrigger?: boolean }) {
|
||||
const navigate = useNavigate();
|
||||
|
||||
const showCloseButton = showCloseButtonProp || rest.onClose || rest.onCloseTo;
|
||||
|
|
@ -92,7 +96,7 @@ function DialogModal({
|
|||
}
|
||||
};
|
||||
|
||||
const onOpenChange = (isOpen: boolean) => {
|
||||
const defaultOnOpenChange = (isOpen: boolean) => {
|
||||
if (!isOpen) {
|
||||
if (rest.onCloseTo) {
|
||||
navigate(rest.onCloseTo);
|
||||
|
|
@ -102,13 +106,16 @@ function DialogModal({
|
|||
}
|
||||
};
|
||||
|
||||
const overlayProps = isControlledByTrigger
|
||||
? R.omit(rest, ["onOpenChange"])
|
||||
: { ...rest, onOpenChange: rest.onOpenChange ?? defaultOnOpenChange };
|
||||
|
||||
return (
|
||||
<ModalOverlay
|
||||
className={clsx(rest.overlayClassName, styles.overlay, {
|
||||
[styles.fullScreenOverlay]: rest.isFullScreen,
|
||||
})}
|
||||
onOpenChange={rest.onOpenChange ?? onOpenChange}
|
||||
{...rest}
|
||||
{...overlayProps}
|
||||
>
|
||||
<Modal
|
||||
className={clsx(className, styles.modal, {
|
||||
|
|
|
|||
33
app/components/icons/MainSlot.tsx
Normal file
33
app/components/icons/MainSlot.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
20
app/components/icons/SideSlot.tsx
Normal file
20
app/components/icons/SideSlot.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -179,6 +179,7 @@ const basicSeeds = (variation?: SeedVariation | null) => [
|
|||
fixAdminId,
|
||||
makeArtists,
|
||||
adminUserWeaponPool,
|
||||
adminUserWidgets,
|
||||
userProfiles,
|
||||
userMapModePreferences,
|
||||
userQWeaponPool,
|
||||
|
|
@ -375,6 +376,30 @@ function adminUserWeaponPool() {
|
|||
}
|
||||
}
|
||||
|
||||
async function adminUserWidgets() {
|
||||
await UserRepository.upsertWidgets(ADMIN_ID, [
|
||||
{
|
||||
id: "bio",
|
||||
settings: { bio: "" },
|
||||
},
|
||||
{
|
||||
id: "badges-owned",
|
||||
},
|
||||
{
|
||||
id: "teams",
|
||||
},
|
||||
{
|
||||
id: "organizations",
|
||||
},
|
||||
{
|
||||
id: "peak-sp",
|
||||
},
|
||||
{
|
||||
id: "peak-xp",
|
||||
},
|
||||
]);
|
||||
}
|
||||
|
||||
function nzapUser() {
|
||||
return UserRepository.upsert({
|
||||
discordId: NZAP_TEST_DISCORD_ID,
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ import type { TEAM_MEMBER_ROLES } from "~/features/team/team-constants";
|
|||
import type { TournamentTierNumber } from "~/features/tournament/core/tiering";
|
||||
import type * as PickBan from "~/features/tournament-bracket/core/PickBan";
|
||||
import type * as Progression from "~/features/tournament-bracket/core/Progression";
|
||||
import type { StoredWidget } from "~/features/user-page/core/widgets/types";
|
||||
import type { ParticipantResult } from "~/modules/brackets-model";
|
||||
import type {
|
||||
Ability,
|
||||
|
|
@ -861,6 +862,8 @@ export interface UserPreferences {
|
|||
* "12h" = 12 hour format (e.g. 2:00 PM)
|
||||
* */
|
||||
clockFormat?: "24h" | "12h" | "auto";
|
||||
/** Is the new widget based user page enabled? (Supporter early preview) */
|
||||
newProfileEnabled?: boolean;
|
||||
}
|
||||
|
||||
export const SUBJECT_PRONOUNS = ["he", "she", "they", "it", "any"] as const;
|
||||
|
|
@ -961,6 +964,11 @@ export interface UserFriendCode {
|
|||
createdAt: GeneratedAlways<number>;
|
||||
}
|
||||
|
||||
export interface UserWidget {
|
||||
userId: number;
|
||||
index: number;
|
||||
widget: JSONColumnType<StoredWidget>;
|
||||
}
|
||||
export type ApiTokenType = "read" | "write";
|
||||
|
||||
export interface ApiToken {
|
||||
|
|
@ -1223,6 +1231,7 @@ export interface DB {
|
|||
UserSubmittedImage: UserSubmittedImage;
|
||||
UserWeapon: UserWeapon;
|
||||
UserFriendCode: UserFriendCode;
|
||||
UserWidget: UserWidget;
|
||||
Video: Video;
|
||||
VideoMatch: VideoMatch;
|
||||
VideoMatchPlayer: VideoMatchPlayer;
|
||||
|
|
|
|||
|
|
@ -110,6 +110,31 @@ export function findManagedByUserId(userId: number) {
|
|||
.execute();
|
||||
}
|
||||
|
||||
export function findByOwnerUserId(userId: number) {
|
||||
return db
|
||||
.selectFrom("BadgeOwner")
|
||||
.innerJoin("Badge", "Badge.id", "BadgeOwner.badgeId")
|
||||
.select(({ fn }) => [
|
||||
fn.count<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({
|
||||
badgeId,
|
||||
managerIds,
|
||||
|
|
|
|||
|
|
@ -21,10 +21,14 @@ export async function allByUserId(
|
|||
options: {
|
||||
showPrivate?: boolean;
|
||||
sortAbilities?: boolean;
|
||||
limit?: number;
|
||||
} = {},
|
||||
) {
|
||||
const { showPrivate = false, sortAbilities: shouldSortAbilities = false } =
|
||||
options;
|
||||
const {
|
||||
showPrivate = false,
|
||||
sortAbilities: shouldSortAbilities = false,
|
||||
limit,
|
||||
} = options;
|
||||
const rows = await db
|
||||
.selectFrom("Build")
|
||||
.select(({ eb }) => [
|
||||
|
|
@ -48,6 +52,8 @@ export async function allByUserId(
|
|||
])
|
||||
.where("Build.ownerId", "=", userId)
|
||||
.$if(!showPrivate, (qb) => qb.where("Build.private", "=", 0))
|
||||
.$if(typeof limit === "number", (qb) => qb.limit(limit!))
|
||||
.orderBy("Build.updatedAt", "desc")
|
||||
.execute();
|
||||
|
||||
return rows.map((row) => {
|
||||
|
|
|
|||
|
|
@ -101,7 +101,7 @@ function tournamentOrganization(organizationId: Expression<number | null>) {
|
|||
"TournamentOrganization.slug",
|
||||
"TournamentOrganization.isEstablished",
|
||||
concatUserSubmittedImagePrefix(eb.ref("UserSubmittedImage.url")).as(
|
||||
"avatarUrl",
|
||||
"logoUrl",
|
||||
),
|
||||
])
|
||||
.whereRef("TournamentOrganization.id", "=", organizationId),
|
||||
|
|
|
|||
|
|
@ -118,10 +118,15 @@ export async function findValidOrganizations(
|
|||
});
|
||||
|
||||
if (isTournamentAdder) {
|
||||
return ["NO_ORG", ...orgs.map((org) => R.omit(org, ["isEstablished"]))];
|
||||
return [
|
||||
"NO_ORG",
|
||||
...orgs.map((org) =>
|
||||
R.omit(org, ["isEstablished", "role", "roleDisplayName"]),
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
return orgs
|
||||
.filter((org) => org.isEstablished)
|
||||
.map((org) => R.omit(org, ["isEstablished"]));
|
||||
.map((org) => R.omit(org, ["isEstablished", "role", "roleDisplayName"]));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -57,6 +57,11 @@ const PERKS = [
|
|||
name: "tournamentsBeta",
|
||||
extraInfo: false,
|
||||
},
|
||||
{
|
||||
tier: 2,
|
||||
name: "earlyAccess",
|
||||
extraInfo: false,
|
||||
},
|
||||
{
|
||||
tier: 2,
|
||||
name: "previewQ",
|
||||
|
|
|
|||
|
|
@ -157,3 +157,12 @@ export function deletePostsByTeamId(teamId: number, trx?: Transaction<DB>) {
|
|||
.where("teamId", "=", teamId)
|
||||
.execute();
|
||||
}
|
||||
|
||||
export async function findByAuthorUserId(authorId: number) {
|
||||
return db
|
||||
.selectFrom("LFGPost")
|
||||
.select(["id", "type"])
|
||||
.where("authorId", "=", authorId)
|
||||
.orderBy("updatedAt", "desc")
|
||||
.execute();
|
||||
}
|
||||
|
|
|
|||
3
app/features/lfg/routes/lfg.module.css
Normal file
3
app/features/lfg/routes/lfg.module.css
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
.post {
|
||||
scroll-margin-top: 6rem;
|
||||
}
|
||||
|
|
@ -1,3 +1,4 @@
|
|||
import clsx from "clsx";
|
||||
import { add, sub } from "date-fns";
|
||||
import React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
|
@ -26,6 +27,7 @@ import {
|
|||
smallStrToFilter,
|
||||
} from "../lfg-types";
|
||||
import { loader } from "../loaders/lfg.server";
|
||||
import styles from "./lfg.module.css";
|
||||
export { loader, action };
|
||||
|
||||
export const handle: SendouRouteHandle = {
|
||||
|
|
@ -124,7 +126,11 @@ export default function LFGPage() {
|
|||
}
|
||||
/>
|
||||
{filteredPosts.map((post) => (
|
||||
<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}
|
||||
<LFGPost post={post} tiersMap={tiersMap} />
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -181,3 +181,12 @@ export function allStarted(date = new Date()) {
|
|||
|
||||
return [0];
|
||||
}
|
||||
/**
|
||||
* Retrieves a list of season numbers that have finished based on the provided date (defaults to now).
|
||||
*
|
||||
* @returns An array of season numbers in descending order. If no seasons have finished, returns an empty array.
|
||||
*/
|
||||
export function allFinished(date = new Date()) {
|
||||
const finishedSeasons = list.filter((s) => date > s.ends);
|
||||
return finishedSeasons.map((s) => s.nth).reverse();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -46,6 +46,7 @@ export function findAllMemberOfByUserId(userId: number) {
|
|||
"Team.id",
|
||||
"Team.customUrl",
|
||||
"Team.name",
|
||||
"TeamMemberWithSecondary.role",
|
||||
concatUserSubmittedImagePrefix(eb.ref("UserSubmittedImage.url")).as(
|
||||
"logoUrl",
|
||||
),
|
||||
|
|
|
|||
|
|
@ -1,15 +1,12 @@
|
|||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { REGULAR_USER_TEST_ID } from "~/db/seed/constants";
|
||||
import { db } from "~/db/sql";
|
||||
import type { SerializeFrom } from "~/utils/remix";
|
||||
import {
|
||||
assertResponseErrored,
|
||||
dbInsertUsers,
|
||||
dbReset,
|
||||
wrappedAction,
|
||||
wrappedLoader,
|
||||
} from "~/utils/Test";
|
||||
import { loader as userProfileLoader } from "../../user-page/loaders/u.$identifier.index.server";
|
||||
import { action as _teamPageAction } from "../actions/t.$customUrl.index.server";
|
||||
import { action as teamIndexPageAction } from "../actions/t.server";
|
||||
import { action as _editTeamAction } from "../routes/t.$customUrl.edit";
|
||||
|
|
@ -20,12 +17,6 @@ import type {
|
|||
teamProfilePageActionSchema,
|
||||
} from "../team-schemas.server";
|
||||
|
||||
const loadUserTeamLoader = wrappedLoader<
|
||||
SerializeFrom<typeof userProfileLoader>
|
||||
>({
|
||||
loader: userProfileLoader,
|
||||
});
|
||||
|
||||
const createTeamAction = wrappedAction<typeof createTeamSchema>({
|
||||
action: teamIndexPageAction,
|
||||
isJsonSubmission: true,
|
||||
|
|
@ -40,14 +31,12 @@ const editTeamAction = wrappedAction<typeof editTeamSchema>({
|
|||
});
|
||||
|
||||
async function loadTeams() {
|
||||
const data = await loadUserTeamLoader({
|
||||
user: "regular",
|
||||
params: {
|
||||
identifier: String(REGULAR_USER_TEST_ID),
|
||||
},
|
||||
});
|
||||
const teams = await TeamRepository.teamsByMemberUserId(REGULAR_USER_TEST_ID);
|
||||
|
||||
return { team: data.user.team, secondaryTeams: data.user.secondaryTeams };
|
||||
const mainTeam = teams.find((t) => t.isMainTeam);
|
||||
const secondaryTeams = teams.filter((t) => !t.isMainTeam);
|
||||
|
||||
return { team: mainTeam, secondaryTeams };
|
||||
}
|
||||
|
||||
describe("Secondary teams", () => {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,9 @@
|
|||
import type { InferResult } from "kysely";
|
||||
import { sql } from "kysely";
|
||||
import { db } from "~/db/sql";
|
||||
import type { Tables } from "~/db/tables";
|
||||
import { modesShort } from "~/modules/in-game-lists/modes";
|
||||
import type { MainWeaponId } from "~/modules/in-game-lists/types";
|
||||
|
||||
export function unlinkPlayerByUserId(userId: number) {
|
||||
return db
|
||||
|
|
@ -54,6 +57,26 @@ export async function findPlacementsByPlayerId(
|
|||
return result.length ? result : null;
|
||||
}
|
||||
|
||||
export async function findPlacementsByUserId(
|
||||
userId: Tables["User"]["id"],
|
||||
options?: { limit?: number; weaponId?: MainWeaponId },
|
||||
) {
|
||||
let query = xRankPlacementsQueryBase()
|
||||
.where("SplatoonPlayer.userId", "=", userId)
|
||||
.orderBy("XRankPlacement.power", "desc");
|
||||
|
||||
if (options?.weaponId) {
|
||||
query = query.where("XRankPlacement.weaponSplId", "=", options.weaponId);
|
||||
}
|
||||
|
||||
if (options?.limit) {
|
||||
query = query.limit(options.limit);
|
||||
}
|
||||
|
||||
const result = await query.execute();
|
||||
return result.length ? result : null;
|
||||
}
|
||||
|
||||
export async function monthYears() {
|
||||
return await db
|
||||
.selectFrom("XRankPlacement")
|
||||
|
|
@ -64,6 +87,44 @@ export async function monthYears() {
|
|||
.execute();
|
||||
}
|
||||
|
||||
export async function findPeaksByUserId(
|
||||
userId: Tables["User"]["id"],
|
||||
division?: "both" | "tentatek" | "takoroka",
|
||||
) {
|
||||
let innerQuery = db
|
||||
.selectFrom("XRankPlacement")
|
||||
.innerJoin("SplatoonPlayer", "XRankPlacement.playerId", "SplatoonPlayer.id")
|
||||
.where("SplatoonPlayer.userId", "=", userId)
|
||||
.select([
|
||||
"XRankPlacement.mode",
|
||||
"XRankPlacement.rank",
|
||||
"XRankPlacement.power",
|
||||
"XRankPlacement.region",
|
||||
"XRankPlacement.playerId",
|
||||
sql<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<
|
||||
ReturnType<typeof xRankPlacementsQueryBase>
|
||||
>[number];
|
||||
|
|
|
|||
|
|
@ -61,7 +61,6 @@
|
|||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--s-1);
|
||||
color: var(--text-main);
|
||||
justify-content: center;
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6925,7 +6925,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({
|
|||
id: 3,
|
||||
name: "Inkling Performance Labs",
|
||||
slug: "inkling-performance-labs",
|
||||
avatarUrl: "fZrToLQrkqV3UZkdgwp0Q-1722263644749.webp",
|
||||
logoUrl: "fZrToLQrkqV3UZkdgwp0Q-1722263644749.webp",
|
||||
members: [
|
||||
{
|
||||
userId: 405,
|
||||
|
|
|
|||
|
|
@ -2028,7 +2028,7 @@ export const SWIM_OR_SINK_167 = (
|
|||
id: 3,
|
||||
name: "Inkling Performance Labs",
|
||||
slug: "inkling-performance-labs",
|
||||
avatarUrl: "fZrToLQrkqV3UZkdgwp0Q-1722263644749.webp",
|
||||
logoUrl: "fZrToLQrkqV3UZkdgwp0Q-1722263644749.webp",
|
||||
members: [
|
||||
{
|
||||
userId: 405,
|
||||
|
|
|
|||
|
|
@ -151,10 +151,21 @@ export function findByUserId(
|
|||
"TournamentOrganization.id",
|
||||
"TournamentOrganizationMember.organizationId",
|
||||
)
|
||||
.select([
|
||||
.leftJoin(
|
||||
"UserSubmittedImage",
|
||||
"UserSubmittedImage.id",
|
||||
"TournamentOrganization.avatarImgId",
|
||||
)
|
||||
.select(({ eb }) => [
|
||||
"TournamentOrganization.id",
|
||||
"TournamentOrganization.name",
|
||||
"TournamentOrganization.slug",
|
||||
"TournamentOrganization.isEstablished",
|
||||
"TournamentOrganizationMember.role",
|
||||
"TournamentOrganizationMember.roleDisplayName",
|
||||
concatUserSubmittedImagePrefix(eb.ref("UserSubmittedImage.url")).as(
|
||||
"logoUrl",
|
||||
),
|
||||
])
|
||||
.where("TournamentOrganizationMember.userId", "=", userId)
|
||||
.$if(roles.length > 0, (qb) =>
|
||||
|
|
|
|||
|
|
@ -164,7 +164,6 @@
|
|||
|
||||
.org__social-link {
|
||||
font-size: var(--fonts-sm);
|
||||
color: var(--text-main);
|
||||
display: flex;
|
||||
gap: var(--s-2);
|
||||
align-items: center;
|
||||
|
|
|
|||
|
|
@ -72,7 +72,7 @@ export async function findById(id: number) {
|
|||
"TournamentOrganization.slug",
|
||||
concatUserSubmittedImagePrefix(
|
||||
innerEb.ref("UserSubmittedImage.url"),
|
||||
).as("avatarUrl"),
|
||||
).as("logoUrl"),
|
||||
jsonArrayFrom(
|
||||
innerEb
|
||||
.selectFrom("TournamentOrganizationMember")
|
||||
|
|
|
|||
|
|
@ -97,7 +97,7 @@ export default function TournamentRegisterPage() {
|
|||
className="stack horizontal sm items-center text-xs text-main-forced"
|
||||
>
|
||||
<Avatar
|
||||
url={tournament.ctx.organization.avatarUrl ?? undefined}
|
||||
url={tournament.ctx.organization.logoUrl ?? undefined}
|
||||
size="xxs"
|
||||
/>
|
||||
{tournament.ctx.organization.name}
|
||||
|
|
|
|||
|
|
@ -73,9 +73,9 @@ export const handle: SendouRouteHandle = {
|
|||
const data = JSON.parse(rawData) as TournamentLoaderData;
|
||||
|
||||
return [
|
||||
data.tournament.ctx.organization?.avatarUrl
|
||||
data.tournament.ctx.organization?.logoUrl
|
||||
? {
|
||||
imgPath: data.tournament.ctx.organization.avatarUrl,
|
||||
imgPath: data.tournament.ctx.organization.logoUrl,
|
||||
href: tournamentOrganizationPage({
|
||||
organizationSlug: data.tournament.ctx.organization.slug,
|
||||
}),
|
||||
|
|
|
|||
|
|
@ -21,8 +21,13 @@ import {
|
|||
tournamentLogoOrNull,
|
||||
userChatNameColor,
|
||||
} from "~/utils/kysely.server";
|
||||
import { logger } from "~/utils/logger";
|
||||
import { safeNumberParse } from "~/utils/number";
|
||||
import { bskyUrl, twitchUrl, youtubeUrl } from "~/utils/urls";
|
||||
import type { ChatUser } from "../chat/chat-types";
|
||||
import { findWidgetById } from "./core/widgets/portfolio";
|
||||
import { WIDGET_LOADERS } from "./core/widgets/portfolio-loaders.server";
|
||||
import type { LoadedWidget } from "./core/widgets/types";
|
||||
|
||||
export const identifierToUserIdQuery = (identifier: string) =>
|
||||
db
|
||||
|
|
@ -78,6 +83,9 @@ export function findLayoutDataByIdentifier(
|
|||
return identifierToUserIdQuery(identifier)
|
||||
.select((eb) => [
|
||||
...COMMON_USER_FIELDS,
|
||||
"User.pronouns",
|
||||
"User.country",
|
||||
"User.inGameName",
|
||||
"User.commissionText",
|
||||
"User.commissionsOpen",
|
||||
sql<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: {
|
||||
favoriteBadgeIds: number[] | null;
|
||||
badges: Array<{
|
||||
|
|
@ -675,6 +779,30 @@ export async function hasHighlightedResultsByUserId(userId: number) {
|
|||
return !!highlightedCalendarEventResult;
|
||||
}
|
||||
|
||||
export async function findResultPlacementsByUserId(userId: number) {
|
||||
const tournamentResults = await db
|
||||
.selectFrom("TournamentResult")
|
||||
.select(["TournamentResult.placement"])
|
||||
.where("userId", "=", userId)
|
||||
.execute();
|
||||
|
||||
const calendarEventResults = await db
|
||||
.selectFrom("CalendarEventResultPlayer")
|
||||
.innerJoin(
|
||||
"CalendarEventResultTeam",
|
||||
"CalendarEventResultTeam.id",
|
||||
"CalendarEventResultPlayer.teamId",
|
||||
)
|
||||
.select(["CalendarEventResultTeam.placement"])
|
||||
.where("CalendarEventResultPlayer.userId", "=", userId)
|
||||
.execute();
|
||||
|
||||
return [
|
||||
...tournamentResults.map((r) => ({ placement: r.placement })),
|
||||
...calendarEventResults.map((r) => ({ placement: r.placement })),
|
||||
];
|
||||
}
|
||||
|
||||
const searchSelectedFields = ({ fn }: { fn: FunctionModule<DB, "User"> }) =>
|
||||
[
|
||||
...COMMON_USER_FIELDS,
|
||||
|
|
@ -862,6 +990,28 @@ export async function inGameNameByUserId(userId: number) {
|
|||
)?.inGameName;
|
||||
}
|
||||
|
||||
export async function patronSinceByUserId(userId: number) {
|
||||
return (
|
||||
await db
|
||||
.selectFrom("User")
|
||||
.select("User.patronSince")
|
||||
.where("id", "=", userId)
|
||||
.executeTakeFirst()
|
||||
)?.patronSince;
|
||||
}
|
||||
|
||||
export async function commissionsByUserId(userId: number) {
|
||||
return await db
|
||||
.selectFrom("User")
|
||||
.select([
|
||||
"User.commissionsOpen",
|
||||
"User.commissionsOpenedAt",
|
||||
"User.commissionText",
|
||||
])
|
||||
.where("id", "=", userId)
|
||||
.executeTakeFirst();
|
||||
}
|
||||
|
||||
export function insertFriendCode(args: TablesInsertable["UserFriendCode"]) {
|
||||
cachedFriendCodes?.add(args.friendCode);
|
||||
|
||||
|
|
@ -1128,6 +1278,45 @@ export async function anyUserPrefersNoScreen(
|
|||
return Boolean(result);
|
||||
}
|
||||
|
||||
export async function socialLinksByUserId(userId: number) {
|
||||
const user = await db
|
||||
.selectFrom("User")
|
||||
.select([
|
||||
"User.twitch",
|
||||
"User.youtubeId",
|
||||
"User.bsky",
|
||||
"User.discordUniqueName",
|
||||
])
|
||||
.where("User.id", "=", userId)
|
||||
.executeTakeFirst();
|
||||
|
||||
if (!user) return [];
|
||||
|
||||
const links: Array<
|
||||
| { type: "url"; value: string }
|
||||
| { type: "popover"; platform: "discord"; value: string }
|
||||
> = [];
|
||||
|
||||
if (user.twitch) {
|
||||
links.push({ type: "url", value: twitchUrl(user.twitch) });
|
||||
}
|
||||
if (user.youtubeId) {
|
||||
links.push({ type: "url", value: youtubeUrl(user.youtubeId) });
|
||||
}
|
||||
if (user.bsky) {
|
||||
links.push({ type: "url", value: bskyUrl(user.bsky) });
|
||||
}
|
||||
if (user.discordUniqueName) {
|
||||
links.push({
|
||||
type: "popover",
|
||||
platform: "discord",
|
||||
value: user.discordUniqueName,
|
||||
});
|
||||
}
|
||||
|
||||
return links;
|
||||
}
|
||||
|
||||
export function findIdsByTwitchUsernames(twitchUsernames: string[]) {
|
||||
if (twitchUsernames.length === 0) return [];
|
||||
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
};
|
||||
|
|
@ -20,7 +20,12 @@ export const action: ActionFunction = async ({ request }) => {
|
|||
};
|
||||
}
|
||||
|
||||
const { inGameNameText, inGameNameDiscriminator, ...data } = parsedInput.data;
|
||||
const {
|
||||
inGameNameText,
|
||||
inGameNameDiscriminator,
|
||||
newProfileEnabled,
|
||||
...data
|
||||
} = parsedInput.data;
|
||||
|
||||
const user = requireUser();
|
||||
const inGameName =
|
||||
|
|
@ -28,15 +33,15 @@ export const action: ActionFunction = async ({ request }) => {
|
|||
? `${inGameNameText}#${inGameNameDiscriminator}`
|
||||
: null;
|
||||
|
||||
const pronouns =
|
||||
data.subjectPronoun && data.objectPronoun
|
||||
? JSON.stringify({
|
||||
subject: data.subjectPronoun,
|
||||
object: data.objectPronoun,
|
||||
})
|
||||
: null;
|
||||
|
||||
try {
|
||||
const pronouns =
|
||||
data.subjectPronoun && data.objectPronoun
|
||||
? JSON.stringify({
|
||||
subject: data.subjectPronoun,
|
||||
object: data.objectPronoun,
|
||||
})
|
||||
: null;
|
||||
|
||||
const editedUser = await UserRepository.updateProfile({
|
||||
...data,
|
||||
pronouns,
|
||||
|
|
@ -44,6 +49,10 @@ export const action: ActionFunction = async ({ request }) => {
|
|||
userId: user.id,
|
||||
});
|
||||
|
||||
await UserRepository.updatePreferences(user.id, {
|
||||
newProfileEnabled: Boolean(newProfileEnabled),
|
||||
});
|
||||
|
||||
// TODO: to transaction
|
||||
if (inGameName) {
|
||||
const tournamentIdsAffected =
|
||||
|
|
|
|||
63
app/features/user-page/components/SubPageHeader.module.css
Normal file
63
app/features/user-page/components/SubPageHeader.module.css
Normal 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);
|
||||
}
|
||||
34
app/features/user-page/components/SubPageHeader.tsx
Normal file
34
app/features/user-page/components/SubPageHeader.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
51
app/features/user-page/components/UserPageIconNav.module.css
Normal file
51
app/features/user-page/components/UserPageIconNav.module.css
Normal 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;
|
||||
}
|
||||
}
|
||||
51
app/features/user-page/components/UserPageIconNav.tsx
Normal file
51
app/features/user-page/components/UserPageIconNav.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
312
app/features/user-page/components/Widget.module.css
Normal file
312
app/features/user-page/components/Widget.module.css
Normal 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;
|
||||
}
|
||||
849
app/features/user-page/components/Widget.tsx
Normal file
849
app/features/user-page/components/Widget.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
268
app/features/user-page/components/WidgetSettingsForm.tsx
Normal file
268
app/features/user-page/components/WidgetSettingsForm.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
313
app/features/user-page/core/widgets/portfolio-loaders.server.ts
Normal file
313
app/features/user-page/core/widgets/portfolio-loaders.server.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
173
app/features/user-page/core/widgets/portfolio.ts
Normal file
173
app/features/user-page/core/widgets/portfolio.ts
Normal 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;
|
||||
}
|
||||
40
app/features/user-page/core/widgets/types.ts
Normal file
40
app/features/user-page/core/widgets/types.ts
Normal 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"];
|
||||
53
app/features/user-page/core/widgets/utils.server.ts
Normal file
53
app/features/user-page/core/widgets/utils.server.ts
Normal 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;
|
||||
}
|
||||
149
app/features/user-page/core/widgets/widget-form-schemas.ts
Normal file
149
app/features/user-page/core/widgets/widget-form-schemas.ts
Normal 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];
|
||||
}
|
||||
|
|
@ -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 };
|
||||
};
|
||||
|
|
@ -20,9 +20,12 @@ export const loader = async ({ params }: LoaderFunctionArgs) => {
|
|||
true,
|
||||
))!;
|
||||
|
||||
const preferences = await UserRepository.preferencesByUserId(user.id);
|
||||
|
||||
return {
|
||||
user: userProfile,
|
||||
favoriteBadgeIds: userProfile.favoriteBadgeIds,
|
||||
discordUniqueName: userProfile.discordUniqueName,
|
||||
newProfileEnabled: preferences?.newProfileEnabled ?? false,
|
||||
};
|
||||
};
|
||||
|
|
|
|||
|
|
@ -3,11 +3,25 @@ import * as UserRepository from "~/features/user-page/UserRepository.server";
|
|||
import { notFoundIfFalsy } from "~/utils/remix.server";
|
||||
|
||||
export const loader = async ({ params }: LoaderFunctionArgs) => {
|
||||
const widgetsEnabled = await UserRepository.widgetsEnabledByIdentifier(
|
||||
params.identifier!,
|
||||
);
|
||||
|
||||
if (widgetsEnabled) {
|
||||
return {
|
||||
type: "new" as const,
|
||||
widgets: notFoundIfFalsy(
|
||||
await UserRepository.widgetsByUserId(params.identifier!),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
const user = notFoundIfFalsy(
|
||||
await UserRepository.findProfileByIdentifier(params.identifier!),
|
||||
);
|
||||
|
||||
return {
|
||||
type: "old" as const,
|
||||
user,
|
||||
};
|
||||
};
|
||||
|
|
|
|||
|
|
@ -16,11 +16,16 @@ export const loader = async ({ params }: LoaderFunctionArgs) => {
|
|||
),
|
||||
);
|
||||
|
||||
const widgetsEnabled = await UserRepository.widgetsEnabledByIdentifier(
|
||||
params.identifier!,
|
||||
);
|
||||
|
||||
return {
|
||||
user: {
|
||||
...user,
|
||||
css: undefined,
|
||||
},
|
||||
css: user.css,
|
||||
type: widgetsEnabled ? ("new" as const) : ("old" as const),
|
||||
};
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,23 +1,34 @@
|
|||
import { useLoaderData } from "react-router";
|
||||
import { useLoaderData, useMatches } from "react-router";
|
||||
import { Divider } from "~/components/Divider";
|
||||
import { SendouButton } from "~/components/elements/Button";
|
||||
import { SendouDialog } from "~/components/elements/Dialog";
|
||||
import { FormWithConfirm } from "~/components/FormWithConfirm";
|
||||
import { PlusIcon } from "~/components/icons/Plus";
|
||||
import { Main } from "~/components/Main";
|
||||
import { useUser } from "~/features/auth/core/user";
|
||||
import { addModNoteSchema } from "~/features/user-page/user-page-schemas";
|
||||
import { SendouForm } from "~/form";
|
||||
import { useTimeFormat } from "~/hooks/useTimeFormat";
|
||||
import { databaseTimestampToDate } from "~/utils/dates";
|
||||
import invariant from "~/utils/invariant";
|
||||
import { userPage } from "~/utils/urls";
|
||||
import { action } from "../actions/u.$identifier.admin.server";
|
||||
import { SubPageHeader } from "../components/SubPageHeader";
|
||||
import { loader } from "../loaders/u.$identifier.admin.server";
|
||||
import type { UserPageLoaderData } from "../loaders/u.$identifier.server";
|
||||
import styles from "./u.$identifier.admin.module.css";
|
||||
export { loader, action };
|
||||
|
||||
export default function UserAdminPage() {
|
||||
const [, parentRoute] = useMatches();
|
||||
invariant(parentRoute);
|
||||
const layoutData = parentRoute.data as UserPageLoaderData;
|
||||
|
||||
return (
|
||||
<Main className="stack xl">
|
||||
<div className="stack xl">
|
||||
<SubPageHeader
|
||||
user={layoutData.user}
|
||||
backTo={userPage(layoutData.user)}
|
||||
/>
|
||||
<AccountInfos />
|
||||
|
||||
<div className="stack sm">
|
||||
|
|
@ -40,7 +51,7 @@ export default function UserAdminPage() {
|
|||
</Divider>
|
||||
<BanLog />
|
||||
</div>
|
||||
</Main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -7,8 +7,9 @@ import { useUser } from "~/features/auth/core/user";
|
|||
import { useSearchParamState } from "~/hooks/useSearchParamState";
|
||||
import invariant from "~/utils/invariant";
|
||||
import type { SendouRouteHandle } from "~/utils/remix.server";
|
||||
import { newArtPage } from "~/utils/urls";
|
||||
import { newArtPage, userPage } from "~/utils/urls";
|
||||
import { action } from "../actions/u.$identifier.art.server";
|
||||
import { SubPageHeader } from "../components/SubPageHeader";
|
||||
import { loader } from "../loaders/u.$identifier.art.server";
|
||||
import type { UserPageLoaderData } from "../loaders/u.$identifier.server";
|
||||
export { action, loader };
|
||||
|
|
@ -52,9 +53,9 @@ export default function UserArtPage() {
|
|||
|
||||
return (
|
||||
<div className="stack md">
|
||||
<div className="stack items-end">
|
||||
<SubPageHeader user={layoutData.user} backTo={userPage(layoutData.user)}>
|
||||
<AddNewButton navIcon="art" to={newArtPage()} />
|
||||
</div>
|
||||
</SubPageHeader>
|
||||
<div className="stack horizontal justify-between items-start text-xs text-lighter">
|
||||
<div>
|
||||
{data.unvalidatedArtCount > 0
|
||||
|
|
|
|||
|
|
@ -19,8 +19,9 @@ import { useSearchParamState } from "~/hooks/useSearchParamState";
|
|||
import type { MainWeaponId } from "~/modules/in-game-lists/types";
|
||||
import { mainWeaponIds } from "~/modules/in-game-lists/weapon-ids";
|
||||
import type { SendouRouteHandle } from "~/utils/remix.server";
|
||||
import { userNewBuildPage, weaponCategoryUrl } from "~/utils/urls";
|
||||
import { userNewBuildPage, userPage, weaponCategoryUrl } from "~/utils/urls";
|
||||
import { action } from "../actions/u.$identifier.builds.server";
|
||||
import { SubPageHeader } from "../components/SubPageHeader";
|
||||
import {
|
||||
loader,
|
||||
type UserBuildsPageData,
|
||||
|
|
@ -81,20 +82,22 @@ export default function UserBuildsPage() {
|
|||
{changingSorting ? (
|
||||
<ChangeSortingDialog close={closeSortingDialog} />
|
||||
) : null}
|
||||
{isOwnPage && (
|
||||
<div className="stack sm horizontal items-center justify-end">
|
||||
<SendouButton
|
||||
onPress={() => setChangingSorting(true)}
|
||||
size="small"
|
||||
variant="outlined"
|
||||
icon={<SortIcon />}
|
||||
data-testid="change-sorting-button"
|
||||
>
|
||||
{t("user:builds.sorting.changeButton")}
|
||||
</SendouButton>
|
||||
<AddNewButton navIcon="builds" to={userNewBuildPage(user)} />
|
||||
</div>
|
||||
)}
|
||||
<SubPageHeader user={layoutData.user} backTo={userPage(layoutData.user)}>
|
||||
{isOwnPage ? (
|
||||
<>
|
||||
<SendouButton
|
||||
onPress={() => setChangingSorting(true)}
|
||||
size="small"
|
||||
variant="outlined"
|
||||
icon={<SortIcon />}
|
||||
data-testid="change-sorting-button"
|
||||
>
|
||||
{t("user:builds.sorting.changeButton")}
|
||||
</SendouButton>
|
||||
<AddNewButton navIcon="builds" to={userNewBuildPage(user)} />
|
||||
</>
|
||||
) : null}
|
||||
</SubPageHeader>
|
||||
<BuildsFilters
|
||||
weaponFilter={weaponFilter}
|
||||
setWeaponFilter={setWeaponFilter}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
444
app/features/user-page/routes/u.$identifier.edit-widgets.tsx
Normal file
444
app/features/user-page/routes/u.$identifier.edit-widgets.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -21,9 +21,10 @@ const DEFAULT_FIELDS = {
|
|||
inGameNameText: null,
|
||||
motionSens: null,
|
||||
showDiscordUniqueName: 1,
|
||||
newProfileEnabled: 0,
|
||||
stickSens: null,
|
||||
objectPronoun: null,
|
||||
subjectPronoun: null,
|
||||
objectPronoun: null,
|
||||
weapons: JSON.stringify([
|
||||
{ weaponSplId: 1 as MainWeaponId, isFavorite: 0 },
|
||||
]) as any,
|
||||
|
|
|
|||
|
|
@ -75,6 +75,7 @@ export default function UserEditPage() {
|
|||
<input type="hidden" name="commissionText" value="" />
|
||||
</>
|
||||
)}
|
||||
<NewProfileToggle />
|
||||
<FormMessage type="info">
|
||||
<Trans i18nKey={"user:discordExplanation"} t={t}>
|
||||
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() {
|
||||
const { t } = useTranslation(["user"]);
|
||||
const data = useLoaderData<typeof loader>();
|
||||
|
|
|
|||
|
|
@ -1,18 +1,28 @@
|
|||
import clsx from "clsx";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Link, useLoaderData, useMatches } from "react-router";
|
||||
import {
|
||||
href,
|
||||
Link,
|
||||
useLoaderData,
|
||||
useMatches,
|
||||
useOutletContext,
|
||||
} from "react-router";
|
||||
import { Avatar } from "~/components/Avatar";
|
||||
import { SendouButton } from "~/components/elements/Button";
|
||||
import { LinkButton, SendouButton } from "~/components/elements/Button";
|
||||
import { SendouPopover } from "~/components/elements/Popover";
|
||||
import { Flag } from "~/components/Flag";
|
||||
import { Image, WeaponImage } from "~/components/Image";
|
||||
import { BattlefyIcon } from "~/components/icons/Battlefy";
|
||||
import { BskyIcon } from "~/components/icons/Bsky";
|
||||
import { DiscordIcon } from "~/components/icons/Discord";
|
||||
import { EditIcon } from "~/components/icons/Edit";
|
||||
import { PuzzleIcon } from "~/components/icons/Puzzle";
|
||||
import { TwitchIcon } from "~/components/icons/Twitch";
|
||||
import { YouTubeIcon } from "~/components/icons/YouTube";
|
||||
import { useUser } from "~/features/auth/core/user";
|
||||
import { BadgeDisplay } from "~/features/badges/components/BadgeDisplay";
|
||||
import { modesShort } from "~/modules/in-game-lists/modes";
|
||||
import { countryCodeToTranslatedName } from "~/utils/i18n";
|
||||
import invariant from "~/utils/invariant";
|
||||
import type { SendouRouteHandle } from "~/utils/remix.server";
|
||||
import { rawSensToString } from "~/utils/strings";
|
||||
|
|
@ -24,19 +34,132 @@ import {
|
|||
teamPage,
|
||||
topSearchPlayerPage,
|
||||
} from "~/utils/urls";
|
||||
import type { UserPageNavItem } from "../components/UserPageIconNav";
|
||||
import { UserPageIconNav } from "../components/UserPageIconNav";
|
||||
import { Widget } from "../components/Widget";
|
||||
import { loader } from "../loaders/u.$identifier.index.server";
|
||||
import type { UserPageLoaderData } from "../loaders/u.$identifier.server";
|
||||
import styles from "./u.$identifier.module.css";
|
||||
export { loader };
|
||||
|
||||
export const handle: SendouRouteHandle = {
|
||||
i18n: ["badges", "team"],
|
||||
i18n: ["badges", "team", "org", "vods", "lfg", "builds", "weapons", "gear"],
|
||||
};
|
||||
|
||||
export default function UserInfoPage() {
|
||||
const data = useLoaderData<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();
|
||||
invariant(parentRoute);
|
||||
const layoutData = parentRoute.data as UserPageLoaderData;
|
||||
const { navItems } = useOutletContext<{ navItems: UserPageNavItem[] }>();
|
||||
|
||||
if (data.type !== "new") {
|
||||
throw new Error("Expected new user data");
|
||||
}
|
||||
|
||||
const mainWidgets = data.widgets.filter((w) => w.slot === "main");
|
||||
const sideWidgets = data.widgets.filter((w) => w.slot === "side");
|
||||
|
||||
const isOwnPage = layoutData.user.id === user?.id;
|
||||
|
||||
return (
|
||||
<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 (
|
||||
<div className="u__container">
|
||||
|
|
@ -81,6 +204,10 @@ function TeamInfo() {
|
|||
const { t } = useTranslation(["team"]);
|
||||
const data = useLoaderData<typeof loader>();
|
||||
|
||||
if (data.type !== "old") {
|
||||
throw new Error("Expected old user data");
|
||||
}
|
||||
|
||||
if (!data.user.team) return null;
|
||||
|
||||
return (
|
||||
|
|
@ -118,6 +245,10 @@ function SecondaryTeamsPopover() {
|
|||
|
||||
const data = useLoaderData<typeof loader>();
|
||||
|
||||
if (data.type !== "old") {
|
||||
throw new Error("Expected old user data");
|
||||
}
|
||||
|
||||
if (data.user.secondaryTeams.length === 0) return null;
|
||||
|
||||
return (
|
||||
|
|
@ -231,6 +362,10 @@ function ExtraInfos() {
|
|||
const { t } = useTranslation(["user"]);
|
||||
const data = useLoaderData<typeof loader>();
|
||||
|
||||
if (data.type !== "old") {
|
||||
throw new Error("Expected old user data");
|
||||
}
|
||||
|
||||
const motionSensText =
|
||||
typeof data.user.motionSens === "number"
|
||||
? `${t("user:motion")} ${rawSensToString(data.user.motionSens)}`
|
||||
|
|
@ -294,6 +429,10 @@ function ExtraInfos() {
|
|||
function WeaponPool() {
|
||||
const data = useLoaderData<typeof loader>();
|
||||
|
||||
if (data.type !== "old") {
|
||||
throw new Error("Expected old user data");
|
||||
}
|
||||
|
||||
if (data.user.weapons.length === 0) return null;
|
||||
|
||||
return (
|
||||
|
|
@ -315,9 +454,57 @@ function WeaponPool() {
|
|||
);
|
||||
}
|
||||
|
||||
function ProfileSubtitle({
|
||||
inGameName,
|
||||
pronouns,
|
||||
country,
|
||||
language,
|
||||
}: {
|
||||
inGameName: string | null;
|
||||
pronouns: { subject: string; object: string } | null;
|
||||
country: string | null;
|
||||
language: string;
|
||||
}) {
|
||||
const parts: React.ReactNode[] = [];
|
||||
|
||||
if (inGameName) {
|
||||
parts.push(inGameName);
|
||||
}
|
||||
|
||||
if (pronouns) {
|
||||
parts.push(`${pronouns.subject}/${pronouns.object}`);
|
||||
}
|
||||
|
||||
if (country) {
|
||||
parts.push(
|
||||
<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() {
|
||||
const data = useLoaderData<typeof loader>();
|
||||
|
||||
if (data.type !== "old") {
|
||||
throw new Error("Expected old user data");
|
||||
}
|
||||
|
||||
if (data.user.topPlacements.length === 0) return null;
|
||||
|
||||
return (
|
||||
|
|
|
|||
133
app/features/user-page/routes/u.$identifier.module.css
Normal file
133
app/features/user-page/routes/u.$identifier.module.css
Normal 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -5,8 +5,9 @@ import { Pagination } from "~/components/Pagination";
|
|||
import { useUser } from "~/features/auth/core/user";
|
||||
import { UserResultsTable } from "~/features/user-page/components/UserResultsTable";
|
||||
import invariant from "~/utils/invariant";
|
||||
import { userResultsEditHighlightsPage } from "~/utils/urls";
|
||||
import { userPage, userResultsEditHighlightsPage } from "~/utils/urls";
|
||||
import { SendouButton } from "../../../components/elements/Button";
|
||||
import { SubPageHeader } from "../components/SubPageHeader";
|
||||
import { loader } from "../loaders/u.$identifier.results.server";
|
||||
import type { UserPageLoaderData } from "../loaders/u.$identifier.server";
|
||||
export { loader };
|
||||
|
|
@ -32,6 +33,10 @@ export default function UserResultsPage() {
|
|||
|
||||
return (
|
||||
<div className="stack lg">
|
||||
<SubPageHeader
|
||||
user={layoutData.user}
|
||||
backTo={userPage(layoutData.user)}
|
||||
/>
|
||||
<div className="stack horizontal justify-between items-center">
|
||||
<h2 className="text-lg">
|
||||
{showAll || !data.hasHighlightedResults
|
||||
|
|
|
|||
|
|
@ -54,8 +54,10 @@ import {
|
|||
sendouQMatchPage,
|
||||
TIERS_PAGE,
|
||||
tournamentTeamPage,
|
||||
userPage,
|
||||
userSeasonsPage,
|
||||
} from "~/utils/urls";
|
||||
import { SubPageHeader } from "../components/SubPageHeader";
|
||||
import {
|
||||
loader,
|
||||
type UserSeasonsPageLoaderData,
|
||||
|
|
@ -71,11 +73,20 @@ const DAYS_WITH_SKILL_NEEDED_TO_SHOW_POWER_CHART = 2;
|
|||
export default function UserSeasonsPage() {
|
||||
const { t } = useTranslation(["user"]);
|
||||
const data = useLoaderData<typeof loader>();
|
||||
const [, parentRoute] = useMatches();
|
||||
invariant(parentRoute);
|
||||
const layoutData = parentRoute.data as UserPageLoaderData;
|
||||
|
||||
if (!data) {
|
||||
return (
|
||||
<div className="text-lg text-lighter font-semi-bold text-center mt-2">
|
||||
{t("user:seasons.noSeasons")}
|
||||
<div>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
|
@ -83,6 +94,10 @@ export default function UserSeasonsPage() {
|
|||
if (data.results.value.length === 0) {
|
||||
return (
|
||||
<div className="stack lg half-width">
|
||||
<SubPageHeader
|
||||
user={layoutData.user}
|
||||
backTo={userPage(layoutData.user)}
|
||||
/>
|
||||
<SeasonHeader
|
||||
seasonViewed={data.season}
|
||||
seasonsParticipatedIn={data.seasonsParticipatedIn}
|
||||
|
|
@ -99,6 +114,10 @@ export default function UserSeasonsPage() {
|
|||
|
||||
return (
|
||||
<div className="stack lg half-width">
|
||||
<SubPageHeader
|
||||
user={layoutData.user}
|
||||
backTo={userPage(layoutData.user)}
|
||||
/>
|
||||
<SeasonHeader
|
||||
seasonViewed={data.season}
|
||||
seasonsParticipatedIn={data.seasonsParticipatedIn}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { useTranslation } from "react-i18next";
|
||||
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 { SubNav, SubNavLink } from "~/components/SubNav";
|
||||
import { useUser } from "~/features/auth/core/user";
|
||||
|
|
@ -19,6 +19,7 @@ import {
|
|||
userSeasonsPage,
|
||||
userVodsPage,
|
||||
} from "~/utils/urls";
|
||||
import type { UserPageNavItem } from "../components/UserPageIconNav";
|
||||
|
||||
import {
|
||||
loader,
|
||||
|
|
@ -66,74 +67,133 @@ export default function UserPageLayout() {
|
|||
const isStaff = useHasRole("STAFF");
|
||||
const location = useLocation();
|
||||
const { t } = useTranslation(["common", "user"]);
|
||||
const matches = useMatches();
|
||||
|
||||
const isOwnPage = data.user.id === user?.id;
|
||||
|
||||
const allResultsCount =
|
||||
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 (
|
||||
<Main bigger={location.pathname.includes("results")}>
|
||||
<SubNav>
|
||||
<SubNavLink to={userPage(data.user)} data-testid="user-profile-tab">
|
||||
{t("common:header.profile")}
|
||||
</SubNavLink>
|
||||
<SubNavLink
|
||||
to={userSeasonsPage({ user: data.user })}
|
||||
data-testid="user-seasons-tab"
|
||||
>
|
||||
{t("user:seasons")}
|
||||
</SubNavLink>
|
||||
{isOwnPage ? (
|
||||
{isNewUserPage ? null : (
|
||||
<SubNav>
|
||||
<SubNavLink to={userPage(data.user)} data-testid="user-profile-tab">
|
||||
{t("common:header.profile")}
|
||||
</SubNavLink>
|
||||
<SubNavLink
|
||||
to={userEditProfilePage(data.user)}
|
||||
prefetch="intent"
|
||||
data-testid="user-edit-tab"
|
||||
to={userSeasonsPage({ user: data.user })}
|
||||
data-testid="user-seasons-tab"
|
||||
>
|
||||
{t("common:actions.edit")}
|
||||
{t("user:seasons")}
|
||||
</SubNavLink>
|
||||
) : null}
|
||||
{allResultsCount > 0 ? (
|
||||
<SubNavLink
|
||||
to={userResultsPage(data.user)}
|
||||
data-testid="user-results-tab"
|
||||
>
|
||||
{t("common:results")} ({allResultsCount})
|
||||
</SubNavLink>
|
||||
) : null}
|
||||
{data.user.buildsCount > 0 || isOwnPage ? (
|
||||
<SubNavLink
|
||||
to={userBuildsPage(data.user)}
|
||||
prefetch="intent"
|
||||
data-testid="user-builds-tab"
|
||||
>
|
||||
{t("common:pages.builds")} ({data.user.buildsCount})
|
||||
</SubNavLink>
|
||||
) : null}
|
||||
{data.user.vodsCount > 0 || isOwnPage ? (
|
||||
<SubNavLink to={userVodsPage(data.user)} data-testid="user-vods-tab">
|
||||
{t("common:pages.vods")} ({data.user.vodsCount})
|
||||
</SubNavLink>
|
||||
) : null}
|
||||
{data.user.artCount > 0 || isOwnPage ? (
|
||||
<SubNavLink
|
||||
to={userArtPage(data.user)}
|
||||
end={false}
|
||||
data-testid="user-art-tab"
|
||||
>
|
||||
{t("common:pages.art")} ({data.user.artCount})
|
||||
</SubNavLink>
|
||||
) : null}
|
||||
{isStaff ? (
|
||||
<SubNavLink
|
||||
to={userAdminPage(data.user)}
|
||||
data-testid="user-admin-tab"
|
||||
>
|
||||
Admin
|
||||
</SubNavLink>
|
||||
) : null}
|
||||
</SubNav>
|
||||
<Outlet />
|
||||
{isOwnPage ? (
|
||||
<SubNavLink
|
||||
to={userEditProfilePage(data.user)}
|
||||
prefetch="intent"
|
||||
data-testid="user-edit-tab"
|
||||
>
|
||||
{t("common:actions.edit")}
|
||||
</SubNavLink>
|
||||
) : null}
|
||||
{allResultsCount > 0 ? (
|
||||
<SubNavLink
|
||||
to={userResultsPage(data.user)}
|
||||
data-testid="user-results-tab"
|
||||
>
|
||||
{t("common:results")} ({allResultsCount})
|
||||
</SubNavLink>
|
||||
) : null}
|
||||
{data.user.buildsCount > 0 || isOwnPage ? (
|
||||
<SubNavLink
|
||||
to={userBuildsPage(data.user)}
|
||||
prefetch="intent"
|
||||
data-testid="user-builds-tab"
|
||||
>
|
||||
{t("common:pages.builds")} ({data.user.buildsCount})
|
||||
</SubNavLink>
|
||||
) : null}
|
||||
{data.user.vodsCount > 0 || isOwnPage ? (
|
||||
<SubNavLink
|
||||
to={userVodsPage(data.user)}
|
||||
data-testid="user-vods-tab"
|
||||
>
|
||||
{t("common:pages.vods")} ({data.user.vodsCount})
|
||||
</SubNavLink>
|
||||
) : null}
|
||||
{data.user.artCount > 0 || isOwnPage ? (
|
||||
<SubNavLink
|
||||
to={userArtPage(data.user)}
|
||||
end={false}
|
||||
data-testid="user-art-tab"
|
||||
>
|
||||
{t("common:pages.art")} ({data.user.artCount})
|
||||
</SubNavLink>
|
||||
) : null}
|
||||
{isStaff ? (
|
||||
<SubNavLink
|
||||
to={userAdminPage(data.user)}
|
||||
data-testid="user-admin-tab"
|
||||
>
|
||||
Admin
|
||||
</SubNavLink>
|
||||
) : null}
|
||||
</SubNav>
|
||||
)}
|
||||
<Outlet context={{ navItems }} />
|
||||
</Main>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,7 +4,9 @@ import { VodListing } from "~/features/vods/components/VodListing";
|
|||
import styles from "~/features/vods/routes/vods.module.css";
|
||||
import invariant from "~/utils/invariant";
|
||||
import type { SendouRouteHandle } from "~/utils/remix.server";
|
||||
import { newVodPage } from "~/utils/urls";
|
||||
import { newVodPage, userPage } from "~/utils/urls";
|
||||
import { SubPageHeader } from "../components/SubPageHeader";
|
||||
import type { UserPageLoaderData } from "../loaders/u.$identifier.server";
|
||||
import { loader } from "../loaders/u.$identifier.vods.server";
|
||||
export { loader };
|
||||
|
||||
|
|
@ -16,12 +18,13 @@ export default function UserVodsPage() {
|
|||
const [, parentRoute] = useMatches();
|
||||
invariant(parentRoute);
|
||||
const data = useLoaderData<typeof loader>();
|
||||
const layoutData = parentRoute.data as UserPageLoaderData;
|
||||
|
||||
return (
|
||||
<div className="stack md">
|
||||
<div className="stack items-end">
|
||||
<SubPageHeader user={layoutData.user} backTo={userPage(layoutData.user)}>
|
||||
<AddNewButton navIcon="vods" to={newVodPage()} />
|
||||
</div>
|
||||
</SubPageHeader>
|
||||
<div className={styles.listingList}>
|
||||
{data.vods.map((vod) => (
|
||||
<VodListing key={vod.id} vod={vod} showUser={false} />
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ export const HIGHLIGHT_TOURNAMENT_CHECKBOX_NAME = "highlightTournamentTeamIds";
|
|||
|
||||
export const USER = {
|
||||
BIO_MAX_LENGTH: 2000,
|
||||
BIO_MD_MAX_LENGTH: 4000,
|
||||
CUSTOM_URL_MAX_LENGTH: 32,
|
||||
CUSTOM_NAME_MAX_LENGTH: 32,
|
||||
BATTLEFY_MAX_LENGTH: 32,
|
||||
|
|
@ -11,6 +12,8 @@ export const USER = {
|
|||
WEAPON_POOL_MAX_SIZE: 5,
|
||||
COMMISSION_TEXT_MAX_LENGTH: 1000,
|
||||
MOD_NOTE_MAX_LENGTH: 2000,
|
||||
MAX_MAIN_WIDGETS: 5,
|
||||
MAX_SIDE_WIDGETS: 7,
|
||||
};
|
||||
|
||||
export const MATCHES_PER_SEASONS_PAGE = 8;
|
||||
|
|
|
|||
|
|
@ -39,6 +39,7 @@ import {
|
|||
undefinedToNull,
|
||||
weaponSplId,
|
||||
} from "~/utils/zod";
|
||||
import { allWidgetsFlat, findWidgetById } from "./core/widgets/portfolio";
|
||||
import {
|
||||
COUNTRY_CODES,
|
||||
HIGHLIGHT_CHECKBOX_NAME,
|
||||
|
|
@ -147,6 +148,7 @@ export const userEditActionSchema = z
|
|||
.nullish(),
|
||||
),
|
||||
showDiscordUniqueName: z.preprocess(checkboxValueToDbBoolean, dbBoolean),
|
||||
newProfileEnabled: z.preprocess(checkboxValueToDbBoolean, dbBoolean),
|
||||
commissionsOpen: z.preprocess(checkboxValueToDbBoolean, dbBoolean),
|
||||
commissionText: z.preprocess(
|
||||
falsyToNull,
|
||||
|
|
@ -199,6 +201,43 @@ export const userResultsPageSearchParamsSchema = z.object({
|
|||
page: z.coerce.number().min(1).max(1_000).catch(1),
|
||||
});
|
||||
|
||||
const widgetSettingsSchemas = allWidgetsFlat().map((widget) => {
|
||||
if ("schema" in widget) {
|
||||
return z.object({
|
||||
id: z.literal(widget.id),
|
||||
settings: widget.schema,
|
||||
});
|
||||
}
|
||||
return z.object({
|
||||
id: z.literal(widget.id),
|
||||
});
|
||||
});
|
||||
|
||||
const widgetSettingsSchema = z.union(widgetSettingsSchemas);
|
||||
|
||||
export const widgetsEditSchema = z.object({
|
||||
widgets: z.preprocess(
|
||||
safeJSONParse,
|
||||
z
|
||||
.array(widgetSettingsSchema)
|
||||
.max(USER.MAX_MAIN_WIDGETS + USER.MAX_SIDE_WIDGETS)
|
||||
.refine((widgets) => {
|
||||
let mainCount = 0;
|
||||
let sideCount = 0;
|
||||
for (const w of widgets) {
|
||||
const def = findWidgetById(w.id);
|
||||
if (!def) return false;
|
||||
if (def.slot === "main") mainCount++;
|
||||
else sideCount++;
|
||||
}
|
||||
return (
|
||||
mainCount <= USER.MAX_MAIN_WIDGETS &&
|
||||
sideCount <= USER.MAX_SIDE_WIDGETS
|
||||
);
|
||||
}),
|
||||
),
|
||||
});
|
||||
|
||||
const headGearIdSchema = z
|
||||
.number()
|
||||
.nullable()
|
||||
|
|
|
|||
|
|
@ -60,6 +60,7 @@ type BaseFormProps<T extends z.ZodRawShape> = {
|
|||
_action?: string;
|
||||
submitButtonTestId?: string;
|
||||
autoSubmit?: boolean;
|
||||
autoApply?: boolean;
|
||||
className?: string;
|
||||
onApply?: (values: z.infer<z.ZodObject<T>>) => void;
|
||||
secondarySubmit?: React.ReactNode;
|
||||
|
|
@ -84,6 +85,7 @@ export function SendouForm<T extends z.ZodRawShape>({
|
|||
_action,
|
||||
submitButtonTestId,
|
||||
autoSubmit,
|
||||
autoApply,
|
||||
className,
|
||||
onApply,
|
||||
secondarySubmit,
|
||||
|
|
@ -248,30 +250,35 @@ export function SendouForm<T extends z.ZodRawShape>({
|
|||
setClientErrors(newErrors);
|
||||
};
|
||||
|
||||
const onFieldChange = autoSubmit
|
||||
? (changedName: string, changedValue: unknown) => {
|
||||
const updatedValues = { ...values, [changedName]: changedValue };
|
||||
const onFieldChange =
|
||||
autoSubmit || autoApply
|
||||
? (changedName: string, changedValue: unknown) => {
|
||||
const updatedValues = { ...values, [changedName]: changedValue };
|
||||
|
||||
const newErrors: Record<string, string> = {};
|
||||
for (const key of Object.keys(schema.shape)) {
|
||||
const error = validateField(schema, key, updatedValues[key]);
|
||||
if (error) {
|
||||
newErrors[key] = error;
|
||||
const newErrors: Record<string, string> = {};
|
||||
for (const key of Object.keys(schema.shape)) {
|
||||
const error = validateField(schema, key, updatedValues[key]);
|
||||
if (error) {
|
||||
newErrors[key] = error;
|
||||
}
|
||||
}
|
||||
|
||||
if (Object.keys(newErrors).length > 0) {
|
||||
setClientErrors(newErrors);
|
||||
return;
|
||||
}
|
||||
|
||||
if (autoApply && onApply) {
|
||||
onApply(updatedValues as z.infer<z.ZodObject<T>>);
|
||||
} else if (autoSubmit) {
|
||||
fetcher.submit(updatedValues as Record<string, string>, {
|
||||
method,
|
||||
action,
|
||||
encType: "application/json",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (Object.keys(newErrors).length > 0) {
|
||||
setClientErrors(newErrors);
|
||||
return;
|
||||
}
|
||||
|
||||
fetcher.submit(updatedValues as Record<string, string>, {
|
||||
method,
|
||||
action,
|
||||
encType: "application/json",
|
||||
});
|
||||
}
|
||||
: undefined;
|
||||
: undefined;
|
||||
|
||||
const submitToServer = (valuesToSubmit: Record<string, unknown>) => {
|
||||
if (!validateAndPrepare()) return;
|
||||
|
|
@ -343,7 +350,7 @@ export function SendouForm<T extends z.ZodRawShape>({
|
|||
>
|
||||
{title ? <h2 className={styles.title}>{title}</h2> : null}
|
||||
<React.Fragment key={locationKey}>{resolvedChildren}</React.Fragment>
|
||||
{autoSubmit ? null : (
|
||||
{autoSubmit || autoApply ? null : (
|
||||
<div className="mt-4 stack horizontal md mx-auto justify-center">
|
||||
<SubmitButton
|
||||
_action={_action}
|
||||
|
|
|
|||
|
|
@ -170,6 +170,35 @@ function textFieldRefined<T extends z.ZodType<string | null>>(
|
|||
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(
|
||||
args: WithTypedTranslationKeys<
|
||||
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(
|
||||
args: WithTypedTranslationKeys<
|
||||
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(
|
||||
args: WithTypedTranslationKeys<
|
||||
Omit<
|
||||
|
|
|
|||
|
|
@ -44,6 +44,10 @@ export default [
|
|||
index("features/user-page/routes/u.$identifier.index.tsx"),
|
||||
route("art", "features/user-page/routes/u.$identifier.art.tsx"),
|
||||
route("edit", "features/user-page/routes/u.$identifier.edit.tsx"),
|
||||
route(
|
||||
"edit-widgets",
|
||||
"features/user-page/routes/u.$identifier.edit-widgets.tsx",
|
||||
),
|
||||
route("seasons", "features/user-page/routes/u.$identifier.seasons.tsx"),
|
||||
route("vods", "features/user-page/routes/u.$identifier.vods.tsx"),
|
||||
route("builds", "features/user-page/routes/u.$identifier.builds.tsx"),
|
||||
|
|
|
|||
|
|
@ -200,7 +200,8 @@
|
|||
.u__weapon {
|
||||
padding: var(--s-2);
|
||||
border-radius: 100%;
|
||||
background-color: var(--bg-lighter);
|
||||
background-color: var(--bg-lightest);
|
||||
border: 2px solid var(--border);
|
||||
}
|
||||
|
||||
.u__build-form {
|
||||
|
|
|
|||
|
|
@ -77,6 +77,8 @@ export const bskyUrl = (accountName: string) =>
|
|||
`https://bsky.app/profile/${accountName}`;
|
||||
export const twitchUrl = (accountName: string) =>
|
||||
`https://twitch.tv/${accountName}`;
|
||||
export const youtubeUrl = (channelId: string) =>
|
||||
`https://youtube.com/channel/${channelId}`;
|
||||
|
||||
export const LOG_IN_URL = "/auth";
|
||||
export const LOG_OUT_URL = "/auth/logout";
|
||||
|
|
@ -492,6 +494,8 @@ export const stageImageUrl = (stageId: StageId) =>
|
|||
`/static-assets/img/stages/${stageId}`;
|
||||
export const tierImageUrl = (tier: TierName | "CALCULATING") =>
|
||||
`/static-assets/img/tiers/${tier.toLowerCase()}`;
|
||||
export const controllerImageUrl = (controller: string) =>
|
||||
`/static-assets/img/controllers/${controller}.avif`;
|
||||
export const preferenceEmojiUrl = (preference?: Preference) => {
|
||||
const emoji =
|
||||
preference === "PREFER"
|
||||
|
|
|
|||
|
|
@ -807,6 +807,8 @@ test.describe("Tournament bracket", () => {
|
|||
});
|
||||
|
||||
test("locks/unlocks matches & sets match as casted", async ({ page }) => {
|
||||
test.slow();
|
||||
|
||||
const tournamentId = 2;
|
||||
|
||||
await seed(page);
|
||||
|
|
|
|||
|
|
@ -132,6 +132,8 @@
|
|||
"actions.next": "",
|
||||
"actions.previous": "",
|
||||
"actions.back": "",
|
||||
"actions.hide": "",
|
||||
"actions.settings": "",
|
||||
"noResults": "",
|
||||
"maps.createMapList": "Lav bane-liste",
|
||||
"maps.halfSz": "50% DD",
|
||||
|
|
@ -255,6 +257,7 @@
|
|||
"support.perk.joinMoreAssociations": "",
|
||||
"support.perk.useBotToLogIn": "",
|
||||
"support.perk.useBotToLogIn.extra": "",
|
||||
"support.perk.earlyAccess": "",
|
||||
"custom.colors.title": "Tilpas farver",
|
||||
"custom.colors.bg": "Baggrund",
|
||||
"custom.colors.bg-darker": "Mørkere baggrund",
|
||||
|
|
|
|||
|
|
@ -168,5 +168,19 @@
|
|||
"labels.banUserExpiresAt": "",
|
||||
"bottomTexts.banUserExpiresAtHelp": "",
|
||||
"labels.scrimCancelReason": "",
|
||||
"bottomTexts.scrimCancelReasonHelp": ""
|
||||
"bottomTexts.scrimCancelReasonHelp": "",
|
||||
"bottomTexts.bioMarkdown": "",
|
||||
"labels.division": "",
|
||||
"options.division.both": "",
|
||||
"options.division.tentatek": "",
|
||||
"options.division.takoroka": "",
|
||||
"labels.timezone": "",
|
||||
"labels.favoriteStage": "",
|
||||
"labels.peakXp": "",
|
||||
"labels.weapon": "",
|
||||
"labels.artSource": "",
|
||||
"options.artSource.ALL": "",
|
||||
"options.artSource.MADE-BY": "",
|
||||
"options.artSource.MADE-OF": "",
|
||||
"labels.tierListUrl": ""
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,6 +5,130 @@
|
|||
"ign.short": "Splatnavn",
|
||||
"country": "Land",
|
||||
"bio": "Biografi",
|
||||
"widget.bio": "",
|
||||
"widget.bio-md": "",
|
||||
"widget.badges-owned": "",
|
||||
"widget.badges-authored": "",
|
||||
"widget.teams": "",
|
||||
"widget.organizations": "",
|
||||
"widget.peak-sp": "",
|
||||
"widget.top-10-seasons": "",
|
||||
"widget.top-100-seasons": "",
|
||||
"widget.peak-xp": "",
|
||||
"widget.peak-xp-unverified": "",
|
||||
"widget.peak-xp-weapon": "",
|
||||
"widget.highlighted-results": "",
|
||||
"widget.placement-results": "",
|
||||
"widget.patron-since": "",
|
||||
"widget.timezone": "",
|
||||
"widget.favorite-stage": "",
|
||||
"widget.videos": "",
|
||||
"widget.lfg-posts": "",
|
||||
"widget.top-500-weapons": "",
|
||||
"widget.top-500-weapons-shooters": "",
|
||||
"widget.top-500-weapons-blasters": "",
|
||||
"widget.top-500-weapons-rollers": "",
|
||||
"widget.top-500-weapons-brushes": "",
|
||||
"widget.top-500-weapons-chargers": "",
|
||||
"widget.top-500-weapons-sloshers": "",
|
||||
"widget.top-500-weapons-splatlings": "",
|
||||
"widget.top-500-weapons-dualies": "",
|
||||
"widget.top-500-weapons-brellas": "",
|
||||
"widget.top-500-weapons-stringers": "",
|
||||
"widget.top-500-weapons-splatanas": "",
|
||||
"widget.x-rank-peaks": "",
|
||||
"widget.builds": "",
|
||||
"widget.weapon-pool": "",
|
||||
"widget.sens": "",
|
||||
"widget.art": "",
|
||||
"widget.commissions": "",
|
||||
"widget.social-links": "",
|
||||
"widget.links": "",
|
||||
"widget.tier-list": "",
|
||||
"widget.link.all": "",
|
||||
"widgets.edit": "",
|
||||
"widgets.editProfile": "",
|
||||
"widgets.editTitle": "",
|
||||
"widgets.available": "",
|
||||
"widgets.mainSlot": "",
|
||||
"widgets.sideSlot": "",
|
||||
"widgets.main": "",
|
||||
"widgets.side": "",
|
||||
"widgets.add": "",
|
||||
"widgets.remove": "",
|
||||
"widgets.maxReached": "",
|
||||
"widgets.search": "",
|
||||
"widgets.category.misc": "",
|
||||
"widgets.category.badges": "",
|
||||
"widgets.category.teams": "",
|
||||
"widgets.category.sendouq": "",
|
||||
"widgets.category.xrank": "",
|
||||
"widgets.category.tournaments": "",
|
||||
"widgets.category.vods": "",
|
||||
"widgets.category.builds": "",
|
||||
"widgets.category.art": "",
|
||||
"widgets.description.bio": "",
|
||||
"widgets.description.bio-md": "",
|
||||
"widgets.description.badges-owned": "",
|
||||
"widgets.description.badges-authored": "",
|
||||
"widgets.description.teams": "",
|
||||
"widgets.description.organizations": "",
|
||||
"widgets.description.peak-sp": "",
|
||||
"widgets.description.top-10-seasons": "",
|
||||
"widgets.description.top-100-seasons": "",
|
||||
"widgets.description.peak-xp": "",
|
||||
"widgets.description.peak-xp-unverified": "",
|
||||
"widgets.description.peak-xp-weapon": "",
|
||||
"widgets.description.highlighted-results": "",
|
||||
"widgets.description.placement-results": "",
|
||||
"widgets.description.patron-since": "",
|
||||
"widgets.description.timezone": "",
|
||||
"widgets.description.favorite-stage": "",
|
||||
"widgets.description.videos": "",
|
||||
"widgets.description.lfg-posts": "",
|
||||
"widgets.description.top-500-weapons": "",
|
||||
"widgets.description.top-500-weapons-shooters": "",
|
||||
"widgets.description.top-500-weapons-blasters": "",
|
||||
"widgets.description.top-500-weapons-rollers": "",
|
||||
"widgets.description.top-500-weapons-brushes": "",
|
||||
"widgets.description.top-500-weapons-chargers": "",
|
||||
"widgets.description.top-500-weapons-sloshers": "",
|
||||
"widgets.description.top-500-weapons-splatlings": "",
|
||||
"widgets.description.top-500-weapons-dualies": "",
|
||||
"widgets.description.top-500-weapons-brellas": "",
|
||||
"widgets.description.top-500-weapons-stringers": "",
|
||||
"widgets.description.top-500-weapons-splatanas": "",
|
||||
"widgets.description.x-rank-peaks": "",
|
||||
"widgets.description.builds": "",
|
||||
"widgets.description.weapon-pool": "",
|
||||
"widgets.description.sens": "",
|
||||
"widgets.description.art": "",
|
||||
"widgets.description.commissions": "",
|
||||
"widgets.description.social-links": "",
|
||||
"widgets.description.links": "",
|
||||
"widgets.description.tier-list": "",
|
||||
"widgets.forms.bio": "",
|
||||
"widgets.forms.bio.markdownSupport": "",
|
||||
"widgets.forms.division": "",
|
||||
"widgets.forms.division.both": "",
|
||||
"widgets.forms.division.tentatek": "",
|
||||
"widgets.forms.division.takoroka": "",
|
||||
"widgets.forms.timezone": "",
|
||||
"widgets.forms.favoriteStage": "",
|
||||
"widgets.forms.weapon": "",
|
||||
"widgets.forms.peakXp": "",
|
||||
"widgets.forms.controller": "",
|
||||
"widgets.forms.source": "",
|
||||
"widgets.forms.source.ALL": "",
|
||||
"widgets.forms.source.MADE-BY": "",
|
||||
"widgets.forms.source.MADE-OF": "",
|
||||
"widgets.forms.links": "",
|
||||
"widgets.forms.tierListUrl": "",
|
||||
"widget.tier-list.untitled": "",
|
||||
"controllers.s1-pro-con": "",
|
||||
"controllers.s2-pro-con": "",
|
||||
"controllers.grip": "",
|
||||
"controllers.handheld": "",
|
||||
"stickSens": "Styrepindsfølsomhed",
|
||||
"motionSens": "Bevægelsesfølsomhed",
|
||||
"motion": "Bevægelse",
|
||||
|
|
@ -17,6 +141,8 @@
|
|||
"discordExplanation": "Brugernavn, Profilbillede, Youtube-, Bluesky- og Twitch-konter er hentet via din Discord-konto. Se <1>FAQ</1> for yderligere information.",
|
||||
"favoriteBadges": "",
|
||||
"battlefy": "Battlefy brugernavn",
|
||||
"forms.newProfileEnabled": "",
|
||||
"forms.newProfileEnabled.info": "",
|
||||
"forms.showDiscordUniqueName": "Vis Discord-brugernavn",
|
||||
"forms.showDiscordUniqueName.info": "Vil du gøre dit unikke Discord-brugernavn ({{discordUniqueName}}) synligt for offentligheden?",
|
||||
"forms.commissionsOpen": "Åben for bestillinger",
|
||||
|
|
@ -82,5 +208,7 @@
|
|||
"builds.sorting.CLOTHES_ID": "Kropsbeklædning (in-game sortering)",
|
||||
"builds.sorting.SHOES_ID": "Sko in-game (in-game sortering)",
|
||||
"builds.sorting.PUBLIC_BUILD": "offentlige sæt",
|
||||
"builds.sorting.PRIVATE_BUILD": "Private sæt"
|
||||
"builds.sorting.PRIVATE_BUILD": "Private sæt",
|
||||
"commissions.open": "",
|
||||
"commissions.closed": ""
|
||||
}
|
||||
|
|
|
|||
|
|
@ -132,6 +132,8 @@
|
|||
"actions.next": "",
|
||||
"actions.previous": "",
|
||||
"actions.back": "",
|
||||
"actions.hide": "",
|
||||
"actions.settings": "",
|
||||
"noResults": "",
|
||||
"maps.createMapList": "Arenen-Liste erstellen",
|
||||
"maps.halfSz": "50% Herrschaft",
|
||||
|
|
@ -255,6 +257,7 @@
|
|||
"support.perk.joinMoreAssociations": "",
|
||||
"support.perk.useBotToLogIn": "",
|
||||
"support.perk.useBotToLogIn.extra": "",
|
||||
"support.perk.earlyAccess": "",
|
||||
"custom.colors.title": "Eigene Farben",
|
||||
"custom.colors.bg": "Hintergrund",
|
||||
"custom.colors.bg-darker": "Hintergrund dunkler",
|
||||
|
|
|
|||
|
|
@ -168,5 +168,19 @@
|
|||
"labels.banUserExpiresAt": "",
|
||||
"bottomTexts.banUserExpiresAtHelp": "",
|
||||
"labels.scrimCancelReason": "",
|
||||
"bottomTexts.scrimCancelReasonHelp": ""
|
||||
"bottomTexts.scrimCancelReasonHelp": "",
|
||||
"bottomTexts.bioMarkdown": "",
|
||||
"labels.division": "",
|
||||
"options.division.both": "",
|
||||
"options.division.tentatek": "",
|
||||
"options.division.takoroka": "",
|
||||
"labels.timezone": "",
|
||||
"labels.favoriteStage": "",
|
||||
"labels.peakXp": "",
|
||||
"labels.weapon": "",
|
||||
"labels.artSource": "",
|
||||
"options.artSource.ALL": "",
|
||||
"options.artSource.MADE-BY": "",
|
||||
"options.artSource.MADE-OF": "",
|
||||
"labels.tierListUrl": ""
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,6 +5,130 @@
|
|||
"ign.short": "IGN",
|
||||
"country": "Land",
|
||||
"bio": "Über mich",
|
||||
"widget.bio": "",
|
||||
"widget.bio-md": "",
|
||||
"widget.badges-owned": "",
|
||||
"widget.badges-authored": "",
|
||||
"widget.teams": "",
|
||||
"widget.organizations": "",
|
||||
"widget.peak-sp": "",
|
||||
"widget.top-10-seasons": "",
|
||||
"widget.top-100-seasons": "",
|
||||
"widget.peak-xp": "",
|
||||
"widget.peak-xp-unverified": "",
|
||||
"widget.peak-xp-weapon": "",
|
||||
"widget.highlighted-results": "",
|
||||
"widget.placement-results": "",
|
||||
"widget.patron-since": "",
|
||||
"widget.timezone": "",
|
||||
"widget.favorite-stage": "",
|
||||
"widget.videos": "",
|
||||
"widget.lfg-posts": "",
|
||||
"widget.top-500-weapons": "",
|
||||
"widget.top-500-weapons-shooters": "",
|
||||
"widget.top-500-weapons-blasters": "",
|
||||
"widget.top-500-weapons-rollers": "",
|
||||
"widget.top-500-weapons-brushes": "",
|
||||
"widget.top-500-weapons-chargers": "",
|
||||
"widget.top-500-weapons-sloshers": "",
|
||||
"widget.top-500-weapons-splatlings": "",
|
||||
"widget.top-500-weapons-dualies": "",
|
||||
"widget.top-500-weapons-brellas": "",
|
||||
"widget.top-500-weapons-stringers": "",
|
||||
"widget.top-500-weapons-splatanas": "",
|
||||
"widget.x-rank-peaks": "",
|
||||
"widget.builds": "",
|
||||
"widget.weapon-pool": "",
|
||||
"widget.sens": "",
|
||||
"widget.art": "",
|
||||
"widget.commissions": "",
|
||||
"widget.social-links": "",
|
||||
"widget.links": "",
|
||||
"widget.tier-list": "",
|
||||
"widget.link.all": "",
|
||||
"widgets.edit": "",
|
||||
"widgets.editProfile": "",
|
||||
"widgets.editTitle": "",
|
||||
"widgets.available": "",
|
||||
"widgets.mainSlot": "",
|
||||
"widgets.sideSlot": "",
|
||||
"widgets.main": "",
|
||||
"widgets.side": "",
|
||||
"widgets.add": "",
|
||||
"widgets.remove": "",
|
||||
"widgets.maxReached": "",
|
||||
"widgets.search": "",
|
||||
"widgets.category.misc": "",
|
||||
"widgets.category.badges": "",
|
||||
"widgets.category.teams": "",
|
||||
"widgets.category.sendouq": "",
|
||||
"widgets.category.xrank": "",
|
||||
"widgets.category.tournaments": "",
|
||||
"widgets.category.vods": "",
|
||||
"widgets.category.builds": "",
|
||||
"widgets.category.art": "",
|
||||
"widgets.description.bio": "",
|
||||
"widgets.description.bio-md": "",
|
||||
"widgets.description.badges-owned": "",
|
||||
"widgets.description.badges-authored": "",
|
||||
"widgets.description.teams": "",
|
||||
"widgets.description.organizations": "",
|
||||
"widgets.description.peak-sp": "",
|
||||
"widgets.description.top-10-seasons": "",
|
||||
"widgets.description.top-100-seasons": "",
|
||||
"widgets.description.peak-xp": "",
|
||||
"widgets.description.peak-xp-unverified": "",
|
||||
"widgets.description.peak-xp-weapon": "",
|
||||
"widgets.description.highlighted-results": "",
|
||||
"widgets.description.placement-results": "",
|
||||
"widgets.description.patron-since": "",
|
||||
"widgets.description.timezone": "",
|
||||
"widgets.description.favorite-stage": "",
|
||||
"widgets.description.videos": "",
|
||||
"widgets.description.lfg-posts": "",
|
||||
"widgets.description.top-500-weapons": "",
|
||||
"widgets.description.top-500-weapons-shooters": "",
|
||||
"widgets.description.top-500-weapons-blasters": "",
|
||||
"widgets.description.top-500-weapons-rollers": "",
|
||||
"widgets.description.top-500-weapons-brushes": "",
|
||||
"widgets.description.top-500-weapons-chargers": "",
|
||||
"widgets.description.top-500-weapons-sloshers": "",
|
||||
"widgets.description.top-500-weapons-splatlings": "",
|
||||
"widgets.description.top-500-weapons-dualies": "",
|
||||
"widgets.description.top-500-weapons-brellas": "",
|
||||
"widgets.description.top-500-weapons-stringers": "",
|
||||
"widgets.description.top-500-weapons-splatanas": "",
|
||||
"widgets.description.x-rank-peaks": "",
|
||||
"widgets.description.builds": "",
|
||||
"widgets.description.weapon-pool": "",
|
||||
"widgets.description.sens": "",
|
||||
"widgets.description.art": "",
|
||||
"widgets.description.commissions": "",
|
||||
"widgets.description.social-links": "",
|
||||
"widgets.description.links": "",
|
||||
"widgets.description.tier-list": "",
|
||||
"widgets.forms.bio": "",
|
||||
"widgets.forms.bio.markdownSupport": "",
|
||||
"widgets.forms.division": "",
|
||||
"widgets.forms.division.both": "",
|
||||
"widgets.forms.division.tentatek": "",
|
||||
"widgets.forms.division.takoroka": "",
|
||||
"widgets.forms.timezone": "",
|
||||
"widgets.forms.favoriteStage": "",
|
||||
"widgets.forms.weapon": "",
|
||||
"widgets.forms.peakXp": "",
|
||||
"widgets.forms.controller": "",
|
||||
"widgets.forms.source": "",
|
||||
"widgets.forms.source.ALL": "",
|
||||
"widgets.forms.source.MADE-BY": "",
|
||||
"widgets.forms.source.MADE-OF": "",
|
||||
"widgets.forms.links": "",
|
||||
"widgets.forms.tierListUrl": "",
|
||||
"widget.tier-list.untitled": "",
|
||||
"controllers.s1-pro-con": "",
|
||||
"controllers.s2-pro-con": "",
|
||||
"controllers.grip": "",
|
||||
"controllers.handheld": "",
|
||||
"stickSens": "Empfindlichkeit R-Stick",
|
||||
"motionSens": "Empfindlichkeit Bewegungssteuerung",
|
||||
"motion": "Bewegungssteuerung",
|
||||
|
|
@ -17,6 +141,8 @@
|
|||
"discordExplanation": "Der Username, Profilbild, YouTube-, Bluesky- und Twitch-Konten stammen von deinem Discord-Konto. Mehr Infos in den <1>FAQ</1>.",
|
||||
"favoriteBadges": "",
|
||||
"battlefy": "",
|
||||
"forms.newProfileEnabled": "",
|
||||
"forms.newProfileEnabled.info": "",
|
||||
"forms.showDiscordUniqueName": "",
|
||||
"forms.showDiscordUniqueName.info": "",
|
||||
"forms.commissionsOpen": "",
|
||||
|
|
@ -82,5 +208,7 @@
|
|||
"builds.sorting.CLOTHES_ID": "",
|
||||
"builds.sorting.SHOES_ID": "",
|
||||
"builds.sorting.PUBLIC_BUILD": "",
|
||||
"builds.sorting.PRIVATE_BUILD": ""
|
||||
"builds.sorting.PRIVATE_BUILD": "",
|
||||
"commissions.open": "",
|
||||
"commissions.closed": ""
|
||||
}
|
||||
|
|
|
|||
|
|
@ -132,6 +132,8 @@
|
|||
"actions.next": "Next",
|
||||
"actions.previous": "Previous",
|
||||
"actions.back": "Back",
|
||||
"actions.hide": "Hide",
|
||||
"actions.settings": "Settings",
|
||||
"noResults": "No results",
|
||||
"maps.createMapList": "Create map list",
|
||||
"maps.halfSz": "50% SZ",
|
||||
|
|
@ -255,6 +257,7 @@
|
|||
"support.perk.joinMoreAssociations": "Join up to 6 associations",
|
||||
"support.perk.useBotToLogIn": "Log-in via Discord bot",
|
||||
"support.perk.useBotToLogIn.extra": "Request a log-in link from the Lohi bot as an alternative to the normal website log-in",
|
||||
"support.perk.earlyAccess": "Occasional early access to features",
|
||||
"custom.colors.title": "Custom colors",
|
||||
"custom.colors.bg": "Background",
|
||||
"custom.colors.bg-darker": "Background darker",
|
||||
|
|
|
|||
|
|
@ -168,5 +168,19 @@
|
|||
"labels.banUserExpiresAt": "Ban expiration date",
|
||||
"bottomTexts.banUserExpiresAtHelp": "Leave empty for a permanent ban",
|
||||
"labels.scrimCancelReason": "Reason for cancellation",
|
||||
"bottomTexts.scrimCancelReasonHelp": "Explain why you are cancelling the scrim. This will be visible to the other team."
|
||||
"bottomTexts.scrimCancelReasonHelp": "Explain why you are cancelling the scrim. This will be visible to the other team.",
|
||||
"bottomTexts.bioMarkdown": "Supports Markdown",
|
||||
"labels.division": "Division",
|
||||
"options.division.both": "Both divisions",
|
||||
"options.division.tentatek": "Tentatek only",
|
||||
"options.division.takoroka": "Takoroka only",
|
||||
"labels.timezone": "Timezone",
|
||||
"labels.favoriteStage": "Favorite Stage",
|
||||
"labels.peakXp": "Peak XP",
|
||||
"labels.weapon": "Weapon",
|
||||
"labels.artSource": "Art source",
|
||||
"options.artSource.ALL": "All",
|
||||
"options.artSource.MADE-BY": "Made by me",
|
||||
"options.artSource.MADE-OF": "Made of me",
|
||||
"labels.tierListUrl": "Tier List URL"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,6 +5,130 @@
|
|||
"ign.short": "IGN",
|
||||
"country": "Country",
|
||||
"bio": "Bio",
|
||||
"widget.bio": "Bio",
|
||||
"widget.bio-md": "Bio",
|
||||
"widget.badges-owned": "Badges",
|
||||
"widget.badges-authored": "Badges Authored",
|
||||
"widget.teams": "Teams",
|
||||
"widget.organizations": "Organizations",
|
||||
"widget.peak-sp": "Peak SP",
|
||||
"widget.top-10-seasons": "SendouQ Top 10 Finishes",
|
||||
"widget.top-100-seasons": "SendouQ Top 100 Finishes",
|
||||
"widget.peak-xp": "Peak XP",
|
||||
"widget.peak-xp-unverified": "Peak XP (Unverified)",
|
||||
"widget.peak-xp-weapon": "Peak XP (Weapon)",
|
||||
"widget.highlighted-results": "Tournament Results",
|
||||
"widget.placement-results": "Tournament Placements",
|
||||
"widget.patron-since": "Supporter Since",
|
||||
"widget.timezone": "User's Current Time",
|
||||
"widget.favorite-stage": "Favorite Stage",
|
||||
"widget.videos": "Videos",
|
||||
"widget.lfg-posts": "LFG Posts",
|
||||
"widget.top-500-weapons": "Top 500 Weapons",
|
||||
"widget.top-500-weapons-shooters": "Top 500 Shooters",
|
||||
"widget.top-500-weapons-blasters": "Top 500 Blasters",
|
||||
"widget.top-500-weapons-rollers": "Top 500 Rollers",
|
||||
"widget.top-500-weapons-brushes": "Top 500 Brushes",
|
||||
"widget.top-500-weapons-chargers": "Top 500 Chargers",
|
||||
"widget.top-500-weapons-sloshers": "Top 500 Sloshers",
|
||||
"widget.top-500-weapons-splatlings": "Top 500 Splatlings",
|
||||
"widget.top-500-weapons-dualies": "Top 500 Dualies",
|
||||
"widget.top-500-weapons-brellas": "Top 500 Brellas",
|
||||
"widget.top-500-weapons-stringers": "Top 500 Stringers",
|
||||
"widget.top-500-weapons-splatanas": "Top 500 Splatanas",
|
||||
"widget.x-rank-peaks": "X Rank Peaks Per Mode",
|
||||
"widget.builds": "Builds",
|
||||
"widget.weapon-pool": "Weapon Pool",
|
||||
"widget.sens": "Sensitivity",
|
||||
"widget.art": "Art",
|
||||
"widget.commissions": "Commissions",
|
||||
"widget.social-links": "Verified Social Links",
|
||||
"widget.links": "Links",
|
||||
"widget.tier-list": "Featured Tier List",
|
||||
"widget.link.all": "All",
|
||||
"widgets.edit": "Edit Widgets",
|
||||
"widgets.editProfile": "Edit Profile",
|
||||
"widgets.editTitle": "Edit Profile Widgets",
|
||||
"widgets.available": "Gallery",
|
||||
"widgets.mainSlot": "Main Widgets",
|
||||
"widgets.sideSlot": "Side Widgets",
|
||||
"widgets.main": "Main",
|
||||
"widgets.side": "Side",
|
||||
"widgets.add": "Add",
|
||||
"widgets.remove": "Remove",
|
||||
"widgets.maxReached": "Maximum widgets reached",
|
||||
"widgets.search": "Search widgets...",
|
||||
"widgets.category.misc": "Miscellaneous",
|
||||
"widgets.category.badges": "Badges",
|
||||
"widgets.category.teams": "Teams",
|
||||
"widgets.category.sendouq": "SendouQ",
|
||||
"widgets.category.xrank": "X Rank",
|
||||
"widgets.category.tournaments": "Tournaments",
|
||||
"widgets.category.vods": "Videos",
|
||||
"widgets.category.builds": "Builds",
|
||||
"widgets.category.art": "Art",
|
||||
"widgets.description.bio": "Share freeform information about yourself",
|
||||
"widgets.description.bio-md": "Share freeform information about yourself with markdown formatting",
|
||||
"widgets.description.badges-owned": "Display your earned badges",
|
||||
"widgets.description.badges-authored": "Display badges you've created",
|
||||
"widgets.description.teams": "Show teams you're a member of",
|
||||
"widgets.description.organizations": "Display tournament organizations you're part of",
|
||||
"widgets.description.peak-sp": "Show your highest SendouQ rating across all seasons",
|
||||
"widgets.description.top-10-seasons": "Show how many times and which seasons you've finished top 10 of SendouQ leaderboards",
|
||||
"widgets.description.top-100-seasons": "Show how many times and which seasons you've finished top 100 of SendouQ leaderboards",
|
||||
"widgets.description.peak-xp": "Show your peak X Rank power",
|
||||
"widgets.description.peak-xp-unverified": "Show your self-reported peak X Rank power",
|
||||
"widgets.description.peak-xp-weapon": "Show your peak X Rank power for a specific weapon with leaderboard position",
|
||||
"widgets.description.highlighted-results": "Display your (highlighted) tournament results",
|
||||
"widgets.description.placement-results": "Show your tournament placements by 1st, 2nd, and 3rd place",
|
||||
"widgets.description.patron-since": "Show the date when you became a supporter",
|
||||
"widgets.description.timezone": "Display your current local time",
|
||||
"widgets.description.favorite-stage": "Show your favorite stage",
|
||||
"widgets.description.videos": "Display your 3 most recent videos",
|
||||
"widgets.description.lfg-posts": "Show your current LFG posts",
|
||||
"widgets.description.top-500-weapons": "Show weapons you've reached top 500 with",
|
||||
"widgets.description.top-500-weapons-shooters": "Show shooters you've reached top 500 with",
|
||||
"widgets.description.top-500-weapons-blasters": "Show blasters you've reached top 500 with",
|
||||
"widgets.description.top-500-weapons-rollers": "Show rollers you've reached top 500 with",
|
||||
"widgets.description.top-500-weapons-brushes": "Show brushes you've reached top 500 with",
|
||||
"widgets.description.top-500-weapons-chargers": "Show chargers you've reached top 500 with",
|
||||
"widgets.description.top-500-weapons-sloshers": "Show sloshers you've reached top 500 with",
|
||||
"widgets.description.top-500-weapons-splatlings": "Show splatlings you've reached top 500 with",
|
||||
"widgets.description.top-500-weapons-dualies": "Show dualies you've reached top 500 with",
|
||||
"widgets.description.top-500-weapons-brellas": "Show brellas you've reached top 500 with",
|
||||
"widgets.description.top-500-weapons-stringers": "Show stringers you've reached top 500 with",
|
||||
"widgets.description.top-500-weapons-splatanas": "Show splatanas you've reached top 500 with",
|
||||
"widgets.description.x-rank-peaks": "Show your peak X Rank placement for each mode, optionally select division",
|
||||
"widgets.description.builds": "Display your 3 most recent builds",
|
||||
"widgets.description.weapon-pool": "Display your weapon pool with favorite selection",
|
||||
"widgets.description.sens": "Show your sensitivity settings and controller of choice",
|
||||
"widgets.description.art": "Display your 3 most recent art pieces",
|
||||
"widgets.description.commissions": "Show your commission status and details",
|
||||
"widgets.description.social-links": "Display your social media links from Discord",
|
||||
"widgets.description.links": "Share your custom social media and website links",
|
||||
"widgets.description.tier-list": "Display a tier list you have made",
|
||||
"widgets.forms.bio": "Bio",
|
||||
"widgets.forms.bio.markdownSupport": "Supports Markdown",
|
||||
"widgets.forms.division": "Division",
|
||||
"widgets.forms.division.both": "Both divisions",
|
||||
"widgets.forms.division.tentatek": "Tentatek only",
|
||||
"widgets.forms.division.takoroka": "Takoroka only",
|
||||
"widgets.forms.timezone": "Timezone",
|
||||
"widgets.forms.favoriteStage": "Favorite Stage",
|
||||
"widgets.forms.weapon": "Weapon",
|
||||
"widgets.forms.peakXp": "Peak XP",
|
||||
"widgets.forms.controller": "Controller",
|
||||
"widgets.forms.source": "Art source",
|
||||
"widgets.forms.source.ALL": "All",
|
||||
"widgets.forms.source.MADE-BY": "Made by me",
|
||||
"widgets.forms.source.MADE-OF": "Made of me",
|
||||
"widgets.forms.links": "Links",
|
||||
"widgets.forms.tierListUrl": "Tier List URL",
|
||||
"widget.tier-list.untitled": "Untitled Tier List",
|
||||
"controllers.s1-pro-con": "Switch 1 Pro Controller",
|
||||
"controllers.s2-pro-con": "Switch 2 Pro Controller",
|
||||
"controllers.grip": "Joy-Con Grip",
|
||||
"controllers.handheld": "Handheld",
|
||||
"stickSens": "R-stick sens",
|
||||
"motionSens": "Motion sens",
|
||||
"motion": "Motion",
|
||||
|
|
@ -17,6 +141,8 @@
|
|||
"discordExplanation": "Username, profile picture, YouTube, Bluesky and Twitch accounts come from your Discord account. See <1>FAQ</1> for more information.",
|
||||
"favoriteBadges": "Favorite badges",
|
||||
"battlefy": "Battlefy account name",
|
||||
"forms.newProfileEnabled": "New profile page",
|
||||
"forms.newProfileEnabled.info": "Enable the new widget-based profile page. Currently available as an early preview for supporters.",
|
||||
"forms.showDiscordUniqueName": "Show Discord username",
|
||||
"forms.showDiscordUniqueName.info": "Show your unique Discord name ({{discordUniqueName}}) publicly?",
|
||||
"forms.commissionsOpen": "Commissions open",
|
||||
|
|
@ -82,5 +208,7 @@
|
|||
"builds.sorting.CLOTHES_ID": "Clothing in-game order",
|
||||
"builds.sorting.SHOES_ID": "Shoes in-game order",
|
||||
"builds.sorting.PUBLIC_BUILD": "Public build",
|
||||
"builds.sorting.PRIVATE_BUILD": "Private build"
|
||||
"builds.sorting.PRIVATE_BUILD": "Private build",
|
||||
"commissions.open": "Open",
|
||||
"commissions.closed": "Closed"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -132,6 +132,8 @@
|
|||
"actions.next": "",
|
||||
"actions.previous": "",
|
||||
"actions.back": "",
|
||||
"actions.hide": "",
|
||||
"actions.settings": "",
|
||||
"noResults": "",
|
||||
"maps.createMapList": "Crear lista de mapas",
|
||||
"maps.halfSz": "50% Pintazonas",
|
||||
|
|
@ -257,6 +259,7 @@
|
|||
"support.perk.joinMoreAssociations": "",
|
||||
"support.perk.useBotToLogIn": "",
|
||||
"support.perk.useBotToLogIn.extra": "",
|
||||
"support.perk.earlyAccess": "",
|
||||
"custom.colors.title": "Colores personalizados",
|
||||
"custom.colors.bg": "Fondo",
|
||||
"custom.colors.bg-darker": "Fondo más oscuro",
|
||||
|
|
|
|||
|
|
@ -168,5 +168,19 @@
|
|||
"labels.banUserExpiresAt": "",
|
||||
"bottomTexts.banUserExpiresAtHelp": "",
|
||||
"labels.scrimCancelReason": "",
|
||||
"bottomTexts.scrimCancelReasonHelp": ""
|
||||
"bottomTexts.scrimCancelReasonHelp": "",
|
||||
"bottomTexts.bioMarkdown": "",
|
||||
"labels.division": "",
|
||||
"options.division.both": "",
|
||||
"options.division.tentatek": "",
|
||||
"options.division.takoroka": "",
|
||||
"labels.timezone": "",
|
||||
"labels.favoriteStage": "",
|
||||
"labels.peakXp": "",
|
||||
"labels.weapon": "",
|
||||
"labels.artSource": "",
|
||||
"options.artSource.ALL": "",
|
||||
"options.artSource.MADE-BY": "",
|
||||
"options.artSource.MADE-OF": "",
|
||||
"labels.tierListUrl": ""
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,6 +5,130 @@
|
|||
"ign.short": "IGN",
|
||||
"country": "País",
|
||||
"bio": "Biografía",
|
||||
"widget.bio": "",
|
||||
"widget.bio-md": "",
|
||||
"widget.badges-owned": "",
|
||||
"widget.badges-authored": "",
|
||||
"widget.teams": "",
|
||||
"widget.organizations": "",
|
||||
"widget.peak-sp": "",
|
||||
"widget.top-10-seasons": "",
|
||||
"widget.top-100-seasons": "",
|
||||
"widget.peak-xp": "",
|
||||
"widget.peak-xp-unverified": "",
|
||||
"widget.peak-xp-weapon": "",
|
||||
"widget.highlighted-results": "",
|
||||
"widget.placement-results": "",
|
||||
"widget.patron-since": "",
|
||||
"widget.timezone": "",
|
||||
"widget.favorite-stage": "",
|
||||
"widget.videos": "",
|
||||
"widget.lfg-posts": "",
|
||||
"widget.top-500-weapons": "",
|
||||
"widget.top-500-weapons-shooters": "",
|
||||
"widget.top-500-weapons-blasters": "",
|
||||
"widget.top-500-weapons-rollers": "",
|
||||
"widget.top-500-weapons-brushes": "",
|
||||
"widget.top-500-weapons-chargers": "",
|
||||
"widget.top-500-weapons-sloshers": "",
|
||||
"widget.top-500-weapons-splatlings": "",
|
||||
"widget.top-500-weapons-dualies": "",
|
||||
"widget.top-500-weapons-brellas": "",
|
||||
"widget.top-500-weapons-stringers": "",
|
||||
"widget.top-500-weapons-splatanas": "",
|
||||
"widget.x-rank-peaks": "",
|
||||
"widget.builds": "",
|
||||
"widget.weapon-pool": "",
|
||||
"widget.sens": "",
|
||||
"widget.art": "",
|
||||
"widget.commissions": "",
|
||||
"widget.social-links": "",
|
||||
"widget.links": "",
|
||||
"widget.tier-list": "",
|
||||
"widget.link.all": "",
|
||||
"widgets.edit": "",
|
||||
"widgets.editProfile": "",
|
||||
"widgets.editTitle": "",
|
||||
"widgets.available": "",
|
||||
"widgets.mainSlot": "",
|
||||
"widgets.sideSlot": "",
|
||||
"widgets.main": "",
|
||||
"widgets.side": "",
|
||||
"widgets.add": "",
|
||||
"widgets.remove": "",
|
||||
"widgets.maxReached": "",
|
||||
"widgets.search": "",
|
||||
"widgets.category.misc": "",
|
||||
"widgets.category.badges": "",
|
||||
"widgets.category.teams": "",
|
||||
"widgets.category.sendouq": "",
|
||||
"widgets.category.xrank": "",
|
||||
"widgets.category.tournaments": "",
|
||||
"widgets.category.vods": "",
|
||||
"widgets.category.builds": "",
|
||||
"widgets.category.art": "",
|
||||
"widgets.description.bio": "",
|
||||
"widgets.description.bio-md": "",
|
||||
"widgets.description.badges-owned": "",
|
||||
"widgets.description.badges-authored": "",
|
||||
"widgets.description.teams": "",
|
||||
"widgets.description.organizations": "",
|
||||
"widgets.description.peak-sp": "",
|
||||
"widgets.description.top-10-seasons": "",
|
||||
"widgets.description.top-100-seasons": "",
|
||||
"widgets.description.peak-xp": "",
|
||||
"widgets.description.peak-xp-unverified": "",
|
||||
"widgets.description.peak-xp-weapon": "",
|
||||
"widgets.description.highlighted-results": "",
|
||||
"widgets.description.placement-results": "",
|
||||
"widgets.description.patron-since": "",
|
||||
"widgets.description.timezone": "",
|
||||
"widgets.description.favorite-stage": "",
|
||||
"widgets.description.videos": "",
|
||||
"widgets.description.lfg-posts": "",
|
||||
"widgets.description.top-500-weapons": "",
|
||||
"widgets.description.top-500-weapons-shooters": "",
|
||||
"widgets.description.top-500-weapons-blasters": "",
|
||||
"widgets.description.top-500-weapons-rollers": "",
|
||||
"widgets.description.top-500-weapons-brushes": "",
|
||||
"widgets.description.top-500-weapons-chargers": "",
|
||||
"widgets.description.top-500-weapons-sloshers": "",
|
||||
"widgets.description.top-500-weapons-splatlings": "",
|
||||
"widgets.description.top-500-weapons-dualies": "",
|
||||
"widgets.description.top-500-weapons-brellas": "",
|
||||
"widgets.description.top-500-weapons-stringers": "",
|
||||
"widgets.description.top-500-weapons-splatanas": "",
|
||||
"widgets.description.x-rank-peaks": "",
|
||||
"widgets.description.builds": "",
|
||||
"widgets.description.weapon-pool": "",
|
||||
"widgets.description.sens": "",
|
||||
"widgets.description.art": "",
|
||||
"widgets.description.commissions": "",
|
||||
"widgets.description.social-links": "",
|
||||
"widgets.description.links": "",
|
||||
"widgets.description.tier-list": "",
|
||||
"widgets.forms.bio": "",
|
||||
"widgets.forms.bio.markdownSupport": "",
|
||||
"widgets.forms.division": "",
|
||||
"widgets.forms.division.both": "",
|
||||
"widgets.forms.division.tentatek": "",
|
||||
"widgets.forms.division.takoroka": "",
|
||||
"widgets.forms.timezone": "",
|
||||
"widgets.forms.favoriteStage": "",
|
||||
"widgets.forms.weapon": "",
|
||||
"widgets.forms.peakXp": "",
|
||||
"widgets.forms.controller": "",
|
||||
"widgets.forms.source": "",
|
||||
"widgets.forms.source.ALL": "",
|
||||
"widgets.forms.source.MADE-BY": "",
|
||||
"widgets.forms.source.MADE-OF": "",
|
||||
"widgets.forms.links": "",
|
||||
"widgets.forms.tierListUrl": "",
|
||||
"widget.tier-list.untitled": "",
|
||||
"controllers.s1-pro-con": "",
|
||||
"controllers.s2-pro-con": "",
|
||||
"controllers.grip": "",
|
||||
"controllers.handheld": "",
|
||||
"stickSens": "Sens de palanca",
|
||||
"motionSens": "Sens del giroscopio",
|
||||
"motion": "Giroscopio",
|
||||
|
|
@ -17,6 +141,8 @@
|
|||
"discordExplanation": "Tu nombre, foto, y cuentas de YouTube, Bluesky y Twitch se obtienen por tu cuenta en Discord. Ver <1>FAQ</1> para más información.",
|
||||
"favoriteBadges": "",
|
||||
"battlefy": "",
|
||||
"forms.newProfileEnabled": "",
|
||||
"forms.newProfileEnabled.info": "",
|
||||
"forms.showDiscordUniqueName": "Mostrar usuario de Discord",
|
||||
"forms.showDiscordUniqueName.info": "¿Mostrar tu nombre de Discord ({{discordUniqueName}}) publicamente?",
|
||||
"forms.commissionsOpen": "Comisiones abiertas",
|
||||
|
|
@ -82,5 +208,7 @@
|
|||
"builds.sorting.CLOTHES_ID": "",
|
||||
"builds.sorting.SHOES_ID": "",
|
||||
"builds.sorting.PUBLIC_BUILD": "",
|
||||
"builds.sorting.PRIVATE_BUILD": ""
|
||||
"builds.sorting.PRIVATE_BUILD": "",
|
||||
"commissions.open": "",
|
||||
"commissions.closed": ""
|
||||
}
|
||||
|
|
|
|||
|
|
@ -132,6 +132,8 @@
|
|||
"actions.next": "",
|
||||
"actions.previous": "",
|
||||
"actions.back": "",
|
||||
"actions.hide": "",
|
||||
"actions.settings": "",
|
||||
"noResults": "",
|
||||
"maps.createMapList": "Crear lista de escenarios",
|
||||
"maps.halfSz": "50% Pintazonas",
|
||||
|
|
@ -257,6 +259,7 @@
|
|||
"support.perk.joinMoreAssociations": "",
|
||||
"support.perk.useBotToLogIn": "",
|
||||
"support.perk.useBotToLogIn.extra": "",
|
||||
"support.perk.earlyAccess": "",
|
||||
"custom.colors.title": "Colores personalizados",
|
||||
"custom.colors.bg": "Fondo",
|
||||
"custom.colors.bg-darker": "Fondo más oscuro",
|
||||
|
|
|
|||
|
|
@ -168,5 +168,19 @@
|
|||
"labels.banUserExpiresAt": "",
|
||||
"bottomTexts.banUserExpiresAtHelp": "",
|
||||
"labels.scrimCancelReason": "",
|
||||
"bottomTexts.scrimCancelReasonHelp": ""
|
||||
"bottomTexts.scrimCancelReasonHelp": "",
|
||||
"bottomTexts.bioMarkdown": "",
|
||||
"labels.division": "",
|
||||
"options.division.both": "",
|
||||
"options.division.tentatek": "",
|
||||
"options.division.takoroka": "",
|
||||
"labels.timezone": "",
|
||||
"labels.favoriteStage": "",
|
||||
"labels.peakXp": "",
|
||||
"labels.weapon": "",
|
||||
"labels.artSource": "",
|
||||
"options.artSource.ALL": "",
|
||||
"options.artSource.MADE-BY": "",
|
||||
"options.artSource.MADE-OF": "",
|
||||
"labels.tierListUrl": ""
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,6 +5,130 @@
|
|||
"ign.short": "IGN",
|
||||
"country": "País",
|
||||
"bio": "Biografía",
|
||||
"widget.bio": "",
|
||||
"widget.bio-md": "",
|
||||
"widget.badges-owned": "",
|
||||
"widget.badges-authored": "",
|
||||
"widget.teams": "",
|
||||
"widget.organizations": "",
|
||||
"widget.peak-sp": "",
|
||||
"widget.top-10-seasons": "",
|
||||
"widget.top-100-seasons": "",
|
||||
"widget.peak-xp": "",
|
||||
"widget.peak-xp-unverified": "",
|
||||
"widget.peak-xp-weapon": "",
|
||||
"widget.highlighted-results": "",
|
||||
"widget.placement-results": "",
|
||||
"widget.patron-since": "",
|
||||
"widget.timezone": "",
|
||||
"widget.favorite-stage": "",
|
||||
"widget.videos": "",
|
||||
"widget.lfg-posts": "",
|
||||
"widget.top-500-weapons": "",
|
||||
"widget.top-500-weapons-shooters": "",
|
||||
"widget.top-500-weapons-blasters": "",
|
||||
"widget.top-500-weapons-rollers": "",
|
||||
"widget.top-500-weapons-brushes": "",
|
||||
"widget.top-500-weapons-chargers": "",
|
||||
"widget.top-500-weapons-sloshers": "",
|
||||
"widget.top-500-weapons-splatlings": "",
|
||||
"widget.top-500-weapons-dualies": "",
|
||||
"widget.top-500-weapons-brellas": "",
|
||||
"widget.top-500-weapons-stringers": "",
|
||||
"widget.top-500-weapons-splatanas": "",
|
||||
"widget.x-rank-peaks": "",
|
||||
"widget.builds": "",
|
||||
"widget.weapon-pool": "",
|
||||
"widget.sens": "",
|
||||
"widget.art": "",
|
||||
"widget.commissions": "",
|
||||
"widget.social-links": "",
|
||||
"widget.links": "",
|
||||
"widget.tier-list": "",
|
||||
"widget.link.all": "",
|
||||
"widgets.edit": "",
|
||||
"widgets.editProfile": "",
|
||||
"widgets.editTitle": "",
|
||||
"widgets.available": "",
|
||||
"widgets.mainSlot": "",
|
||||
"widgets.sideSlot": "",
|
||||
"widgets.main": "",
|
||||
"widgets.side": "",
|
||||
"widgets.add": "",
|
||||
"widgets.remove": "",
|
||||
"widgets.maxReached": "",
|
||||
"widgets.search": "",
|
||||
"widgets.category.misc": "",
|
||||
"widgets.category.badges": "",
|
||||
"widgets.category.teams": "",
|
||||
"widgets.category.sendouq": "",
|
||||
"widgets.category.xrank": "",
|
||||
"widgets.category.tournaments": "",
|
||||
"widgets.category.vods": "",
|
||||
"widgets.category.builds": "",
|
||||
"widgets.category.art": "",
|
||||
"widgets.description.bio": "",
|
||||
"widgets.description.bio-md": "",
|
||||
"widgets.description.badges-owned": "",
|
||||
"widgets.description.badges-authored": "",
|
||||
"widgets.description.teams": "",
|
||||
"widgets.description.organizations": "",
|
||||
"widgets.description.peak-sp": "",
|
||||
"widgets.description.top-10-seasons": "",
|
||||
"widgets.description.top-100-seasons": "",
|
||||
"widgets.description.peak-xp": "",
|
||||
"widgets.description.peak-xp-unverified": "",
|
||||
"widgets.description.peak-xp-weapon": "",
|
||||
"widgets.description.highlighted-results": "",
|
||||
"widgets.description.placement-results": "",
|
||||
"widgets.description.patron-since": "",
|
||||
"widgets.description.timezone": "",
|
||||
"widgets.description.favorite-stage": "",
|
||||
"widgets.description.videos": "",
|
||||
"widgets.description.lfg-posts": "",
|
||||
"widgets.description.top-500-weapons": "",
|
||||
"widgets.description.top-500-weapons-shooters": "",
|
||||
"widgets.description.top-500-weapons-blasters": "",
|
||||
"widgets.description.top-500-weapons-rollers": "",
|
||||
"widgets.description.top-500-weapons-brushes": "",
|
||||
"widgets.description.top-500-weapons-chargers": "",
|
||||
"widgets.description.top-500-weapons-sloshers": "",
|
||||
"widgets.description.top-500-weapons-splatlings": "",
|
||||
"widgets.description.top-500-weapons-dualies": "",
|
||||
"widgets.description.top-500-weapons-brellas": "",
|
||||
"widgets.description.top-500-weapons-stringers": "",
|
||||
"widgets.description.top-500-weapons-splatanas": "",
|
||||
"widgets.description.x-rank-peaks": "",
|
||||
"widgets.description.builds": "",
|
||||
"widgets.description.weapon-pool": "",
|
||||
"widgets.description.sens": "",
|
||||
"widgets.description.art": "",
|
||||
"widgets.description.commissions": "",
|
||||
"widgets.description.social-links": "",
|
||||
"widgets.description.links": "",
|
||||
"widgets.description.tier-list": "",
|
||||
"widgets.forms.bio": "",
|
||||
"widgets.forms.bio.markdownSupport": "",
|
||||
"widgets.forms.division": "",
|
||||
"widgets.forms.division.both": "",
|
||||
"widgets.forms.division.tentatek": "",
|
||||
"widgets.forms.division.takoroka": "",
|
||||
"widgets.forms.timezone": "",
|
||||
"widgets.forms.favoriteStage": "",
|
||||
"widgets.forms.weapon": "",
|
||||
"widgets.forms.peakXp": "",
|
||||
"widgets.forms.controller": "",
|
||||
"widgets.forms.source": "",
|
||||
"widgets.forms.source.ALL": "",
|
||||
"widgets.forms.source.MADE-BY": "",
|
||||
"widgets.forms.source.MADE-OF": "",
|
||||
"widgets.forms.links": "",
|
||||
"widgets.forms.tierListUrl": "",
|
||||
"widget.tier-list.untitled": "",
|
||||
"controllers.s1-pro-con": "",
|
||||
"controllers.s2-pro-con": "",
|
||||
"controllers.grip": "",
|
||||
"controllers.handheld": "",
|
||||
"stickSens": "Sens de palanca",
|
||||
"motionSens": "Sens del giroscopio",
|
||||
"motion": "Giroscopio",
|
||||
|
|
@ -17,6 +141,8 @@
|
|||
"discordExplanation": "Tu nombre, foto, y cuentas de YouTube, Bluesky y Twitch se obtienen por tu cuenta en Discord. Ver <1>FAQ</1> para más información.",
|
||||
"favoriteBadges": "",
|
||||
"battlefy": "Nombre de cuenta de Battlefy",
|
||||
"forms.newProfileEnabled": "",
|
||||
"forms.newProfileEnabled.info": "",
|
||||
"forms.showDiscordUniqueName": "Mostrar usuario de Discord",
|
||||
"forms.showDiscordUniqueName.info": "¿Mostrar tu nombre de Discord ({{discordUniqueName}}) publicamente?",
|
||||
"forms.commissionsOpen": "Comisiones abiertas",
|
||||
|
|
@ -82,5 +208,7 @@
|
|||
"builds.sorting.CLOTHES_ID": "Ordenación de ropa en juego",
|
||||
"builds.sorting.SHOES_ID": "Ordenación de calzados en juego",
|
||||
"builds.sorting.PUBLIC_BUILD": "Build público",
|
||||
"builds.sorting.PRIVATE_BUILD": "Build privado"
|
||||
"builds.sorting.PRIVATE_BUILD": "Build privado",
|
||||
"commissions.open": "",
|
||||
"commissions.closed": ""
|
||||
}
|
||||
|
|
|
|||
|
|
@ -132,6 +132,8 @@
|
|||
"actions.next": "",
|
||||
"actions.previous": "",
|
||||
"actions.back": "",
|
||||
"actions.hide": "",
|
||||
"actions.settings": "",
|
||||
"noResults": "",
|
||||
"maps.createMapList": "Créer une liste de stages",
|
||||
"maps.halfSz": "50% DdZ",
|
||||
|
|
@ -257,6 +259,7 @@
|
|||
"support.perk.joinMoreAssociations": "",
|
||||
"support.perk.useBotToLogIn": "",
|
||||
"support.perk.useBotToLogIn.extra": "",
|
||||
"support.perk.earlyAccess": "",
|
||||
"custom.colors.title": "Couleurs personalisées",
|
||||
"custom.colors.bg": "Arrière-plan",
|
||||
"custom.colors.bg-darker": "Arrière-plan sombre",
|
||||
|
|
|
|||
|
|
@ -168,5 +168,19 @@
|
|||
"labels.banUserExpiresAt": "",
|
||||
"bottomTexts.banUserExpiresAtHelp": "",
|
||||
"labels.scrimCancelReason": "",
|
||||
"bottomTexts.scrimCancelReasonHelp": ""
|
||||
"bottomTexts.scrimCancelReasonHelp": "",
|
||||
"bottomTexts.bioMarkdown": "",
|
||||
"labels.division": "",
|
||||
"options.division.both": "",
|
||||
"options.division.tentatek": "",
|
||||
"options.division.takoroka": "",
|
||||
"labels.timezone": "",
|
||||
"labels.favoriteStage": "",
|
||||
"labels.peakXp": "",
|
||||
"labels.weapon": "",
|
||||
"labels.artSource": "",
|
||||
"options.artSource.ALL": "",
|
||||
"options.artSource.MADE-BY": "",
|
||||
"options.artSource.MADE-OF": "",
|
||||
"labels.tierListUrl": ""
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,6 +5,130 @@
|
|||
"ign.short": "PEJ",
|
||||
"country": "Pays",
|
||||
"bio": "Bio",
|
||||
"widget.bio": "",
|
||||
"widget.bio-md": "",
|
||||
"widget.badges-owned": "",
|
||||
"widget.badges-authored": "",
|
||||
"widget.teams": "",
|
||||
"widget.organizations": "",
|
||||
"widget.peak-sp": "",
|
||||
"widget.top-10-seasons": "",
|
||||
"widget.top-100-seasons": "",
|
||||
"widget.peak-xp": "",
|
||||
"widget.peak-xp-unverified": "",
|
||||
"widget.peak-xp-weapon": "",
|
||||
"widget.highlighted-results": "",
|
||||
"widget.placement-results": "",
|
||||
"widget.patron-since": "",
|
||||
"widget.timezone": "",
|
||||
"widget.favorite-stage": "",
|
||||
"widget.videos": "",
|
||||
"widget.lfg-posts": "",
|
||||
"widget.top-500-weapons": "",
|
||||
"widget.top-500-weapons-shooters": "",
|
||||
"widget.top-500-weapons-blasters": "",
|
||||
"widget.top-500-weapons-rollers": "",
|
||||
"widget.top-500-weapons-brushes": "",
|
||||
"widget.top-500-weapons-chargers": "",
|
||||
"widget.top-500-weapons-sloshers": "",
|
||||
"widget.top-500-weapons-splatlings": "",
|
||||
"widget.top-500-weapons-dualies": "",
|
||||
"widget.top-500-weapons-brellas": "",
|
||||
"widget.top-500-weapons-stringers": "",
|
||||
"widget.top-500-weapons-splatanas": "",
|
||||
"widget.x-rank-peaks": "",
|
||||
"widget.builds": "",
|
||||
"widget.weapon-pool": "",
|
||||
"widget.sens": "",
|
||||
"widget.art": "",
|
||||
"widget.commissions": "",
|
||||
"widget.social-links": "",
|
||||
"widget.links": "",
|
||||
"widget.tier-list": "",
|
||||
"widget.link.all": "",
|
||||
"widgets.edit": "",
|
||||
"widgets.editProfile": "",
|
||||
"widgets.editTitle": "",
|
||||
"widgets.available": "",
|
||||
"widgets.mainSlot": "",
|
||||
"widgets.sideSlot": "",
|
||||
"widgets.main": "",
|
||||
"widgets.side": "",
|
||||
"widgets.add": "",
|
||||
"widgets.remove": "",
|
||||
"widgets.maxReached": "",
|
||||
"widgets.search": "",
|
||||
"widgets.category.misc": "",
|
||||
"widgets.category.badges": "",
|
||||
"widgets.category.teams": "",
|
||||
"widgets.category.sendouq": "",
|
||||
"widgets.category.xrank": "",
|
||||
"widgets.category.tournaments": "",
|
||||
"widgets.category.vods": "",
|
||||
"widgets.category.builds": "",
|
||||
"widgets.category.art": "",
|
||||
"widgets.description.bio": "",
|
||||
"widgets.description.bio-md": "",
|
||||
"widgets.description.badges-owned": "",
|
||||
"widgets.description.badges-authored": "",
|
||||
"widgets.description.teams": "",
|
||||
"widgets.description.organizations": "",
|
||||
"widgets.description.peak-sp": "",
|
||||
"widgets.description.top-10-seasons": "",
|
||||
"widgets.description.top-100-seasons": "",
|
||||
"widgets.description.peak-xp": "",
|
||||
"widgets.description.peak-xp-unverified": "",
|
||||
"widgets.description.peak-xp-weapon": "",
|
||||
"widgets.description.highlighted-results": "",
|
||||
"widgets.description.placement-results": "",
|
||||
"widgets.description.patron-since": "",
|
||||
"widgets.description.timezone": "",
|
||||
"widgets.description.favorite-stage": "",
|
||||
"widgets.description.videos": "",
|
||||
"widgets.description.lfg-posts": "",
|
||||
"widgets.description.top-500-weapons": "",
|
||||
"widgets.description.top-500-weapons-shooters": "",
|
||||
"widgets.description.top-500-weapons-blasters": "",
|
||||
"widgets.description.top-500-weapons-rollers": "",
|
||||
"widgets.description.top-500-weapons-brushes": "",
|
||||
"widgets.description.top-500-weapons-chargers": "",
|
||||
"widgets.description.top-500-weapons-sloshers": "",
|
||||
"widgets.description.top-500-weapons-splatlings": "",
|
||||
"widgets.description.top-500-weapons-dualies": "",
|
||||
"widgets.description.top-500-weapons-brellas": "",
|
||||
"widgets.description.top-500-weapons-stringers": "",
|
||||
"widgets.description.top-500-weapons-splatanas": "",
|
||||
"widgets.description.x-rank-peaks": "",
|
||||
"widgets.description.builds": "",
|
||||
"widgets.description.weapon-pool": "",
|
||||
"widgets.description.sens": "",
|
||||
"widgets.description.art": "",
|
||||
"widgets.description.commissions": "",
|
||||
"widgets.description.social-links": "",
|
||||
"widgets.description.links": "",
|
||||
"widgets.description.tier-list": "",
|
||||
"widgets.forms.bio": "",
|
||||
"widgets.forms.bio.markdownSupport": "",
|
||||
"widgets.forms.division": "",
|
||||
"widgets.forms.division.both": "",
|
||||
"widgets.forms.division.tentatek": "",
|
||||
"widgets.forms.division.takoroka": "",
|
||||
"widgets.forms.timezone": "",
|
||||
"widgets.forms.favoriteStage": "",
|
||||
"widgets.forms.weapon": "",
|
||||
"widgets.forms.peakXp": "",
|
||||
"widgets.forms.controller": "",
|
||||
"widgets.forms.source": "",
|
||||
"widgets.forms.source.ALL": "",
|
||||
"widgets.forms.source.MADE-BY": "",
|
||||
"widgets.forms.source.MADE-OF": "",
|
||||
"widgets.forms.links": "",
|
||||
"widgets.forms.tierListUrl": "",
|
||||
"widget.tier-list.untitled": "",
|
||||
"controllers.s1-pro-con": "",
|
||||
"controllers.s2-pro-con": "",
|
||||
"controllers.grip": "",
|
||||
"controllers.handheld": "",
|
||||
"stickSens": "Sensibilité du stick droit",
|
||||
"motionSens": "Sensibilité du gyroscope",
|
||||
"motion": "Gyro",
|
||||
|
|
@ -17,6 +141,8 @@
|
|||
"discordExplanation": "Votre pseudo, votre photo de profil et vos comptes Youtube, Bluesky et Twitch viennent de votre compte Discord. Voir la <1>FAQ</1> pour plus d'informations.",
|
||||
"favoriteBadges": "",
|
||||
"battlefy": "",
|
||||
"forms.newProfileEnabled": "",
|
||||
"forms.newProfileEnabled.info": "",
|
||||
"forms.showDiscordUniqueName": "Montrer le pseudo Discord",
|
||||
"forms.showDiscordUniqueName.info": "Show your unique Discord name ({{discordUniqueName}}) publicly?",
|
||||
"forms.commissionsOpen": "Commissions acceptées",
|
||||
|
|
@ -82,5 +208,7 @@
|
|||
"builds.sorting.CLOTHES_ID": "",
|
||||
"builds.sorting.SHOES_ID": "",
|
||||
"builds.sorting.PUBLIC_BUILD": "",
|
||||
"builds.sorting.PRIVATE_BUILD": ""
|
||||
"builds.sorting.PRIVATE_BUILD": "",
|
||||
"commissions.open": "",
|
||||
"commissions.closed": ""
|
||||
}
|
||||
|
|
|
|||
|
|
@ -132,6 +132,8 @@
|
|||
"actions.next": "",
|
||||
"actions.previous": "",
|
||||
"actions.back": "",
|
||||
"actions.hide": "",
|
||||
"actions.settings": "",
|
||||
"noResults": "Aucun résultats",
|
||||
"maps.createMapList": "Créer une liste de stages",
|
||||
"maps.halfSz": "50% DdZ",
|
||||
|
|
@ -257,6 +259,7 @@
|
|||
"support.perk.joinMoreAssociations": "Rejoignez jusqu'à un maximum 6 associations",
|
||||
"support.perk.useBotToLogIn": "Se connecter via le bot Discord",
|
||||
"support.perk.useBotToLogIn.extra": "Demander un lien de connexion au bot Lohi comme alternative à la connexion normale au site Web",
|
||||
"support.perk.earlyAccess": "",
|
||||
"custom.colors.title": "Couleurs personalisées",
|
||||
"custom.colors.bg": "Arrière-plan",
|
||||
"custom.colors.bg-darker": "Arrière-plan sombre",
|
||||
|
|
|
|||
|
|
@ -168,5 +168,19 @@
|
|||
"labels.banUserExpiresAt": "",
|
||||
"bottomTexts.banUserExpiresAtHelp": "",
|
||||
"labels.scrimCancelReason": "",
|
||||
"bottomTexts.scrimCancelReasonHelp": ""
|
||||
"bottomTexts.scrimCancelReasonHelp": "",
|
||||
"bottomTexts.bioMarkdown": "",
|
||||
"labels.division": "",
|
||||
"options.division.both": "",
|
||||
"options.division.tentatek": "",
|
||||
"options.division.takoroka": "",
|
||||
"labels.timezone": "",
|
||||
"labels.favoriteStage": "",
|
||||
"labels.peakXp": "",
|
||||
"labels.weapon": "",
|
||||
"labels.artSource": "",
|
||||
"options.artSource.ALL": "",
|
||||
"options.artSource.MADE-BY": "",
|
||||
"options.artSource.MADE-OF": "",
|
||||
"labels.tierListUrl": ""
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,6 +5,130 @@
|
|||
"ign.short": "PEJ",
|
||||
"country": "Pays",
|
||||
"bio": "Bio",
|
||||
"widget.bio": "",
|
||||
"widget.bio-md": "",
|
||||
"widget.badges-owned": "",
|
||||
"widget.badges-authored": "",
|
||||
"widget.teams": "",
|
||||
"widget.organizations": "",
|
||||
"widget.peak-sp": "",
|
||||
"widget.top-10-seasons": "",
|
||||
"widget.top-100-seasons": "",
|
||||
"widget.peak-xp": "",
|
||||
"widget.peak-xp-unverified": "",
|
||||
"widget.peak-xp-weapon": "",
|
||||
"widget.highlighted-results": "",
|
||||
"widget.placement-results": "",
|
||||
"widget.patron-since": "",
|
||||
"widget.timezone": "",
|
||||
"widget.favorite-stage": "",
|
||||
"widget.videos": "",
|
||||
"widget.lfg-posts": "",
|
||||
"widget.top-500-weapons": "",
|
||||
"widget.top-500-weapons-shooters": "",
|
||||
"widget.top-500-weapons-blasters": "",
|
||||
"widget.top-500-weapons-rollers": "",
|
||||
"widget.top-500-weapons-brushes": "",
|
||||
"widget.top-500-weapons-chargers": "",
|
||||
"widget.top-500-weapons-sloshers": "",
|
||||
"widget.top-500-weapons-splatlings": "",
|
||||
"widget.top-500-weapons-dualies": "",
|
||||
"widget.top-500-weapons-brellas": "",
|
||||
"widget.top-500-weapons-stringers": "",
|
||||
"widget.top-500-weapons-splatanas": "",
|
||||
"widget.x-rank-peaks": "",
|
||||
"widget.builds": "",
|
||||
"widget.weapon-pool": "",
|
||||
"widget.sens": "",
|
||||
"widget.art": "",
|
||||
"widget.commissions": "",
|
||||
"widget.social-links": "",
|
||||
"widget.links": "",
|
||||
"widget.tier-list": "",
|
||||
"widget.link.all": "",
|
||||
"widgets.edit": "",
|
||||
"widgets.editProfile": "",
|
||||
"widgets.editTitle": "",
|
||||
"widgets.available": "",
|
||||
"widgets.mainSlot": "",
|
||||
"widgets.sideSlot": "",
|
||||
"widgets.main": "",
|
||||
"widgets.side": "",
|
||||
"widgets.add": "",
|
||||
"widgets.remove": "",
|
||||
"widgets.maxReached": "",
|
||||
"widgets.search": "",
|
||||
"widgets.category.misc": "",
|
||||
"widgets.category.badges": "",
|
||||
"widgets.category.teams": "",
|
||||
"widgets.category.sendouq": "",
|
||||
"widgets.category.xrank": "",
|
||||
"widgets.category.tournaments": "",
|
||||
"widgets.category.vods": "",
|
||||
"widgets.category.builds": "",
|
||||
"widgets.category.art": "",
|
||||
"widgets.description.bio": "",
|
||||
"widgets.description.bio-md": "",
|
||||
"widgets.description.badges-owned": "",
|
||||
"widgets.description.badges-authored": "",
|
||||
"widgets.description.teams": "",
|
||||
"widgets.description.organizations": "",
|
||||
"widgets.description.peak-sp": "",
|
||||
"widgets.description.top-10-seasons": "",
|
||||
"widgets.description.top-100-seasons": "",
|
||||
"widgets.description.peak-xp": "",
|
||||
"widgets.description.peak-xp-unverified": "",
|
||||
"widgets.description.peak-xp-weapon": "",
|
||||
"widgets.description.highlighted-results": "",
|
||||
"widgets.description.placement-results": "",
|
||||
"widgets.description.patron-since": "",
|
||||
"widgets.description.timezone": "",
|
||||
"widgets.description.favorite-stage": "",
|
||||
"widgets.description.videos": "",
|
||||
"widgets.description.lfg-posts": "",
|
||||
"widgets.description.top-500-weapons": "",
|
||||
"widgets.description.top-500-weapons-shooters": "",
|
||||
"widgets.description.top-500-weapons-blasters": "",
|
||||
"widgets.description.top-500-weapons-rollers": "",
|
||||
"widgets.description.top-500-weapons-brushes": "",
|
||||
"widgets.description.top-500-weapons-chargers": "",
|
||||
"widgets.description.top-500-weapons-sloshers": "",
|
||||
"widgets.description.top-500-weapons-splatlings": "",
|
||||
"widgets.description.top-500-weapons-dualies": "",
|
||||
"widgets.description.top-500-weapons-brellas": "",
|
||||
"widgets.description.top-500-weapons-stringers": "",
|
||||
"widgets.description.top-500-weapons-splatanas": "",
|
||||
"widgets.description.x-rank-peaks": "",
|
||||
"widgets.description.builds": "",
|
||||
"widgets.description.weapon-pool": "",
|
||||
"widgets.description.sens": "",
|
||||
"widgets.description.art": "",
|
||||
"widgets.description.commissions": "",
|
||||
"widgets.description.social-links": "",
|
||||
"widgets.description.links": "",
|
||||
"widgets.description.tier-list": "",
|
||||
"widgets.forms.bio": "",
|
||||
"widgets.forms.bio.markdownSupport": "",
|
||||
"widgets.forms.division": "",
|
||||
"widgets.forms.division.both": "",
|
||||
"widgets.forms.division.tentatek": "",
|
||||
"widgets.forms.division.takoroka": "",
|
||||
"widgets.forms.timezone": "",
|
||||
"widgets.forms.favoriteStage": "",
|
||||
"widgets.forms.weapon": "",
|
||||
"widgets.forms.peakXp": "",
|
||||
"widgets.forms.controller": "",
|
||||
"widgets.forms.source": "",
|
||||
"widgets.forms.source.ALL": "",
|
||||
"widgets.forms.source.MADE-BY": "",
|
||||
"widgets.forms.source.MADE-OF": "",
|
||||
"widgets.forms.links": "",
|
||||
"widgets.forms.tierListUrl": "",
|
||||
"widget.tier-list.untitled": "",
|
||||
"controllers.s1-pro-con": "",
|
||||
"controllers.s2-pro-con": "",
|
||||
"controllers.grip": "",
|
||||
"controllers.handheld": "",
|
||||
"stickSens": "Sensibilité du stick droit",
|
||||
"motionSens": "Sensibilité du gyroscope",
|
||||
"motion": "Gyro",
|
||||
|
|
@ -17,6 +141,8 @@
|
|||
"discordExplanation": "Votre pseudo, votre photo de profil et vos comptes Youtube, Bluesky et Twitch viennent de votre compte Discord. Voir la <1>FAQ</1> pour plus d'informations.",
|
||||
"favoriteBadges": "Badge favori",
|
||||
"battlefy": "Nom du compte Battlefy",
|
||||
"forms.newProfileEnabled": "",
|
||||
"forms.newProfileEnabled.info": "",
|
||||
"forms.showDiscordUniqueName": "Montrer le pseudo Discord",
|
||||
"forms.showDiscordUniqueName.info": "Show your unique Discord name ({{discordUniqueName}}) publicly?",
|
||||
"forms.commissionsOpen": "Commissions acceptées",
|
||||
|
|
@ -82,5 +208,7 @@
|
|||
"builds.sorting.CLOTHES_ID": "Ordre des Vêtements dans le jeu",
|
||||
"builds.sorting.SHOES_ID": "Ordre des Chaussures dans le jeu",
|
||||
"builds.sorting.PUBLIC_BUILD": "Build public",
|
||||
"builds.sorting.PRIVATE_BUILD": "Build privée"
|
||||
"builds.sorting.PRIVATE_BUILD": "Build privée",
|
||||
"commissions.open": "",
|
||||
"commissions.closed": ""
|
||||
}
|
||||
|
|
|
|||
|
|
@ -132,6 +132,8 @@
|
|||
"actions.next": "",
|
||||
"actions.previous": "",
|
||||
"actions.back": "",
|
||||
"actions.hide": "",
|
||||
"actions.settings": "",
|
||||
"noResults": "",
|
||||
"maps.createMapList": "יצירת רשימת מפות",
|
||||
"maps.halfSz": "50% SZ",
|
||||
|
|
@ -256,6 +258,7 @@
|
|||
"support.perk.joinMoreAssociations": "",
|
||||
"support.perk.useBotToLogIn": "",
|
||||
"support.perk.useBotToLogIn.extra": "",
|
||||
"support.perk.earlyAccess": "",
|
||||
"custom.colors.title": "צבעים מותאמים אישית",
|
||||
"custom.colors.bg": "רקע",
|
||||
"custom.colors.bg-darker": "רקע חשוך",
|
||||
|
|
|
|||
|
|
@ -168,5 +168,19 @@
|
|||
"labels.banUserExpiresAt": "",
|
||||
"bottomTexts.banUserExpiresAtHelp": "",
|
||||
"labels.scrimCancelReason": "",
|
||||
"bottomTexts.scrimCancelReasonHelp": ""
|
||||
"bottomTexts.scrimCancelReasonHelp": "",
|
||||
"bottomTexts.bioMarkdown": "",
|
||||
"labels.division": "",
|
||||
"options.division.both": "",
|
||||
"options.division.tentatek": "",
|
||||
"options.division.takoroka": "",
|
||||
"labels.timezone": "",
|
||||
"labels.favoriteStage": "",
|
||||
"labels.peakXp": "",
|
||||
"labels.weapon": "",
|
||||
"labels.artSource": "",
|
||||
"options.artSource.ALL": "",
|
||||
"options.artSource.MADE-BY": "",
|
||||
"options.artSource.MADE-OF": "",
|
||||
"labels.tierListUrl": ""
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,6 +5,130 @@
|
|||
"ign.short": "IGN",
|
||||
"country": "מדינה",
|
||||
"bio": "ביו",
|
||||
"widget.bio": "",
|
||||
"widget.bio-md": "",
|
||||
"widget.badges-owned": "",
|
||||
"widget.badges-authored": "",
|
||||
"widget.teams": "",
|
||||
"widget.organizations": "",
|
||||
"widget.peak-sp": "",
|
||||
"widget.top-10-seasons": "",
|
||||
"widget.top-100-seasons": "",
|
||||
"widget.peak-xp": "",
|
||||
"widget.peak-xp-unverified": "",
|
||||
"widget.peak-xp-weapon": "",
|
||||
"widget.highlighted-results": "",
|
||||
"widget.placement-results": "",
|
||||
"widget.patron-since": "",
|
||||
"widget.timezone": "",
|
||||
"widget.favorite-stage": "",
|
||||
"widget.videos": "",
|
||||
"widget.lfg-posts": "",
|
||||
"widget.top-500-weapons": "",
|
||||
"widget.top-500-weapons-shooters": "",
|
||||
"widget.top-500-weapons-blasters": "",
|
||||
"widget.top-500-weapons-rollers": "",
|
||||
"widget.top-500-weapons-brushes": "",
|
||||
"widget.top-500-weapons-chargers": "",
|
||||
"widget.top-500-weapons-sloshers": "",
|
||||
"widget.top-500-weapons-splatlings": "",
|
||||
"widget.top-500-weapons-dualies": "",
|
||||
"widget.top-500-weapons-brellas": "",
|
||||
"widget.top-500-weapons-stringers": "",
|
||||
"widget.top-500-weapons-splatanas": "",
|
||||
"widget.x-rank-peaks": "",
|
||||
"widget.builds": "",
|
||||
"widget.weapon-pool": "",
|
||||
"widget.sens": "",
|
||||
"widget.art": "",
|
||||
"widget.commissions": "",
|
||||
"widget.social-links": "",
|
||||
"widget.links": "",
|
||||
"widget.tier-list": "",
|
||||
"widget.link.all": "",
|
||||
"widgets.edit": "",
|
||||
"widgets.editProfile": "",
|
||||
"widgets.editTitle": "",
|
||||
"widgets.available": "",
|
||||
"widgets.mainSlot": "",
|
||||
"widgets.sideSlot": "",
|
||||
"widgets.main": "",
|
||||
"widgets.side": "",
|
||||
"widgets.add": "",
|
||||
"widgets.remove": "",
|
||||
"widgets.maxReached": "",
|
||||
"widgets.search": "",
|
||||
"widgets.category.misc": "",
|
||||
"widgets.category.badges": "",
|
||||
"widgets.category.teams": "",
|
||||
"widgets.category.sendouq": "",
|
||||
"widgets.category.xrank": "",
|
||||
"widgets.category.tournaments": "",
|
||||
"widgets.category.vods": "",
|
||||
"widgets.category.builds": "",
|
||||
"widgets.category.art": "",
|
||||
"widgets.description.bio": "",
|
||||
"widgets.description.bio-md": "",
|
||||
"widgets.description.badges-owned": "",
|
||||
"widgets.description.badges-authored": "",
|
||||
"widgets.description.teams": "",
|
||||
"widgets.description.organizations": "",
|
||||
"widgets.description.peak-sp": "",
|
||||
"widgets.description.top-10-seasons": "",
|
||||
"widgets.description.top-100-seasons": "",
|
||||
"widgets.description.peak-xp": "",
|
||||
"widgets.description.peak-xp-unverified": "",
|
||||
"widgets.description.peak-xp-weapon": "",
|
||||
"widgets.description.highlighted-results": "",
|
||||
"widgets.description.placement-results": "",
|
||||
"widgets.description.patron-since": "",
|
||||
"widgets.description.timezone": "",
|
||||
"widgets.description.favorite-stage": "",
|
||||
"widgets.description.videos": "",
|
||||
"widgets.description.lfg-posts": "",
|
||||
"widgets.description.top-500-weapons": "",
|
||||
"widgets.description.top-500-weapons-shooters": "",
|
||||
"widgets.description.top-500-weapons-blasters": "",
|
||||
"widgets.description.top-500-weapons-rollers": "",
|
||||
"widgets.description.top-500-weapons-brushes": "",
|
||||
"widgets.description.top-500-weapons-chargers": "",
|
||||
"widgets.description.top-500-weapons-sloshers": "",
|
||||
"widgets.description.top-500-weapons-splatlings": "",
|
||||
"widgets.description.top-500-weapons-dualies": "",
|
||||
"widgets.description.top-500-weapons-brellas": "",
|
||||
"widgets.description.top-500-weapons-stringers": "",
|
||||
"widgets.description.top-500-weapons-splatanas": "",
|
||||
"widgets.description.x-rank-peaks": "",
|
||||
"widgets.description.builds": "",
|
||||
"widgets.description.weapon-pool": "",
|
||||
"widgets.description.sens": "",
|
||||
"widgets.description.art": "",
|
||||
"widgets.description.commissions": "",
|
||||
"widgets.description.social-links": "",
|
||||
"widgets.description.links": "",
|
||||
"widgets.description.tier-list": "",
|
||||
"widgets.forms.bio": "",
|
||||
"widgets.forms.bio.markdownSupport": "",
|
||||
"widgets.forms.division": "",
|
||||
"widgets.forms.division.both": "",
|
||||
"widgets.forms.division.tentatek": "",
|
||||
"widgets.forms.division.takoroka": "",
|
||||
"widgets.forms.timezone": "",
|
||||
"widgets.forms.favoriteStage": "",
|
||||
"widgets.forms.weapon": "",
|
||||
"widgets.forms.peakXp": "",
|
||||
"widgets.forms.controller": "",
|
||||
"widgets.forms.source": "",
|
||||
"widgets.forms.source.ALL": "",
|
||||
"widgets.forms.source.MADE-BY": "",
|
||||
"widgets.forms.source.MADE-OF": "",
|
||||
"widgets.forms.links": "",
|
||||
"widgets.forms.tierListUrl": "",
|
||||
"widget.tier-list.untitled": "",
|
||||
"controllers.s1-pro-con": "",
|
||||
"controllers.s2-pro-con": "",
|
||||
"controllers.grip": "",
|
||||
"controllers.handheld": "",
|
||||
"stickSens": "רגישות סטיק ימני",
|
||||
"motionSens": "רגישות תנועה",
|
||||
"motion": "תנועה",
|
||||
|
|
@ -17,6 +141,8 @@
|
|||
"discordExplanation": "שם משתמש, תמונת פרופיל, חשבונות YouTube, Bluesky ו-Twitch מגיעים מחשבון Discord שלך. ראו <1>שאלות נפוצות</1> למידע נוסף.",
|
||||
"favoriteBadges": "",
|
||||
"battlefy": "",
|
||||
"forms.newProfileEnabled": "",
|
||||
"forms.newProfileEnabled.info": "",
|
||||
"forms.showDiscordUniqueName": "הראה שם משתמש Discord",
|
||||
"forms.showDiscordUniqueName.info": "להראות את שם ה-Discord היחודי שלכם ({{discordUniqueName}}) בפומבי?",
|
||||
"forms.commissionsOpen": "בקשות פתוחות",
|
||||
|
|
@ -82,5 +208,7 @@
|
|||
"builds.sorting.CLOTHES_ID": "",
|
||||
"builds.sorting.SHOES_ID": "",
|
||||
"builds.sorting.PUBLIC_BUILD": "",
|
||||
"builds.sorting.PRIVATE_BUILD": ""
|
||||
"builds.sorting.PRIVATE_BUILD": "",
|
||||
"commissions.open": "",
|
||||
"commissions.closed": ""
|
||||
}
|
||||
|
|
|
|||
|
|
@ -132,6 +132,8 @@
|
|||
"actions.next": "",
|
||||
"actions.previous": "",
|
||||
"actions.back": "",
|
||||
"actions.hide": "",
|
||||
"actions.settings": "",
|
||||
"noResults": "",
|
||||
"maps.createMapList": "Crea lista scenari",
|
||||
"maps.halfSz": "50% ZS",
|
||||
|
|
@ -257,6 +259,7 @@
|
|||
"support.perk.joinMoreAssociations": "",
|
||||
"support.perk.useBotToLogIn": "",
|
||||
"support.perk.useBotToLogIn.extra": "",
|
||||
"support.perk.earlyAccess": "",
|
||||
"custom.colors.title": "Colori personalizzati",
|
||||
"custom.colors.bg": "Sfondo",
|
||||
"custom.colors.bg-darker": "Sfondo più scuro",
|
||||
|
|
|
|||
|
|
@ -168,5 +168,19 @@
|
|||
"labels.banUserExpiresAt": "",
|
||||
"bottomTexts.banUserExpiresAtHelp": "",
|
||||
"labels.scrimCancelReason": "",
|
||||
"bottomTexts.scrimCancelReasonHelp": ""
|
||||
"bottomTexts.scrimCancelReasonHelp": "",
|
||||
"bottomTexts.bioMarkdown": "",
|
||||
"labels.division": "",
|
||||
"options.division.both": "",
|
||||
"options.division.tentatek": "",
|
||||
"options.division.takoroka": "",
|
||||
"labels.timezone": "",
|
||||
"labels.favoriteStage": "",
|
||||
"labels.peakXp": "",
|
||||
"labels.weapon": "",
|
||||
"labels.artSource": "",
|
||||
"options.artSource.ALL": "",
|
||||
"options.artSource.MADE-BY": "",
|
||||
"options.artSource.MADE-OF": "",
|
||||
"labels.tierListUrl": ""
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,6 +5,130 @@
|
|||
"ign.short": "IGN",
|
||||
"country": "Paese",
|
||||
"bio": "Biografia",
|
||||
"widget.bio": "",
|
||||
"widget.bio-md": "",
|
||||
"widget.badges-owned": "",
|
||||
"widget.badges-authored": "",
|
||||
"widget.teams": "",
|
||||
"widget.organizations": "",
|
||||
"widget.peak-sp": "",
|
||||
"widget.top-10-seasons": "",
|
||||
"widget.top-100-seasons": "",
|
||||
"widget.peak-xp": "",
|
||||
"widget.peak-xp-unverified": "",
|
||||
"widget.peak-xp-weapon": "",
|
||||
"widget.highlighted-results": "",
|
||||
"widget.placement-results": "",
|
||||
"widget.patron-since": "",
|
||||
"widget.timezone": "",
|
||||
"widget.favorite-stage": "",
|
||||
"widget.videos": "",
|
||||
"widget.lfg-posts": "",
|
||||
"widget.top-500-weapons": "",
|
||||
"widget.top-500-weapons-shooters": "",
|
||||
"widget.top-500-weapons-blasters": "",
|
||||
"widget.top-500-weapons-rollers": "",
|
||||
"widget.top-500-weapons-brushes": "",
|
||||
"widget.top-500-weapons-chargers": "",
|
||||
"widget.top-500-weapons-sloshers": "",
|
||||
"widget.top-500-weapons-splatlings": "",
|
||||
"widget.top-500-weapons-dualies": "",
|
||||
"widget.top-500-weapons-brellas": "",
|
||||
"widget.top-500-weapons-stringers": "",
|
||||
"widget.top-500-weapons-splatanas": "",
|
||||
"widget.x-rank-peaks": "",
|
||||
"widget.builds": "",
|
||||
"widget.weapon-pool": "",
|
||||
"widget.sens": "",
|
||||
"widget.art": "",
|
||||
"widget.commissions": "",
|
||||
"widget.social-links": "",
|
||||
"widget.links": "",
|
||||
"widget.tier-list": "",
|
||||
"widget.link.all": "",
|
||||
"widgets.edit": "",
|
||||
"widgets.editProfile": "",
|
||||
"widgets.editTitle": "",
|
||||
"widgets.available": "",
|
||||
"widgets.mainSlot": "",
|
||||
"widgets.sideSlot": "",
|
||||
"widgets.main": "",
|
||||
"widgets.side": "",
|
||||
"widgets.add": "",
|
||||
"widgets.remove": "",
|
||||
"widgets.maxReached": "",
|
||||
"widgets.search": "",
|
||||
"widgets.category.misc": "",
|
||||
"widgets.category.badges": "",
|
||||
"widgets.category.teams": "",
|
||||
"widgets.category.sendouq": "",
|
||||
"widgets.category.xrank": "",
|
||||
"widgets.category.tournaments": "",
|
||||
"widgets.category.vods": "",
|
||||
"widgets.category.builds": "",
|
||||
"widgets.category.art": "",
|
||||
"widgets.description.bio": "",
|
||||
"widgets.description.bio-md": "",
|
||||
"widgets.description.badges-owned": "",
|
||||
"widgets.description.badges-authored": "",
|
||||
"widgets.description.teams": "",
|
||||
"widgets.description.organizations": "",
|
||||
"widgets.description.peak-sp": "",
|
||||
"widgets.description.top-10-seasons": "",
|
||||
"widgets.description.top-100-seasons": "",
|
||||
"widgets.description.peak-xp": "",
|
||||
"widgets.description.peak-xp-unverified": "",
|
||||
"widgets.description.peak-xp-weapon": "",
|
||||
"widgets.description.highlighted-results": "",
|
||||
"widgets.description.placement-results": "",
|
||||
"widgets.description.patron-since": "",
|
||||
"widgets.description.timezone": "",
|
||||
"widgets.description.favorite-stage": "",
|
||||
"widgets.description.videos": "",
|
||||
"widgets.description.lfg-posts": "",
|
||||
"widgets.description.top-500-weapons": "",
|
||||
"widgets.description.top-500-weapons-shooters": "",
|
||||
"widgets.description.top-500-weapons-blasters": "",
|
||||
"widgets.description.top-500-weapons-rollers": "",
|
||||
"widgets.description.top-500-weapons-brushes": "",
|
||||
"widgets.description.top-500-weapons-chargers": "",
|
||||
"widgets.description.top-500-weapons-sloshers": "",
|
||||
"widgets.description.top-500-weapons-splatlings": "",
|
||||
"widgets.description.top-500-weapons-dualies": "",
|
||||
"widgets.description.top-500-weapons-brellas": "",
|
||||
"widgets.description.top-500-weapons-stringers": "",
|
||||
"widgets.description.top-500-weapons-splatanas": "",
|
||||
"widgets.description.x-rank-peaks": "",
|
||||
"widgets.description.builds": "",
|
||||
"widgets.description.weapon-pool": "",
|
||||
"widgets.description.sens": "",
|
||||
"widgets.description.art": "",
|
||||
"widgets.description.commissions": "",
|
||||
"widgets.description.social-links": "",
|
||||
"widgets.description.links": "",
|
||||
"widgets.description.tier-list": "",
|
||||
"widgets.forms.bio": "",
|
||||
"widgets.forms.bio.markdownSupport": "",
|
||||
"widgets.forms.division": "",
|
||||
"widgets.forms.division.both": "",
|
||||
"widgets.forms.division.tentatek": "",
|
||||
"widgets.forms.division.takoroka": "",
|
||||
"widgets.forms.timezone": "",
|
||||
"widgets.forms.favoriteStage": "",
|
||||
"widgets.forms.weapon": "",
|
||||
"widgets.forms.peakXp": "",
|
||||
"widgets.forms.controller": "",
|
||||
"widgets.forms.source": "",
|
||||
"widgets.forms.source.ALL": "",
|
||||
"widgets.forms.source.MADE-BY": "",
|
||||
"widgets.forms.source.MADE-OF": "",
|
||||
"widgets.forms.links": "",
|
||||
"widgets.forms.tierListUrl": "",
|
||||
"widget.tier-list.untitled": "",
|
||||
"controllers.s1-pro-con": "",
|
||||
"controllers.s2-pro-con": "",
|
||||
"controllers.grip": "",
|
||||
"controllers.handheld": "",
|
||||
"stickSens": "Sensitività Joystick Dx",
|
||||
"motionSens": "Sensitività Giroscopio",
|
||||
"motion": "Giroscopio",
|
||||
|
|
@ -17,6 +141,8 @@
|
|||
"discordExplanation": "Username, foto profilo, account YouTube, Bluesky e Twitch vengono dal tuo account Discord. Visita <1>FAQ</1> per ulteriori informazioni.",
|
||||
"favoriteBadges": "",
|
||||
"battlefy": "Nome account Battlefy",
|
||||
"forms.newProfileEnabled": "",
|
||||
"forms.newProfileEnabled.info": "",
|
||||
"forms.showDiscordUniqueName": "Mostra username Discord",
|
||||
"forms.showDiscordUniqueName.info": "Mostrare il proprio nome unico Discord ({{discordUniqueName}}) pubblicamente?",
|
||||
"forms.commissionsOpen": "Commissioni aperte",
|
||||
|
|
@ -82,5 +208,7 @@
|
|||
"builds.sorting.CLOTHES_ID": "Vestiti in ordine del gioco",
|
||||
"builds.sorting.SHOES_ID": "Scarpe in ordine del gioco",
|
||||
"builds.sorting.PUBLIC_BUILD": "Build pubblica",
|
||||
"builds.sorting.PRIVATE_BUILD": "Build privata"
|
||||
"builds.sorting.PRIVATE_BUILD": "Build privata",
|
||||
"commissions.open": "",
|
||||
"commissions.closed": ""
|
||||
}
|
||||
|
|
|
|||
|
|
@ -132,6 +132,8 @@
|
|||
"actions.next": "",
|
||||
"actions.previous": "",
|
||||
"actions.back": "",
|
||||
"actions.hide": "",
|
||||
"actions.settings": "",
|
||||
"noResults": "",
|
||||
"maps.createMapList": "ステージ一覧を作る",
|
||||
"maps.halfSz": "ガチエリア (2ヶ所)",
|
||||
|
|
@ -251,6 +253,7 @@
|
|||
"support.perk.joinMoreAssociations": "",
|
||||
"support.perk.useBotToLogIn": "",
|
||||
"support.perk.useBotToLogIn.extra": "",
|
||||
"support.perk.earlyAccess": "",
|
||||
"custom.colors.title": "カスタムカラー",
|
||||
"custom.colors.bg": "バックグラウンド",
|
||||
"custom.colors.bg-darker": "バックグランド 暗め",
|
||||
|
|
|
|||
|
|
@ -168,5 +168,19 @@
|
|||
"labels.banUserExpiresAt": "",
|
||||
"bottomTexts.banUserExpiresAtHelp": "",
|
||||
"labels.scrimCancelReason": "",
|
||||
"bottomTexts.scrimCancelReasonHelp": ""
|
||||
"bottomTexts.scrimCancelReasonHelp": "",
|
||||
"bottomTexts.bioMarkdown": "",
|
||||
"labels.division": "",
|
||||
"options.division.both": "",
|
||||
"options.division.tentatek": "",
|
||||
"options.division.takoroka": "",
|
||||
"labels.timezone": "",
|
||||
"labels.favoriteStage": "",
|
||||
"labels.peakXp": "",
|
||||
"labels.weapon": "",
|
||||
"labels.artSource": "",
|
||||
"options.artSource.ALL": "",
|
||||
"options.artSource.MADE-BY": "",
|
||||
"options.artSource.MADE-OF": "",
|
||||
"labels.tierListUrl": ""
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,6 +5,130 @@
|
|||
"ign.short": "ゲーム中の名前",
|
||||
"country": "国",
|
||||
"bio": "自己紹介",
|
||||
"widget.bio": "",
|
||||
"widget.bio-md": "",
|
||||
"widget.badges-owned": "",
|
||||
"widget.badges-authored": "",
|
||||
"widget.teams": "",
|
||||
"widget.organizations": "",
|
||||
"widget.peak-sp": "",
|
||||
"widget.top-10-seasons": "",
|
||||
"widget.top-100-seasons": "",
|
||||
"widget.peak-xp": "",
|
||||
"widget.peak-xp-unverified": "",
|
||||
"widget.peak-xp-weapon": "",
|
||||
"widget.highlighted-results": "",
|
||||
"widget.placement-results": "",
|
||||
"widget.patron-since": "",
|
||||
"widget.timezone": "",
|
||||
"widget.favorite-stage": "",
|
||||
"widget.videos": "",
|
||||
"widget.lfg-posts": "",
|
||||
"widget.top-500-weapons": "",
|
||||
"widget.top-500-weapons-shooters": "",
|
||||
"widget.top-500-weapons-blasters": "",
|
||||
"widget.top-500-weapons-rollers": "",
|
||||
"widget.top-500-weapons-brushes": "",
|
||||
"widget.top-500-weapons-chargers": "",
|
||||
"widget.top-500-weapons-sloshers": "",
|
||||
"widget.top-500-weapons-splatlings": "",
|
||||
"widget.top-500-weapons-dualies": "",
|
||||
"widget.top-500-weapons-brellas": "",
|
||||
"widget.top-500-weapons-stringers": "",
|
||||
"widget.top-500-weapons-splatanas": "",
|
||||
"widget.x-rank-peaks": "",
|
||||
"widget.builds": "",
|
||||
"widget.weapon-pool": "",
|
||||
"widget.sens": "",
|
||||
"widget.art": "",
|
||||
"widget.commissions": "",
|
||||
"widget.social-links": "",
|
||||
"widget.links": "",
|
||||
"widget.tier-list": "",
|
||||
"widget.link.all": "",
|
||||
"widgets.edit": "",
|
||||
"widgets.editProfile": "",
|
||||
"widgets.editTitle": "",
|
||||
"widgets.available": "",
|
||||
"widgets.mainSlot": "",
|
||||
"widgets.sideSlot": "",
|
||||
"widgets.main": "",
|
||||
"widgets.side": "",
|
||||
"widgets.add": "",
|
||||
"widgets.remove": "",
|
||||
"widgets.maxReached": "",
|
||||
"widgets.search": "",
|
||||
"widgets.category.misc": "",
|
||||
"widgets.category.badges": "",
|
||||
"widgets.category.teams": "",
|
||||
"widgets.category.sendouq": "",
|
||||
"widgets.category.xrank": "",
|
||||
"widgets.category.tournaments": "",
|
||||
"widgets.category.vods": "",
|
||||
"widgets.category.builds": "",
|
||||
"widgets.category.art": "",
|
||||
"widgets.description.bio": "",
|
||||
"widgets.description.bio-md": "",
|
||||
"widgets.description.badges-owned": "",
|
||||
"widgets.description.badges-authored": "",
|
||||
"widgets.description.teams": "",
|
||||
"widgets.description.organizations": "",
|
||||
"widgets.description.peak-sp": "",
|
||||
"widgets.description.top-10-seasons": "",
|
||||
"widgets.description.top-100-seasons": "",
|
||||
"widgets.description.peak-xp": "",
|
||||
"widgets.description.peak-xp-unverified": "",
|
||||
"widgets.description.peak-xp-weapon": "",
|
||||
"widgets.description.highlighted-results": "",
|
||||
"widgets.description.placement-results": "",
|
||||
"widgets.description.patron-since": "",
|
||||
"widgets.description.timezone": "",
|
||||
"widgets.description.favorite-stage": "",
|
||||
"widgets.description.videos": "",
|
||||
"widgets.description.lfg-posts": "",
|
||||
"widgets.description.top-500-weapons": "",
|
||||
"widgets.description.top-500-weapons-shooters": "",
|
||||
"widgets.description.top-500-weapons-blasters": "",
|
||||
"widgets.description.top-500-weapons-rollers": "",
|
||||
"widgets.description.top-500-weapons-brushes": "",
|
||||
"widgets.description.top-500-weapons-chargers": "",
|
||||
"widgets.description.top-500-weapons-sloshers": "",
|
||||
"widgets.description.top-500-weapons-splatlings": "",
|
||||
"widgets.description.top-500-weapons-dualies": "",
|
||||
"widgets.description.top-500-weapons-brellas": "",
|
||||
"widgets.description.top-500-weapons-stringers": "",
|
||||
"widgets.description.top-500-weapons-splatanas": "",
|
||||
"widgets.description.x-rank-peaks": "",
|
||||
"widgets.description.builds": "",
|
||||
"widgets.description.weapon-pool": "",
|
||||
"widgets.description.sens": "",
|
||||
"widgets.description.art": "",
|
||||
"widgets.description.commissions": "",
|
||||
"widgets.description.social-links": "",
|
||||
"widgets.description.links": "",
|
||||
"widgets.description.tier-list": "",
|
||||
"widgets.forms.bio": "",
|
||||
"widgets.forms.bio.markdownSupport": "",
|
||||
"widgets.forms.division": "",
|
||||
"widgets.forms.division.both": "",
|
||||
"widgets.forms.division.tentatek": "",
|
||||
"widgets.forms.division.takoroka": "",
|
||||
"widgets.forms.timezone": "",
|
||||
"widgets.forms.favoriteStage": "",
|
||||
"widgets.forms.weapon": "",
|
||||
"widgets.forms.peakXp": "",
|
||||
"widgets.forms.controller": "",
|
||||
"widgets.forms.source": "",
|
||||
"widgets.forms.source.ALL": "",
|
||||
"widgets.forms.source.MADE-BY": "",
|
||||
"widgets.forms.source.MADE-OF": "",
|
||||
"widgets.forms.links": "",
|
||||
"widgets.forms.tierListUrl": "",
|
||||
"widget.tier-list.untitled": "",
|
||||
"controllers.s1-pro-con": "",
|
||||
"controllers.s2-pro-con": "",
|
||||
"controllers.grip": "",
|
||||
"controllers.handheld": "",
|
||||
"stickSens": "右スティック感度",
|
||||
"motionSens": "モーション感度",
|
||||
"motion": "モーション",
|
||||
|
|
@ -17,6 +141,8 @@
|
|||
"discordExplanation": "ユーザー名、プロファイル画像、YouTube、Bluesky と Twitch アカウントは Discord のアカウントに設定されているものが使用されます。詳しくは <1>FAQ</1> をご覧ください。",
|
||||
"favoriteBadges": "",
|
||||
"battlefy": "Battlefyアカウント名",
|
||||
"forms.newProfileEnabled": "",
|
||||
"forms.newProfileEnabled.info": "",
|
||||
"forms.showDiscordUniqueName": "Discord のユーザー名を表示する",
|
||||
"forms.showDiscordUniqueName.info": "Discord のユニーク名 ({{discordUniqueName}}) 公表しますか?",
|
||||
"forms.commissionsOpen": "依頼を受付中",
|
||||
|
|
@ -82,5 +208,7 @@
|
|||
"builds.sorting.CLOTHES_ID": "フク(ギア)ゲーム内の順番",
|
||||
"builds.sorting.SHOES_ID": "クツ(ギア)ゲーム内の順番",
|
||||
"builds.sorting.PUBLIC_BUILD": "公開ビルド",
|
||||
"builds.sorting.PRIVATE_BUILD": "非公開ビルド"
|
||||
"builds.sorting.PRIVATE_BUILD": "非公開ビルド",
|
||||
"commissions.open": "",
|
||||
"commissions.closed": ""
|
||||
}
|
||||
|
|
|
|||
|
|
@ -132,6 +132,8 @@
|
|||
"actions.next": "",
|
||||
"actions.previous": "",
|
||||
"actions.back": "",
|
||||
"actions.hide": "",
|
||||
"actions.settings": "",
|
||||
"noResults": "",
|
||||
"maps.createMapList": "맵 목록 생성",
|
||||
"maps.halfSz": "에어리어 50%",
|
||||
|
|
@ -251,6 +253,7 @@
|
|||
"support.perk.joinMoreAssociations": "",
|
||||
"support.perk.useBotToLogIn": "",
|
||||
"support.perk.useBotToLogIn.extra": "",
|
||||
"support.perk.earlyAccess": "",
|
||||
"custom.colors.title": "",
|
||||
"custom.colors.bg": "",
|
||||
"custom.colors.bg-darker": "",
|
||||
|
|
|
|||
|
|
@ -168,5 +168,19 @@
|
|||
"labels.banUserExpiresAt": "",
|
||||
"bottomTexts.banUserExpiresAtHelp": "",
|
||||
"labels.scrimCancelReason": "",
|
||||
"bottomTexts.scrimCancelReasonHelp": ""
|
||||
"bottomTexts.scrimCancelReasonHelp": "",
|
||||
"bottomTexts.bioMarkdown": "",
|
||||
"labels.division": "",
|
||||
"options.division.both": "",
|
||||
"options.division.tentatek": "",
|
||||
"options.division.takoroka": "",
|
||||
"labels.timezone": "",
|
||||
"labels.favoriteStage": "",
|
||||
"labels.peakXp": "",
|
||||
"labels.weapon": "",
|
||||
"labels.artSource": "",
|
||||
"options.artSource.ALL": "",
|
||||
"options.artSource.MADE-BY": "",
|
||||
"options.artSource.MADE-OF": "",
|
||||
"labels.tierListUrl": ""
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,6 +5,130 @@
|
|||
"ign.short": "",
|
||||
"country": "국가",
|
||||
"bio": "소개",
|
||||
"widget.bio": "",
|
||||
"widget.bio-md": "",
|
||||
"widget.badges-owned": "",
|
||||
"widget.badges-authored": "",
|
||||
"widget.teams": "",
|
||||
"widget.organizations": "",
|
||||
"widget.peak-sp": "",
|
||||
"widget.top-10-seasons": "",
|
||||
"widget.top-100-seasons": "",
|
||||
"widget.peak-xp": "",
|
||||
"widget.peak-xp-unverified": "",
|
||||
"widget.peak-xp-weapon": "",
|
||||
"widget.highlighted-results": "",
|
||||
"widget.placement-results": "",
|
||||
"widget.patron-since": "",
|
||||
"widget.timezone": "",
|
||||
"widget.favorite-stage": "",
|
||||
"widget.videos": "",
|
||||
"widget.lfg-posts": "",
|
||||
"widget.top-500-weapons": "",
|
||||
"widget.top-500-weapons-shooters": "",
|
||||
"widget.top-500-weapons-blasters": "",
|
||||
"widget.top-500-weapons-rollers": "",
|
||||
"widget.top-500-weapons-brushes": "",
|
||||
"widget.top-500-weapons-chargers": "",
|
||||
"widget.top-500-weapons-sloshers": "",
|
||||
"widget.top-500-weapons-splatlings": "",
|
||||
"widget.top-500-weapons-dualies": "",
|
||||
"widget.top-500-weapons-brellas": "",
|
||||
"widget.top-500-weapons-stringers": "",
|
||||
"widget.top-500-weapons-splatanas": "",
|
||||
"widget.x-rank-peaks": "",
|
||||
"widget.builds": "",
|
||||
"widget.weapon-pool": "",
|
||||
"widget.sens": "",
|
||||
"widget.art": "",
|
||||
"widget.commissions": "",
|
||||
"widget.social-links": "",
|
||||
"widget.links": "",
|
||||
"widget.tier-list": "",
|
||||
"widget.link.all": "",
|
||||
"widgets.edit": "",
|
||||
"widgets.editProfile": "",
|
||||
"widgets.editTitle": "",
|
||||
"widgets.available": "",
|
||||
"widgets.mainSlot": "",
|
||||
"widgets.sideSlot": "",
|
||||
"widgets.main": "",
|
||||
"widgets.side": "",
|
||||
"widgets.add": "",
|
||||
"widgets.remove": "",
|
||||
"widgets.maxReached": "",
|
||||
"widgets.search": "",
|
||||
"widgets.category.misc": "",
|
||||
"widgets.category.badges": "",
|
||||
"widgets.category.teams": "",
|
||||
"widgets.category.sendouq": "",
|
||||
"widgets.category.xrank": "",
|
||||
"widgets.category.tournaments": "",
|
||||
"widgets.category.vods": "",
|
||||
"widgets.category.builds": "",
|
||||
"widgets.category.art": "",
|
||||
"widgets.description.bio": "",
|
||||
"widgets.description.bio-md": "",
|
||||
"widgets.description.badges-owned": "",
|
||||
"widgets.description.badges-authored": "",
|
||||
"widgets.description.teams": "",
|
||||
"widgets.description.organizations": "",
|
||||
"widgets.description.peak-sp": "",
|
||||
"widgets.description.top-10-seasons": "",
|
||||
"widgets.description.top-100-seasons": "",
|
||||
"widgets.description.peak-xp": "",
|
||||
"widgets.description.peak-xp-unverified": "",
|
||||
"widgets.description.peak-xp-weapon": "",
|
||||
"widgets.description.highlighted-results": "",
|
||||
"widgets.description.placement-results": "",
|
||||
"widgets.description.patron-since": "",
|
||||
"widgets.description.timezone": "",
|
||||
"widgets.description.favorite-stage": "",
|
||||
"widgets.description.videos": "",
|
||||
"widgets.description.lfg-posts": "",
|
||||
"widgets.description.top-500-weapons": "",
|
||||
"widgets.description.top-500-weapons-shooters": "",
|
||||
"widgets.description.top-500-weapons-blasters": "",
|
||||
"widgets.description.top-500-weapons-rollers": "",
|
||||
"widgets.description.top-500-weapons-brushes": "",
|
||||
"widgets.description.top-500-weapons-chargers": "",
|
||||
"widgets.description.top-500-weapons-sloshers": "",
|
||||
"widgets.description.top-500-weapons-splatlings": "",
|
||||
"widgets.description.top-500-weapons-dualies": "",
|
||||
"widgets.description.top-500-weapons-brellas": "",
|
||||
"widgets.description.top-500-weapons-stringers": "",
|
||||
"widgets.description.top-500-weapons-splatanas": "",
|
||||
"widgets.description.x-rank-peaks": "",
|
||||
"widgets.description.builds": "",
|
||||
"widgets.description.weapon-pool": "",
|
||||
"widgets.description.sens": "",
|
||||
"widgets.description.art": "",
|
||||
"widgets.description.commissions": "",
|
||||
"widgets.description.social-links": "",
|
||||
"widgets.description.links": "",
|
||||
"widgets.description.tier-list": "",
|
||||
"widgets.forms.bio": "",
|
||||
"widgets.forms.bio.markdownSupport": "",
|
||||
"widgets.forms.division": "",
|
||||
"widgets.forms.division.both": "",
|
||||
"widgets.forms.division.tentatek": "",
|
||||
"widgets.forms.division.takoroka": "",
|
||||
"widgets.forms.timezone": "",
|
||||
"widgets.forms.favoriteStage": "",
|
||||
"widgets.forms.weapon": "",
|
||||
"widgets.forms.peakXp": "",
|
||||
"widgets.forms.controller": "",
|
||||
"widgets.forms.source": "",
|
||||
"widgets.forms.source.ALL": "",
|
||||
"widgets.forms.source.MADE-BY": "",
|
||||
"widgets.forms.source.MADE-OF": "",
|
||||
"widgets.forms.links": "",
|
||||
"widgets.forms.tierListUrl": "",
|
||||
"widget.tier-list.untitled": "",
|
||||
"controllers.s1-pro-con": "",
|
||||
"controllers.s2-pro-con": "",
|
||||
"controllers.grip": "",
|
||||
"controllers.handheld": "",
|
||||
"stickSens": "",
|
||||
"motionSens": "",
|
||||
"motion": "",
|
||||
|
|
@ -17,6 +141,8 @@
|
|||
"discordExplanation": "",
|
||||
"favoriteBadges": "",
|
||||
"battlefy": "",
|
||||
"forms.newProfileEnabled": "",
|
||||
"forms.newProfileEnabled.info": "",
|
||||
"forms.showDiscordUniqueName": "",
|
||||
"forms.showDiscordUniqueName.info": "",
|
||||
"forms.commissionsOpen": "",
|
||||
|
|
@ -82,5 +208,7 @@
|
|||
"builds.sorting.CLOTHES_ID": "",
|
||||
"builds.sorting.SHOES_ID": "",
|
||||
"builds.sorting.PUBLIC_BUILD": "",
|
||||
"builds.sorting.PRIVATE_BUILD": ""
|
||||
"builds.sorting.PRIVATE_BUILD": "",
|
||||
"commissions.open": "",
|
||||
"commissions.closed": ""
|
||||
}
|
||||
|
|
|
|||
|
|
@ -132,6 +132,8 @@
|
|||
"actions.next": "",
|
||||
"actions.previous": "",
|
||||
"actions.back": "",
|
||||
"actions.hide": "",
|
||||
"actions.settings": "",
|
||||
"noResults": "",
|
||||
"maps.createMapList": "Maak levellijst",
|
||||
"maps.halfSz": "50% SZ",
|
||||
|
|
@ -255,6 +257,7 @@
|
|||
"support.perk.joinMoreAssociations": "",
|
||||
"support.perk.useBotToLogIn": "",
|
||||
"support.perk.useBotToLogIn.extra": "",
|
||||
"support.perk.earlyAccess": "",
|
||||
"custom.colors.title": "",
|
||||
"custom.colors.bg": "",
|
||||
"custom.colors.bg-darker": "",
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user